diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index b414f2e..8a98338 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -1192,6 +1192,44 @@ portalRouter.get('/portal/admin/ai', (c) => }), ); +// GET /portal/admin/execution-log-retention — 讀本實例的執行紀錄保留期設定(P7,role=admin 閘)。 +// retention_days: number=自訂天數;null=已設「不刪除」(企業稽核);未設定過的租戶也回一個值 +// (KBDB 端會退回預設 90 天,見 kbdb/src/actions/execution-log.ts DEFAULT_RETENTION_DAYS)。 +portalRouter.get('/portal/admin/execution-log-retention', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const ownerId = portalTenant(c.env); + const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`); + if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`); + const data = (await res.json()) as { retention_days?: number | null; default_days?: number }; + return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 }); + }), +); + +// PUT /portal/admin/execution-log-retention — 設定保留天數(P7,role=admin 閘)。 +// body: { retention_days: number|null }。null=不刪除(leo 08-07:「我願意花很多錢保存, +// 不要刪除」,這是稽核用途的付費理由,不是成本負擔);正整數=自訂天數,覆蓋預設 90 天。 +portalRouter.put('/portal/admin/execution-log-retention', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { retention_days?: number | null } | null; + const days = body?.retention_days; + if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) { + return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400); + } + const ownerId = portalTenant(c.env); + const res = await kbdbFetch(c.env, '/execution-log/retention', { + method: 'PUT', + body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }), + }); + if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`); + const data = (await res.json()) as { retention_days?: number | null }; + return c.json({ success: true, retention_days: data.retention_days ?? null }); + }), +); + // DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。 // 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。 // ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。 diff --git a/cypher-executor/src/scheduled.ts b/cypher-executor/src/scheduled.ts index ffc5c2b..6adbe51 100644 --- a/cypher-executor/src/scheduled.ts +++ b/cypher-executor/src/scheduled.ts @@ -6,6 +6,7 @@ * 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度) * 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name}) * 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋) + * 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5) * * 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限, * 改成單一固定 key 只 get 一次 → list 歸零。 @@ -18,6 +19,7 @@ import type { Bindings } from './types'; import { cronMatch } from './lib/cron-match'; import { readCronIndex, parseCronEntryKey } from './lib/cron-index'; import { executeWebhookGraph } from './actions/webhook-handlers'; +import { kbdbBase } from './routes/kbdb-proxy'; type StoredWorkflowRecord = { graph: Record; @@ -73,4 +75,22 @@ export async function handleScheduled( ); } console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`); + + // §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護 + // 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」: + // 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30, + // 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的 + // POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick + // 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。 + if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) { + const { base, headers } = kbdbBase(env); + ctx.waitUntil( + fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers }) + .then(async (r) => { + const body = await r.json().catch(() => null); + console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body)); + }) + .catch((e) => console.error('[scheduled] execution-log cleanup failed', e)), + ); + } } diff --git a/kbdb/src/actions/execution-log.ts b/kbdb/src/actions/execution-log.ts index c1b1e94..222cd47 100644 --- a/kbdb/src/actions/execution-log.ts +++ b/kbdb/src/actions/execution-log.ts @@ -1,6 +1,8 @@ -// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07) +// 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(事故修復任務)。root cause 見 kbdb/migrations/0004_execution_log_template.sql +// 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)。 // @@ -21,9 +23,19 @@ // 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的 // workflow 執行永遠照跑——寫入永不 throw(recordExecutionLog 本身 catch 見呼叫端 route)。 // -// 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage' 是內部型別,與既有 -// 'value'/'workflow' 同層級。cypher-executor 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這兩者 -// 一併排除;本模組也從不設 metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。 +// 隔離(不污染知識搜尋):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'; @@ -199,3 +211,193 @@ export async function latestExecutionLog( 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; +} diff --git a/kbdb/src/routes/execution-log.ts b/kbdb/src/routes/execution-log.ts index 5148f03..d61da00 100644 --- a/kbdb/src/routes/execution-log.ts +++ b/kbdb/src/routes/execution-log.ts @@ -1,10 +1,18 @@ -// Execution log route(KV 額度事故修復,2026-08-07)。 +// 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 } from '../actions/execution-log'; +import { + recordExecutionLog, + listExecutionLog, + latestExecutionLog, + getRetentionDays, + setRetentionDays, + cleanupExpiredLogs, + DEFAULT_RETENTION_DAYS, +} from '../actions/execution-log'; export const executionLogRoutes = new Hono<{ Bindings: Bindings }>(); @@ -51,3 +59,39 @@ executionLogRoutes.get('/latest', async (c) => { 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 }); +}); diff --git a/kbdb/tests/execution-log.test.ts b/kbdb/tests/execution-log.test.ts index ccedca4..c330b8c 100644 --- a/kbdb/tests/execution-log.test.ts +++ b/kbdb/tests/execution-log.test.ts @@ -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() { return { results: raw.prepare(sql).all(...params) as T[] }; }, async first() { 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 route(Hono 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、GET/PUT /execution-log/retention route(Hono 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 → 400;retention_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); + }); +});