-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(blueprint): add snapshot prune/delete commands for retention management #5453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5e76755
fd79ea0
f46d36c
87cc2fa
bafcd45
c174d90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void | |
| } | ||
|
|
||
| if (!action) { | ||
| if (rawAction === "snapshots") { | ||
| await actionSnapshots(argv.slice(1)); | ||
| return; | ||
| } | ||
| throw new Error( | ||
| `Unknown action '${rawAction ?? "(missing)"}'. Use: plan, apply, status, rollback`, | ||
| `Unknown action '${rawAction ?? "(missing)"}'. Use: plan, apply, status, rollback, snapshots`, | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -998,3 +1008,101 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void | |
| break; | ||
| } | ||
| } | ||
|
|
||
| // ── Snapshot Management ───────────────────────────────────────── | ||
|
|
||
| function snapshotsUsage(): string { | ||
| return "Usage: snapshots <list|prune|delete> [options]\n" + | ||
| "\n" + | ||
| "Subcommands:\n" + | ||
| " list List all snapshots\n" + | ||
| " prune --keep <N> Keep N most recent snapshots, delete the rest\n" + | ||
| " delete --path <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}`); | ||
| } | ||
|
Comment on lines
+1092
to
+1104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Lines 1092-1104 pass user-provided Suggested fix-import { join, sep } from "node:path";
+import { join, resolve, sep } from "node:path";
...
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");
+
+ const snapshotsRoot = resolve(join(homedir(), ".nemoclaw", "snapshots"));
+ const targetPath = resolve(snapshotPath);
+ if (!targetPath.startsWith(snapshotsRoot + sep)) {
+ throw new Error(`--path must be under ${snapshotsRoot}`);
+ }
- if (deleteSnapshot(snapshotPath)) {
- log(`Deleted snapshot: ${snapshotPath}`);
+ if (deleteSnapshot(targetPath)) {
+ log(`Deleted snapshot: ${targetPath}`);
} else {
- throw new Error(`Failed to delete snapshot: ${snapshotPath}`);
+ throw new Error(`Failed to delete snapshot: ${targetPath}`);
}
break;
}🤖 Prompt for AI Agents |
||
| break; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: [] }; | ||
| } | ||
|
Comment on lines
+329
to
+333
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle negative If Consider clamping or throwing early: 🛡️ Suggested guard export function pruneSnapshots(keep: number): { deleted: string[]; kept: string[]; failed: string[] } {
+ if (keep < 0) {
+ throw new Error(`Invalid keep count: ${keep}. Must be a non-negative integer.`);
+ }
const snapshots = listSnapshots();
if (snapshots.length <= keep) {🤖 Prompt for AI Agents |
||
|
|
||
| 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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--keepvalidation accepts malformed values.Line 1065 uses
Number.parseInt, so inputs like--keep 3abcor--keep 1.5are accepted as3/1, despite the “non-negative integer” requirement.Suggested fix
🤖 Prompt for AI Agents