// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07; // 保留期可設定=P7,2026-08-09,leo 08-08 confirm:`system-dev/docs/3-specs/pending-changes.md` P7) // // SDD:無專屬 SDD(延續 2026-08-07 的事故修復任務範圍——同一個 execution_log 資料模型, // 加保留期設定與清理,不是新架構)。root cause 見 kbdb/migrations/0004_execution_log_template.sql // 開頭註解:cypher-executor 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(永不覆蓋) // ⇒ 封測者 690 個檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write)。 // // KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table;新資料類型一律用 template。 // 本模組 schema 走 template 機制(tpl-execution-log,見上述 migration),但**儲存精神比照既有 // recipe-stat.ts**:template 只負責文件化(GET /templates 可發現欄位定義),實際一筆執行紀錄 // 是 entries 表的**一列**(entry_type='execution_log',結構化欄位打包進 metadata_json), // 不走 entry_values 全展開的多列 record——那樣一筆執行要拆 5+ 列,1 次執行變 6+ 次 D1 寫入, // 直接違反「少記」精神;recipe_stat 早已示範「template 存在+entries 直接存」這個模式合法。 // // leo 兩條判準: // ① 執行紀錄是稽核資料 → 搬 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。 // ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/ // duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(見 SUCCESS/FAILED_MESSAGE_MAX)。 // // A2 自我降級:執行紀錄與知識卡(一般 entries)共用同一顆 D1 100,000 rows/日,搬 D1 只是油箱 // 大了 100 倍,不是解掉共用額度本身。本模組自設更低的「軟上限」(DEFAULT_DAILY_LIMIT), // 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的 // workflow 執行永遠照跑——寫入永不 throw(recordExecutionLog 本身 catch 見呼叫端 route)。 // // 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage'/ // 'execution_log_retention_config' 是內部型別,與既有 'value'/'workflow' 同層級。cypher-executor // 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這些一併排除;本模組也從不設 // metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。 // // P7 保留期(leo 08-07 兩段發言合起來的最終規格,見 pending-changes.md「提議的規格」段): // 儲存 D1、預設保留 90 天(3 個月),過期即清;租戶可自訂天數,也可設「不刪除」(企業稽核)。 // 清理不掛 Cloudflare Cron(wrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯—— // 見 InkStoneCo 頂層 P9 段 L1 權限閘),改「搭便車」:cypher-executor 既有的每分鐘 // scheduled tick(cron workflow 用,見 cypher-executor/src/scheduled.ts)本來就會醒, // 在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可, // 不需要新的排程基礎設施、不違反「禁輪詢」(那條鐵律管的是主動去戳外部系統要狀態, // 這裡是既有 tick 順手打理自己的表,且頻率仍是「一天一次」而非高頻輪詢)。 import type { Bindings } from '../types'; import { createEntry, listEntries } from './entry-crud'; export interface ExecutionLogInput { workflow_id: string; owner_id?: string | null; verdict: 'success' | 'failed'; duration_ms: number; message?: string; target?: string | null; } export interface ExecutionLogRow { workflow_id: string; verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number; // unix seconds(entries.created_at 既有慣例,非毫秒) } /** 成功訊息截斷長度(少記:夠看一眼結果就好,不留診斷用的長上下文)。 */ const SUCCESS_MESSAGE_MAX = 200; /** 失敗訊息截斷長度(不對稱:失敗要留夠診斷用的上下文,比成功多 10 倍)。 */ const FAILED_MESSAGE_MAX = 2000; /** target 欄位截斷長度(page_name / path 通常是檔名或路徑,不會太長;異常長輸入也不整包吞)。 */ const TARGET_MAX = 300; /** * 每日軟上限預設值:D1 免費層 100,000 rows written/日與知識卡(一般 entries)共用, * 本模組自設 20%(20,000)——不是 Cloudflare 硬限制,是「執行紀錄不該把知識卡的額度吃光」的 * 自我節制門檻,可用 env.EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫。 */ const DEFAULT_DAILY_LIMIT = 20000; /** 用量超過門檻比例 → 降成只記失敗(寫死比例+可測試,不靠感覺調參)。 */ const DEGRADE_RATIO = 0.8; export type UsageMode = 'log' | 'log_failure_only' | 'skip'; function dailyLimit(env: Pick): number { const raw = env.EXECUTION_LOG_DAILY_WRITE_LIMIT; const n = raw ? parseInt(raw, 10) : NaN; return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT; } function utcDay(): string { return new Date().toISOString().slice(0, 10); } function truncate(s: string, max: number): string { if (s.length <= max) return s; return s.slice(0, Math.max(0, max - 1)) + '…'; } /** * A2 用量計數+降級判斷。單一 entries 列/日(id=`exlog-usage:{day}`,entry_type= * 'execution_log_usage',計數包進 metadata_json)——精神完全比照 recipe-stat.ts 的 * upsert 慣例(讀現有列 → +1 → UPDATE,不存在則 INSERT)。 * * 刻意計「每次呼叫嘗試次數」而非「實際寫入 execution_log 的列數」——即使已降級到 * 「只記失敗」或「完全停止」,仍要繼續計數,不然額度耗盡後下一次呼叫又會誤判成 * 「還沒超過」而重新開始寫爆(等於沒有降級機制)。day 用 UTC 日期字串,換日自然歸零。 */ export async function checkUsage(db: D1Database, limit: number): Promise { const id = `exlog-usage:${utcDay()}`; const existing = await db .prepare('SELECT metadata_json FROM entries WHERE id = ?') .bind(id) .first<{ metadata_json: string | null }>(); let count: number; if (existing) { let prevWrites = 0; try { const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {}; prevWrites = Number(prev.writes) || 0; } catch { prevWrites = 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死降級機制 } count = prevWrites + 1; await db .prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?') .bind(JSON.stringify({ day: utcDay(), writes: count }), id) .run(); } else { count = 1; await db .prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`) .bind(id, JSON.stringify({ day: utcDay(), writes: count })) .run(); } if (count > limit) return 'skip'; if (count > limit * DEGRADE_RATIO) return 'log_failure_only'; return 'log'; } /** * 寫入一筆執行紀錄(fire-and-forget 語意由呼叫端 route 的 try/catch 保證,本函式本身 * 不主動吞錯——route 層統一吞,保持單一吞錯點,避免兩層都吞導致除錯時看不到真因)。 */ export async function recordExecutionLog( db: D1Database, env: Pick, input: ExecutionLogInput, ): Promise<{ written: boolean; mode: UsageMode }> { const limit = dailyLimit(env); let mode: UsageMode; try { mode = await checkUsage(db, limit); } catch { // fail-open:計數機制本身故障(含 D1 額度打滿)不該連執行紀錄都不寫, // 寧可暫時失去降級能力也不要靜默漏記——這一步的失敗仍不影響下面的實際寫入。 mode = 'log'; } if (mode === 'skip') return { written: false, mode }; if (mode === 'log_failure_only' && input.verdict !== 'failed') return { written: false, mode }; const maxLen = input.verdict === 'failed' ? FAILED_MESSAGE_MAX : SUCCESS_MESSAGE_MAX; const target = input.target ? truncate(String(input.target), TARGET_MAX) : null; await createEntry(db, { entry_type: 'execution_log', owner_id: input.owner_id ?? null, page_name: input.workflow_id, // 索引欄位(idx_entries_page)=查詢鍵,讀取端靠它篩單一 workflow content: truncate(input.message ?? '', maxLen), metadata_json: JSON.stringify({ verdict: input.verdict, duration_ms: Math.max(0, Math.round(input.duration_ms)), target, }), }); return { written: true, mode }; } /** 讀某 workflow 最近 N 次執行紀錄(降冪)。owner_id 給了才過濾(租戶隔離,caller 決定)。 */ export async function listExecutionLog( db: D1Database, workflowId: string, ownerId: string | undefined, limit: number, ): Promise { const { entries } = await listEntries(db, { entry_type: 'execution_log', page_name: workflowId, owner_id: ownerId, limit, }); return entries.map((e) => { let meta: { verdict?: string; duration_ms?: number; target?: string | null } = {}; try { meta = e.metadata_json ? (JSON.parse(e.metadata_json) as typeof meta) : {}; } catch { /* 壞資料誠實留空,不整筆丟掉(still 回傳 verdict='unknown' 好過整筆消失) */ } return { workflow_id: workflowId, verdict: meta.verdict ?? 'unknown', duration_ms: meta.duration_ms ?? 0, message: e.content ?? '', ...(meta.target ? { target: meta.target } : {}), recorded_at: e.created_at, }; }); } /** 讀某 workflow 最新一次執行紀錄(portal-data.ts last_execution 用)。 */ export async function latestExecutionLog( db: D1Database, workflowId: string, ownerId: string | undefined, ): Promise { const rows = await listExecutionLog(db, workflowId, ownerId, 1); return rows[0] ?? null; } // ── P7:保留期可設定(2026-08-09) ────────────────────────────────────────── // // leo 08-07 原話合起來的規格:「預設可以永久保存,但我設定每 3 個月把超過的刪掉…… // 我願意花很多錢保存,不要刪除」——翻成可執行規則=**預設保留 90 天、租戶可自訂天數、 // 也可設「不刪除」**(企業稽核用,這是付費理由不是成本負擔,schema 不擋未來計費)。 // // 儲存:沿用 execution_log_usage 的 upsert 慣例——單一 entries 列/租戶 // (id=`exlog-retention:{owner_id}`,entry_type='execution_log_retention_config')。 // 無租戶(owner_id 缺,例如舊版 /execute 路徑)套用預設天數,不可個別設定 // (沒有租戶就沒有「誰的設定」這個概念,硬要存會變成一筆沒有主人的孤兒設定)。 /** 預設保留天數:3 個月(leo 08-07:「我設定每 3 個月把超過的刪掉」)。 */ export const DEFAULT_RETENTION_DAYS = 90; /** 單次清理呼叫最多刪幾列——避免單次 D1 查詢過重;呼叫端(cypher 每日一次 tick)多次呼叫可逐步清完累積量。 */ const CLEANUP_BATCH_LIMIT = 500; function retentionConfigId(ownerId: string): string { return `exlog-retention:${ownerId}`; } /** 讀某租戶的保留天數;null=該租戶已設「不刪除」;未設定過=回預設值(不是 null)。 */ export async function getRetentionDays( db: D1Database, ownerId: string | null | undefined, ): Promise { if (!ownerId) return DEFAULT_RETENTION_DAYS; // 無租戶=套預設,不可個別設定(見上方註解) const row = await db .prepare(`SELECT metadata_json FROM entries WHERE id = ?`) .bind(retentionConfigId(ownerId)) .first<{ metadata_json: string | null }>(); if (!row) return DEFAULT_RETENTION_DAYS; try { const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { retention_days?: number | null }) : {}; if (parsed.retention_days === null) return null; // 「不刪除」 const n = Number(parsed.retention_days); return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; // 壞資料誠實退回預設,不讓損毀設定卡死清理 } catch { return DEFAULT_RETENTION_DAYS; } } /** 設定某租戶的保留天數。days=null=「不刪除」(企業稽核選項);days=正整數=自訂天數。 */ export async function setRetentionDays( db: D1Database, ownerId: string, days: number | null, ): Promise { const id = retentionConfigId(ownerId); const metadata = JSON.stringify({ retention_days: days, updated_at: Math.floor(Date.now() / 1000) }); const existing = await db.prepare(`SELECT id FROM entries WHERE id = ?`).bind(id).first(); if (existing) { await db .prepare(`UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?`) .bind(metadata, id) .run(); } else { await db .prepare( `INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`, ) .bind(id, ownerId, metadata) .run(); } } export interface CleanupResult { deleted: number; checked_overrides: number; } /** * 清掉過期的執行紀錄(entry_type='execution_log' 且早於各自租戶的保留期限)。 * 分兩段跑: * ① 有自訂天數的租戶:各自用自己的 cutoff 刪。 * ② 其餘(含無租戶/未設定過的租戶):套預設 90 天,但排除「已設不刪除」與 * 「剛才①處理過」的租戶,避免同一輪重複掃描。 * 每段各受 CLEANUP_BATCH_LIMIT 界限——呼叫端(cypher 每日一次 tick)長期呼叫可逐步清完累積量, * 不追求一次清光(那樣單次 D1 查詢會過重,且清理本身不是使用者等待中的路徑,慢慢清沒有壞處)。 */ export async function cleanupExpiredLogs(db: D1Database): Promise { const nowSec = Math.floor(Date.now() / 1000); const overridesRes = await db .prepare(`SELECT owner_id, metadata_json FROM entries WHERE entry_type = 'execution_log_retention_config'`) .all<{ owner_id: string | null; metadata_json: string | null }>(); const overrides = overridesRes.results ?? []; const neverDeleteOwners: string[] = []; const customOwners: Array<{ owner_id: string; days: number }> = []; for (const row of overrides) { if (!row.owner_id) continue; let parsed: { retention_days?: number | null } = {}; try { parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as typeof parsed) : {}; } catch { continue; // 壞資料:不當成任何一種 override,讓該租戶回退到①之外的預設路徑 } if (parsed.retention_days === null) { neverDeleteOwners.push(row.owner_id); } else { const n = Number(parsed.retention_days); if (Number.isFinite(n) && n > 0) customOwners.push({ owner_id: row.owner_id, days: n }); } } let deleted = 0; // ① 自訂天數的租戶,各自 cutoff for (const { owner_id, days } of customOwners) { const cutoff = nowSec - days * 86400; const res = await db .prepare( `DELETE FROM entries WHERE id IN ( SELECT id FROM entries WHERE entry_type = 'execution_log' AND owner_id = ? AND created_at < ? LIMIT ? )`, ) .bind(owner_id, cutoff, CLEANUP_BATCH_LIMIT) .run(); deleted += (res.meta?.changes as number | undefined) ?? 0; } // ② 其餘:預設 90 天,排除「不刪除」與①已處理的租戶 const defaultCutoff = nowSec - DEFAULT_RETENTION_DAYS * 86400; const excluded = [...neverDeleteOwners, ...customOwners.map((o) => o.owner_id)]; const sql = excluded.length > 0 ? `DELETE FROM entries WHERE id IN ( SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? AND (owner_id IS NULL OR owner_id NOT IN (${excluded.map(() => '?').join(',')})) LIMIT ? )` : `DELETE FROM entries WHERE id IN ( SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? LIMIT ? )`; const binds = excluded.length > 0 ? [defaultCutoff, ...excluded, CLEANUP_BATCH_LIMIT] : [defaultCutoff, CLEANUP_BATCH_LIMIT]; const res2 = await db.prepare(sql).bind(...binds).run(); deleted += (res2.meta?.changes as number | undefined) ?? 0; return { deleted, checked_overrides: overrides.length }; } // ── 測試專用 helpers(P7,2026-08-09) ────────────────────────────────────── // 這支檔在 kbdb/src/actions/ 下(資料層 worker 自己=API-as-Wall 的牆本身,D38 允許在 // 這裡直接碰 D1)。單元測試(kbdb/tests/execution-log.test.ts)不該自己在測試檔裡寫原生 // SQL——那個檔在「牆外」,即使是測試治具也不該養成在那裡打 SQL 的習慣。所以把「插入一列 // 指定 created_at 的過期紀錄」「數某類設定列有幾筆」這兩個測試才需要的原語做成正式匯出的 // 函式,放在牆內、由牆內的程式碼實際執行 SQL,測試檔只呼叫函式——與正式的 recordExecutionLog // 刻意不開放指定過去時間形成對照(那是正式寫入路徑的正確限制,這裡是測試的例外通道)。 /** 測試專用:直接寫一列指定 created_at 的 execution_log(模擬「N 天前寫入的紀錄」)。 */ export async function testInsertAgedExecutionLog( db: D1Database, id: string, ownerId: string | null, daysAgo: number, ): Promise { const createdAt = Math.floor(Date.now() / 1000) - daysAgo * 86400; await db .prepare( `INSERT INTO entries (id, entry_type, owner_id, page_name, content, metadata_json, created_at) VALUES (?, 'execution_log', ?, 'wf-aged', 'old', '{"verdict":"success","duration_ms":1}', ?)`, ) .bind(id, ownerId, createdAt) .run(); } /** 測試專用:寫一列**損毀** metadata_json 的保留期設定(驗證 cleanupExpiredLogs 對壞資料的容錯)。 */ export async function testInsertBrokenRetentionConfig(db: D1Database, ownerId: string): Promise { await db .prepare( `INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`, ) .bind(retentionConfigId(ownerId), ownerId, '{not valid json') .run(); } /** 測試專用:數某租戶目前有幾列保留期設定(驗證 setRetentionDays 是 upsert,不是每次都新增一列)。 */ export async function testCountRetentionConfigRows(db: D1Database, ownerId: string): Promise { const row = await db .prepare(`SELECT COUNT(*) as n FROM entries WHERE entry_type = 'execution_log_retention_config' AND owner_id = ?`) .bind(ownerId) .first<{ n: number }>(); return row?.n ?? 0; }