diff --git a/.agents/specs/arcrun/arcrun.md b/.agents/specs/arcrun/arcrun.md index dc026a4..1eb7a86 100644 --- a/.agents/specs/arcrun/arcrun.md +++ b/.agents/specs/arcrun/arcrun.md @@ -189,6 +189,22 @@ P0 #10 修完後 mira 嘗試做 wiki 多段結構,又踩出三個 cypher bindi ### 三-A、P1 待改進(不擋封測,但 mira 已踩到) +#### P1 #3:cypher-executor 缺 `scheduled()` handler(2026-05-14 mira 7B.3h 提出) + +**現況**:cron 零件 (`registry/components/cron/`) 只做 cron expression validation,不實作排程。cypher-executor 的 wrangler.toml 沒 `[triggers].crons`,src/index.ts 沒 `scheduled()` handler。所以 workflow YAML 寫了 cron 零件當 trigger 也不會真的跑。 + +**短期 workaround**(mira 採用,2026-05-14):前端 fire-and-forget 觸發 — 河道 post 成功後直接 POST 到 `/mira/wiki-from-raw`,server 端把 wiki_synthesis 用 `waitUntil` 跑掉。無 retry、無 batch、無排程。 + +**長期解**: +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: "..."` + +工:3-4 小時。對 RSS 抓 / voice-stt / mira ai-canon-wiki 等 cron-driven source 都有用。封測前不擋(用前端觸發 + 補跑按鈕即可)。 + +--- + #### P1 #1:workflow 缺 IF/branch 能力(2026-05-14 mira 7B.3f 提出) **現象**:mira 想做「找有則 PATCH 沒則 CREATE」(index-entry upsert),arcrun 目前只有 `ON_SUCCESS` + `對每個 X`(FOREACH)+ 已存在但壞掉的 `if_control`(見已知限制 #1),沒有 `>> ON_TRUE >>` / `>> ON_FALSE >>` 條件路由。 diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index c8b29b3..f8267e8 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -16,6 +16,7 @@ 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 }>(); @@ -42,6 +43,7 @@ app.route('/', recipesRouter); app.route('/', credentialsRouter); app.route('/', authRouter); app.route('/', resumeRouter); +app.route('/', miraRouter); // Worker 導出 export default app; diff --git a/cypher-executor/src/routes/mira.ts b/cypher-executor/src/routes/mira.ts new file mode 100644 index 0000000..218186f --- /dev/null +++ b/cypher-executor/src/routes/mira.ts @@ -0,0 +1,110 @@ +/** + * 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/types.ts b/cypher-executor/src/types.ts index 9e197ed..3ce0e73 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -57,6 +57,10 @@ 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/landing/app/mira/feed/page.tsx b/landing/app/mira/feed/page.tsx index bcf9591..e81d0dd 100644 --- a/landing/app/mira/feed/page.tsx +++ b/landing/app/mira/feed/page.tsx @@ -158,6 +158,29 @@ 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; @@ -272,6 +295,10 @@ 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 }); onAiTriggered(pageName); // 給 D1 GROUP BY 查詢看到新資料的時間