Files
Arcrun/cypher-executor/src/scheduled.ts
T
uncle6me-web 4ca23c256a 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>
2026-08-09 00:43:55 +08:00

97 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
*
* 流程:
* 1. 單次 get cron indexcron-idx:_all,集中存所有 cron workflow 的 cron_expr
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTimeUTC 分鐘精度)
* 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 歸零。
*
* SDD: arcrun.md 三-A P1 #3 / kbdb-base §8.2
*/
import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
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<string, unknown>;
cron_expr?: string;
// 其他欄位(id, name, created_at 等)忽略
};
export async function handleScheduled(
controller: ScheduledController,
env: Bindings,
ctx: ExecutionContext,
): Promise<void> {
const now = new Date(controller.scheduledTime);
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
// 8.P0:單次 get 集中索引(取代每分鐘 list),主 workflow record 仍在 {apiKey}:wf:{name}
const index = await readCronIndex(env.WEBHOOKS);
const entries = Object.entries(index);
let triggered = 0;
for (const [entryKey, cronExpr] of entries) {
const parsed = parseCronEntryKey(entryKey);
if (!parsed) continue;
const { apiKey, name } = parsed;
if (!cronExpr) continue;
if (!cronMatch(cronExpr, now)) continue;
// 匹配才去讀完整 workflow record
const wfKey = `${apiKey}:wf:${name}`;
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
if (!wfRaw) {
console.warn('[scheduled] cron-idx 對應 workflow 不存在', wfKey);
continue;
}
let record: StoredWorkflowRecord;
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
triggered++;
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', cronExpr);
// 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
const triggerContext = {
api_key: apiKey,
_triggered_by: 'cron' as const,
_scheduled_at: now.toISOString(),
};
ctx.waitUntil(
executeWebhookGraph(env, record.graph, triggerContext, name, apiKey)
.then(
(r) => console.log('[scheduled] done', name, r.success, r.duration_ms + 'ms'),
(e) => console.error('[scheduled] fail', name, e),
),
);
}
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)),
);
}
}