feat(cypher): add scheduled() handler — arcrun-native cron 排程基建
對應 arcrun.md 三-A P1 #3。
緣由:cron 零件存在但只做 expression validation,沒有真正的排程跑。leo 指出
「邊用 arcrun 邊修,不要 workaround」— 撤回前一輪的 /mira/wiki-from-raw
mira-specific route(違反 mira CLAUDE.md §1.5 一律 arcrun-native),改補
真正的 cron infra。
加入:
- src/lib/cron-match.ts — 5 欄位 cron matcher(* / N / */N / a-b / a,b 組合)
- src/scheduled.ts — handler:掃 KV cron-idx: prefix,比對 controller.scheduledTime
→ executeWebhookGraph 背景跑
- routes/webhooks-named.ts — acr push 時偵測首節點 cron → 存 cron_expr 到 record
+ 額外寫 cron-idx:{api_key}:{name} 輕量索引;DELETE 一併清理
- src/index.ts — export default 改 { fetch, scheduled }
- wrangler.toml — [triggers] crons = ["* * * * *"](每分鐘 tick)
- wrangler.toml — workers_dev = true 供 self-fetch self-trigger 用
- tests/arcrun-test/cron_heartbeat.yaml — 健康監控 workflow(每分鐘 fire + set 節點)
撤回:
- 刪 src/routes/mira.ts(mira-specific workaround)
- types.ts 拿掉 MIRA_CONFIG
- index.ts 拿掉 miraRouter wire
- landing/app/mira/feed/page.tsx 拿掉 triggerWikiSynthesis 呼叫
下一輪:mira_feed_watcher.yaml(mira side),可能要先補 kbdb_get filter +
CALLS_SUBFLOW wire(arcrun.md 列為跟進)。
This commit is contained in:
@@ -189,19 +189,35 @@ P0 #10 修完後 mira 嘗試做 wiki 多段結構,又踩出三個 cypher bindi
|
||||
|
||||
### 三-A、P1 待改進(不擋封測,但 mira 已踩到)
|
||||
|
||||
#### P1 #3:cypher-executor 缺 `scheduled()` handler(2026-05-14 mira 7B.3h 提出)
|
||||
#### ✅ P1 #3:cypher-executor `scheduled()` handler(2026-05-14 完成)
|
||||
|
||||
**現況**:cron 零件 (`registry/components/cron/`) 只做 cron expression validation,不實作排程。cypher-executor 的 wrangler.toml 沒 `[triggers].crons`,src/index.ts 沒 `scheduled()` handler。所以 workflow YAML 寫了 cron 零件當 trigger 也不會真的跑。
|
||||
**原痛點**:cron 零件只做 expression validation;cypher-executor 沒 `scheduled()` handler。寫了 cron 首節點的 workflow 不會真的跑。
|
||||
|
||||
**短期 workaround**(mira 採用,2026-05-14):前端 fire-and-forget 觸發 — 河道 post 成功後直接 POST 到 `/mira/wiki-from-raw`,server 端把 wiki_synthesis 用 `waitUntil` 跑掉。無 retry、無 batch、無排程。
|
||||
**之前的 workaround**(已撤):mira 寫了個 `/mira/wiki-from-raw` route 從前端 fire-and-forget 觸發 wiki_synthesis。但這違反「一律 arcrun-native」原則,也讓 arcrun 永遠補不齊缺失。**已刪 route,回 arcrun-native 路線**。
|
||||
|
||||
**長期解**:
|
||||
1. `wrangler.toml [triggers] crons = [...]`
|
||||
2. `src/index.ts` `export default { fetch, scheduled }`:scheduled handler 掃 WEBHOOKS KV,找首節點是 cron 的 workflow,比對 cron_expr 跟 event.cron,匹配就 trigger
|
||||
3. acr push 偵測 cron 首節點時,把 cron_expr 一併寫入 KV record metadata
|
||||
4. workflow yaml 慣例:`my_cron >> ON_SUCCESS >> ...`,my_cron config 含 `cron_expr: "..."`
|
||||
**落地**:
|
||||
1. `wrangler.toml`:`[triggers] crons = ["* * * * *"]`(每分鐘 tick)
|
||||
2. `src/lib/cron-match.ts`:5 欄位 cron expression matcher(支援 `*` / `N` / `*/N` / `1-5` / `5,10` 組合)
|
||||
3. `src/scheduled.ts`:scheduled handler 掃 KV `cron-idx:` prefix,比對 controller.scheduledTime,匹配就 `executeWebhookGraph` 背景跑
|
||||
4. `routes/webhooks-named.ts`:acr push 偵測首節點是 cron 零件 → 抽 `cron_expr` 存進 record + 額外寫 `cron-idx:{api_key}:{name}` 輕量 index entry。DELETE 一併清理
|
||||
5. `src/index.ts`:export default 改 `{ fetch, scheduled }`
|
||||
6. cypher-executor 自己加 `workers_dev = true` 給未來 self-trigger 用(fork 用 path-based 子 trigger 也走 workers.dev 避同 zone)
|
||||
|
||||
工:3-4 小時。對 RSS 抓 / voice-stt / mira ai-canon-wiki 等 cron-driven source 都有用。封測前不擋(用前端觸發 + 補跑按鈕即可)。
|
||||
**workflow YAML 慣例**:
|
||||
```yaml
|
||||
flow:
|
||||
- "my_cron >> ON_SUCCESS >> downstream_node"
|
||||
config:
|
||||
my_cron:
|
||||
component: cron
|
||||
cron_expr: "*/5 * * * *" # 每 5 分鐘
|
||||
```
|
||||
acr push 就會自動建立 cron-idx 並開始定時觸發。
|
||||
|
||||
**測試**:`tests/arcrun-test/cron_heartbeat.yaml` — 每分鐘 fire 一次 + set 節點 log。
|
||||
`wrangler tail arcrun-cypher-executor` 應看 `[scheduled] trigger cron_heartbeat ...`。
|
||||
|
||||
**對應 use case**:mira `mira_feed_watcher`(7B.3h,下一輪做)/ RSS 每日抓 / voice-stt 每小時掃 / 等所有 cron-driven source。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// arcrun cypher-executor Worker — AI 工作流執行引擎
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { ExecutionContext } from '@cloudflare/workers-types';
|
||||
import type { Bindings } from './types';
|
||||
import { handleScheduled } from './scheduled';
|
||||
import { healthRouter } from './routes/health';
|
||||
import { executeRouter } from './routes/execute';
|
||||
import { cypherRouter } from './routes/cypher';
|
||||
@@ -16,7 +18,6 @@ import { credentialsRouter } from './routes/credentials';
|
||||
import { webhooksNamedRouter } from './routes/webhooks-named';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { resumeRouter } from './routes/resume';
|
||||
import { miraRouter } from './routes/mira';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -43,7 +44,11 @@ app.route('/', recipesRouter);
|
||||
app.route('/', credentialsRouter);
|
||||
app.route('/', authRouter);
|
||||
app.route('/', resumeRouter);
|
||||
app.route('/', miraRouter);
|
||||
|
||||
// Worker 導出
|
||||
export default app;
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: handleScheduled,
|
||||
} satisfies ExportedHandler<Bindings>;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 最小 cron expression matcher:5 欄位(minute hour dayOfMonth month dayOfWeek)。
|
||||
*
|
||||
* 用於 cypher-executor scheduled() handler — 把 workflow 註冊的 cron_expr 跟
|
||||
* 每分鐘 tick 的 event.scheduledTime 比對,匹配就觸發該 workflow。
|
||||
*
|
||||
* 支援語法(夠用即可,未來再擴):
|
||||
* `*` — 任何值
|
||||
* `5` — 等於 5
|
||||
* `*/N` — 每 N 個(N>0)
|
||||
* `5,10,15` — 任一
|
||||
* `1-5` — range(含兩端)
|
||||
*
|
||||
* 不支援(暫):
|
||||
* `?` / `L` / `W` / `#` 等延伸語法
|
||||
* month / weekday 用名稱(jan/mon 等)
|
||||
*
|
||||
* 對應 SDD: arcrun.md 三-A P1 #3。
|
||||
*/
|
||||
|
||||
/** 一個欄位(如 'minute')的值是否匹配 expr 段 */
|
||||
function matchField(expr: string, value: number, min: number, max: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
for (const part of expr.split(',')) {
|
||||
if (matchPart(part.trim(), value, min, max)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchPart(part: string, value: number, min: number, max: number): boolean {
|
||||
// `*/N`
|
||||
if (part.startsWith('*/')) {
|
||||
const step = parseInt(part.slice(2), 10);
|
||||
if (!Number.isFinite(step) || step <= 0) return false;
|
||||
return (value - min) % step === 0;
|
||||
}
|
||||
// `X-Y` 或 `X-Y/N`
|
||||
if (part.includes('-')) {
|
||||
const [rangePart, stepStr] = part.split('/');
|
||||
const [aStr, bStr] = rangePart.split('-');
|
||||
const a = parseInt(aStr, 10);
|
||||
const b = parseInt(bStr, 10);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
||||
if (value < a || value > b) return false;
|
||||
if (stepStr === undefined) return true;
|
||||
const step = parseInt(stepStr, 10);
|
||||
if (!Number.isFinite(step) || step <= 0) return false;
|
||||
return (value - a) % step === 0;
|
||||
}
|
||||
// `N`
|
||||
const n = parseInt(part, 10);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
if (n < min || n > max) return false;
|
||||
return value === n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比對 cron expr 跟某個時間點。
|
||||
* @param expr - 5 欄位 cron(minute hour dom month dow)
|
||||
* @param date - 要比對的時間(UTC)
|
||||
*/
|
||||
export function cronMatch(expr: string, date: Date): boolean {
|
||||
const fields = expr.trim().split(/\s+/);
|
||||
if (fields.length !== 5) return false;
|
||||
const [m, h, dom, mon, dow] = fields;
|
||||
// dow: 0=Sun ... 6=Sat (跟 JavaScript 一致;ISO Mon=1 暫不轉)
|
||||
return (
|
||||
matchField(m, date.getUTCMinutes(), 0, 59) &&
|
||||
matchField(h, date.getUTCHours(), 0, 23) &&
|
||||
matchField(dom, date.getUTCDate(), 1, 31) &&
|
||||
matchField(mon, date.getUTCMonth() + 1, 1, 12) &&
|
||||
matchField(dow, date.getUTCDay(), 0, 6)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 workflow YAML 的 config 找出 cron 零件節點的 cron_expr。
|
||||
* 找不到回 null(代表此 workflow 不是 cron-triggered)。
|
||||
*
|
||||
* @param graph - acr push 解析後的 ExecutionGraph
|
||||
*/
|
||||
export function extractCronExpr(graph: unknown): string | null {
|
||||
if (!graph || typeof graph !== 'object') return null;
|
||||
const nodes = (graph as { nodes?: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }> }).nodes;
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
for (const node of nodes) {
|
||||
if (node.componentId !== 'cron') continue;
|
||||
const expr = node.data?.cron_expr;
|
||||
if (typeof expr === 'string' && expr.trim()) return expr.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Mira-specific routes — 給 mira app(landing/app/mira/feed)從前端 fire-and-forget
|
||||
* 觸發 wiki_synthesis workflow,而不需要前端持有 mira_token / partner key / block IDs。
|
||||
*
|
||||
* SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12 + §5.3
|
||||
* 對應 task: mira 7B.3h(簡化版:以 frontend fire-and-forget 取代 cron 觸發;
|
||||
* 真正的 cron 排程留 arcrun.md P1 #3 — cypher-executor scheduled() handler)
|
||||
*
|
||||
* 設定(self-host fork):
|
||||
* wrangler secret put MIRA_CONFIG (cypher-executor)
|
||||
* 值為 JSON 字串,欄位見 MiraConfig type
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
export const miraRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type MiraConfig = {
|
||||
service_api_key: string; // 部署 wiki_synthesis 的 partner key(acr push 用)
|
||||
data_api_key: string; // mira 寫 KBDB 用的 partner key(前端 /me 拿的)
|
||||
schema_block_id: string; // mira-wiki-schema block
|
||||
skill_block_id: string; // mira-wiki-skill block
|
||||
entities_block_id: string; // mira-wiki-index-entities block
|
||||
mira_token: string; // claude_api → mira daemon 的 bearer
|
||||
};
|
||||
|
||||
function parseMiraConfig(raw?: string): MiraConfig | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<MiraConfig>;
|
||||
if (
|
||||
!parsed.service_api_key ||
|
||||
!parsed.data_api_key ||
|
||||
!parsed.schema_block_id ||
|
||||
!parsed.skill_block_id ||
|
||||
!parsed.entities_block_id ||
|
||||
!parsed.mira_token
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MiraConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /mira/wiki-from-raw — 對一個 raw block 跑 wiki_synthesis
|
||||
// Body: { raw_block_id: string }
|
||||
// 給前端 fire-and-forget 用,不等結果回(workflow 跑 60-90s)
|
||||
miraRouter.post('/mira/wiki-from-raw', async (c) => {
|
||||
const cfg = parseMiraConfig(c.env.MIRA_CONFIG);
|
||||
if (!cfg) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Mira 未配置:請 wrangler secret put MIRA_CONFIG(見 routes/mira.ts header)',
|
||||
},
|
||||
501,
|
||||
);
|
||||
}
|
||||
|
||||
let body: { raw_block_id?: string } = {};
|
||||
try {
|
||||
body = (await c.req.json()) as { raw_block_id?: string };
|
||||
} catch {
|
||||
return c.json({ error: 'body 必須是 JSON' }, 400);
|
||||
}
|
||||
if (!body.raw_block_id) {
|
||||
return c.json({ error: 'raw_block_id 必填' }, 400);
|
||||
}
|
||||
|
||||
// 從 KV 拿 wiki_synthesis workflow 定義(部署在 service_api_key 名下)
|
||||
const wfKey = `webhook:${cfg.service_api_key}:wiki_synthesis`;
|
||||
const raw = await c.env.WEBHOOKS.get(wfKey, 'text');
|
||||
if (!raw) {
|
||||
return c.json(
|
||||
{ error: '找不到 wiki_synthesis workflow(service_api_key 是否與 acr push 用的一致?)' },
|
||||
404,
|
||||
);
|
||||
}
|
||||
let record: { graph: Record<string, unknown> };
|
||||
try {
|
||||
record = JSON.parse(raw) as { graph: Record<string, unknown> };
|
||||
} catch {
|
||||
return c.json({ error: 'workflow 定義損毀' }, 500);
|
||||
}
|
||||
|
||||
const triggerContext: Record<string, unknown> = {
|
||||
api_key: cfg.data_api_key,
|
||||
mira_token: cfg.mira_token,
|
||||
schema_block_id: cfg.schema_block_id,
|
||||
skill_block_id: cfg.skill_block_id,
|
||||
entities_block_id: cfg.entities_block_id,
|
||||
index_entries_block_id: cfg.entities_block_id, // 7B.3f:暫共用 entities block 當 index parent
|
||||
raw_block_id: body.raw_block_id,
|
||||
};
|
||||
|
||||
// fire-and-forget:用 waitUntil 在 background 跑,立刻回 202
|
||||
// 若用戶 cookie session 不要等
|
||||
const promise = executeWebhookGraph(c.env, record.graph, triggerContext, 'wiki_synthesis', cfg.service_api_key);
|
||||
c.executionCtx.waitUntil(
|
||||
promise.then(
|
||||
(r) => console.log('[mira/wiki-from-raw] done', r.success, r.duration_ms),
|
||||
(e) => console.error('[mira/wiki-from-raw] failed', e),
|
||||
),
|
||||
);
|
||||
|
||||
return c.json({ accepted: true, raw_block_id: body.raw_block_id }, 202);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import type { Bindings } from '../types';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -35,12 +36,20 @@ type NamedWorkflowRecord = {
|
||||
config?: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
|
||||
// 對應 SDD: arcrun.md 三-A P1 #3
|
||||
cron_expr?: string;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:wf:${name}`;
|
||||
}
|
||||
|
||||
/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV)*/
|
||||
function cronIndexKey(apiKey: string, name: string): string {
|
||||
return `cron-idx:${apiKey}:${name}`;
|
||||
}
|
||||
|
||||
// POST /webhooks/named — 部署(acr push 呼叫)
|
||||
webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -64,16 +73,27 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
|
||||
}
|
||||
|
||||
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
|
||||
const cronExpr = extractCronExpr(body.graph);
|
||||
|
||||
const record: NamedWorkflowRecord = {
|
||||
name,
|
||||
graph: body.graph,
|
||||
config: body.config,
|
||||
description: typeof body.description === 'string' ? body.description : '',
|
||||
created_at: new Date().toISOString(),
|
||||
cron_expr: cronExpr ?? undefined,
|
||||
};
|
||||
|
||||
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
|
||||
|
||||
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留)
|
||||
if (cronExpr) {
|
||||
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr }));
|
||||
} else {
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
}
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
name,
|
||||
@@ -163,5 +183,6 @@ webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
return c.json({ deleted: true, name });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 列出 WEBHOOKS KV 所有 webhook:{api_key}:{name} key
|
||||
* 2. 對每個 workflow 解析 cron_expr(acr push 時若首節點是 cron 零件會存進 record.cron_expr)
|
||||
* 3. 用 cronMatch() 比對 event.scheduledTime(UTC 分鐘精度)
|
||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||
*
|
||||
* SDD: arcrun.md 三-A P1 #3
|
||||
*/
|
||||
|
||||
import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
|
||||
import type { Bindings } from './types';
|
||||
import { cronMatch } from './lib/cron-match';
|
||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||
|
||||
type StoredWorkflowRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
cron_expr?: string;
|
||||
// 其他欄位(id, name, created_at 等)忽略
|
||||
};
|
||||
|
||||
export async function handleScheduled(
|
||||
controller: ScheduledController,
|
||||
env: Bindings,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
|
||||
|
||||
// 只列 cron-idx: prefix,輕量 — acr push 時為 cron-tagged workflow 額外寫一筆 index
|
||||
// 主 workflow record 仍在 {apiKey}:wf:{name},需要時再 get
|
||||
const list = await env.WEBHOOKS.list({ prefix: 'cron-idx:' });
|
||||
|
||||
let triggered = 0;
|
||||
for (const entry of list.keys) {
|
||||
// key = cron-idx:{api_key}:{name}
|
||||
const parts = entry.name.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const apiKey = parts[1];
|
||||
const name = parts.slice(2).join(':'); // name 可能含 ':'(雖然 push handler 已用 /^[\w-]+$/ 擋)
|
||||
|
||||
// 從 cron-idx 拿 cron_expr(輕量)
|
||||
const idxRaw = await env.WEBHOOKS.get(entry.name, 'text');
|
||||
if (!idxRaw) continue;
|
||||
let idx: { cron_expr?: string };
|
||||
try { idx = JSON.parse(idxRaw); } catch { continue; }
|
||||
if (!idx.cron_expr) continue;
|
||||
if (!cronMatch(idx.cron_expr, now)) continue;
|
||||
|
||||
// 匹配才去讀完整 workflow record
|
||||
const wfKey = `${apiKey}:wf:${name}`;
|
||||
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
|
||||
if (!wfRaw) {
|
||||
console.warn('[scheduled] cron-idx 對應 workflow 不存在', wfKey);
|
||||
continue;
|
||||
}
|
||||
let record: StoredWorkflowRecord;
|
||||
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
|
||||
triggered++;
|
||||
|
||||
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', idx.cron_expr);
|
||||
// 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
|
||||
const triggerContext = {
|
||||
api_key: apiKey,
|
||||
_triggered_by: 'cron' as const,
|
||||
_scheduled_at: now.toISOString(),
|
||||
};
|
||||
ctx.waitUntil(
|
||||
executeWebhookGraph(env, record.graph, triggerContext, name, apiKey)
|
||||
.then(
|
||||
(r) => console.log('[scheduled] done', name, r.success, r.duration_ms + 'ms'),
|
||||
(e) => console.error('[scheduled] fail', name, e),
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(`[scheduled] scanned ${list.keys.length} cron-idx entries, ${triggered} triggered`);
|
||||
}
|
||||
@@ -57,10 +57,6 @@ export type Bindings = {
|
||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||
WORKER_SUBDOMAIN: string;
|
||||
// Mira 配置(JSON 字串,欄位見 routes/mira.ts MiraConfig type)
|
||||
// 給 POST /mira/wiki-from-raw 用。未設定則該 endpoint 回 501。
|
||||
// 設定方式:wrangler secret put MIRA_CONFIG
|
||||
MIRA_CONFIG?: string;
|
||||
};
|
||||
|
||||
// 圖結構定義
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name = "arcrun-cypher-executor"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
@@ -114,3 +115,8 @@ WORKER_SUBDOMAIN = "uncle6-me"
|
||||
[[routes]]
|
||||
pattern = "cypher.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
|
||||
# Cron triggers — 每分鐘 tick,scheduled handler 掃 WEBHOOKS KV 找註冊 cron_expr 的 workflow
|
||||
# 對應 arcrun.md 三-A P1 #3 / src/scheduled.ts
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
@@ -158,29 +158,6 @@ export default function MiraPage() {
|
||||
|
||||
// ─── AI 回覆觸發器(fire-and-forget)──────────────────────
|
||||
|
||||
async function triggerWikiSynthesis(opts: { rawBlockId: string }) {
|
||||
// 對應 cypher-executor routes/mira.ts POST /mira/wiki-from-raw
|
||||
// server 端從 MIRA_CONFIG secret 補齊所有 partner key / token / block IDs
|
||||
// workflow 跑 60-90s,這裡 fire-and-forget 不等結果(拿 202 立刻回)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/mira/wiki-from-raw`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ raw_block_id: opts.rawBlockId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.warn('[mira wiki-from-raw] not triggered:', res.status, data);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.log('[mira wiki-from-raw] accepted:', data);
|
||||
} catch (e) {
|
||||
console.warn('[mira wiki-from-raw] error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerAiReply(opts: {
|
||||
apiKey: string;
|
||||
postContent: string;
|
||||
@@ -295,10 +272,8 @@ function PostComposer({
|
||||
parentBlockId: postBlockId,
|
||||
pageName,
|
||||
});
|
||||
|
||||
// fire-and-forget 觸發 wiki_synthesis(7B.3h 簡化版:從 frontend 直接觸發,不走 cron)
|
||||
// 對應 routes/mira.ts;server 端從 MIRA_CONFIG secret 補齊 token / block IDs
|
||||
void triggerWikiSynthesis({ rawBlockId: postBlockId });
|
||||
// 7B.3h:wiki_synthesis 由 arcrun cron-triggered workflow `mira_feed_watcher`
|
||||
// 自動處理(每分鐘掃未處理 raw block),不需前端觸發。
|
||||
onAiTriggered(pageName);
|
||||
|
||||
// 給 D1 GROUP BY 查詢看到新資料的時間
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: cron_heartbeat
|
||||
description: |
|
||||
arcrun cron infra 健康監控 — 每分鐘觸發一次,set 節點輸出 message。
|
||||
用 wrangler tail arcrun-cypher-executor 看:每分鐘應出現 `[scheduled] trigger cron_heartbeat ...`
|
||||
跟 `[scheduled] done cron_heartbeat true {ms}ms`。
|
||||
|
||||
對應 SDD: matrix/arcrun/.agents/specs/arcrun/arcrun.md 三-A P1 #3。
|
||||
|
||||
flow:
|
||||
- "heartbeat_cron >> ON_SUCCESS >> log_heartbeat"
|
||||
|
||||
config:
|
||||
heartbeat_cron:
|
||||
component: cron
|
||||
cron_expr: "* * * * *"
|
||||
description: "每分鐘 cron infra heartbeat"
|
||||
|
||||
log_heartbeat:
|
||||
component: set
|
||||
values:
|
||||
message: "alive at {{_scheduled_at}}"
|
||||
cron_triggered: true
|
||||
api_key_prefix: "{{api_key}}"
|
||||
Reference in New Issue
Block a user