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>
101 lines
4.4 KiB
TypeScript
101 lines
4.4 KiB
TypeScript
/**
|
||
* execution-logger 測試(KV 額度事故修復,2026-08-07)
|
||
*
|
||
* KBDB=API-as-Wall(leo 2026-06-14):cypher-executor 端不直連任何 D1,一律 fire-and-forget
|
||
* fetch KBDB `/execution-log/record`。本檔驗的是「cypher 這一側」的職責,測試手法比照姊妹模組
|
||
* execution-evaluator.test.ts(recordComponentStats,同款「fire-and-forget POST 統計」):
|
||
* `vi.stubGlobal('fetch', ...)` 直接攔截,不用 fetchMock。
|
||
* 1. 送出的 payload 形狀正確(workflow_id/owner_id/verdict/duration_ms/message/target)
|
||
* 2. target 從 trigger context 的 page_name/path 擷取(少記:不整包送 input)
|
||
* 3. 任何錯誤(fetch reject、KBDB 回非 2xx)都不影響呼叫端(永不 throw)
|
||
*
|
||
* 「少記截斷長度」「A2 自我降級」的實際邏輯與驗證在 KBDB 端(kbdb/tests/execution-log.test.ts),
|
||
* 因為決策/儲存都搬到 KBDB 做了,cypher 只是薄殼呼叫端。
|
||
*/
|
||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||
import { writeExecutionVerdict } from '../src/actions/execution-logger';
|
||
import type { Bindings } from '../src/types';
|
||
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
|
||
function fakeEnv(): Bindings {
|
||
return {
|
||
KBDB_BASE_URL: 'https://kbdb.test',
|
||
ENVIRONMENT: 'test',
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
function stubFetchCapture(): { calls: Array<{ url: string; body: Record<string, unknown> }> } {
|
||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
|
||
return new Response(JSON.stringify({ success: true, written: true, mode: 'log' }), { status: 200 });
|
||
}));
|
||
return { calls };
|
||
}
|
||
|
||
describe('writeExecutionVerdict — 送出正確 payload(少記,不整包 input)', () => {
|
||
it('成功:POST 到 KBDB_BASE_URL/execution-log/record,帶 workflow_id/owner_id/verdict/duration_ms/message', async () => {
|
||
const { calls } = stubFetchCapture();
|
||
await writeExecutionVerdict(
|
||
fakeEnv(), 'wf-1', [], 'success', 123, '執行完成', { page_name: 'a.md' }, 'ak_test',
|
||
);
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].url).toBe('https://kbdb.test/execution-log/record');
|
||
expect(calls[0].body).toEqual({
|
||
workflow_id: 'wf-1',
|
||
owner_id: 'ak_test',
|
||
verdict: 'success',
|
||
duration_ms: 123,
|
||
message: '執行完成',
|
||
target: 'a.md',
|
||
});
|
||
});
|
||
|
||
it('target:page_name 優先,沒有時 fallback path;都沒有則為 null', async () => {
|
||
const { calls: calls1 } = stubFetchCapture();
|
||
await writeExecutionVerdict(fakeEnv(), 'wf-2', [], 'failed', 1, 'err', { path: 'docs/x.md' });
|
||
expect(calls1[0].body.target).toBe('docs/x.md');
|
||
|
||
vi.unstubAllGlobals();
|
||
const { calls: calls2 } = stubFetchCapture();
|
||
await writeExecutionVerdict(fakeEnv(), 'wf-3', [], 'failed', 1, 'err', undefined);
|
||
expect(calls2[0].body.target).toBeNull();
|
||
});
|
||
|
||
it('不整包送 input:巨大的無關欄位不會出現在送出的 payload 裡', async () => {
|
||
const { calls } = stubFetchCapture();
|
||
await writeExecutionVerdict(fakeEnv(), 'wf-4', [], 'failed', 1, 'err', {
|
||
page_name: 'a.md',
|
||
unrelated_huge_field: 'z'.repeat(10000),
|
||
});
|
||
expect(Object.keys(calls[0].body).sort()).toEqual(
|
||
['duration_ms', 'message', 'owner_id', 'target', 'verdict', 'workflow_id'],
|
||
);
|
||
});
|
||
|
||
it('沒有 apiKey(/execute 舊路徑):owner_id 送 null,不炸', async () => {
|
||
const { calls } = stubFetchCapture();
|
||
await writeExecutionVerdict(fakeEnv(), 'wf-5', [], 'success', 1, 'ok');
|
||
expect(calls[0].body.owner_id).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('writeExecutionVerdict — 記錄失敗不影響主流程(永不 throw)', () => {
|
||
it('KBDB 端點連不上(fetch reject):函式仍正常 resolve', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
|
||
await expect(
|
||
writeExecutionVerdict(fakeEnv(), 'wf-broken', [], 'failed', 1, '任何訊息'),
|
||
).resolves.toBeUndefined();
|
||
});
|
||
|
||
it('KBDB 回非 2xx(例如額度打滿的 5xx):函式仍正常 resolve', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||
new Response(JSON.stringify({ success: false, error: 'quota exceeded' }), { status: 500 }),
|
||
));
|
||
await expect(
|
||
writeExecutionVerdict(fakeEnv(), 'wf-broken2', [], 'failed', 1, '任何訊息'),
|
||
).resolves.toBeUndefined();
|
||
});
|
||
});
|