diff --git a/bin/lib/runtime-recovery.js b/bin/lib/runtime-recovery.js index ddd358932..0f0346c7d 100644 --- a/bin/lib/runtime-recovery.js +++ b/bin/lib/runtime-recovery.js @@ -1,84 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/runtime-recovery.ts, +// compiled to dist/lib/runtime-recovery.js. -const onboardSession = require("./onboard-session"); - -function stripAnsi(text) { - // eslint-disable-next-line no-control-regex - return String(text || "").replace(/\x1b\[[0-9;]*m/g, ""); -} - -function parseLiveSandboxNames(listOutput = "") { - const clean = stripAnsi(listOutput); - const names = new Set(); - for (const rawLine of clean.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue; - if (/^Error:/i.test(line)) continue; - const cols = line.split(/\s+/); - if (cols[0]) { - names.add(cols[0]); - } - } - return names; -} - -function classifySandboxLookup(output = "") { - const clean = stripAnsi(output).trim(); - if (!clean) { - return { state: "missing", reason: "empty" }; - } - if (/sandbox not found|status:\s*NotFound/i.test(clean)) { - return { state: "missing", reason: "not_found" }; - } - if ( - /transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test( - clean, - ) - ) { - return { state: "unavailable", reason: "gateway_unavailable" }; - } - return { state: "present", reason: "ok" }; -} - -function classifyGatewayStatus(output = "") { - const clean = stripAnsi(output).trim(); - if (!clean) { - return { state: "inactive", reason: "empty" }; - } - if (/Connected/i.test(clean)) { - return { state: "connected", reason: "ok" }; - } - if ( - /No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test( - clean, - ) - ) { - return { state: "unavailable", reason: "gateway_unavailable" }; - } - return { state: "inactive", reason: "not_connected" }; -} - -function shouldAttemptGatewayRecovery({ - sandboxState = "missing", - gatewayState = "inactive", -} = {}) { - return sandboxState === "unavailable" && gatewayState !== "connected"; -} - -function getRecoveryCommand() { - const session = onboardSession.loadSession(); - if (session && session.resumable !== false) { - return "nemoclaw onboard --resume"; - } - return "nemoclaw onboard"; -} - -module.exports = { - classifyGatewayStatus, - classifySandboxLookup, - getRecoveryCommand, - parseLiveSandboxNames, - shouldAttemptGatewayRecovery, -}; +module.exports = require("../../dist/lib/runtime-recovery"); diff --git a/test/runtime-recovery.test.js b/src/lib/runtime-recovery.test.ts similarity index 82% rename from test/runtime-recovery.test.js rename to src/lib/runtime-recovery.test.ts index c58b51af3..cf364bd8b 100644 --- a/test/runtime-recovery.test.js +++ b/src/lib/runtime-recovery.test.ts @@ -3,12 +3,13 @@ import { describe, expect, it } from "vitest"; +// Import from compiled dist/ for correct coverage attribution. import { classifyGatewayStatus, classifySandboxLookup, parseLiveSandboxNames, shouldAttemptGatewayRecovery, -} from "../bin/lib/runtime-recovery"; +} from "../../dist/lib/runtime-recovery"; describe("runtime recovery helpers", () => { it("parses live sandbox names from openshell sandbox list output", () => { @@ -29,6 +30,15 @@ describe("runtime recovery helpers", () => { expect(Array.from(parseLiveSandboxNames("No sandboxes found."))).toEqual([]); }); + it("skips error lines", () => { + expect(Array.from(parseLiveSandboxNames("Error: something went wrong"))).toEqual([]); + }); + + it("handles empty input", () => { + expect(Array.from(parseLiveSandboxNames(""))).toEqual([]); + expect(Array.from(parseLiveSandboxNames())).toEqual([]); + }); + it("classifies missing sandbox lookups", () => { expect( classifySandboxLookup('Error: × status: NotFound, message: "sandbox not found"').state, @@ -52,14 +62,7 @@ describe("runtime recovery helpers", () => { it("classifies successful sandbox lookups as present", () => { expect( classifySandboxLookup( - [ - "Sandbox:", - "", - " Id: abc", - " Name: my-assistant", - " Namespace: openshell", - " Phase: Ready", - ].join("\n"), + ["Sandbox:", "", " Id: abc", " Name: my-assistant", " Phase: Ready"].join("\n"), ).state, ).toBe("present"); }); @@ -68,6 +71,9 @@ describe("runtime recovery helpers", () => { expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Connected").state).toBe("connected"); expect(classifyGatewayStatus("Error: × No active gateway").state).toBe("unavailable"); expect(classifyGatewayStatus("").state).toBe("inactive"); + expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Disconnected").state).toBe( + "inactive", + ); }); it("only attempts gateway recovery when sandbox access is unavailable and gateway is down", () => { diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts new file mode 100644 index 000000000..134d433f2 --- /dev/null +++ b/src/lib/runtime-recovery.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Runtime recovery helpers — classify sandbox/gateway state from CLI + * output and determine recovery strategy. + */ + +// onboard-session is CJS — keep as require +// eslint-disable-next-line @typescript-eslint/no-require-imports +const onboardSession = require("../../bin/lib/onboard-session"); + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(text: unknown): string { + return String(text || "").replace(ANSI_RE, ""); +} + +export interface StateClassification { + state: string; + reason: string; +} + +export function parseLiveSandboxNames(listOutput = ""): Set { + const clean = stripAnsi(listOutput); + const names = new Set(); + for (const rawLine of clean.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue; + if (/^Error:/i.test(line)) continue; + const cols = line.split(/\s+/); + if (cols[0]) { + names.add(cols[0]); + } + } + return names; +} + +export function classifySandboxLookup(output = ""): StateClassification { + const clean = stripAnsi(output).trim(); + if (!clean) { + return { state: "missing", reason: "empty" }; + } + if (/sandbox not found|status:\s*NotFound/i.test(clean)) { + return { state: "missing", reason: "not_found" }; + } + if ( + /transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test( + clean, + ) + ) { + return { state: "unavailable", reason: "gateway_unavailable" }; + } + return { state: "present", reason: "ok" }; +} + +export function classifyGatewayStatus(output = ""): StateClassification { + const clean = stripAnsi(output).trim(); + if (!clean) { + return { state: "inactive", reason: "empty" }; + } + if (/\bConnected\b/i.test(clean) && !/\bDisconnected\b/i.test(clean)) { + return { state: "connected", reason: "ok" }; + } + if ( + /No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test( + clean, + ) + ) { + return { state: "unavailable", reason: "gateway_unavailable" }; + } + return { state: "inactive", reason: "not_connected" }; +} + +export function shouldAttemptGatewayRecovery({ + sandboxState = "missing", + gatewayState = "inactive", +} = {}): boolean { + return sandboxState === "unavailable" && gatewayState !== "connected"; +} + +export function getRecoveryCommand(): string { + const session = onboardSession.loadSession(); + if (session && session.resumable !== false) { + return "nemoclaw onboard --resume"; + } + return "nemoclaw onboard"; +}