Skip to content

Commit a8a5a28

Browse files
mirror29claude
andcommitted
feat(orchestration): scheduler 模块全套(7b706fe 漏 add 文件补齐)
7b706fe 加了 mastra/index.ts 对 ../scheduler/{api,index} 的 import 和 tools/index.ts 对 ./scheduler 的 import,但 scheduler 本体文件全部未 add 导致 tsc --noEmit 报 TS2307 找不到模块。本 commit 补齐: - src/scheduler/types.ts: ScheduledJob / ScheduledJobInput / ScheduledRun 类型 - src/scheduler/repo.ts: pg 持久化(job CRUD + run history + 分布式锁) - src/scheduler/runner.ts: 单 job 执行 + run 状态推进 + scheduler 锁 - src/scheduler/index.ts: croner 调度循环 + bootstrap - src/scheduler/api.ts: hono REST 端点(list/enable/trigger/runs) - src/tools/scheduler.ts: 7 个 Mastra tool 给 orchestrator 管理 schedule - tests/scheduler.test.ts: 6 个单测覆盖 repo/runner 基础路径 - scripts/scheduler-{admin.html,trigger.ts}: 运维辅助 - infra/migrations/0004_scheduler.py: scheduled_jobs / scheduled_runs 表 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b2f6bd1 commit a8a5a28

10 files changed

