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:
+50
-1
@@ -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)`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user