Files
Arcrun/kbdb/tests/execution-log.test.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

402 lines
21 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.
// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。
// 測試策略比照既有 library-map.test.ts:真 SQLitenode: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,
getRetentionDays,
setRetentionDays,
cleanupExpiredLogs,
DEFAULT_RETENTION_DAYS,
testInsertAgedExecutionLog as insertAgedLog,
testInsertBrokenRetentionConfig,
testCountRetentionConfigRows,
} 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'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
async run() {
// P7 新增:cleanupExpiredLogs 靠 result.meta.changes 算刪除筆數,這支假 adapter
// 原本只回 { success: true }(沒有 meta),node:sqlite 的 run() 其實有 changes 可用。
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具本身(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
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');
});
// 0007(樹狀 record 模型,v7 定稿 2026-08-15 confirm)後的正解表清單:entry_values 已拆
// ——它是「關係」的第二套實作(D92),關係列改住 entries 的指標欄。這條斷言在改版前
// 釘的是「三張核心表」,規格層變更(pending-changes.md「record 要有身分」)把答案改掉,
// 不是實作去配合測試。
it('template 存在(tpl-execution-log),核心表只剩 entries/templatesentry_values 已拆,0007', 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', '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('targetpage_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% → log80%~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=2degrade 門檻=2*0.8=1.6skip 門檻=2。
// 第 1 次 count=11<=1.6)→ log;第 2 次 count=21.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 routeHono 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 → 400GET /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);
});
});
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
// 測試策略:recordExecutionLog 寫入的 created_at 一律是「現在」,測不出「過期」;
// 用 action 層匯出的測試專用函式(insertAgedLog 別名 testInsertAgedExecutionLog)灌一列
// 指定 created_at 的紀錄,模擬「N 天前寫入」,藉此驗證 cleanupExpiredLogs 的 cutoff 判斷
// (原生 SQL 留在 kbdb/src/actions/execution-log.ts 牆內執行,本檔不直接碰 D1)。
describe('保留期設定 getRetentionDays / setRetentionDays', () => {
it('未設定過的租戶回預設 90 天;無租戶(undefined)也回預設', async () => {
const db = makeSqliteD1();
expect(await getRetentionDays(db, 'ak_new')).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, undefined)).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, null)).toBe(DEFAULT_RETENTION_DAYS);
});
it('setRetentionDays 設自訂天數後,getRetentionDays 讀得回同一個值(不影響其他租戶)', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_custom', 30);
expect(await getRetentionDays(db, 'ak_custom')).toBe(30);
expect(await getRetentionDays(db, 'ak_other')).toBe(DEFAULT_RETENTION_DAYS); // 隔離:沒設定的租戶不受影響
});
it('setRetentionDays(null) =「不刪除」(企業稽核),getRetentionDays 回 null 而非預設值', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
expect(await getRetentionDays(db, 'ak_forever')).toBeNull();
});
it('重複 set 同一租戶=更新,不是新增第二列(upsert 慣例,同 recipe-stat.ts', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_x', 30);
await setRetentionDays(db, 'ak_x', 60);
expect(await getRetentionDays(db, 'ak_x')).toBe(60);
expect(await testCountRetentionConfigRows(db, 'ak_x')).toBe(1);
});
});
describe('cleanupExpiredLogs — 過期清理(P7', () => {
it('預設 90 天:91 天前的紀錄被刪,89 天前的保留(無自訂設定的租戶)', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-1', 'ak_default', 91);
await insertAgedLog(db, 'new-1', 'ak_default', 89);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
const remaining = await listExecutionLog(db, 'wf-aged', 'ak_default', 10);
expect(remaining.length).toBe(1);
expect(remaining[0].recorded_at).toBeGreaterThan(Math.floor(Date.now() / 1000) - 90 * 86400);
});
it('自訂天數的租戶用自己的 cutoff,不受預設 90 天影響', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_short', 7); // 只留 7 天
await insertAgedLog(db, 'old-2', 'ak_short', 10); // 10 天前 → 該租戶 cutoff=7 天 → 過期
await insertAgedLog(db, 'default-owner', null, 10); // 無租戶,10 天 < 預設 90 天 → 保留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(0);
});
it('設「不刪除」的租戶(null)永遠不被清,即使紀錄非常舊', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await insertAgedLog(db, 'ancient-1', 'ak_forever', 3650); // 10 年前
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(0);
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
});
it('壞掉的保留期設定(metadata_json 壞掉)不讓整個清理流程掛掉,該租戶回退到預設 90 天規則', async () => {
const db = makeSqliteD1();
await testInsertBrokenRetentionConfig(db, 'ak_broken');
await insertAgedLog(db, 'old-3', 'ak_broken', 91);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1); // 壞設定被忽略 → 走預設 90 天路徑照樣清掉
});
it('混合情境:不刪除租戶+自訂天數租戶+預設租戶同時存在,各自套各自的規則', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await setRetentionDays(db, 'ak_short', 7);
await insertAgedLog(db, 'a', 'ak_forever', 3650); // 永不刪
await insertAgedLog(db, 'b', 'ak_short', 10); // 超過 7 天 → 刪
await insertAgedLog(db, 'c', 'ak_short', 3); // 未滿 7 天 → 留
await insertAgedLog(db, 'd', 'ak_plain', 91); // 超過預設 90 天 → 刪
await insertAgedLog(db, 'e', 'ak_plain', 10); // 未滿 90 天 → 留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(2); // b、d
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_plain', 10)).length).toBe(1);
});
});
describe('POST /execution-log/cleanup、GETPUT /execution-log/retention routeHono app.request', () => {
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/execution-log', executionLogRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
it('PUT retention 設值 → GET retention 讀得回同一個值', async () => {
const db = makeSqliteD1();
const a = app(db);
const put = await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak1', retention_days: 45 }),
});
expect(put.status).toBe(200);
const get = await a.fetch('/execution-log/retention?owner_id=ak1');
const body = (await get.json()) as { retention_days: number };
expect(body.retention_days).toBe(45);
});
it('PUT retention_days: null → 讀回 null(不刪除)', async () => {
const db = makeSqliteD1();
const a = app(db);
await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak2', retention_days: null }),
});
const get = await a.fetch('/execution-log/retention?owner_id=ak2');
const body = (await get.json()) as { retention_days: number | null };
expect(body.retention_days).toBeNull();
});
it('PUT retention 缺 owner_id → 400retention_days 不合法(0、負數)→ 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect(
(await a.fetch('/execution-log/retention', { method: 'PUT', body: JSON.stringify({ retention_days: 30 }) })).status,
).toBe(400);
expect(
(
await a.fetch('/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: 'ak3', retention_days: 0 }),
})
).status,
).toBe(400);
});
it('GET retention 缺 owner_id → 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect((await a.fetch('/execution-log/retention')).status).toBe(400);
});
it('POST /cleanup 回刪除筆數,實際刪掉過期紀錄', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-route', 'ak_route', 91);
const a = app(db);
const res = await a.fetch('/execution-log/cleanup', { method: 'POST' });
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; deleted: number };
expect(body.success).toBe(true);
expect(body.deleted).toBe(1);
});
});