Lines changed: 2064 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""scheduler_jobs + scheduler_runs 表(D-9 · 类 Hermes 定时 agent 模式)
2+
3+
Revision ID: 0004
4+
Revises: 0003
5+
Create Date: 2026-05-22
6+
7+
D-9 需求:让 Inalpha 支持类 Hermes 定时模式,扩展场景如:
8+
- 每日盘前 deep_dive
9+
- 周期性 backfill 行情
10+
- 后续:定时复盘、定时重训因子
11+
12+
本 migration 加两张表:
13+
1. scheduler_jobs —— 任务定义(cron 表达式 / 时区 / mode / payload)
14+
2. scheduler_runs —— 执行历史(每次触发一行,含状态、结果、错误)
15+
16+
并 INSERT 两条种子任务(enabled=false,需用户手动开启)作为 MVP:
17+
- daily_btc_deep_dive:每日 08:00 (Asia/Shanghai) agent mode 调 orchestrator
18+
- hourly_btc_backfill:每小时 5 分 tool mode 调 data.backfill_bars
19+
"""
20+
from __future__ import annotations
21+
22+
from alembic import op
23+
24+
revision: str = "0004"
25+
down_revision: str | None = "0003"
26+
branch_labels: str | tuple[str, ...] | None = None
27+
depends_on: str | tuple[str, ...] | None = None
28+
29+
30+
def upgrade() -> None:
31+
# ============ scheduler_jobs ============
32+
op.execute(
33+
"""
34+
CREATE TABLE scheduler_jobs (
35+
job_id TEXT PRIMARY KEY,
36+
cron_expr TEXT NOT NULL,
37+
timezone TEXT NOT NULL DEFAULT 'UTC',
38+
mode TEXT NOT NULL CHECK (mode IN ('tool', 'agent')),
39+
payload JSONB NOT NULL,
40+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
41+
description TEXT,
42+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
43+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
44+
)
45+
"""
46+
)
47+
48+
# ============ scheduler_runs ============
49+
op.execute(
50+
"""
51+
CREATE TABLE scheduler_runs (
52+
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
53+
job_id TEXT NOT NULL REFERENCES scheduler_jobs(job_id) ON DELETE CASCADE,
54+
scheduled_at TIMESTAMPTZ NOT NULL,
55+
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
56+
finished_at TIMESTAMPTZ,
57+
status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failed', 'timeout')),
58+
trigger TEXT NOT NULL DEFAULT 'cron' CHECK (trigger IN ('cron', 'manual')),
59+
result JSONB,
60+
error JSONB
61+
)
62+
"""
63+
)
64+
op.execute(
65+
"CREATE INDEX scheduler_runs_job_started_idx "
66+
"ON scheduler_runs (job_id, started_at DESC)"
67+
)
68+
op.execute(
69+
"CREATE INDEX scheduler_runs_running_idx "
70+
"ON scheduler_runs (job_id) WHERE status = 'running'"
71+
)
72+
73+
# ============ 种子任务(enabled=false,需用户手动开启) ============
74+
op.execute(
75+
"""
76+
INSERT INTO scheduler_jobs (job_id, cron_expr, timezone, mode, payload, enabled, description)
77+
VALUES (
78+
'daily_btc_deep_dive',
79+
'0 8 * * *',
80+
'Asia/Shanghai',
81+
'agent',
82+
'{"agent": "orchestrator", "prompt": "对 BTC/USDT 做 deep_dive(lookback 30d, timeframe 1h),输出 ResearchPlan 摘要并写入 audit log。不要下单。"}'::jsonb,
83+
FALSE,
84+
'D-9 种子:每日 08:00 BTC 盘前研究'
85+
)
86+
"""
87+
)
88+
op.execute(
89+
"""
90+
INSERT INTO scheduler_jobs (job_id, cron_expr, timezone, mode, payload, enabled, description)
91+
VALUES (
92+
'hourly_btc_backfill',
93+
'5 * * * *',
94+
'UTC',
95+
'tool',
96+
'{"tool": "data.backfill_bars", "input": {"venue": "binance", "symbol": "BTC/USDT", "timeframe": "1h"}}'::jsonb,
97+
FALSE,
98+
'D-9 种子:每小时 BTC 1h K 线增量'
99+
)
100+
"""
101+
)
102+
103+
104+
def downgrade() -> None:
105+
op.execute("DROP INDEX IF EXISTS scheduler_runs_running_idx")
106+
op.execute("DROP INDEX IF EXISTS scheduler_runs_job_started_idx")
107+
op.execute("DROP TABLE IF EXISTS scheduler_runs")
108+
op.execute("DROP TABLE IF EXISTS scheduler_jobs")
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<!DOCTYPE html>
2+
<!--
3+
Inalpha Scheduler · 极简 admin 界面(D-9)
4+
5+
用法:
6+
7+
1. SCHEDULER_ENABLED=true bash scripts/dev.sh # 起 mastra:4111
8+
2. 浏览器打开本文件(直接 open,或 file://)
9+
3. 默认连 http://localhost:4111;右上角输入框可改
10+
11+
为什么不用 Next.js:D-9 阶段仓库还没 apps/web;这是临时管理面。
12+
13+
特性:列 jobs / 切 enabled / 手动 trigger / 查 runs。
14+
没有:编辑 cron 表达式 / 创建 job(用 curl POST + 复杂 JSON 更稳)。
15+
-->
16+
<html lang="zh-CN">
17+
<head>
18+
<meta charset="UTF-8">
19+
<title>Inalpha Scheduler Admin</title>
20+
<style>
21+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
22+
max-width: 1100px; margin: 24px auto; padding: 0 16px; color: #222; }
23+
h1 { font-size: 20px; margin-bottom: 4px; }
24+
.meta { color: #888; font-size: 13px; margin-bottom: 16px; }
25+
.controls { margin-bottom: 16px; }
26+
.controls input { padding: 4px 8px; width: 280px; font-family: monospace; }
27+
.controls button { padding: 4px 12px; cursor: pointer; }
28+
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 13px; }
29+
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; vertical-align: top; }
30+
th { background: #f5f5f5; }
31+
tr.disabled { opacity: 0.5; }
32+
.pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
33+
font-size: 11px; font-weight: 600; }
34+
.pill.tool { background: #eaf4ff; color: #1769aa; }
35+
.pill.agent { background: #fff3cd; color: #856404; }
36+
.pill.success { background: #d4edda; color: #155724; }
37+
.pill.failed, .pill.timeout { background: #f8d7da; color: #721c24; }
38+
.pill.running { background: #cce5ff; color: #004085; }
39+
button.action { padding: 2px 8px; margin-right: 4px; font-size: 12px; cursor: pointer; }
40+
pre { background: #f9f9f9; padding: 8px; border: 1px solid #eee; max-height: 200px;
41+
overflow: auto; font-size: 11px; margin: 0; }
42+
#status { margin: 8px 0; padding: 6px 10px; border-radius: 4px; font-size: 12px; }
43+
#status.ok { background: #d4edda; color: #155724; }
44+
#status.err { background: #f8d7da; color: #721c24; }
45+
</style>
46+
</head>
47+
<body>
48+
<h1>Inalpha Scheduler · admin</h1>
49+
<div class="meta">D-9 临时管理面(HTML+fetch,无 build)。每 10s 自动刷新。</div>
50+
51+
<div class="controls">
52+
Base URL:
53+
<input id="baseUrl" value="http://localhost:4111">
54+
<button onclick="loadAll()">刷新</button>
55+
<span id="schedRunning" style="margin-left:16px;color:#888;"></span>
56+
</div>
57+
<div id="status"></div>
58+
59+
<h2 style="font-size:16px;">Jobs</h2>
60+
<table id="jobsTable">
61+
<thead><tr>
62+
<th>job_id</th><th>mode</th><th>cron</th><th>tz</th><th>enabled</th>
63+
<th>next fire</th><th>actions</th>
64+
</tr></thead>
65+
<tbody></tbody>
66+
</table>
67+
68+
<h2 style="font-size:16px;">Recent Runs (50)</h2>
69+
<table id="runsTable">
70+
<thead><tr>
71+
<th>job_id</th><th>status</th><th>trigger</th>
72+
<th>scheduled_at</th><th>finished_at</th><th>result / error</th>
73+
</tr></thead>
74+
<tbody></tbody>
75+
</table>
76+
77+
<script>
78+
const $ = (q) => document.querySelector(q);
79+
const base = () => $("#baseUrl").value.replace(/\/$/, "");
80+
81+
function setStatus(msg, ok = true) {
82+
const el = $("#status");
83+
el.className = ok ? "ok" : "err";
84+
el.textContent = msg;
85+
if (ok) setTimeout(() => { if (el.textContent === msg) el.textContent = ""; }, 3000);
86+
}
87+
88+
async function api(path, init = {}) {
89+
const url = base() + path;
90+
const res = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init.headers || {}) } });
91+
const body = await res.json().catch(() => null);
92+
if (!res.ok) throw new Error((body && body.message) || `${res.status} ${res.statusText}`);
93+
return body;
94+
}
95+
96+
function fmtDate(s) {
97+
if (!s) return "-";
98+
return new Date(s).toLocaleString();
99+
}
100+
101+
function fmtJson(o) {
102+
if (o === null || o === undefined) return "";
103+
return JSON.stringify(o, null, 2);
104+
}
105+
106+
async function loadJobs() {
107+
const data = await api("/scheduler/jobs");
108+
$("#schedRunning").textContent = "scheduler running: " + (data.schedulerRunning ? "yes" : "no");
109+
const tbody = $("#jobsTable tbody");
110+
tbody.innerHTML = "";
111+
for (const j of data.jobs) {
112+
const tr = document.createElement("tr");
113+
if (!j.enabled) tr.className = "disabled";
114+
tr.innerHTML = `
115+
<td><b>${j.jobId}</b><div style="color:#888;font-size:11px;">${j.description || ""}</div></td>
116+
<td><span class="pill ${j.mode}">${j.mode}</span></td>
117+
<td><code>${j.cronExpr}</code></td>
118+
<td>${j.timezone}</td>
119+
<td>${j.enabled ? "✓" : ""}</td>
120+
<td style="font-size:11px;">${fmtDate(j.nextFireAt)}</td>
121+
<td>
122+
<button class="action" onclick="toggleEnabled('${j.jobId}', ${!j.enabled})">${j.enabled ? "disable" : "enable"}</button>
123+
<button class="action" onclick="triggerJob('${j.jobId}')">trigger</button>
124+
<button class="action" onclick="deleteJob('${j.jobId}')">delete</button>
125+
</td>`;
126+
tbody.appendChild(tr);
127+
}
128+
}
129+
130+
async function loadRuns() {
131+
const data = await api("/scheduler/runs?limit=50");
132+
const tbody = $("#runsTable tbody");
133+
tbody.innerHTML = "";
134+
for (const r of data.runs) {
135+
const tr = document.createElement("tr");
136+
const blob = r.status === "success" ? r.result : r.error;
137+
tr.innerHTML = `
138+
<td>${r.jobId}</td>
139+
<td><span class="pill ${r.status}">${r.status}</span></td>
140+
<td>${r.trigger}</td>
141+
<td style="font-size:11px;">${fmtDate(r.scheduledAt)}</td>
142+
<td style="font-size:11px;">${fmtDate(r.finishedAt)}</td>
143+
<td><pre>${fmtJson(blob)}</pre></td>`;
144+
tbody.appendChild(tr);
145+
}
146+
}
147+
148+
async function loadAll() {
149+
try { await loadJobs(); await loadRuns(); }
150+
catch (e) { setStatus("load failed: " + e.message, false); }
151+
}
152+
153+
async function toggleEnabled(id, enabled) {
154+
try {
155+
await api(`/scheduler/jobs/${encodeURIComponent(id)}`, {
156+
method: "PATCH", body: JSON.stringify({ enabled }),
157+
});
158+
setStatus(`${id} ${enabled ? "enabled" : "disabled"}`);
159+
await loadJobs();
160+
} catch (e) { setStatus("toggle failed: " + e.message, false); }
161+
}
162+
163+
async function triggerJob(id) {
164+
setStatus(`triggering ${id}...`);
165+
try {
166+
const r = await api(`/scheduler/jobs/${encodeURIComponent(id)}/trigger`, { method: "POST" });
167+
setStatus(`${id}${r.status}`, r.status === "success");
168+
await loadRuns();
169+
} catch (e) { setStatus("trigger failed: " + e.message, false); }
170+
}
171+
172+
async function deleteJob(id) {
173+
if (!confirm(`delete ${id}? 关联 runs 会级联清除。`)) return;
174+
try {
175+
await api(`/scheduler/jobs/${encodeURIComponent(id)}`, { method: "DELETE" });
176+
setStatus(`${id} deleted`);
177+
await loadAll();
178+
} catch (e) { setStatus("delete failed: " + e.message, false); }
179+
}
180+
181+
loadAll();
182+
setInterval(loadAll, 10000);
183+
</script>
184+
</body>
185+
</html>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Scheduler CLI 手动触发 —— 跳过 cron,立即执行一次指定 job。
3+
*
4+
* 用法:
5+
*
6+
* pnpm scheduler:trigger <job_id> # 跑一个 job 并打印结果
7+
* pnpm scheduler:trigger --list # 列出全部 jobs
8+
*
9+
* 何时用:
10+
*
11+
* - 本地 dev 验证 job 配置正确
12+
* - 故障恢复(cron 错过窗口想立刻补一次)
13+
* - smoke test(CI 跑通整条链路)
14+
*
15+
* 何时不用:
16+
*
17+
* - 不要在生产长期用此 CLI 替代 cron(会绕过 advisory lock)
18+
*
19+
* 坑:本脚本直接调 runJob,不走 cron 路径 —— 也就是说 trigger 字段会标 'manual'。
20+
* 因为不在 mastra dev 进程内,agent mode 调 generate 会另起 LLM 调用,可能耗时较长。
21+
*/
22+
import { existsSync } from "node:fs";
23+
import { resolve } from "node:path";
24+
import { loadEnvFile } from "node:process";
25+
26+
const envPath = resolve(process.cwd(), ".env");
27+
if (existsSync(envPath)) {
28+
loadEnvFile(envPath);
29+
}
30+
31+
import { mastra } from "../src/mastra/index.js";
32+
import { closePool, getJob, listAllJobs } from "../src/scheduler/repo.js";
33+
import { runJob } from "../src/scheduler/runner.js";
34+
35+
async function main(): Promise<void> {
36+
const args = process.argv.slice(2);
37+
38+
if (args.includes("--list") || args.length === 0) {
39+
const jobs = await listAllJobs();
40+
console.log(`scheduler 共 ${jobs.length} 个 job:\n`);
41+
for (const j of jobs) {
42+
const state = j.enabled ? "ENABLED " : "disabled";
43+
console.log(
44+
` [${state}] ${j.jobId.padEnd(30)} cron='${j.cronExpr}' tz=${j.timezone} mode=${j.mode}`,
45+
);
46+
}
47+
if (args.length === 0) {
48+
console.log("\n用法:pnpm scheduler:trigger <job_id>");
49+
}
50+
return;
51+
}
52+
53+
const jobId = args[0]!;
54+
const job = await getJob(jobId);
55+
if (job === null) {
56+
console.error(`✗ job ${jobId} 不存在`);
57+
process.exitCode = 2;
58+
return;
59+
}
60+
61+
console.log(`─── 触发 ${jobId} (${job.mode}) ───`);
62+
const result = await runJob({
63+
job,
64+
mastra,
65+
scheduledAt: new Date(),
66+
trigger: "manual",
67+
});
68+
console.log(JSON.stringify(result, null, 2));
69+
if (result.status !== "success") {
70+
process.exitCode = 1;
71+
}
72+
}
73+
74+
main()
75+
.catch((err: unknown) => {
76+
console.error("✗ scheduler-trigger 异常:");
77+
console.error(err);
78+
process.exitCode = 1;
79+
})
80+
.finally(() => {
81+
void closePool();
82+
});

0 commit comments

Comments
 (0)