Files
Arcrun/cypher-executor/src/routes/webhooks.ts
T
uncle6me-web 60688c3108 fix(kv-quota): workflow 執行紀錄搬離 KV,改走 KBDB template 機制(A1/A2/A7)
事故:cypher-executor/src/actions/execution-logger.ts 舊版每跑完一次 workflow 就
ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)= 只增不減,封測者 Evan 處理約 690 個
檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write),整個實例 429。

A1 少記:workflow 執行紀錄改走 KBDB template 機制(entries 表 entry_type='execution_log',
kbdb/migrations/0004_execution_log_template.sql 只 seed 一列 template 定義,零建表/改表)。
儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 只負責文件化,
實際一筆執行是 entries 表一列(1 次執行=1 次 D1 寫入,不走 entry_values 全展開)。欄位收斂:
時間/workflow/verdict/duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記(訊息截斷長度
不對稱:200 vs 2000 字)。target 只認 trigger context 的 page_name/path,不整包存 input。

A2 自我降級:D1 額度仍與知識卡共用同一顆 100,000 rows/日,本模組自設 20% 軟上限(可用
EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫),超過 80% 降成只記失敗、超過 100% 完全停止記錄,
但 workflow 執行永遠照跑(cypher-executor 端 fire-and-forget 永不 throw)。

A7 讀取端:/workflows/:name/executions、/portal/data/workflows 的 last_execution、MCP
list_recent_executions 全部改打 KBDB HTTP API(GET /execution-log、/execution-log/latest),
取代原本的 ANALYTICS_KV list/get(免費層 list 也是 1,000/日)。

架構鐵律修正(本次施工中兩度被抓到走偏,過程留痕於 commit 訊息供後續參考):
- KBDB 三張表打天下(entries/templates/entry_values),永遠不加新 table——新資料類型
  一律用 template + entries,不建表、不 ALTER TABLE。
- KBDB = API-as-Wall,零 SQL:cypher-executor 端一律走 KBDB 的 HTTP API(連法比照既有
  recordRecipeStats/kbdbFetch 慣例),不直連任何 D1、不對 arcrun-kbdb 下任何原生 SQL。

順帶修復:kbdb/src/actions/entry-crud.ts listEntries 的 ORDER BY 補 `, rowid DESC` 二級
排序——entries.created_at 是 unixepoch() 秒級解析度,高頻寫入(execution_log 一秒內多筆)
常同秒,單靠 created_at DESC 不保證「最新一筆」正確,此為本次測試(latestExecutionLog)
發現的既有潛在缺陷,順手補上決定性排序,不改變任何既有查詢在 created_at 不同時的行為。

隔離:portal-data.ts INTERNAL_ENTRY_TYPES 加入 execution_log/execution_log_usage(與既有
value/workflow 同層級排除),避免用戶知識搜尋混進執行 log;本模組從不設 metadata_json.embed,
故永不進 Vectorize 語意搜尋索引。

不動:registry/src/actions/recordAnalytics.ts(零件市場統計,獨立 Worker、獨立 KV 命名空間、
不同資料模型,非本次事故根因所指範圍);cypher-executor/{wrangler.toml,kbdb/wrangler.toml}
未變動(repo 層級 deny 規則保護這兩個生產設定檔不被 AI 編輯)——ANALYTICS_KV binding
因此仍留在 wrangler.toml 宣告中但程式碼零讀寫點(見 PR 說明的完整 grep 佐證)。

KV 裡既有的 stats:* 舊資料不搬移(是統計不是真相源,維持原樣任其依 90 天 TTL 自然過期)。

測試:kbdb/tests/execution-log.test.ts(13 個,含零建表證明/少記/A2 降級/route)、
cypher-executor/tests/execution-logger.test.ts(payload 正確性/永不 throw)、
cypher-executor/tests/executions-route.test.ts(讀取端轉發)、portal-data.test.ts 對應區塊
改寫。kbdb 全測試 104/104 通過;cypher-executor 320 個測試中 9 個失敗為 main 既有(與本次
改動無關,改動前後 stash 對照確認)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:13:00 +08:00

81 lines
2.8 KiB
TypeScript

import { Hono } from 'hono';
import type { Bindings } from '../types';
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
import { writeExecutionVerdict } from '../actions/execution-logger';
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
type WebhookRecord = {
graph: Record<string, unknown>;
description: string;
created_at: string;
};
// POST /webhooks — 接受 graph、triplets 或直接 nodes/edges
webhooksRouter.post('/webhooks', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: 'invalid json' }, 400);
const description = typeof body.description === 'string' ? body.description : '';
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
if (resolved.error) {
return c.json({ error: resolved.error }, 400);
}
const token = generateToken();
const record: WebhookRecord = {
graph: resolved.resolvedGraph,
description,
created_at: new Date().toISOString(),
};
await c.env.WEBHOOKS.put(token, JSON.stringify(record));
const baseUrl = new URL(c.req.url).origin;
return c.json({
token,
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
description: record.description,
created_at: record.created_at,
}, 201);
});
// POST /webhooks/:token/trigger — 觸發執行
webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
const token = c.req.param('token');
if (!token || token.length < 16) {
return c.json({ error: 'invalid token' }, 400);
}
const raw = await c.env.WEBHOOKS.get(token, 'text');
if (!raw) return c.json({ error: 'webhook not found' }, 404);
const record = await validateAndParseWebhook(raw);
if (!record) return c.json({ error: 'webhook 定義損毀' }, 500);
let triggerContext: Record<string, unknown> = {};
try {
const body = await c.req.json().catch(() => null);
if (body && typeof body === 'object') {
triggerContext = body as Record<string, unknown>;
}
} catch {
// 無 body 時使用空 context
}
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token, apiKey);
// fire-and-forget analytics(不阻擋回應)
const graph = record.graph as { id?: string; nodes?: unknown[] };
const workflowId = graph.id ?? token;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);
});