4ca23c256a
背景: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>
98 lines
5.0 KiB
TypeScript
98 lines
5.0 KiB
TypeScript
// Execution log route(KV 額度事故修復,2026-08-07;保留期=P7,2026-08-09)。
|
||
// cypher-executor 對每次 workflow 執行 fire-and-forget POST /execution-log/record;
|
||
// executions.ts / portal-data.ts 讀 GET /execution-log 取代舊的 ANALYTICS_KV list/get。
|
||
// 形狀比照 recipe-stats.ts(同一種「cypher 寫、KBDB 存」的 fire-and-forget stat 端點)。
|
||
import { Hono } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import {
|
||
recordExecutionLog,
|
||
listExecutionLog,
|
||
latestExecutionLog,
|
||
getRetentionDays,
|
||
setRetentionDays,
|
||
cleanupExpiredLogs,
|
||
DEFAULT_RETENTION_DAYS,
|
||
} from '../actions/execution-log';
|
||
|
||
export const executionLogRoutes = new Hono<{ Bindings: Bindings }>();
|
||
|
||
// POST /execution-log/record — { workflow_id, owner_id?, verdict, duration_ms, message?, target? }
|
||
executionLogRoutes.post('/record', async (c) => {
|
||
const body = await c.req.json().catch(() => null) as {
|
||
workflow_id?: string;
|
||
owner_id?: string | null;
|
||
verdict?: string;
|
||
duration_ms?: number;
|
||
message?: string;
|
||
target?: string | null;
|
||
} | null;
|
||
if (!body || !body.workflow_id || (body.verdict !== 'success' && body.verdict !== 'failed')) {
|
||
return c.json({ success: false, error: 'workflow_id 與 verdict("success"|"failed") 必填' }, 400);
|
||
}
|
||
const result = await recordExecutionLog(c.env.DB, c.env, {
|
||
workflow_id: body.workflow_id,
|
||
owner_id: body.owner_id ?? null,
|
||
verdict: body.verdict,
|
||
duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0,
|
||
message: body.message ?? '',
|
||
target: body.target ?? null,
|
||
});
|
||
return c.json({ success: true, ...result });
|
||
});
|
||
|
||
// GET /execution-log?workflow_id=&owner_id=&limit= — 最近 N 次(降冪)
|
||
executionLogRoutes.get('/', async (c) => {
|
||
const workflowId = c.req.query('workflow_id');
|
||
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
|
||
const ownerId = c.req.query('owner_id') || undefined;
|
||
const limitParam = c.req.query('limit');
|
||
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10) || 10, 1), 100);
|
||
const executions = await listExecutionLog(c.env.DB, workflowId, ownerId, limit);
|
||
return c.json({ success: true, executions });
|
||
});
|
||
|
||
// GET /execution-log/latest?workflow_id=&owner_id= — 最新一次(portal 卡片用)
|
||
executionLogRoutes.get('/latest', async (c) => {
|
||
const workflowId = c.req.query('workflow_id');
|
||
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
|
||
const ownerId = c.req.query('owner_id') || undefined;
|
||
const execution = await latestExecutionLog(c.env.DB, workflowId, ownerId);
|
||
return c.json({ success: true, execution });
|
||
});
|
||
|
||
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
|
||
|
||
// GET /execution-log/retention?owner_id= — 讀某租戶目前的保留天數
|
||
// (回 retention_days: number | null;null=該租戶已設「不刪除」)。owner_id 必填——
|
||
// 沒有租戶就沒有「誰的設定」這回事,讀無租戶的保留期用不到這支,走 DEFAULT_RETENTION_DAYS 常數即可。
|
||
executionLogRoutes.get('/retention', async (c) => {
|
||
const ownerId = c.req.query('owner_id');
|
||
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
|
||
const retentionDays = await getRetentionDays(c.env.DB, ownerId);
|
||
return c.json({ success: true, owner_id: ownerId, retention_days: retentionDays, default_days: DEFAULT_RETENTION_DAYS });
|
||
});
|
||
|
||
// PUT /execution-log/retention — body { owner_id, retention_days: number|null }
|
||
// retention_days=null=「不刪除」(leo 08-07:「我願意花很多錢保存,不要刪除」,企業稽核選項)。
|
||
// retention_days=正整數=自訂天數(覆蓋預設 90 天)。
|
||
executionLogRoutes.put('/retention', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as
|
||
| { owner_id?: string; retention_days?: number | null }
|
||
| null;
|
||
if (!body || !body.owner_id) return c.json({ success: false, error: 'owner_id 必填' }, 400);
|
||
const days = body.retention_days;
|
||
if (days !== null && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
||
return c.json({ success: false, error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
|
||
}
|
||
await setRetentionDays(c.env.DB, body.owner_id, days === null ? null : Math.round(days));
|
||
return c.json({ success: true, owner_id: body.owner_id, retention_days: days === null ? null : Math.round(days) });
|
||
});
|
||
|
||
// POST /execution-log/cleanup — 清一批過期執行紀錄(見 actions/execution-log.ts 頂部註解:
|
||
// 呼叫端=cypher-executor 既有的每分鐘 scheduled tick,一天呼叫一次,不是新排程基礎設施)。
|
||
// 內部維運端點,無 body;每次呼叫界限刪除量,長期多次呼叫可逐步清完累積量。
|
||
executionLogRoutes.post('/cleanup', async (c) => {
|
||
const result = await cleanupExpiredLogs(c.env.DB);
|
||
return c.json({ success: true, ...result });
|
||
});
|