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>
84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
/**
|
||
* GET /workflows/:name/executions — KV 額度事故修復(2026-08-07)路由測試。
|
||
* 改打 KBDB GET /execution-log(原走 ANALYTICS_KV list);KBDB=API-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`);
|
||
});
|
||
});
|