/** * Cron session reaper — prunes completed isolated cron run sessions * from the session store after a configurable retention period. * * Pattern: sessions keyed as `...:cron::run:` are ephemeral * run records. The base session (`...:cron:`) is kept as-is. */ import path from "node:path"; import { parseDurationMs } from "../cli/parse-duration.js"; import { loadSessionStore, updateSessionStore } from "../config/sessions.js"; import type { CronConfig } from "../config/types.cron.js"; import { archiveSessionTranscripts, cleanupArchivedSessionTranscripts, } from "../gateway/session-utils.fs.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import type { Logger } from "./service/state.js"; const DEFAULT_RETENTION_MS = 24 * 3_600_000; // 24 hours /** Minimum interval between reaper sweeps (avoid running every timer tick). */ const MIN_SWEEP_INTERVAL_MS = 5 * 60_000; // 5 minutes const lastSweepAtMsByStore = new Map(); export function resolveRetentionMs(cronConfig?: CronConfig): number | null { if (cronConfig?.sessionRetention === false) { return null; // pruning disabled } const raw = cronConfig?.sessionRetention; if (typeof raw === "string" && raw.trim()) { try { return parseDurationMs(raw.trim(), { defaultUnit: "h" }); } catch { return DEFAULT_RETENTION_MS; } } return DEFAULT_RETENTION_MS; } export type ReaperResult = { swept: boolean; pruned: number; }; /** * Sweep the session store and prune expired cron run sessions. * Designed to be called from the cron timer tick — self-throttles via * MIN_SWEEP_INTERVAL_MS to avoid excessive I/O. * * Lock ordering: this function acquires the session-store file lock via * `updateSessionStore`. It must be called OUTSIDE of the cron service's * own `locked()` section to avoid lock-order inversions. The cron timer * calls this after all `locked()` sections have been released. */ export async function sweepCronRunSessions(params: { cronConfig?: CronConfig; /** Resolved path to sessions.json — required. */ sessionStorePath: string; nowMs?: number; log: Logger; /** Override for testing — skips the min-interval throttle. */ force?: boolean; }): Promise { const now = params.nowMs ?? Date.now(); const storePath = params.sessionStorePath; const lastSweepAtMs = lastSweepAtMsByStore.get(storePath) ?? 0; // Throttle: don't sweep more often than every 5 minutes. if (!params.force && now - lastSweepAtMs < MIN_SWEEP_INTERVAL_MS) { return { swept: false, pruned: 0 }; } const retentionMs = resolveRetentionMs(params.cronConfig); if (retentionMs === null) { lastSweepAtMsByStore.set(storePath, now); return { swept: false, pruned: 0 }; } let pruned = 0; const prunedSessions = new Map(); try { await updateSessionStore(storePath, (store) => { const cutoff = now - retentionMs; for (const key of Object.keys(store)) { if (!isCronRunSessionKey(key)) { continue; } const entry = store[key]; if (!entry) { continue; } const updatedAt = entry.updatedAt ?? 0; if (updatedAt < cutoff) { if (!prunedSessions.has(entry.sessionId) || entry.sessionFile) { prunedSessions.set(entry.sessionId, entry.sessionFile); } delete store[key]; pruned++; } } }); } catch (err) { params.log.warn({ err: String(err) }, "cron-reaper: failed to sweep session store"); return { swept: false, pruned: 0 }; } lastSweepAtMsByStore.set(storePath, now); if (prunedSessions.size > 0) { try { const store = loadSessionStore(storePath, { skipCache: true }); const referencedSessionIds = new Set( Object.values(store) .map((entry) => entry?.sessionId) .filter((id): id is string => Boolean(id)), ); const archivedDirs = new Set(); for (const [sessionId, sessionFile] of prunedSessions) { if (referencedSessionIds.has(sessionId)) { continue; } const archived = archiveSessionTranscripts({ sessionId, storePath, sessionFile, reason: "deleted", restrictToStoreDir: true, }); for (const archivedPath of archived) { archivedDirs.add(path.dirname(archivedPath)); } } if (archivedDirs.size > 0) { await cleanupArchivedSessionTranscripts({ directories: [...archivedDirs], olderThanMs: retentionMs, reason: "deleted", nowMs: now, }); } } catch (err) { params.log.warn({ err: String(err) }, "cron-reaper: transcript cleanup failed"); } } if (pruned > 0) { params.log.info( { pruned, retentionMs }, `cron-reaper: pruned ${pruned} expired cron run session(s)`, ); } return { swept: true, pruned }; } /** Reset the throttle timer (for tests). */ export function resetReaperThrottle(): void { lastSweepAtMsByStore.clear(); }