diff --git a/src/lib/global-cli-actions.ts b/src/lib/global-cli-actions.ts index 49334c3adbe..8b09589634d 100644 --- a/src/lib/global-cli-actions.ts +++ b/src/lib/global-cli-actions.ts @@ -39,7 +39,10 @@ export function runBackupAllAction(): void { } export async function runUpgradeSandboxesAction(args: string[] = []): Promise { - await getNemoClawRuntimeBridge().upgradeSandboxes(args); + const { upgradeSandboxes } = require("./upgrade-sandboxes-action") as { + upgradeSandboxes: (args?: string[]) => Promise; + }; + await upgradeSandboxes(args); } export async function runGarbageCollectImagesAction(args: string[] = []): Promise { diff --git a/src/lib/nemoclaw-runtime-bridge.ts b/src/lib/nemoclaw-runtime-bridge.ts index 5811dfee5a3..a762ea197cd 100644 --- a/src/lib/nemoclaw-runtime-bridge.ts +++ b/src/lib/nemoclaw-runtime-bridge.ts @@ -3,9 +3,7 @@ /* v8 ignore start -- transitional bridge until command actions are extracted from src/nemoclaw.ts. */ -export interface NemoClawRuntimeBridge { - upgradeSandboxes: (args?: string[]) => Promise; -} +export interface NemoClawRuntimeBridge {} let runtimeFactory = (): NemoClawRuntimeBridge => { const runtimeModule = require("../nemoclaw") as { diff --git a/src/lib/sandbox-runtime-actions.ts b/src/lib/sandbox-runtime-actions.ts index 6a8d06b1da4..81171c171e6 100644 --- a/src/lib/sandbox-runtime-actions.ts +++ b/src/lib/sandbox-runtime-actions.ts @@ -5,7 +5,6 @@ import type { SandboxConnectOptions } from "./sandbox-connect-action"; import type { SandboxLogsOptions } from "./sandbox-logs-options"; -import { getNemoClawRuntimeBridge } from "./nemoclaw-runtime-bridge"; export async function connectSandbox( sandboxName: string, diff --git a/src/lib/upgrade-sandboxes-action.ts b/src/lib/upgrade-sandboxes-action.ts new file mode 100644 index 00000000000..281487bd940 --- /dev/null +++ b/src/lib/upgrade-sandboxes-action.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- exercised through CLI subprocess upgrade tests. */ + +import { CLI_NAME } from "./branding"; +import { prompt as askPrompt } from "./credentials"; +import { captureOpenshell } from "./openshell-runtime"; +import * as registry from "./registry"; +import { parseLiveSandboxNames } from "./runtime-recovery"; +import { rebuildSandbox } from "./sandbox-rebuild-action"; +import * as sandboxVersion from "./sandbox-version"; +import { B, D, G, R, YW } from "./terminal-style"; + +// ── Upgrade sandboxes (#1904) ──────────────────────────────────── +// Detect sandboxes running stale agent versions and offer to rebuild them. + +export async function upgradeSandboxes(args: string[] = []): Promise { + const checkOnly = args.includes("--check"); + const auto = args.includes("--auto"); + const skipConfirm = auto || args.includes("--yes"); + + const sandboxes = registry.listSandboxes().sandboxes; + if (sandboxes.length === 0) { + console.log(" No sandboxes found in the registry."); + return; + } + + // Query live sandboxes so we can tell the user which are running + const liveResult = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + if (liveResult.status !== 0) { + console.error(" Failed to query running sandboxes from OpenShell."); + console.error(" Ensure OpenShell is running: openshell status"); + process.exit(liveResult.status || 1); + } + const liveNames = parseLiveSandboxNames(liveResult.output || ""); + + // Classify sandboxes as stale, unknown, or current + const stale = []; + const unknown = []; + for (const sb of sandboxes) { + const versionCheck = sandboxVersion.checkAgentVersion(sb.name); + if (versionCheck.isStale) { + stale.push({ + name: sb.name, + current: versionCheck.sandboxVersion, + expected: versionCheck.expectedVersion, + running: liveNames.has(sb.name), + }); + } else if (versionCheck.detectionMethod === "unavailable") { + unknown.push({ + name: sb.name, + expected: versionCheck.expectedVersion, + running: liveNames.has(sb.name), + }); + } + } + + if (stale.length === 0 && unknown.length === 0) { + console.log(" All sandboxes are up to date."); + return; + } + + if (stale.length > 0) { + console.log(`\n ${B}Stale sandboxes:${R}`); + for (const s of stale) { + const status = s.running ? `${G}running${R}` : `${D}stopped${R}`; + console.log(` ${s.name} v${s.current || "?"} → v${s.expected} (${status})`); + } + } + if (unknown.length > 0) { + console.log(`\n ${YW}Unknown version:${R}`); + for (const s of unknown) { + const status = s.running ? `${G}running${R}` : `${D}stopped${R}`; + console.log(` ${s.name} v? → v${s.expected} (${status})`); + } + } + console.log(""); + + if (checkOnly) { + if (stale.length > 0) console.log(` ${stale.length} sandbox(es) need upgrading.`); + if (unknown.length > 0) { + console.log( + ` ${unknown.length} sandbox(es) could not be version-checked; start them and rerun, or rebuild manually.`, + ); + } + console.log(` Run \`${CLI_NAME} upgrade-sandboxes\` to rebuild them.`); + return; + } + + const rebuildable = stale.filter((s: { running: boolean }) => s.running); + const stopped = stale.filter((s: { running: boolean }) => !s.running); + if (stopped.length > 0) { + console.log(` ${D}Skipping ${stopped.length} stopped sandbox(es) — start them first.${R}`); + } + if (rebuildable.length === 0) { + console.log(" No running stale sandboxes to rebuild."); + return; + } + + let rebuilt = 0; + let failed = 0; + for (const s of rebuildable) { + if (!skipConfirm) { + const answer = await askPrompt(` Rebuild '${s.name}'? [y/N]: `); + if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { + console.log(` Skipped '${s.name}'.`); + continue; + } + } + try { + await rebuildSandbox(s.name, ["--yes"], { throwOnError: true }); + rebuilt++; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.error(` ${YW}⚠${R} Failed to rebuild '${s.name}': ${errorMessage}`); + failed++; + } + } + + console.log(""); + if (rebuilt > 0) console.log(` ${G}✓${R} ${rebuilt} sandbox(es) rebuilt.`); + if (failed > 0) console.log(` ${YW}⚠${R} ${failed} sandbox(es) failed — see errors above.`); + if (failed > 0) process.exit(1); +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 1e5c6a9461e..3984e225af3 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -36,7 +36,6 @@ const { } = require("./lib/docker"); const { resolveOpenshell } = require("./lib/resolve-openshell"); const { hydrateCredentialEnv, isNonInteractive } = require("./lib/onboard"); -const { prompt: askPrompt } = require("./lib/credentials"); const registry = require("./lib/registry"); import type { SandboxEntry } from "./lib/registry"; const nim = require("./lib/nim"); @@ -48,10 +47,8 @@ const { buildStatusCommandDeps } = require("./lib/status-command-deps"); const { help, version } = require("./lib/root-help-action"); const onboardSession = require("./lib/onboard-session"); import type { Session } from "./lib/onboard-session"; -const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); const { stripAnsi } = require("./lib/openshell"); const { - captureOpenshell, getInstalledOpenshellVersionOrNull, runOpenshell, } = require("./lib/openshell-runtime"); @@ -70,11 +67,9 @@ const { const { getSandboxDeleteOutcome, } = require("./lib/sandbox-destroy-action"); -const { rebuildSandbox: sandboxRebuild } = require("./lib/sandbox-rebuild-action"); const { runRegisteredOclifCommand } = require("./lib/oclif-runner"); const { isErrnoException }: typeof import("./lib/errno") = require("./lib/errno"); const agentRuntime = require("../bin/lib/agent-runtime"); -const sandboxVersion = require("./lib/sandbox-version"); const sandboxState = require("./lib/sandbox-state"); const { parseRestoreArgs } = sandboxState; const { @@ -119,9 +114,6 @@ const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT); const DEFAULT_LOGS_PROBE_TIMEOUT_MS = 5000; const LOGS_PROBE_TIMEOUT_ENV = "NEMOCLAW_LOGS_PROBE_TIMEOUT_MS"; -exports.runtimeBridge = { - upgradeSandboxes, -}; /** Print user-facing guidance when OpenShell is too old to support `openshell logs`. */ function printOldLogsCompatibilityGuidance(installedVersion = null) { const versionText = installedVersion ? ` (${installedVersion})` : ""; @@ -150,117 +142,6 @@ function printSandboxActionUsage(action: string): void { console.log(` Usage: ${CLI_NAME} ${action}`); } -// ── Rebuild ────────────────────────────────────────────────────── - -async function upgradeSandboxes(args: string[] = []): Promise { - const checkOnly = args.includes("--check"); - const auto = args.includes("--auto"); - const skipConfirm = auto || args.includes("--yes"); - - const sandboxes = registry.listSandboxes().sandboxes; - if (sandboxes.length === 0) { - console.log(" No sandboxes found in the registry."); - return; - } - - // Query live sandboxes so we can tell the user which are running - const liveResult = captureOpenshell(["sandbox", "list"], { ignoreError: true }); - if (liveResult.status !== 0) { - console.error(" Failed to query running sandboxes from OpenShell."); - console.error(" Ensure OpenShell is running: openshell status"); - process.exit(liveResult.status || 1); - } - const liveNames = parseLiveSandboxNames(liveResult.output || ""); - - // Classify sandboxes as stale, unknown, or current - const stale = []; - const unknown = []; - for (const sb of sandboxes) { - const versionCheck = sandboxVersion.checkAgentVersion(sb.name); - if (versionCheck.isStale) { - stale.push({ - name: sb.name, - current: versionCheck.sandboxVersion, - expected: versionCheck.expectedVersion, - running: liveNames.has(sb.name), - }); - } else if (versionCheck.detectionMethod === "unavailable") { - unknown.push({ - name: sb.name, - expected: versionCheck.expectedVersion, - running: liveNames.has(sb.name), - }); - } - } - - if (stale.length === 0 && unknown.length === 0) { - console.log(" All sandboxes are up to date."); - return; - } - - if (stale.length > 0) { - console.log(`\n ${B}Stale sandboxes:${R}`); - for (const s of stale) { - const status = s.running ? `${G}running${R}` : `${D}stopped${R}`; - console.log(` ${s.name} v${s.current || "?"} → v${s.expected} (${status})`); - } - } - if (unknown.length > 0) { - console.log(`\n ${YW}Unknown version:${R}`); - for (const s of unknown) { - const status = s.running ? `${G}running${R}` : `${D}stopped${R}`; - console.log(` ${s.name} v? → v${s.expected} (${status})`); - } - } - console.log(""); - - if (checkOnly) { - if (stale.length > 0) console.log(` ${stale.length} sandbox(es) need upgrading.`); - if (unknown.length > 0) { - console.log( - ` ${unknown.length} sandbox(es) could not be version-checked; start them and rerun, or rebuild manually.`, - ); - } - console.log(` Run \`${CLI_NAME} upgrade-sandboxes\` to rebuild them.`); - return; - } - - const rebuildable = stale.filter((s: { running: boolean }) => s.running); - const stopped = stale.filter((s: { running: boolean }) => !s.running); - if (stopped.length > 0) { - console.log(` ${D}Skipping ${stopped.length} stopped sandbox(es) — start them first.${R}`); - } - if (rebuildable.length === 0) { - console.log(" No running stale sandboxes to rebuild."); - return; - } - - let rebuilt = 0; - let failed = 0; - for (const s of rebuildable) { - if (!skipConfirm) { - const answer = await askPrompt(` Rebuild '${s.name}'? [y/N]: `); - if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { - console.log(` Skipped '${s.name}'.`); - continue; - } - } - try { - await sandboxRebuild(s.name, ["--yes"], { throwOnError: true }); - rebuilt++; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - console.error(` ${YW}\u26a0${R} Failed to rebuild '${s.name}': ${errorMessage}`); - failed++; - } - } - - console.log(""); - if (rebuilt > 0) console.log(` ${G}\u2713${R} ${rebuilt} sandbox(es) rebuilt.`); - if (failed > 0) console.log(` ${YW}\u26a0${R} ${failed} sandbox(es) failed — see errors above.`); - if (failed > 0) process.exit(1); -} - // ── Pre-upgrade backup ─────────────────────────────────────────── // ── Snapshot ─────────────────────────────────────────────────────