import { readdir, rm, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; export interface TraceRetentionOptions { sessionDir: string; activeRunPath: string; retentionDays: number; maxProjectBytes: number; now: Date; } export interface TraceRetentionResult { warnings: string[]; } interface RunInfo { path: string; bytes: number; modifiedAtMs: number; active: boolean; removed: boolean; } function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isMissing(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } async function readDirectories(path: string, warnings: string[]): Promise { try { const entries = await readdir(path, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => join(path, entry.name)); } catch (error) { if (!isMissing(error)) warnings.push(`Could not scan trace directory ${path}: ${describeError(error)}`); return []; } } async function inspectRun(path: string, activeRunPath: string, warnings: string[]): Promise { let bytes = 0; let modifiedAtMs = 0; try { const runStat = await stat(path); modifiedAtMs = runStat.mtimeMs; } catch (error) { if (!isMissing(error)) warnings.push(`Could not inspect trace run ${path}: ${describeError(error)}`); return undefined; } for (const name of ["events.jsonl", "viewer.log"]) { const filePath = join(path, name); try { const fileStat = await stat(filePath); bytes += fileStat.size; modifiedAtMs = Math.max(modifiedAtMs, fileStat.mtimeMs); } catch (error) { if (!isMissing(error)) warnings.push(`Could not inspect trace file ${filePath}: ${describeError(error)}`); } } return { path, bytes, modifiedAtMs, active: resolve(path) === resolve(activeRunPath), removed: false }; } async function removeRun(run: RunInfo, warnings: string[]): Promise { try { await rm(run.path, { recursive: true, force: true }); run.removed = true; return true; } catch (error) { warnings.push(`Could not remove trace run ${run.path}: ${describeError(error)}`); return false; } } export async function enforceTraceRetention(options: TraceRetentionOptions): Promise { const warnings: string[] = []; const tracesDir = join(options.sessionDir, "traces"); const sessionPaths = await readDirectories(tracesDir, warnings); const runPaths: string[] = []; for (const sessionPath of sessionPaths) { runPaths.push(...await readDirectories(join(sessionPath, "runs"), warnings)); } const runs: RunInfo[] = []; for (const runPath of runPaths) { const run = await inspectRun(runPath, options.activeRunPath, warnings); if (run !== undefined) runs.push(run); } const expirationMs = options.now.getTime() - options.retentionDays * 24 * 60 * 60 * 1_000; for (const run of runs) { if (!run.active && run.modifiedAtMs < expirationMs) await removeRun(run, warnings); } let totalBytes = runs.reduce((total, run) => total + (run.removed ? 0 : run.bytes), 0); const oldestFirst = runs .filter((run) => !run.active && !run.removed) .sort((left, right) => left.modifiedAtMs - right.modifiedAtMs); for (const run of oldestFirst) { if (totalBytes <= options.maxProjectBytes) break; if (await removeRun(run, warnings)) totalBytes -= run.bytes; } return { warnings }; }