/** * Retention for the per-run artifact directories hooks write into. * * **By AGE, not by run count**, and the difference is not cosmetic. For a * persistent failure — the normal case, since a broken deploy stays broken * — the FIRST artifact set carries the original cause and runs 2..N are the * same wall re-hit. A count-based rule therefore keeps the least * informative sets and discards the one worth having: at the 15-minute * cadence a health monitor runs on, "keep the last 5" is seventy-five * minutes, and the operator typically reads the alert hours later. * * The size ceiling is the backstop, not the policy. ~96 runs a day of * full-page screenshots on a management host is a `disk_space` alert * waiting to happen, and celilo has a builtin check that would fire on it. * * Pruning is a pure function of mtime, so it needs no streak-tracking * state and cannot drift out of sync with what is on disk. */ import { readdirSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; /** How long a run's artifacts are kept. Long enough to survive a night. */ export const ARTIFACT_RETENTION_MS = 24 * 60 * 60 * 1000; /** * Total bytes of retained artifacts per module. Generous — this is the * backstop against an unforeseen writer, not the mechanism that normally * reclaims space. */ export const ARTIFACT_SIZE_CEILING_BYTES = 64 * 1024 * 1024; export interface PruneOptions { /** Defaults to `Date.now()`; injected so tests need no sleeping. */ now?: number; retentionMs?: number; sizeCeilingBytes?: number; } export interface PruneOutcome { /** Directories removed, oldest first. */ removed: string[]; /** Bytes retained after pruning. */ retainedBytes: number; } interface RunDirectory { path: string; mtimeMs: number; bytes: number; } /** * Remove aged-out and over-ceiling run directories under a module's * artifact root. * * Best effort by construction: a hook run must never fail because a stale * directory could not be deleted, so every filesystem error is swallowed * and the outcome reports only what actually happened. */ export function pruneModuleArtifacts( artifactRoot: string, options: PruneOptions = {}, ): PruneOutcome { const now = options.now ?? Date.now(); const retentionMs = options.retentionMs ?? ARTIFACT_RETENTION_MS; const ceiling = options.sizeCeilingBytes ?? ARTIFACT_SIZE_CEILING_BYTES; const runs = readRunDirectories(artifactRoot); // Oldest first, so both passes evict from the same end. runs.sort((a, b) => a.mtimeMs - b.mtimeMs); const removed: string[] = []; const surviving: RunDirectory[] = []; for (const run of runs) { if (now - run.mtimeMs > retentionMs) { if (remove(run.path)) removed.push(run.path); continue; } surviving.push(run); } let retainedBytes = surviving.reduce((sum, run) => sum + run.bytes, 0); while (retainedBytes > ceiling && surviving.length > 0) { // biome-ignore lint/style/noNonNullAssertion: length checked above const oldest = surviving.shift()!; if (remove(oldest.path)) { removed.push(oldest.path); retainedBytes -= oldest.bytes; } else { // Undeletable: stop rather than spin, and leave it counted. break; } } return { removed, retainedBytes }; } function readRunDirectories(artifactRoot: string): RunDirectory[] { let entries: string[]; try { entries = readdirSync(artifactRoot); } catch { return []; // No artifact root yet — nothing to prune. } const runs: RunDirectory[] = []; for (const entry of entries) { const path = join(artifactRoot, entry); try { if (!statSync(path).isDirectory()) continue; runs.push({ path, mtimeMs: newestMtime(path), bytes: directoryBytes(path) }); } catch { // Vanished mid-scan, or unreadable. Not ours to fix. } } return runs; } /** * Age a run by the NEWEST file in it, not by the directory's own mtime. * A directory's mtime tracks its last entry change, which on some * filesystems does not move when a file inside it is rewritten in place — * so a run still being appended to could otherwise read as old enough to * evict while the check that owns it is still running. */ function newestMtime(dir: string): number { let newest = statSync(dir).mtimeMs; for (const file of readdirSync(dir)) { try { newest = Math.max(newest, statSync(join(dir, file)).mtimeMs); } catch { // Skip what we cannot stat. } } return newest; } function directoryBytes(dir: string): number { let total = 0; for (const file of readdirSync(dir)) { try { const stat = statSync(join(dir, file)); if (stat.isFile()) total += stat.size; } catch { // Skip what we cannot stat. } } return total; } function remove(path: string): boolean { try { rmSync(path, { recursive: true, force: true }); return true; } catch { return false; } }