feat(retention): run-record pruning - keep newest N, dry-run default (#32)

- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
This commit is contained in:
2026-09-03 06:33:27 -05:00
parent 439bea6915
commit 88eef507b0
2 changed files with 81 additions and 11 deletions
+50 -1
View File
@@ -529,6 +529,52 @@ function retryRun(runId) {
runTask(tempTaskFile, { retriedFrom: runId });
}
function pruneRuns(args) {
const resolved = loadConfig();
const root = runsRoot(resolved);
let keep = 50;
let apply = false;
for (const arg of args) {
if (arg === "--yes") apply = true;
else if (arg === "--keep") fail(4, "prune: --keep requires a value");
else if (arg.startsWith("--keep=")) {
keep = Number(arg.slice("--keep=".length));
if (!Number.isInteger(keep) || keep < 1) fail(4, `--keep must be a positive integer (got ${arg.slice(7)})`);
} else fail(4, `unknown prune option: ${arg}`);
}
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true })
.filter((e) => e.name.startsWith("r-") && e.isDirectory() && !e.isSymbolicLink())
.map((e) => e.name)
.sort();
} catch {
// No runs yet.
}
if (entries.length <= keep) {
process.stdout.write(`prune: ${entries.length} run(s) present, keep=${keep} -> nothing to prune\n`);
process.exit(0);
}
const doomed = entries.slice(0, entries.length - keep); // oldest first
if (!apply) {
process.stdout.write(`prune (dry-run): would remove ${doomed.length} oldest run(s), keep ${entries.length - doomed.length}:\n`);
for (const id of doomed) process.stdout.write(` would remove: ${id}\n`);
process.stdout.write("prune: re-run with --yes to apply\n");
process.exit(0);
}
const receipt = path.join(root, ".pruned.log");
for (const id of doomed) {
fs.rmSync(path.join(root, id), { recursive: true, force: true });
fs.appendFileSync(receipt, `${JSON.stringify({ at: new Date().toISOString(), event: "pruned", runId: id })}\n`);
}
process.stdout.write(`prune: removed ${doomed.length} run(s), kept ${keep}; receipt: ${receipt}\n`);
process.exit(0);
}
const operation = process.argv[2];
const target = process.argv[3];
@@ -552,10 +598,13 @@ switch (operation) {
case "list":
listRuns();
process.exit(0);
case "prune":
pruneRuns(process.argv.slice(3));
break;
case "retry":
if (!target) fail(4, "usage: mosaic-task.mjs retry <runId>");
retryRun(target);
break;
default:
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry)`);
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry | prune)`);
}