diff --git a/README.md b/README.md index 3e64e3d..ea0768c 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,11 @@ Each invocation writes one atomic bundle under `.out/runs//`: `.out/latest.json` is an atomic pointer to the most recent complete bundle. A failed or skipped stage cannot leave an older stage artifact looking current. +List runs newest-first with `aas runs`, and remove oldest runs beyond a +window with `aas prune --keep ` (`--dry-run` previews). Pruning never +deletes the run the latest pointer identifies, and nothing is deleted +without an explicit `--keep`. + ## Guided local GUI Run `npm run gui` and open the printed loopback URL. The GUI calls the same diff --git a/bin/aas.mjs b/bin/aas.mjs index 5a114f3..da68c37 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -16,6 +16,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, renameSync, rmSync, unlinkSync, @@ -245,6 +246,8 @@ Commands: demo Run decide, act, and prove and persist one run bundle export Print one run bundle as portable JSON (or write it with --out) replay Re-verify an exported bundle offline without rerunning the action + runs List persisted runs newest-first + prune Remove oldest runs beyond --keep (latest stays; --dry-run previews) Options: --response pass|fail Policy fixture to evaluate (default: pass) @@ -1013,7 +1016,83 @@ export function readRunBundle(outputRoot, runId) { return { manifest, report, stages }; } +const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/; + +function runsDirectory(outputRoot) { + return join(outputRoot, "runs"); +} + +/** + * List persisted runs newest-first. Entries without a readable manifest + * (interrupted writes, stray files) are omitted; export and replay still + * fail closed on them when addressed directly. + */ +export function listRuns({ outputRoot = DEFAULT_PATHS.outputRoot } = {}) { + const dir = runsDirectory(outputRoot); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const runs = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".") || !RUN_ID_PATTERN.test(entry.name)) continue; + let manifest; + try { + manifest = JSON.parse(readFileSync(join(dir, entry.name, "manifest.json"), "utf8")); + } catch { + continue; + } + if (!manifest || typeof manifest !== "object") continue; + const stages = {}; + for (const name of STAGE_NAMES) stages[name] = manifest.stages?.[name]?.status ?? "unknown"; + runs.push({ + run_id: entry.name, + created_at: typeof manifest.created_at === "string" ? manifest.created_at : null, + exit_code: manifest.exit_code ?? null, + stages, + }); + } + runs.sort((left, right) => (left.run_id < right.run_id ? 1 : left.run_id > right.run_id ? -1 : 0)); + return runs; +} + +function readLatestRunId(outputRoot) { + try { + const pointer = JSON.parse(readFileSync(join(outputRoot, "latest.json"), "utf8")); + const runId = pointer?.run_id; + return typeof runId === "string" && RUN_ID_PATTERN.test(runId) ? runId : null; + } catch { + return null; + } +} + +/** + * Remove oldest runs beyond `keep`, newest-first retention. The run the + * latest pointer identifies is always kept so exports and downloads never + * dangle; dry runs report without deleting. Returns kept/removed run ids. + */ +export function pruneRuns({ outputRoot = DEFAULT_PATHS.outputRoot, keep, dryRun = false } = {}) { + if (!Number.isInteger(keep) || keep < 1) { + throw new UsageError("prune requires --keep "); + } + const runs = [...listRuns({ outputRoot })].reverse(); + const latest = readLatestRunId(outputRoot); + const keepSet = new Set(runs.slice(-keep).map((run) => run.run_id)); + if (latest !== null && runs.some((run) => run.run_id === latest)) keepSet.add(latest); + const removed = []; + for (const run of runs) { + if (keepSet.has(run.run_id)) continue; + removed.push(run.run_id); + if (!dryRun) rmSync(join(runsDirectory(outputRoot), run.run_id), { recursive: true, force: true }); + } + return { kept: [...keepSet], removed, latest, dryRun }; +} + /** Export one run bundle as a single portable JSON document. */ + export function exportRunBundle(runId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) { return readRunBundle(outputRoot, runId); } @@ -1382,6 +1461,63 @@ function readReplayInput(source, { stdin = process.stdin } = {}) { return text; } +function runRunsCommand(args, { asJson } = {}) { + if (args.some((token) => token !== "--json")) { + throw new UsageError(`Unsupported runs option (expected [--json])`); + } + const runs = listRuns({}); + if (asJson) { + process.stdout.write(`${JSON.stringify({ ok: true, runs }, null, 2)}\n`); + } else if (runs.length === 0) { + process.stdout.write("no runs yet\n"); + } else { + for (const run of runs) { + process.stdout.write( + `${run.run_id} exit=${run.exit_code ?? "?"} decide=${run.stages.decide} act=${run.stages.act} prove=${run.stages.prove}\n`, + ); + } + } + process.exitCode = 0; +} + +function runPruneCommand(args, { asJson } = {}) { + let keep = null; + let dryRun = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--keep") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("-")) throw new UsageError("Missing value for prune option: --keep"); + if (keep !== null) throw new UsageError("Duplicate prune option: --keep"); + if (!/^[0-9]+$/.test(value) || Number(value) < 1) throw new UsageError("prune requires --keep "); + keep = Number(value); + index += 1; + } else if (token === "--dry-run") { + dryRun = true; + } else if (token !== "--json") { + throw new UsageError(`Unsupported prune option: ${token} (expected --keep [--dry-run] [--json])`); + } + } + if (keep === null) throw new UsageError("Usage: aas prune --keep [--dry-run] [--json]"); + const result = pruneRuns({ keep, dryRun }); + if (asJson) { + process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); + } else if (dryRun) { + process.stdout.write( + result.removed.length === 0 + ? `would keep ${result.kept.length} run(s); nothing to remove\n` + : `would remove ${result.removed.length} run(s): ${result.removed.join(", ")}\n`, + ); + } else { + process.stdout.write( + result.removed.length === 0 + ? `kept ${result.kept.length} run(s); nothing removed\n` + : `removed ${result.removed.length} run(s): ${result.removed.join(", ")}\n`, + ); + } + process.exitCode = 0; +} + function printReplayReport(result, asJson) { if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -1489,6 +1625,17 @@ export async function main(argv = process.argv.slice(2), options = {}) { process.exitCode = 0; return; } + if (command === "runs" || command === "prune") { + try { + if (command === "runs") runRunsCommand(argv.slice(1), { asJson }); + else runPruneCommand(argv.slice(1), { asJson }); + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, { asJson, usage }); + process.exitCode = usage ? 2 : 1; + } + return; + } if (command === "export" || command === "replay") { try { if (command === "export") runExportCommand(argv.slice(1), { asJson }); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 8f0f4d9..2b75932 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -34,6 +34,8 @@ import { runProve, runProveRail, selectPython, + listRuns, + pruneRuns, validateRailReview, writeAtomicFile, } from "../bin/aas.mjs"; @@ -1273,3 +1275,69 @@ test("export and replay CLI validate arguments and missing files", async () => { const extra = await captureMain(["replay", "a", "b"]); assert.equal(extra.exitCode, 2); }); + +async function makeRuns(outputRoot, count) { + const ids = []; + for (let index = 0; index < count; index += 1) { + const runId = `2026-09-06T050000000Z-run${String(index).padStart(2, "0")}`; + const result = await runDemo(["--response", "pass"], stubOptions(outputRoot, { runId })); + assert.equal(result.exitCode, 0); + ids.push(runId); + } + return ids; +} + +test("runs lists persisted runs newest-first", async () => { + const outputRoot = tempRoot(); + assert.deepEqual(listRuns({ outputRoot }), []); + const ids = await makeRuns(outputRoot, 3); + const listed = listRuns({ outputRoot }); + assert.deepEqual(listed.map((run) => run.run_id), [...ids].reverse()); + assert.equal(listed[0].stages.decide, "passed"); + assert.equal(listed[0].exit_code, 0); +}); + +test("prune keeps the newest runs and never the latest pointer target", async () => { + const outputRoot = tempRoot(); + const ids = await makeRuns(outputRoot, 5); + const preview = pruneRuns({ outputRoot, keep: 2, dryRun: true }); + assert.deepEqual(preview.removed.sort(), [ids[0], ids[1], ids[2]].sort()); + assert.equal(listRuns({ outputRoot }).length, 5); + const done = pruneRuns({ outputRoot, keep: 2 }); + assert.deepEqual(done.removed.sort(), [ids[0], ids[1], ids[2]].sort()); + assert.deepEqual(listRuns({ outputRoot }).map((run) => run.run_id).sort(), [ids[3], ids[4]].sort()); + const exported = exportRunBundle(ids[4], { outputRoot }); + assert.equal(exported.report.run_id, ids[4]); +}); + +test("prune protects the latest pointer target beyond the keep window", async () => { + const outputRoot = tempRoot(); + const ids = await makeRuns(outputRoot, 3); + writeFileSync(join(outputRoot, "latest.json"), `${JSON.stringify({ run_id: ids[0], manifest: `runs/${ids[0]}/manifest.json` })}\n`); + const done = pruneRuns({ outputRoot, keep: 1 }); + assert.deepEqual(done.removed, [ids[1]]); + assert.deepEqual(listRuns({ outputRoot }).map((run) => run.run_id).sort(), [ids[0], ids[2]].sort()); +}); + +test("prune validates input and handles empty stores", async () => { + const outputRoot = tempRoot(); + assert.throws(() => pruneRuns({ outputRoot }), /positive integer/); + assert.throws(() => pruneRuns({ outputRoot, keep: 0 }), /positive integer/); + assert.throws(() => pruneRuns({ outputRoot, keep: -2 }), /positive integer/); + assert.throws(() => pruneRuns({ outputRoot, keep: 1.5 }), /positive integer/); + assert.deepEqual(pruneRuns({ outputRoot, keep: 5 }), { kept: [], removed: [], latest: null, dryRun: false }); +}); + +test("runs and prune CLI commands validate arguments", async () => { + const listed = await captureMain(["runs"]); + assert.equal(listed.exitCode, 0); + const junk = await captureMain(["runs", "--bogus"]); + assert.equal(junk.exitCode, 2); + const missing = await captureMain(["prune"]); + assert.equal(missing.exitCode, 2); + assert.match(missing.stderr, /Usage: aas prune/); + const zero = await captureMain(["prune", "--keep", "0"]); + assert.equal(zero.exitCode, 2); + const words = await captureMain(["prune", "--keep", "many"]); + assert.equal(words.exitCode, 2); +});