Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 127 additions & 10 deletions src/lib/sandbox-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export interface RebuildManifest {
blueprintDigest: string | null;
policyPresets?: string[];
instances?: InstanceBackup[];
// Optional user-provided label for `snapshot restore <name>`.
name?: string;
}

// Manifest enriched with a virtual version number computed at list time.
// Versions are position-based (v1 = oldest by timestamp) and NOT persisted,
// so they can shift if snapshots are deleted.
export type SnapshotEntry = RebuildManifest & { snapshotVersion: number };

export interface BackupOptions {
name?: string | null;
}

export interface InstanceBackup {
Expand All @@ -66,9 +77,14 @@ export interface InstanceBackup {

export interface BackupResult {
success: boolean;
manifest: RebuildManifest;
// Only set once the backup has been written to disk — absent on
// precondition failures like an invalid --name.
manifest?: RebuildManifest;
backedUpDirs: string[];
failedDirs: string[];
// Set when the failure is a precondition (e.g. duplicate --name) rather
// than a mid-backup error. CLI surfaces this to the user verbatim.
error?: string;
}

export interface RestoreResult {
Expand Down Expand Up @@ -380,20 +396,72 @@ function _log(msg: string): void {
if (_verbose()) console.error(` [sandbox-state ${new Date().toISOString()}] ${msg}`);
}

// ── Naming / versioning helpers ────────────────────────────────────

const VERSION_SELECTOR_RE = /^v(\d+)$/i;
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;

export function validateSnapshotName(name: string): string | null {
if (!NAME_RE.test(name)) {
return (
`Invalid snapshot name '${name}'. Use 1–63 chars from [A-Za-z0-9._-], ` +
`starting with an alphanumeric.`
);
}
if (VERSION_SELECTOR_RE.test(name)) {
return (
`Snapshot name '${name}' conflicts with the auto-assigned version format ` +
`(v<N>). Pick a different name.`
);
}
return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ── Backup ─────────────────────────────────────────────────────────

/**
* Back up all state directories from a running sandbox.
* Uses the agent manifest to determine which directories contain state.
*/
export function backupSandboxState(sandboxName: string): BackupResult {
export function backupSandboxState(
sandboxName: string,
options: BackupOptions = {},
): BackupResult {
const sb = registry.getSandbox(sandboxName);
const agentName = sb?.agent || "openclaw";
const agent = loadAgent(agentName);
const writableDir = agent.configPaths.writableDir;
const stateDirs = agent.stateDirs;
_log(`backupSandboxState: agent=${agentName}, writableDir=${writableDir}, stateDirs=[${stateDirs.join(",")}]`);

// Validate user-supplied name and check for conflicts BEFORE creating any
// files on disk.
const existingBackups = listBackups(sandboxName);
// Preserve empty strings so `--name ""` hits validateSnapshotName and fails
// with a clear error instead of silently creating an unnamed snapshot.
const providedName = options.name ?? null;
if (providedName !== null) {
const validationError = validateSnapshotName(providedName);
if (validationError) {
return {
success: false,
backedUpDirs: [],
failedDirs: [],
error: validationError,
};
}
const conflict = existingBackups.find((b) => b.name === providedName);
if (conflict) {
return {
success: false,
backedUpDirs: [],
failedDirs: [],
error:
`Snapshot name '${providedName}' already exists for '${sandboxName}' ` +
`(at ${conflict.timestamp}). Pick a different name or delete the existing snapshot.`,
};
}
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(REBUILD_BACKUPS_DIR, sandboxName, timestamp);
mkdirSync(backupPath, { recursive: true, mode: 0o700 });
Expand All @@ -418,6 +486,7 @@ export function backupSandboxState(sandboxName: string): BackupResult {
backupPath,
blueprintDigest: computeBlueprintDigest(),
policyPresets,
...(providedName !== null ? { name: providedName } : {}),
};

const backedUpDirs: string[] = [];
Expand Down Expand Up @@ -633,28 +702,76 @@ function readManifest(backupPath: string): RebuildManifest | null {
// ── Listing ────────────────────────────────────────────────────────

/**
* List available backups for a sandbox, newest first.
* List available backups for a sandbox, newest first, each enriched with a
* virtual `snapshotVersion` number.
*
* Version numbers are position-based (v1 = oldest by timestamp, vN = newest)
* and computed fresh on every call — they are NOT persisted, so deleting a
* snapshot will re-number everything newer than it.
*/
export function listBackups(sandboxName: string): RebuildManifest[] {
export function listBackups(sandboxName: string): SnapshotEntry[] {
const dir = path.join(REBUILD_BACKUPS_DIR, sandboxName);
if (!existsSync(dir)) return [];

const entries = readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.sort((a, b) => b.name.localeCompare(a.name));
const rawEntries = readdirSync(dir, { withFileTypes: true }).filter((e) =>
e.isDirectory(),
);

const manifests: RebuildManifest[] = [];
for (const entry of entries) {
for (const entry of rawEntries) {
const m = readManifest(path.join(dir, entry.name));
if (m) manifests.push(m);
}
return manifests;

// Assign version numbers by timestamp-ascending position (v1 = oldest).
const asc = [...manifests].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
const numbered: SnapshotEntry[] = asc.map((m, i) => ({
...m,
snapshotVersion: i + 1,
}));

// Return newest-first for display.
return numbered.reverse();
}

/**
* Get the most recent backup for a sandbox, or null.
*/
export function getLatestBackup(sandboxName: string): RebuildManifest | null {
export function getLatestBackup(sandboxName: string): SnapshotEntry | null {
const backups = listBackups(sandboxName);
return backups[0] || null;
}

export interface SnapshotMatchResult {
match: SnapshotEntry | null;
}

/**
* Resolve a user-supplied snapshot selector to a single backup.
*
* Selector precedence:
* 1. `v<N>` — exact (virtual) snapshotVersion match (case-insensitive)
* 2. exact user-assigned name match
* 3. exact timestamp match
*/
export function findBackup(
sandboxName: string,
selector: string,
): SnapshotMatchResult {
const backups = listBackups(sandboxName);

const versionMatch = VERSION_SELECTOR_RE.exec(selector);
if (versionMatch) {
const wanted = Number.parseInt(versionMatch[1], 10);
const hit = backups.find((b) => b.snapshotVersion === wanted);
return { match: hit ?? null };
}

const byName = backups.find((b) => b.name === selector);
if (byName) return { match: byName };

const byExactTimestamp = backups.find((b) => b.timestamp === selector);
if (byExactTimestamp) return { match: byExactTimestamp };

return { match: null };
}
121 changes: 87 additions & 34 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2557,10 +2557,57 @@ async function upgradeSandboxes(args = []) {

// ── Snapshot ─────────────────────────────────────────────────────

function parseSnapshotCreateFlags(flags) {
const opts = { name: null };
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
if (flag === "--name") {
if (i + 1 >= flags.length || flags[i + 1].startsWith("--")) {
console.error(" --name requires a value");
process.exit(1);
}
opts.name = flags[++i];
} else {
console.error(` Unknown flag: ${flag}`);
process.exit(1);
}
}
return opts;
}

function formatSnapshotVersion(b) {
return `v${b.snapshotVersion}`;
}

function renderSnapshotTable(backups) {
const rows = backups.map((b) => ({
version: formatSnapshotVersion(b),
name: b.name || "",
timestamp: b.timestamp,
backupPath: b.backupPath,
}));
const widths = {
version: Math.max(7, ...rows.map((r) => r.version.length)),
name: Math.max(4, ...rows.map((r) => r.name.length)),
timestamp: Math.max(9, ...rows.map((r) => r.timestamp.length)),
backupPath: Math.max(4, ...rows.map((r) => r.backupPath.length)),
};
const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
console.log(
` ${B}${pad("Version", widths.version)} ${pad("Name", widths.name)} ${pad("Timestamp", widths.timestamp)} ${pad("Path", widths.backupPath)}${R}`,
);
for (const r of rows) {
console.log(
` ${pad(r.version, widths.version)} ${pad(r.name, widths.name)} ${pad(r.timestamp, widths.timestamp)} ${D}${pad(r.backupPath, widths.backupPath)}${R}`,
);
}
}

function sandboxSnapshot(sandboxName, subArgs) {
const subcommand = subArgs[0] || "help";
switch (subcommand) {
case "create": {
const opts = parseSnapshotCreateFlags(subArgs.slice(1));
const isLive = captureOpenshell(["sandbox", "list"], { ignoreError: true });
if (isLive.status !== 0) {
console.error(" Failed to query live sandbox state from OpenShell.");
Expand All @@ -2571,17 +2618,29 @@ function sandboxSnapshot(sandboxName, subArgs) {
console.error(` Sandbox '${sandboxName}' is not running. Cannot create snapshot.`);
process.exit(1);
}
console.log(` Creating snapshot of '${sandboxName}'...`);
const result = sandboxState.backupSandboxState(sandboxName);
const label = opts.name ? ` (--name ${opts.name})` : "";
console.log(` Creating snapshot of '${sandboxName}'${label}...`);
const result = sandboxState.backupSandboxState(sandboxName, { name: opts.name });
if (result.success) {
// Virtual snapshotVersion is only assigned by listBackups, so re-resolve
// the just-created snapshot by its timestamp to get a valid v<N>.
const entry =
sandboxState.findBackup(sandboxName, result.manifest.timestamp).match ??
result.manifest;
const v = formatSnapshotVersion(entry);
const nameSuffix = entry.name ? ` name=${entry.name}` : "";
Comment thread
cr7258 marked this conversation as resolved.
console.log(
` ${G}\u2713${R} Snapshot created (${result.backedUpDirs.length} directories)`,
` ${G}\u2713${R} Snapshot ${v}${nameSuffix} created (${result.backedUpDirs.length} directories)`,
);
console.log(` ${result.manifest.backupPath}`);
} else {
console.error(" Snapshot failed.");
if (result.failedDirs.length > 0) {
console.error(` Failed directories: ${result.failedDirs.join(", ")}`);
if (result.error) {
console.error(` ${result.error}`);
} else {
console.error(" Snapshot failed.");
if (result.failedDirs.length > 0) {
console.error(` Failed directories: ${result.failedDirs.join(", ")}`);
}
}
process.exit(1);
}
Expand All @@ -2595,15 +2654,10 @@ function sandboxSnapshot(sandboxName, subArgs) {
}
console.log(` Snapshots for '${sandboxName}':`);
console.log("");
for (const b of backups) {
const dirs = b.stateDirs?.length || 0;
const version = b.agentVersion || "unknown";
console.log(` ${b.timestamp} ${D}(${dirs} dirs, agent v${version})${R}`);
console.log(` ${b.backupPath}`);
}
renderSnapshotTable(backups);
console.log("");
console.log(` ${backups.length} snapshot(s). Restore with:`);
console.log(` nemoclaw ${sandboxName} snapshot restore [timestamp]`);
console.log(` nemoclaw ${sandboxName} snapshot restore [version|name|timestamp]`);
break;
}
case "restore": {
Expand All @@ -2617,34 +2671,30 @@ function sandboxSnapshot(sandboxName, subArgs) {
console.error(` Sandbox '${sandboxName}' is not running. Cannot restore snapshot.`);
process.exit(1);
}
const timestamp = subArgs[1] || null;
const selector = subArgs[1] || null;
let backupPath;
if (timestamp) {
const all = sandboxState.listBackups(sandboxName);
const matches = all.filter(
(b) => b.timestamp === timestamp || b.timestamp.startsWith(timestamp),
);
if (matches.length === 0) {
console.error(` No snapshot matching '${timestamp}' found for '${sandboxName}'.`);
if (selector) {
const { match } = sandboxState.findBackup(sandboxName, selector);
if (!match) {
console.error(` No snapshot matching '${selector}' found for '${sandboxName}'.`);
console.error(" Selector must be an exact version (v<N>), name, or timestamp.");
console.error(" Run: nemoclaw " + sandboxName + " snapshot list");
process.exit(1);
}
if (matches.length > 1) {
console.error(` Snapshot selector '${timestamp}' is ambiguous.`);
console.error(" Matching timestamps:");
for (const m of matches) console.error(` ${m.timestamp}`);
console.error(" Re-run with an exact timestamp from `snapshot list`.");
process.exit(1);
}
backupPath = matches[0].backupPath;
backupPath = match.backupPath;
const v = formatSnapshotVersion(match);
const nameSuffix = match.name ? ` name=${match.name}` : "";
console.log(` Using snapshot ${v}${nameSuffix} (${match.timestamp})`);
} else {
const latest = sandboxState.getLatestBackup(sandboxName);
if (!latest) {
console.error(` No snapshots found for '${sandboxName}'.`);
process.exit(1);
}
backupPath = latest.backupPath;
console.log(` Using latest snapshot: ${latest.timestamp}`);
const v = formatSnapshotVersion(latest);
const nameSuffix = latest.name ? ` name=${latest.name}` : "";
console.log(` Using latest snapshot ${v}${nameSuffix} (${latest.timestamp})`);
}
console.log(` Restoring snapshot into '${sandboxName}'...`);
const result = sandboxState.restoreSandboxState(sandboxName, backupPath);
Expand All @@ -2664,9 +2714,12 @@ function sandboxSnapshot(sandboxName, subArgs) {
}
default:
console.log(` Usage:`);
console.log(` nemoclaw ${sandboxName} snapshot create Create a snapshot`);
console.log(` nemoclaw ${sandboxName} snapshot create [--name <name>]`);
console.log(` Create a snapshot (auto-versioned v1, v2, ...)`);
console.log(` nemoclaw ${sandboxName} snapshot list List available snapshots`);
console.log(` nemoclaw ${sandboxName} snapshot restore [ts] Restore from a snapshot`);
console.log(` nemoclaw ${sandboxName} snapshot restore [selector]`);
console.log(` Restore by version (v1), name, or timestamp.`);
console.log(` Omit to restore the most recent.`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break;
}
}
Expand Down Expand Up @@ -2830,9 +2883,9 @@ function help() {
nemoclaw <name> connect Shell into a running sandbox
nemoclaw <name> status Sandbox health + NIM status
nemoclaw <name> logs ${D}[--follow]${R} Stream sandbox logs
nemoclaw <name> snapshot create Create a snapshot of sandbox state
nemoclaw <name> snapshot create Create a snapshot of sandbox state ${D}([--name <label>] to tag it)${R}
nemoclaw <name> snapshot list List available snapshots
nemoclaw <name> snapshot restore Restore state from a snapshot ${D}([timestamp] for specific)${R}
nemoclaw <name> snapshot restore Restore state from a snapshot ${D}([v<N>|name|timestamp], omit for latest)${R}
nemoclaw <name> rebuild Upgrade sandbox to current agent version ${D}(--yes to skip prompt)${R}
nemoclaw <name> destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R}

Expand Down
Loading
Loading