// 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 }); });