60688c3108
事故: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>
198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
/**
|
||
* Executions routes — LI SDD M2.1
|
||
*
|
||
* 對應 .agents/specs/llm-interface/ Milestone 2.1。給 AI 看 workflow 執行狀態的端點。
|
||
*
|
||
* - GET /executions/paused — 列當前所有 paused 的 workflow(等 callback resume)
|
||
* - GET /executions/:task_id — 看單一 paused state 細節(含 trace、graph、node id)
|
||
* - GET /workflows/:name/executions — 列某 workflow 最近 N 次執行 verdict
|
||
*
|
||
* 設計:純讀,無 side effect。所有路由要 api_key auth(防偷看他人 workflow state)。
|
||
*/
|
||
|
||
import { Hono } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||
import { kbdbBase } from './kbdb-proxy';
|
||
|
||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||
|
||
/**
|
||
* GET /executions/paused — 列當前 api_key 下所有 paused workflow
|
||
*
|
||
* 走 per-user index `paused_idx:{api_key}`(單 KV get,強 consistent,無 KV list 延遲)
|
||
* 取代舊的 `paused_run:*` prefix scan(CF KV list 30-60 秒 eventual consistent)
|
||
*/
|
||
executionsRouter.get('/executions/paused', async (c) => {
|
||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||
if (!apiKey) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'auth_missing',
|
||
human_message: '缺 X-Arcrun-API-Key header',
|
||
next_actions: ['call /me 取得你的 ak_xxx,加進 header'],
|
||
}, 401);
|
||
}
|
||
|
||
const limitParam = c.req.query('limit');
|
||
const limit = Math.min(Math.max(parseInt(limitParam || '20', 10), 1), 100);
|
||
|
||
const paused = await listPausedRunsByApiKey(c.env.EXEC_CONTEXT, apiKey, limit);
|
||
|
||
return c.json({
|
||
ok: true,
|
||
data: { count: paused.length, paused },
|
||
hints: paused.length > 0
|
||
? [`${paused.length} 個 workflow 等 callback resume。call get_execution_trace(task_id) 看細節`]
|
||
: ['沒有任何 paused workflow'],
|
||
});
|
||
});
|
||
|
||
/**
|
||
* GET /executions/:task_id — 看單一 paused workflow 的 state(trace、graph、context)
|
||
*
|
||
* task_id 來源:trigger workflow 時 response 含 paused 結果,task_id 在 error 字串裡,
|
||
* 或前端 list_paused_executions 回的 task_id。
|
||
*
|
||
* 隔離:只能讀自己 api_key 的 state。
|
||
*/
|
||
executionsRouter.get('/executions/:task_id', async (c) => {
|
||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||
if (!apiKey) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'auth_missing',
|
||
human_message: '缺 X-Arcrun-API-Key header',
|
||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||
}, 401);
|
||
}
|
||
|
||
const taskId = c.req.param('task_id');
|
||
const raw = await c.env.EXEC_CONTEXT.get(`paused_run:${taskId}`);
|
||
|
||
if (!raw) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'not_found',
|
||
human_message: `task_id "${taskId}" 沒對應的 paused state(可能已 resume 完、過 24h TTL 被 GC、或從未存在)`,
|
||
next_actions: [
|
||
'call /executions/paused 看當前所有 paused,確認 task_id 正確',
|
||
'若該 workflow 不是 paused 型,看 /workflows/:name/executions 查歷史 verdict',
|
||
],
|
||
}, 404);
|
||
}
|
||
|
||
let state: {
|
||
run_id: string;
|
||
graph?: unknown;
|
||
paused_node_id: string;
|
||
paused_context?: Record<string, unknown>;
|
||
paused_pending_result?: Record<string, unknown>;
|
||
trace_so_far?: unknown;
|
||
api_key?: string;
|
||
expires_at?: number;
|
||
};
|
||
try {
|
||
state = JSON.parse(raw);
|
||
} catch {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'internal_error',
|
||
human_message: 'paused state JSON 損毀',
|
||
next_actions: ['告訴 leo / 平台維護者'],
|
||
}, 500);
|
||
}
|
||
|
||
if (state.api_key !== apiKey) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'not_found', // 不洩漏存在性
|
||
human_message: `task_id "${taskId}" 找不到`,
|
||
next_actions: ['確認 task_id 屬於你 (用 /executions/paused 列出)'],
|
||
}, 404);
|
||
}
|
||
|
||
return c.json({
|
||
ok: true,
|
||
data: {
|
||
task_id: taskId,
|
||
run_id: state.run_id,
|
||
paused_node_id: state.paused_node_id,
|
||
paused_context: state.paused_context,
|
||
paused_pending_result: state.paused_pending_result,
|
||
trace_so_far: state.trace_so_far,
|
||
expires_at: state.expires_at,
|
||
},
|
||
hints: [
|
||
'paused 狀態 = workflow 等 daemon callback。等對應 service 回 POST /workflows/resume 即可繼續',
|
||
'若 daemon 掛了,看 expires_at — 過 24h KV TTL 會 GC 此 state',
|
||
],
|
||
});
|
||
});
|
||
|
||
/**
|
||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||
*
|
||
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
|
||
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
|
||
* 的實例刷 90 次 portal 就見底)。KBDB=API-as-Wall(leo 2026-06-14):本路由**不直連
|
||
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
|
||
*
|
||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name,與舊 KV
|
||
* key 同語意,沿用既有限制不在這次修復裡處理)。
|
||
*/
|
||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||
if (!apiKey) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'auth_missing',
|
||
human_message: '缺 X-Arcrun-API-Key header',
|
||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||
}, 401);
|
||
}
|
||
|
||
const name = c.req.param('name');
|
||
const limitParam = c.req.query('limit');
|
||
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10), 1), 100);
|
||
|
||
// 確認 workflow 是該 api_key 的(防偷看他人)
|
||
const wfRaw = await c.env.WEBHOOKS.get(`${apiKey}:wf:${name}`, 'text');
|
||
if (!wfRaw) {
|
||
return c.json({
|
||
ok: false,
|
||
error_code: 'not_found',
|
||
human_message: `workflow "${name}" 不存在或不屬於你`,
|
||
next_actions: ['call /webhooks/named 看你有什麼 workflow'],
|
||
}, 404);
|
||
}
|
||
|
||
const { base, headers } = kbdbBase(c.env);
|
||
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
|
||
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
|
||
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
|
||
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
|
||
}> } | null;
|
||
|
||
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
|
||
timestamp: String(r.recorded_at),
|
||
workflow_id: name,
|
||
verdict: r.verdict,
|
||
duration_ms: r.duration_ms,
|
||
message: r.message ?? '',
|
||
...(r.target ? { target: r.target } : {}),
|
||
}));
|
||
|
||
return c.json({
|
||
ok: true,
|
||
data: {
|
||
workflow_name: name,
|
||
count: executions.length,
|
||
executions,
|
||
},
|
||
hints: executions.length === 0
|
||
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
|
||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||
});
|
||
});
|