Files
Arcrun/cypher-executor/src/routes/executions.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

198 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 scanCF 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 的 statetrace、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 就見底)。KBDBAPI-as-Wallleo 2026-06-14):本路由**不直連
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
*
* workflowId 等於 webhook nameexecution-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`],
});
});