From 5e7675536dc85b2d00b77490641948b67384a9f5 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Mon, 15 Jun 2026 12:07:15 +0530 Subject: [PATCH] feat(blueprint): add snapshot prune/delete commands for retention management Add deleteSnapshot and pruneSnapshots to the blueprint snapshot module so users can clean up accumulated migration snapshots under ~/.nemoclaw/snapshots/. - deleteSnapshot(path): remove a single snapshot directory, rejecting symlinks - pruneSnapshots(keep): keep N most recent, delete the rest, report failures - Wire through CLI as 'snapshots list', 'snapshots prune --keep N', and 'snapshots delete --path ' subcommands - Test CLI dispatch in snapshot.test.ts to stay under test file size budget - 20 new tests covering snapshot CLI and edge cases Signed-off-by: vasanth53 --- nemoclaw/src/blueprint/runner.ts | 110 ++++++++++++++- nemoclaw/src/blueprint/snapshot.test.ts | 177 ++++++++++++++++++++++++ nemoclaw/src/blueprint/snapshot.ts | 32 +++++ 3 files changed, 318 insertions(+), 1 deletion(-) diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index 43555203f8..34f848263d 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -23,8 +23,10 @@ import YAML from "yaml"; import { validateEndpointUrl } from "./ssrf.js"; import { buildSubprocessEnv } from "../lib/subprocess-env.js"; import { DASHBOARD_PORT } from "../lib/ports.js"; +import { deleteSnapshot, listSnapshots, pruneSnapshots } from "./snapshot.js"; type Action = "plan" | "apply" | "status" | "rollback"; +type SnapshotsAction = "list" | "prune" | "delete"; type RollbackPlanSource = { sandbox_name?: unknown }; type UnknownRecord = { [key: string]: unknown }; @@ -66,6 +68,10 @@ function isAction(value: string | undefined): value is Action { return value === "plan" || value === "apply" || value === "status" || value === "rollback"; } +function isSnapshotsAction(value: string | undefined): value is SnapshotsAction { + return value === "list" || value === "prune" || value === "delete"; +} + function isObjectLike(value: unknown): value is UnknownRecord { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; @@ -951,8 +957,12 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise [options]\n" + + "\n" + + "Subcommands:\n" + + " list List all snapshots\n" + + " prune --keep Keep N most recent snapshots, delete the rest\n" + + " delete --path Delete a specific snapshot by path\n" + + "\n" + + "Examples:\n" + + " snapshots list\n" + + " snapshots prune --keep 3\n" + + " snapshots delete --path ~/.nemoclaw/snapshots/20260101T000000Z\n"; +} + +export function actionSnapshots(argv: string[]): void { + const sub = argv.at(0); + + if (!sub || sub === "--help" || sub === "-h") { + log(snapshotsUsage()); + return; + } + + if (!isSnapshotsAction(sub)) { + throw new Error( + `Unknown snapshots subcommand '${sub}'. Use: list, prune, delete`, + ); + } + + switch (sub) { + case "list": { + const snapshots = listSnapshots(); + if (snapshots.length === 0) { + log("No snapshots found."); + return; + } + log(`Found ${snapshots.length} snapshot(s):\n`); + for (const snap of snapshots) { + log(` ${snap.timestamp}`); + log(` Path: ${snap.path}`); + log(` Source: ${snap.source}`); + log(` Files: ${snap.file_count}`); + log(""); + } + break; + } + case "prune": { + let keep = -1; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--keep") { + const val = argv[++i]; + if (val === undefined) throw new Error("--keep requires a numeric value"); + keep = Number.parseInt(val, 10); + if (!Number.isFinite(keep) || keep < 0) { + throw new Error("--keep must be a non-negative integer"); + } + } + } + if (keep < 0) throw new Error("--keep is required for prune"); + + const { deleted, kept, failed } = pruneSnapshots(keep); + if (deleted.length === 0 && failed.length === 0) { + log(`Nothing to prune. ${kept.length} snapshot(s) kept (--keep=${String(keep)}).`); + return; + } + if (deleted.length > 0) { + log(`Pruned ${deleted.length} snapshot(s), kept ${kept.length}:\n`); + for (const path of deleted) { + log(` Deleted: ${path}`); + } + } + if (failed.length > 0) { + for (const path of failed) { + log(` Failed: ${path}`); + } + } + break; + } + case "delete": { + let snapshotPath: string | undefined; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--path") { + snapshotPath = argv[++i]; + } + } + if (!snapshotPath) throw new Error("--path is required for delete"); + + if (deleteSnapshot(snapshotPath)) { + log(`Deleted snapshot: ${snapshotPath}`); + } else { + throw new Error(`Failed to delete snapshot: ${snapshotPath}`); + } + break; + } + } +} diff --git a/nemoclaw/src/blueprint/snapshot.test.ts b/nemoclaw/src/blueprint/snapshot.test.ts index 6a5c82452b..2be406ba93 100644 --- a/nemoclaw/src/blueprint/snapshot.test.ts +++ b/nemoclaw/src/blueprint/snapshot.test.ts @@ -137,8 +137,12 @@ const { rollbackFromSnapshot, listSnapshots, moveSync, + deleteSnapshot, + pruneSnapshots, } = await import("./snapshot.js"); +const { actionSnapshots } = await import("./runner.js"); + const OPENCLAW_DIR = `${FAKE_HOME}/.openclaw`; const SNAPSHOTS_DIR = `${FAKE_HOME}/.nemoclaw/snapshots`; @@ -483,4 +487,177 @@ describe("snapshot", () => { expect(listSnapshots()).toEqual([]); }); }); + + describe("deleteSnapshot", () => { + it("removes a snapshot directory", () => { + addDir(`${SNAPSHOTS_DIR}/20260101T000000Z`); + addFile(`${SNAPSHOTS_DIR}/20260101T000000Z/snapshot.json`, "{}"); + + expect(deleteSnapshot(`${SNAPSHOTS_DIR}/20260101T000000Z`)).toBe(true); + expect(store.has(`${SNAPSHOTS_DIR}/20260101T000000Z`)).toBe(false); + }); + + it("returns false when path is a symlink", () => { + addSymlink(`${SNAPSHOTS_DIR}/evil`, "/attacker"); + + expect(deleteSnapshot(`${SNAPSHOTS_DIR}/evil`)).toBe(false); + }); + + it("returns true for non-existent path", () => { + expect(deleteSnapshot(`${SNAPSHOTS_DIR}/nonexistent`)).toBe(true); + }); + }); + + describe("pruneSnapshots", () => { + it("keeps N snapshots and deletes the rest", () => { + addDir(`${SNAPSHOTS_DIR}/20260101T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260101T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260101T000000Z", source: OPENCLAW_DIR, file_count: 1, contents: [] }), + ); + addDir(`${SNAPSHOTS_DIR}/20260201T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260201T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260201T000000Z", source: OPENCLAW_DIR, file_count: 1, contents: [] }), + ); + addDir(`${SNAPSHOTS_DIR}/20260301T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260301T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260301T000000Z", source: OPENCLAW_DIR, file_count: 1, contents: [] }), + ); + + const result = pruneSnapshots(2); + + expect(result.kept).toHaveLength(2); + expect(result.deleted).toHaveLength(1); + expect(result.failed).toHaveLength(0); + expect(result.kept[0]).toContain("20260301T000000Z"); + expect(result.kept[1]).toContain("20260201T000000Z"); + expect(result.deleted[0]).toContain("20260101T000000Z"); + // Verify the deleted snapshot is actually removed from the store + expect(store.has(`${SNAPSHOTS_DIR}/20260101T000000Z`)).toBe(false); + expect(store.has(`${SNAPSHOTS_DIR}/20260201T000000Z`)).toBe(true); + expect(store.has(`${SNAPSHOTS_DIR}/20260301T000000Z`)).toBe(true); + }); + + it("returns empty deleted when keep >= snapshot count", () => { + addDir(`${SNAPSHOTS_DIR}/20260101T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260101T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260101T000000Z", source: OPENCLAW_DIR, file_count: 1, contents: [] }), + ); + + const result = pruneSnapshots(5); + + expect(result.deleted).toHaveLength(0); + expect(result.failed).toHaveLength(0); + expect(result.kept).toHaveLength(1); + }); + + it("handles no snapshots gracefully", () => { + const result = pruneSnapshots(3); + + expect(result.deleted).toHaveLength(0); + expect(result.failed).toHaveLength(0); + expect(result.kept).toHaveLength(0); + }); + + it("reports failures when deleteSnapshot returns false", () => { + addDir(`${SNAPSHOTS_DIR}/20260101T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260101T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260101T000000Z", source: OPENCLAW_DIR, file_count: 2, contents: [] }), + ); + addDir(`${SNAPSHOTS_DIR}/20260201T000000Z`); + addFile( + `${SNAPSHOTS_DIR}/20260201T000000Z/snapshot.json`, + JSON.stringify({ timestamp: "20260201T000000Z", source: OPENCLAW_DIR, file_count: 2, contents: [] }), + ); + // Make the older snapshot a symlink so deleteSnapshot rejects it + addSymlink(`${SNAPSHOTS_DIR}/20260101T000000Z`, "/attacker"); + + const result = pruneSnapshots(1); + + expect(result.deleted).toHaveLength(0); + expect(result.failed).toHaveLength(1); + expect(result.kept).toHaveLength(1); + expect(result.failed[0]).toContain("20260101T000000Z"); + expect(result.kept[0]).toContain("20260201T000000Z"); + }); + }); + + describe("actionSnapshots (CLI dispatch)", () => { + const stdoutChunks: string[] = []; + + function captureStdout(): void { + vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }); + } + + function stdoutText(): string { + return stdoutChunks.join(""); + } + + beforeEach(() => { + store.clear(); + stdoutChunks.length = 0; + vi.clearAllMocks(); + captureStdout(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([["--help"], ["-h"], []])("shows usage for %j", (...argv) => { + actionSnapshots(argv); + expect(stdoutText()).toContain("Usage: snapshots "); + }); + + it("throws on unknown subcommand", () => { + expect(() => actionSnapshots(["bogus"])).toThrow(/Unknown snapshots subcommand/); + }); + + it("lists snapshots when none exist", () => { + actionSnapshots(["list"]); + expect(stdoutText()).toContain("No snapshots found."); + }); + + it("lists existing snapshots", () => { + const snapDir = `${FAKE_HOME}/.nemoclaw/snapshots/20260101T000000Z`; + addDir(snapDir); + addFile(snapDir + "/snapshot.json", JSON.stringify({ timestamp: "20260101T000000Z", source: FAKE_HOME, file_count: 1, contents: [] })); + actionSnapshots(["list"]); + expect(stdoutText()).toContain("20260101T000000Z"); + }); + + it("prune --keep N deletes oldest", () => { + [1, 2, 3].forEach((n) => { + const ts = `2026010${n}T000000Z`; + addDir(`${FAKE_HOME}/.nemoclaw/snapshots/${ts}`); + addFile(`${FAKE_HOME}/.nemoclaw/snapshots/${ts}/snapshot.json`, JSON.stringify({ timestamp: ts, source: FAKE_HOME, file_count: 1, contents: [] })); + }); + actionSnapshots(["prune", "--keep", "2"]); + expect(stdoutText()).toContain("Pruned 1 snapshot(s), kept 2"); + }); + + it.each([["prune"], ["delete"]])("%s throws without required arg", (...argv) => { + expect(() => actionSnapshots(argv)).toThrow(); + }); + + it("delete removes a snapshot by path", () => { + const sd = `${FAKE_HOME}/.nemoclaw/snapshots/20260101T000000Z`; + addDir(sd); + addFile(sd + "/snapshot.json", JSON.stringify({ timestamp: "20260101T000000Z", source: FAKE_HOME, file_count: 1, contents: [] })); + actionSnapshots(["delete", "--path", sd]); + expect(stdoutText()).toContain(`Deleted snapshot: ${sd}`); + }); + + it("delete succeeds on non-existent path", () => { + actionSnapshots(["delete", "--path", `${FAKE_HOME}/.nemoclaw/snapshots/nonexistent`]); + expect(stdoutText()).toContain("Deleted snapshot:"); + }); + }); }); diff --git a/nemoclaw/src/blueprint/snapshot.ts b/nemoclaw/src/blueprint/snapshot.ts index 9c5b2f00c7..3cef4c07db 100644 --- a/nemoclaw/src/blueprint/snapshot.ts +++ b/nemoclaw/src/blueprint/snapshot.ts @@ -316,6 +316,38 @@ function readStringArray(value: SnapshotManifestJson["contents"]): string[] { : []; } +export function deleteSnapshot(snapshotPath: string): boolean { + try { + rejectSymlinksOnPath(snapshotPath); + rmSync(snapshotPath, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +export function pruneSnapshots(keep: number): { deleted: string[]; kept: string[]; failed: string[] } { + const snapshots = listSnapshots(); + if (snapshots.length <= keep) { + return { deleted: [], kept: snapshots.map((s) => s.path), failed: [] }; + } + + const toDelete = snapshots.slice(keep); + const toKeep = snapshots.slice(0, keep); + + const deleted: string[] = []; + const failed: string[] = []; + for (const snap of toDelete) { + if (deleteSnapshot(snap.path)) { + deleted.push(snap.path); + } else { + failed.push(snap.path); + } + } + + return { deleted, kept: toKeep.map((s) => s.path), failed }; +} + export function listSnapshots(): BlueprintSnapshotManifest[] { let entries: Dirent[]; try {