feat(mira): 河道 → wiki 自動化(fire-and-forget 觸發 wiki_synthesis)

對應 polaris/mira/.agents/specs/mira-app/tasks.md 7B.3h(簡化版)。

原計畫用 arcrun cron 零件 → cypher-executor scheduled() handler,但發現
cron 零件只是 validator,cypher-executor 還沒實作 scheduled()。為了不擋
「河道書寫 → 自動產 wiki」這條 UX,先做 fire-and-forget 版本:

- 新 cypher-executor route POST /mira/wiki-from-raw
  - body: { raw_block_id }
  - server 端從 MIRA_CONFIG secret 補 partner key / mira_token / 三個 block IDs
  - waitUntil 背景跑 executeWebhookGraph,立刻回 202
- landing 河道 post composer 成功寫 raw 後 fire-and-forget triggerWikiSynthesis()
  跟既有 triggerAiReply() 同範式
- types.ts 加 MIRA_CONFIG?: string

部署後需手動:
  echo '{"service_api_key":"ak_...","data_api_key":"ak_...","schema_block_id":"...","skill_block_id":"...","entities_block_id":"...","mira_token":"..."}' \
    | wrangler secret put MIRA_CONFIG

UX:河道貼一則 → AI reply 30s 內 → wiki 60-90s 內出現在 /mira/wiki。

arcrun.md 記 P1 #3:cypher-executor 加 scheduled() handler,那是真正的
cron 路線,封測前不擋。
This commit is contained in:
2026-05-14 13:50:13 +08:00
parent 933ae6cb13
commit 660b32eafd
5 changed files with 159 additions and 0 deletions
+16
View File
@@ -189,6 +189,22 @@ P0 #10 修完後 mira 嘗試做 wiki 多段結構,又踩出三個 cypher bindi
### 三-A、P1 待改進(不擋封測,但 mira 已踩到) ### 三-A、P1 待改進(不擋封測,但 mira 已踩到)
#### P1 #3cypher-executor 缺 `scheduled()` handler2026-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 #1workflow 缺 IF/branch 能力(2026-05-14 mira 7B.3f 提出) #### P1 #1workflow 缺 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 >>` 條件路由。 **現象**mira 想做「找有則 PATCH 沒則 CREATE」(index-entry upsert),arcrun 目前只有 `ON_SUCCESS` + `對每個 X`FOREACH+ 已存在但壞掉的 `if_control`(見已知限制 #1),沒有 `>> ON_TRUE >>` / `>> ON_FALSE >>` 條件路由。
+2
View File
@@ -16,6 +16,7 @@ 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 }>();
@@ -42,6 +43,7 @@ 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 導出
export default app; export default app;
+110
View File
@@ -0,0 +1,110 @@
/**
* 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);
});
+4
View File
@@ -57,6 +57,10 @@ 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;
}; };
// 圖結構定義 // 圖結構定義
+27
View File
@@ -158,6 +158,29 @@ 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;
@@ -272,6 +295,10 @@ function PostComposer({
parentBlockId: postBlockId, parentBlockId: postBlockId,
pageName, pageName,
}); });
// fire-and-forget 觸發 wiki_synthesis7B.3h 簡化版:從 frontend 直接觸發,不走 cron
// 對應 routes/mira.tsserver 端從 MIRA_CONFIG secret 補齊 token / block IDs
void triggerWikiSynthesis({ rawBlockId: postBlockId });
onAiTriggered(pageName); onAiTriggered(pageName);
// 給 D1 GROUP BY 查詢看到新資料的時間 // 給 D1 GROUP BY 查詢看到新資料的時間