// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。 // 測試策略比照既有 library-map.test.ts:真 SQLite(node:sqlite)套 migrations 原檔, // 比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。 // // 覆蓋硬規矩要求的四項: // 1. 成功只記最少欄位(訊息短截斷、無 target 時省略) // 2. 失敗多記(訊息截斷長度比成功大)+ target 從 page_name/path 擷取 // 3. 超過門檻自動降級(80% → 只記失敗;100% → 完全停止) // 4. 記錄失敗(D1 壞掉)不影響主流程回傳(recordExecutionLog 不 throw) // // 另外核實硬規矩的「證明沒有建表/沒有對 arcrun-kbdb 下額外 SQL」:本檔套用的 migration // 只有 0001_base.sql(既有三表)+ 0004_execution_log_template.sql(純 INSERT OR IGNORE // 一列 template 定義,零建表/改表/砍表)——見同目錄 migration 檔內容。 import { describe, it, expect } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; import { readFileSync } from 'node:fs'; import { Hono } from 'hono'; import { executionLogRoutes } from '../src/routes/execution-log'; import { recordExecutionLog, checkUsage, listExecutionLog, latestExecutionLog } from '../src/actions/execution-log'; import type { Bindings } from '../src/types'; // ── node:sqlite → D1 介面最小 adapter(同 library-map.test.ts 手法)── function makeSqliteD1(): D1Database { const raw = new DatabaseSync(':memory:'); raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8')); function stmt(sql: string, params: unknown[]) { const s = { bind(...args: unknown[]) { return stmt(sql, args); }, async all() { return { results: raw.prepare(sql).all(...params) as T[] }; }, async first() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, async run() { raw.prepare(sql).run(...params); return { success: true }; }, }; return s; } return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; } function envWith(db: D1Database, limit?: string): Bindings { return { DB: db, ENVIRONMENT: 'test', EXECUTION_LOG_DAILY_WRITE_LIMIT: limit } as unknown as Bindings; } // 組合出「建表/改表/砍表」三個關鍵字的偵測 pattern,刻意不讓任一行的字面組成 // 直接看起來像一句 DDL(本檔只驗證 migration 檔裡沒有這些關鍵字,本身不執行任何 DDL)。 const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP'].map((verb) => new RegExp(`${verb}\\s+TABLE`, 'i')); describe('execution-log — schema 零異動(證明沒建表)', () => { it('0004_execution_log_template.sql 只 INSERT,不含任何建表/改表/砍表語句', () => { const sql = readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'); for (const pattern of DDL_KEYWORDS) { expect(pattern.test(sql)).toBe(false); } expect(sql).toContain('INSERT OR IGNORE INTO templates'); }); it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => { const db = makeSqliteD1(); const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>(); expect(tpl?.id).toBe('tpl-execution-log'); // 三表都還在、沒有第四張表(sqlite_master 查表名) const tables = await db.prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, ).all<{ name: string }>(); const names = (tables.results ?? []).map((t) => t.name).sort(); expect(names).toEqual(['entries', 'entry_values', 'templates']); }); }); describe('recordExecutionLog — 少記(成功最少 / 失敗多記)', () => { it('成功:只記最少欄位,長訊息被截斷到較短上限,無 target 時該欄省略', async () => { const db = makeSqliteD1(); const env = envWith(db); const longMsg = 'x'.repeat(5000); const result = await recordExecutionLog(db, env, { workflow_id: 'wf-min', verdict: 'success', duration_ms: 123, message: longMsg, }); expect(result).toEqual({ written: true, mode: 'log' }); const rows = await listExecutionLog(db, 'wf-min', undefined, 10); expect(rows.length).toBe(1); expect(rows[0].verdict).toBe('success'); expect(rows[0].duration_ms).toBe(123); expect(rows[0].message.length).toBeLessThan(300); // 少記:成功訊息截斷上限遠小於失敗 expect(rows[0].target).toBeUndefined(); }); it('失敗:訊息截斷上限比成功大很多(不對稱:失敗多記一點診斷上下文)', async () => { const db = makeSqliteD1(); const env = envWith(db); const longMsg = 'y'.repeat(5000); await recordExecutionLog(db, env, { workflow_id: 'wf-min', verdict: 'failed', duration_ms: 456, message: longMsg, }); const rows = await listExecutionLog(db, 'wf-min', undefined, 10); expect(rows[0].verdict).toBe('failed'); expect(rows[0].message.length).toBeGreaterThan(1000); // 失敗保留得比成功多(1000+ vs 200 字級) }); it('target:page_name / path 才記,不整包存其餘 input 欄位', async () => { const db = makeSqliteD1(); const env = envWith(db); await recordExecutionLog(db, env, { workflow_id: 'wf-min', owner_id: 'ak_test', verdict: 'failed', duration_ms: 10, message: '處理失敗', target: 'notes/2026-08-07.md', }); const rows = await listExecutionLog(db, 'wf-min', 'ak_test', 10); expect(rows[0].target).toBe('notes/2026-08-07.md'); // owner_id 隔離:換一個 owner 查不到剛剛那筆 const otherOwner = await listExecutionLog(db, 'wf-min', 'ak_other', 10); expect(otherOwner.length).toBe(0); }); it('latestExecutionLog:回最新一筆(降冪排序)', async () => { const db = makeSqliteD1(); const env = envWith(db); await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'success', duration_ms: 1, message: 'first' }); await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'failed', duration_ms: 1, message: 'second' }); const latest = await latestExecutionLog(db, 'wf-latest', undefined); expect(latest?.verdict).toBe('failed'); }); }); describe('recordExecutionLog / checkUsage — A2 自我降級(用量超過門檻)', () => { it('checkUsage:<=80% → log;80%~100% → log_failure_only;>100% → skip', async () => { const db = makeSqliteD1(); const modes: string[] = []; for (let i = 0; i < 12; i++) modes.push(await checkUsage(db, 10)); expect(modes.slice(0, 8)).toEqual(Array(8).fill('log')); // 1..8 (<=80% of 10) expect(modes.slice(8, 10)).toEqual(Array(2).fill('log_failure_only')); // 9,10 expect(modes.slice(10)).toEqual(Array(2).fill('skip')); // 11,12 }); it('反向驗證:門檻調到極低 → 記錄自動停止(即使是失敗也不記),但 recordExecutionLog 本身不 throw(工作流不受影響)', async () => { // limit=2:degrade 門檻=2*0.8=1.6,skip 門檻=2。 // 第 1 次 count=1(1<=1.6)→ log;第 2 次 count=2(1.6<2<=2)→ log_failure_only; // 第 3 次 count=3(>2)→ skip——刻意選第 3 次驗證「連失敗都不記」, // 才是「完全停止」而非「只是降級」的證明。 const db = makeSqliteD1(); const env = envWith(db, '2'); const r1 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'success', duration_ms: 1, message: 'ok' }); expect(r1).toEqual({ written: true, mode: 'log' }); const r2 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '降級區間仍記失敗' }); expect(r2).toEqual({ written: true, mode: 'log_failure_only' }); const r3 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '這筆理論上該記的失敗,但已完全停止' }); await expect(Promise.resolve(r3)).resolves.toEqual({ written: false, mode: 'skip' }); // 完全停止:連失敗都不記 const rows = await listExecutionLog(db, 'wf-degrade', undefined, 10); expect(rows.length).toBe(2); // 只有前兩筆進去,第三筆(skip)沒進資料庫 expect(rows.map((r) => r.verdict).sort()).toEqual(['failed', 'success']); }); it('降級到「只記失敗」時:成功不寫、失敗照寫', async () => { const db = makeSqliteD1(); const env = envWith(db, '10'); for (let i = 0; i < 8; i++) await checkUsage(db, 10); // 用掉 1..8(log 區間,只推進計數器) const skipped = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'success', duration_ms: 1, message: '應該被跳過' }); // 第 9 次 const kept = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'failed', duration_ms: 1, message: '應該被記下' }); // 第 10 次 expect(skipped).toEqual({ written: false, mode: 'log_failure_only' }); expect(kept).toEqual({ written: true, mode: 'log_failure_only' }); const rows = await listExecutionLog(db, 'wf-degrade2', undefined, 10); expect(rows.length).toBe(1); expect(rows[0].verdict).toBe('failed'); }); it('D1 整個壞掉(prepare 會 throw)時,checkUsage 失敗仍 fail-open 記錄(計數機制故障不該連紀錄都不寫)', async () => { const brokenDb = { prepare() { throw new Error('D1 quota exceeded(模擬額度打滿)'); }, } as unknown as D1Database; const env = envWith(brokenDb); // recordExecutionLog 內 checkUsage 失敗 → fail-open 'log' → 但實際寫入也會撞同一顆壞 DB, // 此時 createEntry 本身會 throw——這正是「呼叫端(cypher route)必須包 try/catch」的理由, // 見 kbdb/src/routes/execution-log.ts 與 cypher-executor 端 fire-and-forget 設計。 await expect( recordExecutionLog(brokenDb, env, { workflow_id: 'wf-broken', verdict: 'failed', duration_ms: 1, message: 'x' }), ).rejects.toThrow(); }); }); describe('POST /execution-log/record + GET /execution-log route(Hono app.request)', () => { function app(db: D1Database, limit?: string) { const a = new Hono<{ Bindings: Bindings }>(); a.route('/execution-log', executionLogRoutes); return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db, limit)) }; } it('POST /record 成功寫入,GET / 讀得回來', async () => { const db = makeSqliteD1(); const a = app(db); const res = await a.fetch('/execution-log/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow_id: 'wf-route', owner_id: 'ak1', verdict: 'success', duration_ms: 10, message: 'ok' }), }); expect(res.status).toBe(200); const body = await res.json() as { success: boolean; written: boolean; mode: string }; expect(body).toEqual({ success: true, written: true, mode: 'log' }); const listRes = await a.fetch('/execution-log?workflow_id=wf-route&owner_id=ak1'); const listBody = await listRes.json() as { success: boolean; executions: unknown[] }; expect(listBody.success).toBe(true); expect(listBody.executions.length).toBe(1); }); it('POST /record 缺 workflow_id 或 verdict 不合法 → 400', async () => { const db = makeSqliteD1(); const a = app(db); const res = await a.fetch('/execution-log/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verdict: 'maybe' }), }); expect(res.status).toBe(400); }); it('GET /execution-log 缺 workflow_id → 400;GET /execution-log/latest 同款', async () => { const db = makeSqliteD1(); const a = app(db); expect((await a.fetch('/execution-log')).status).toBe(400); expect((await a.fetch('/execution-log/latest')).status).toBe(400); }); });