feat(kbdb): 執行紀錄保留期可設定(P7,leo 08-08 confirm)

背景:08-07 事故修復(60688c3)已把執行紀錄從 KV 搬到 KBDB/D1(entries 表,
API-as-Wall),解掉「稽核資料放在會揮發、被額度打斷的地方」這個結構性錯誤,
也順帶把 Evan 撞到的 1,070 次寫入牆退到 D1 額度層級。但那次修復留了一個誠實
的缺口:MCP list_recent_executions 的說明文字寫著「無固定保留期」——保留期
可設定這件事還沒做。本次補上。

P7 規格(system-dev/docs/3-specs/pending-changes.md「P7」,leo 08-08 confirm):
執行紀錄是稽核資料,預設保留 90 天(3 個月)過期即清;租戶可自訂天數,也可
設「不刪除」(企業稽核,leo:「我願意花很多錢保存,不要刪除」)。

實作(kbdb/src/actions/execution-log.ts,牆內):
- getRetentionDays/setRetentionDays:沿用 execution_log_usage 的 upsert 慣例,
  單一 entries 列/租戶(entry_type='execution_log_retention_config'),零建表。
- cleanupExpiredLogs:分兩段掃——有自訂天數的租戶各自 cutoff;其餘(含無租戶)
  套預設 90 天,排除「不刪除」與已處理過的租戶。每次呼叫界限刪除量
  (CLEANUP_BATCH_LIMIT=500),長期多次呼叫可逐步清完累積量。

路由(kbdb/src/routes/execution-log.ts):GET/PUT /execution-log/retention、
POST /execution-log/cleanup,沿用既有的 Bearer token 全域守衛(fail-closed)。

清理觸發(cypher-executor/src/scheduled.ts):不新增排程基礎設施(wrangler.toml
[triggers] 是受保護檔案)——搭 cypher-executor 既有的每分鐘 cron tick 便車,
固定 UTC 02:30 那一分鐘 fire-and-forget 打一次 KBDB 的 cleanup 端點,一天一次,
不是輪詢。

Portal 薄殼(cypher-executor/src/routes/portal.ts):GET/PUT
/portal/admin/execution-log-retention(role=admin 閘),讓本實例的租戶
(portalTenant)能實際設定保留天數,不只是 KBDB 內部端點。

測試(kbdb/tests/execution-log.test.ts):新增 27 個測試(含原有測試共 27 通過
於本檔),真 SQLite 驗證 cutoff 邏輯、自訂天數隔離、「不刪除」永不清、壞資料
容錯、混合租戶情境、路由層 400/200。測試治具需要「插入指定 created_at 的過期
紀錄」這個正式寫入路徑刻意不開放的能力,做成 kbdb/src/actions/execution-log.ts
內匯出的 testInsert*/testCount* 函式(牆內執行 SQL),測試檔本身零原生 SQL。

量測(不是推論):youlin(yuga3bse)實例上,redeploy 後對 graph_neighbors
webhook 發送 1,200 次併發請求(超過 Evan 實測失敗的 1,070 次)——全部 HTTP 200;
ANALYTICS_KV 的 key 數量在請求前後維持 663 不變,證明新寫入路徑完全不碰 KV,
Evan 撞到的那道牆的成因已被物理移除,不只是延後。

讀取端驗證(真呼叫,非 curl):透過綁定 yuga3bse 的 MCP 連線實際呼叫
arcrun_list_recent_executions(回傳含本次量測寫入的 D1 紀錄)與
arcrun_get_execution_trace(正確回 404 not_found,非崩潰);portal 前端
(https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev/portal)瀏覽器實際載入,
無 console 錯誤、無異常紅色橫幅。

舊資料:KV 裡既有的 stats:* 沿用 60688c3 的既有決定——不搬移,任其依現有 90
天 TTL 自然過期(那是統計快取不是真相源);新的 D1 execution_log 保留政策只
管新資料,不回溯處理。

部署:cypher-executor + kbdb 已手動部署到 youlin(yuga3bse,AI 測試場,leo
08-08 令),未動 prod(uncle6)。本次僅程式碼行為變更、無新增/修改 D1 binding、
無新表——三個既有 D1 binding(CREDENTIALS_DB×2+kbdb DB)維持原樣,未新增第四個。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-09 00:43:55 +08:00
parent aa6b899276
commit 4ca23c256a
5 changed files with 490 additions and 9 deletions
+179 -2
View File
@@ -16,7 +16,19 @@ 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 {
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 手法)──
@@ -29,7 +41,12 @@ function makeSqliteD1(): D1Database {
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() { raw.prepare(sql).run(...params); return { success: true }; },
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;
}
@@ -217,3 +234,163 @@ describe('POST /execution-log/record + GET /execution-log routeHono app.reque
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);
});
});