Files
Arcrun/cypher-executor/tests/executions-route.test.ts
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

84 lines
3.3 KiB
TypeScript
Raw Permalink 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.
/**
* GET /workflows/:name/executions — KV 額度事故修復(2026-08-07)路由測試。
* 改打 KBDB GET /execution-log(原走 ANALYTICS_KV list);KBDBAPI-as-Wall
* 本檔一律 fetchMock 攔截,不碰任何 D1(比照 tests/portal-data.test.ts 慣例)。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KBDB = 'https://kbdb.test'; // wrangler.test.toml KBDB_BASE_URL
const API_KEY = 'ak_exec_test';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function get(path: string, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, { headers });
}
describe('GET /workflows/:name/executions', () => {
it('缺 X-Arcrun-API-Key → 401,不打 KBDB', async () => {
const res = await get('/workflows/wf-x/executions');
expect(res.status).toBe(401);
});
it('workflow 不存在或不屬於該 api_key → 404,不打 KBDB', async () => {
const res = await get('/workflows/nope/executions', { 'X-Arcrun-API-Key': API_KEY });
expect(res.status).toBe(404);
});
it('workflow 存在 → 轉發打 KBDB GET /execution-log,回傳其 executions', async () => {
await env.WEBHOOKS.put(
`${API_KEY}:wf:daily_report`,
JSON.stringify({ graph: { id: 'daily_report', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
);
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => p.startsWith('/execution-log?'),
method: 'GET',
})
.reply(200, {
success: true,
executions: [
{ verdict: 'success', duration_ms: 100, message: 'ok', recorded_at: 1783500000 },
{ verdict: 'failed', duration_ms: 50, message: '找不到 workflow', target: 'a.md', recorded_at: 1783400000 },
],
});
const res = await get('/workflows/daily_report/executions', { 'X-Arcrun-API-Key': API_KEY });
expect(res.status).toBe(200);
const body = await res.json() as {
ok: boolean;
data: { workflow_name: string; count: number; executions: Array<{ verdict: string; target?: string }> };
};
expect(body.ok).toBe(true);
expect(body.data.count).toBe(2);
expect(body.data.executions[0].verdict).toBe('success');
expect(body.data.executions[1].target).toBe('a.md');
await env.WEBHOOKS.delete(`${API_KEY}:wf:daily_report`);
});
it('KBDB 回非 success(例如全降級停記錄後空清單)→ 誠實回空陣列,不是假資料', async () => {
await env.WEBHOOKS.put(
`${API_KEY}:wf:empty_wf`,
JSON.stringify({ graph: { id: 'empty_wf', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/execution-log?'), method: 'GET' })
.reply(200, { success: true, executions: [] });
const res = await get('/workflows/empty_wf/executions', { 'X-Arcrun-API-Key': API_KEY });
const body = await res.json() as { data: { count: number; executions: unknown[] } };
expect(body.data.count).toBe(0);
expect(body.data.executions).toEqual([]);
await env.WEBHOOKS.delete(`${API_KEY}:wf:empty_wf`);
});
});