From 9560485937983d614838a0e301b5face9d7bed25 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 14 May 2026 14:04:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(cypher):=20add=20scheduled()=20handler=20?= =?UTF-8?q?=E2=80=94=20arcrun-native=20cron=20=E6=8E=92=E7=A8=8B=E5=9F=BA?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 對應 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 列為跟進)。 --- .agents/specs/arcrun/arcrun.md | 34 ++++-- cypher-executor/src/index.ts | 13 ++- cypher-executor/src/lib/cron-match.ts | 92 ++++++++++++++++ cypher-executor/src/routes/mira.ts | 110 ------------------- cypher-executor/src/routes/webhooks-named.ts | 21 ++++ cypher-executor/src/scheduled.ts | 79 +++++++++++++ cypher-executor/src/types.ts | 4 - cypher-executor/wrangler.toml | 6 + landing/app/mira/feed/page.tsx | 29 +---- tests/arcrun-test/cron_heartbeat.yaml | 23 ++++ 10 files changed, 257 insertions(+), 154 deletions(-) create mode 100644 cypher-executor/src/lib/cron-match.ts delete mode 100644 cypher-executor/src/routes/mira.ts create mode 100644 cypher-executor/src/scheduled.ts create mode 100644 tests/arcrun-test/cron_heartbeat.yaml diff --git a/.agents/specs/arcrun/arcrun.md b/.agents/specs/arcrun/arcrun.md index 1eb7a86..cd3964f 100644 --- a/.agents/specs/arcrun/arcrun.md +++ b/.agents/specs/arcrun/arcrun.md @@ -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。 --- diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index f8267e8..6015d21 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -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; diff --git a/cypher-executor/src/lib/cron-match.ts b/cypher-executor/src/lib/cron-match.ts new file mode 100644 index 0000000..de4f714 --- /dev/null +++ b/cypher-executor/src/lib/cron-match.ts @@ -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 }> }).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; +} diff --git a/cypher-executor/src/routes/mira.ts b/cypher-executor/src/routes/mira.ts deleted file mode 100644 index 218186f..0000000 --- a/cypher-executor/src/routes/mira.ts +++ /dev/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; - 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 }; - try { - record = JSON.parse(raw) as { graph: Record }; - } catch { - return c.json({ error: 'workflow 定義損毀' }, 500); - } - - const triggerContext: Record = { - 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); -}); diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index e297e85..e949999 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -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; 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 }); }); diff --git a/cypher-executor/src/scheduled.ts b/cypher-executor/src/scheduled.ts new file mode 100644 index 0000000..2e36295 --- /dev/null +++ b/cypher-executor/src/scheduled.ts @@ -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; + cron_expr?: string; + // 其他欄位(id, name, created_at 等)忽略 +}; + +export async function handleScheduled( + controller: ScheduledController, + env: Bindings, + ctx: ExecutionContext, +): Promise { + 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`); +} diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 3ce0e73..9e197ed 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -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; }; // 圖結構定義 diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 06bdd42..35cb037 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -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 = ["* * * * *"] diff --git a/landing/app/mira/feed/page.tsx b/landing/app/mira/feed/page.tsx index e81d0dd..fcf1ead 100644 --- a/landing/app/mira/feed/page.tsx +++ b/landing/app/mira/feed/page.tsx @@ -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 查詢看到新資料的時間 diff --git a/tests/arcrun-test/cron_heartbeat.yaml b/tests/arcrun-test/cron_heartbeat.yaml new file mode 100644 index 0000000..4965dbe --- /dev/null +++ b/tests/arcrun-test/cron_heartbeat.yaml @@ -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}}"