diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index 2a43f8db..86b71dcb 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -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 "); 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)`); } diff --git a/scripts/test-task.sh b/scripts/test-task.sh index 53fce5f7..a7bb72a2 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -42,10 +42,6 @@ latest_reason() { [ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null } -check() { - if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi -} - CONFIG="$SANDBOX/config.json" DATA_ROOT="$SANDBOX/data" mkdir -p "$DATA_ROOT" @@ -108,6 +104,37 @@ $TASK validate "$SANDBOX/ok.json" >/dev/null 2>&1 M2=$(stat -c %Y "$SANDBOX/ok.json") [ "$M1" = "$M2" ] && check "validation does not modify the task file" 0 || check "validation does not modify the task file" 1 +# ---------- retention: prune (deterministic, no Docker) ---------- +mkdir -p "$SANDBOX/data" +cat > "$SANDBOX/prune-config.json" < "$D/result.json" +done +mkdir -p "$SANDBOX/data/sessions/sentinel" "$SANDBOX/data/workspaces/sentinel" + +expect_exit "prune dry-run exits 0" 0 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune +[ "$(ls "$SANDBOX/data/runs" | grep -c '^r-')" -eq 5 ] \ + && check "dry-run deleted nothing" 0 || check "dry-run deleted nothing" 1 + +expect_exit "prune --keep=2 --yes removes oldest" 0 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune --keep=2 --yes +[ "$(ls "$SANDBOX/data/runs" | grep -c '^r-')" -eq 2 ] \ + && check "kept exactly 2 newest runs" 0 || check "kept exactly 2 newest runs" 1 +NEWEST="r-20260903T0100_05Z-suite005" +[ -d "$SANDBOX/data/runs/$NEWEST" ] \ + && check "newest run kept, oldest pruned" 0 || check "newest run kept, oldest pruned" 1 +[ -f "$SANDBOX/data/runs/.pruned.log" ] \ + && [ "$(grep -c 'pruned' "$SANDBOX/data/runs/.pruned.log")" -eq 3 ] \ + && check "append-only receipt written (3 entries)" 0 \ + || check "append-only receipt written (3 entries)" 1 +[ -d "$SANDBOX/data/sessions/sentinel" ] && [ -d "$SANDBOX/data/workspaces/sentinel" ] \ + && check "sessions/workspaces untouched by prune" 0 \ + || check "sessions/workspaces untouched by prune" 1 +expect_exit "prune with invalid keep exits 4" 4 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune --keep=0 --yes + # ---------- adapter seam: deterministic mock cases (Docker, no provider) ---------- if docker info >/dev/null 2>&1; then good_task "$SANDBOX/ok.json" @@ -244,12 +271,6 @@ dump_latest_run() { fi } -latest_reason() { - local latest - latest="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" - [ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null -} - if docker info >/dev/null 2>&1; then RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l) if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then