Skip to content

Commit 347568e

Browse files
cvclaude
andauthored
refactor(cli): migrate runtime-recovery.js to TypeScript (#1268)
## Summary - Convert `bin/lib/runtime-recovery.js` (84 lines) to `src/lib/runtime-recovery.ts` - Typed `StateClassification` interface for sandbox/gateway state - 5 pure classifiers/parsers + 1 I/O function (`getRecoveryCommand`) - Co-locate tests with additional edge cases Stacked on #1240. 617 CLI tests pass. Coverage ratchet passes. Relates to #924 (shell consolidation). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved runtime recovery logic to better parse CLI output and determine recovery actions, including more reliable sandbox/gateway state classification and recovery command selection. * **Bug Fixes** * Correctly treats "Disconnected" gateway status as inactive and handles error/empty lines when listing sandboxes. * **Tests** * Expanded test coverage for edge cases, malformed input, and empty states. * **Refactor** * Runtime recovery helpers consolidated to a shared compiled module. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ff3d13 commit 347568e

File tree

3 files changed

+109
-90
lines changed

3 files changed

+109
-90
lines changed

bin/lib/runtime-recovery.js

Lines changed: 4 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Thin re-export shim — the implementation lives in src/lib/runtime-recovery.ts,
5+
// compiled to dist/lib/runtime-recovery.js.
36

4-
const onboardSession = require("./onboard-session");
5-
6-
function stripAnsi(text) {
7-
// eslint-disable-next-line no-control-regex
8-
return String(text || "").replace(/\x1b\[[0-9;]*m/g, "");
9-
}
10-
11-
function parseLiveSandboxNames(listOutput = "") {
12-
const clean = stripAnsi(listOutput);
13-
const names = new Set();
14-
for (const rawLine of clean.split("\n")) {
15-
const line = rawLine.trim();
16-
if (!line) continue;
17-
if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue;
18-
if (/^Error:/i.test(line)) continue;
19-
const cols = line.split(/\s+/);
20-
if (cols[0]) {
21-
names.add(cols[0]);
22-
}
23-
}
24-
return names;
25-
}
26-
27-
function classifySandboxLookup(output = "") {
28-
const clean = stripAnsi(output).trim();
29-
if (!clean) {
30-
return { state: "missing", reason: "empty" };
31-
}
32-
if (/sandbox not found|status:\s*NotFound/i.test(clean)) {
33-
return { state: "missing", reason: "not_found" };
34-
}
35-
if (
36-
/transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test(
37-
clean,
38-
)
39-
) {
40-
return { state: "unavailable", reason: "gateway_unavailable" };
41-
}
42-
return { state: "present", reason: "ok" };
43-
}
44-
45-
function classifyGatewayStatus(output = "") {
46-
const clean = stripAnsi(output).trim();
47-
if (!clean) {
48-
return { state: "inactive", reason: "empty" };
49-
}
50-
if (/Connected/i.test(clean)) {
51-
return { state: "connected", reason: "ok" };
52-
}
53-
if (
54-
/No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test(
55-
clean,
56-
)
57-
) {
58-
return { state: "unavailable", reason: "gateway_unavailable" };
59-
}
60-
return { state: "inactive", reason: "not_connected" };
61-
}
62-
63-
function shouldAttemptGatewayRecovery({
64-
sandboxState = "missing",
65-
gatewayState = "inactive",
66-
} = {}) {
67-
return sandboxState === "unavailable" && gatewayState !== "connected";
68-
}
69-
70-
function getRecoveryCommand() {
71-
const session = onboardSession.loadSession();
72-
if (session && session.resumable !== false) {
73-
return "nemoclaw onboard --resume";
74-
}
75-
return "nemoclaw onboard";
76-
}
77-
78-
module.exports = {
79-
classifyGatewayStatus,
80-
classifySandboxLookup,
81-
getRecoveryCommand,
82-
parseLiveSandboxNames,
83-
shouldAttemptGatewayRecovery,
84-
};
7+
module.exports = require("../../dist/lib/runtime-recovery");
Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33

44
import { describe, expect, it } from "vitest";
55

6+
// Import from compiled dist/ for correct coverage attribution.
67
import {
78
classifyGatewayStatus,
89
classifySandboxLookup,
910
parseLiveSandboxNames,
1011
shouldAttemptGatewayRecovery,
11-
} from "../bin/lib/runtime-recovery";
12+
} from "../../dist/lib/runtime-recovery";
1213

1314
describe("runtime recovery helpers", () => {
1415
it("parses live sandbox names from openshell sandbox list output", () => {
@@ -29,6 +30,15 @@ describe("runtime recovery helpers", () => {
2930
expect(Array.from(parseLiveSandboxNames("No sandboxes found."))).toEqual([]);
3031
});
3132

33+
it("skips error lines", () => {
34+
expect(Array.from(parseLiveSandboxNames("Error: something went wrong"))).toEqual([]);
35+
});
36+
37+
it("handles empty input", () => {
38+
expect(Array.from(parseLiveSandboxNames(""))).toEqual([]);
39+
expect(Array.from(parseLiveSandboxNames())).toEqual([]);
40+
});
41+
3242
it("classifies missing sandbox lookups", () => {
3343
expect(
3444
classifySandboxLookup('Error: × status: NotFound, message: "sandbox not found"').state,
@@ -52,14 +62,7 @@ describe("runtime recovery helpers", () => {
5262
it("classifies successful sandbox lookups as present", () => {
5363
expect(
5464
classifySandboxLookup(
55-
[
56-
"Sandbox:",
57-
"",
58-
" Id: abc",
59-
" Name: my-assistant",
60-
" Namespace: openshell",
61-
" Phase: Ready",
62-
].join("\n"),
65+
["Sandbox:", "", " Id: abc", " Name: my-assistant", " Phase: Ready"].join("\n"),
6366
).state,
6467
).toBe("present");
6568
});
@@ -68,6 +71,9 @@ describe("runtime recovery helpers", () => {
6871
expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Connected").state).toBe("connected");
6972
expect(classifyGatewayStatus("Error: × No active gateway").state).toBe("unavailable");
7073
expect(classifyGatewayStatus("").state).toBe("inactive");
74+
expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Disconnected").state).toBe(
75+
"inactive",
76+
);
7177
});
7278

7379
it("only attempts gateway recovery when sandbox access is unavailable and gateway is down", () => {

src/lib/runtime-recovery.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Runtime recovery helpers — classify sandbox/gateway state from CLI
6+
* output and determine recovery strategy.
7+
*/
8+
9+
// onboard-session is CJS — keep as require
10+
// eslint-disable-next-line @typescript-eslint/no-require-imports
11+
const onboardSession = require("../../bin/lib/onboard-session");
12+
13+
// eslint-disable-next-line no-control-regex
14+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
15+
16+
function stripAnsi(text: unknown): string {
17+
return String(text || "").replace(ANSI_RE, "");
18+
}
19+
20+
export interface StateClassification {
21+
state: string;
22+
reason: string;
23+
}
24+
25+
export function parseLiveSandboxNames(listOutput = ""): Set<string> {
26+
const clean = stripAnsi(listOutput);
27+
const names = new Set<string>();
28+
for (const rawLine of clean.split("\n")) {
29+
const line = rawLine.trim();
30+
if (!line) continue;
31+
if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue;
32+
if (/^Error:/i.test(line)) continue;
33+
const cols = line.split(/\s+/);
34+
if (cols[0]) {
35+
names.add(cols[0]);
36+
}
37+
}
38+
return names;
39+
}
40+
41+
export function classifySandboxLookup(output = ""): StateClassification {
42+
const clean = stripAnsi(output).trim();
43+
if (!clean) {
44+
return { state: "missing", reason: "empty" };
45+
}
46+
if (/sandbox not found|status:\s*NotFound/i.test(clean)) {
47+
return { state: "missing", reason: "not_found" };
48+
}
49+
if (
50+
/transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test(
51+
clean,
52+
)
53+
) {
54+
return { state: "unavailable", reason: "gateway_unavailable" };
55+
}
56+
return { state: "present", reason: "ok" };
57+
}
58+
59+
export function classifyGatewayStatus(output = ""): StateClassification {
60+
const clean = stripAnsi(output).trim();
61+
if (!clean) {
62+
return { state: "inactive", reason: "empty" };
63+
}
64+
if (/\bConnected\b/i.test(clean) && !/\bDisconnected\b/i.test(clean)) {
65+
return { state: "connected", reason: "ok" };
66+
}
67+
if (
68+
/No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test(
69+
clean,
70+
)
71+
) {
72+
return { state: "unavailable", reason: "gateway_unavailable" };
73+
}
74+
return { state: "inactive", reason: "not_connected" };
75+
}
76+
77+
export function shouldAttemptGatewayRecovery({
78+
sandboxState = "missing",
79+
gatewayState = "inactive",
80+
} = {}): boolean {
81+
return sandboxState === "unavailable" && gatewayState !== "connected";
82+
}
83+
84+
export function getRecoveryCommand(): string {
85+
const session = onboardSession.loadSession();
86+
if (session && session.resumable !== false) {
87+
return "nemoclaw onboard --resume";
88+
}
89+
return "nemoclaw onboard";
90+
}

0 commit comments

Comments
 (0)