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:
2026-05-14 14:04:57 +08:00
parent 660b32eafd
commit 9560485937
10 changed files with 257 additions and 154 deletions
+25 -9
View File
@@ -189,19 +189,35 @@ P0 #10 修完後 mira 嘗試做 wiki 多段結構,又踩出三個 cypher bindi
### 三-A、P1 待改進(不擋封測,但 mira 已踩到) ### 三-A、P1 待改進(不擋封測,但 mira 已踩到)
#### P1 #3cypher-executor `scheduled()` handler2026-05-14 mira 7B.3h 提出 #### P1 #3cypher-executor `scheduled()` handler2026-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 validationcypher-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 = [...]` 1. `wrangler.toml``[triggers] crons = ["* * * * *"]`(每分鐘 tick
2. `src/index.ts` `export default { fetch, scheduled }`scheduled handler 掃 WEBHOOKS KV,找首節點是 cron 的 workflow,比對 cron_expr 跟 event.cron,匹配就 trigger 2. `src/lib/cron-match.ts`5 欄位 cron expression matcher(支援 `*` / `N` / `*/N` / `1-5` / `5,10` 組合)
3. acr push 偵測 cron 首節點時,把 cron_expr 一併寫入 KV record metadata 3. `src/scheduled.ts`scheduled handler 掃 KV `cron-idx:` prefix,比對 controller.scheduledTime,匹配就 `executeWebhookGraph` 背景跑
4. workflow yaml 慣例:`my_cron >> ON_SUCCESS >> ...`my_cron config 含 `cron_expr: "..."` 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。
--- ---
+9 -4
View File
@@ -1,7 +1,9 @@
// arcrun cypher-executor Worker — AI 工作流執行引擎 // arcrun cypher-executor Worker — AI 工作流執行引擎
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import type { ExecutionContext } from '@cloudflare/workers-types';
import type { Bindings } from './types'; import type { Bindings } from './types';
import { handleScheduled } from './scheduled';
import { healthRouter } from './routes/health'; import { healthRouter } from './routes/health';
import { executeRouter } from './routes/execute'; import { executeRouter } from './routes/execute';
import { cypherRouter } from './routes/cypher'; import { cypherRouter } from './routes/cypher';
@@ -16,7 +18,6 @@ import { credentialsRouter } from './routes/credentials';
import { webhooksNamedRouter } from './routes/webhooks-named'; import { webhooksNamedRouter } from './routes/webhooks-named';
import { authRouter } from './routes/auth'; import { authRouter } from './routes/auth';
import { resumeRouter } from './routes/resume'; import { resumeRouter } from './routes/resume';
import { miraRouter } from './routes/mira';
const app = new Hono<{ Bindings: Bindings }>(); const app = new Hono<{ Bindings: Bindings }>();
@@ -43,7 +44,11 @@ app.route('/', recipesRouter);
app.route('/', credentialsRouter); app.route('/', credentialsRouter);
app.route('/', authRouter); app.route('/', authRouter);
app.route('/', resumeRouter); app.route('/', resumeRouter);
app.route('/', miraRouter);
// Worker 導出 // Worker 導出fetch + scheduled
export default app; // 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>;
+92
View File
@@ -0,0 +1,92 @@
/**
* 最小 cron expression matcher5 欄位(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 欄位 cronminute 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;
}
-110
View File
@@ -1,110 +0,0 @@
/**
* Mira-specific routes — 給 mira applanding/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 keyacr 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 workflowservice_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 { executeWebhookGraph } from '../actions/webhook-handlers';
import { writeExecutionVerdict } from '../actions/execution-logger'; import { writeExecutionVerdict } from '../actions/execution-logger';
import type { GraphNode } from '../types'; import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>(); export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
@@ -35,12 +36,20 @@ type NamedWorkflowRecord = {
config?: Record<string, unknown>; config?: Record<string, unknown>;
description: string; description: string;
created_at: 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 { function kvKey(apiKey: string, name: string): string {
return `${apiKey}:wf:${name}`; 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 呼叫) // POST /webhooks/named — 部署(acr push 呼叫)
webhooksNamedRouter.post('/webhooks/named', async (c) => { webhooksNamedRouter.post('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); 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); return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
} }
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
const cronExpr = extractCronExpr(body.graph);
const record: NamedWorkflowRecord = { const record: NamedWorkflowRecord = {
name, name,
graph: body.graph, graph: body.graph,
config: body.config, config: body.config,
description: typeof body.description === 'string' ? body.description : '', description: typeof body.description === 'string' ? body.description : '',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
cron_expr: cronExpr ?? undefined,
}; };
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record)); 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; const baseUrl = new URL(c.req.url).origin;
return c.json({ return c.json({
name, 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(kvKey(apiKey, name));
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
return c.json({ deleted: true, name }); return c.json({ deleted: true, name });
}); });
+79
View File
@@ -0,0 +1,79 @@
/**
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
*
* 流程:
* 1. 列出 WEBHOOKS KV 所有 webhook:{api_key}:{name} key
* 2. 對每個 workflow 解析 cron_expracr push 時若首節點是 cron 零件會存進 record.cron_expr
* 3. 用 cronMatch() 比對 event.scheduledTimeUTC 分鐘精度)
* 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`);
}
-4
View File
@@ -57,10 +57,6 @@ export type Bindings = {
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9) // 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain // self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
WORKER_SUBDOMAIN: string; 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;
}; };
// 圖結構定義 // 圖結構定義
+6
View File
@@ -1,6 +1,7 @@
name = "arcrun-cypher-executor" name = "arcrun-cypher-executor"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2025-02-19" compatibility_date = "2025-02-19"
workers_dev = true
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
[[kv_namespaces]] [[kv_namespaces]]
@@ -114,3 +115,8 @@ WORKER_SUBDOMAIN = "uncle6-me"
[[routes]] [[routes]]
pattern = "cypher.arcrun.dev/*" pattern = "cypher.arcrun.dev/*"
zone_name = "arcrun.dev" zone_name = "arcrun.dev"
# Cron triggers — 每分鐘 tickscheduled handler 掃 WEBHOOKS KV 找註冊 cron_expr 的 workflow
# 對應 arcrun.md 三-A P1 #3 / src/scheduled.ts
[triggers]
crons = ["* * * * *"]
+2 -27
View File
@@ -158,29 +158,6 @@ export default function MiraPage() {
// ─── AI 回覆觸發器(fire-and-forget)────────────────────── // ─── 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: { async function triggerAiReply(opts: {
apiKey: string; apiKey: string;
postContent: string; postContent: string;
@@ -295,10 +272,8 @@ function PostComposer({
parentBlockId: postBlockId, parentBlockId: postBlockId,
pageName, pageName,
}); });
// 7B.3hwiki_synthesis 由 arcrun cron-triggered workflow `mira_feed_watcher`
// fire-and-forget 觸發 wiki_synthesis7B.3h 簡化版:從 frontend 直接觸發,不走 cron // 自動處理(每分鐘掃未處理 raw block),不需前端觸發。
// 對應 routes/mira.tsserver 端從 MIRA_CONFIG secret 補齊 token / block IDs
void triggerWikiSynthesis({ rawBlockId: postBlockId });
onAiTriggered(pageName); onAiTriggered(pageName);
// 給 D1 GROUP BY 查詢看到新資料的時間 // 給 D1 GROUP BY 查詢看到新資料的時間
+23
View File
@@ -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}}"