Skip to content
Merged
207 changes: 2 additions & 205 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const dashboard = require("../../dist/lib/dashboard");
const httpProbe = require("../../dist/lib/http-probe");
const modelPrompts = require("../../dist/lib/model-prompts");
const providerModels = require("../../dist/lib/provider-models");
const sandboxCreateStream = require("../../dist/lib/sandbox-create-stream");
const validationRecovery = require("../../dist/lib/validation-recovery");
const webSearch = require("../../dist/lib/web-search");

Expand Down Expand Up @@ -224,211 +225,7 @@ function repairRecordedSandbox(sandboxName) {
registry.removeSandbox(sandboxName);
}

function streamSandboxCreate(command, env = process.env, options = {}) {
const child = spawn("bash", ["-lc", command], {
cwd: ROOT,
env,
stdio: ["ignore", "pipe", "pipe"],
});

const lines = [];
let pending = "";
let lastPrintedLine = "";
let sawProgress = false;
let settled = false;
let polling = false;
const pollIntervalMs = options.pollIntervalMs || 2000;
const heartbeatIntervalMs = options.heartbeatIntervalMs || 5000;
const silentPhaseMs = options.silentPhaseMs || 15000;
const startedAt = Date.now();
let lastOutputAt = startedAt;
let currentPhase = "build";
let lastHeartbeatPhase = null;
let lastHeartbeatBucket = -1;

function getDisplayWidth() {
return Math.max(60, Number(process.stdout.columns || 100));
}

function trimDisplayLine(line) {
const width = getDisplayWidth();
const maxLen = Math.max(40, width - 4);
if (line.length <= maxLen) return line;
return `${line.slice(0, Math.max(0, maxLen - 3))}...`;
}

function printProgressLine(line) {
const display = trimDisplayLine(line);
if (display !== lastPrintedLine) {
console.log(display);
lastPrintedLine = display;
}
}

function elapsedSeconds() {
return Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
}

function setPhase(nextPhase) {
if (!nextPhase || nextPhase === currentPhase) return;
currentPhase = nextPhase;
lastHeartbeatPhase = null;
lastHeartbeatBucket = -1;
const phaseLine =
nextPhase === "build"
? " Building sandbox image..."
: nextPhase === "upload"
? " Uploading image into OpenShell gateway..."
: nextPhase === "create"
? " Creating sandbox in gateway..."
: nextPhase === "ready"
? " Waiting for sandbox to become ready..."
: null;
if (phaseLine) printProgressLine(phaseLine);
}

function finish(result) {
if (settled) return;
settled = true;
if (pending) flushLine(pending);
if (readyTimer) clearInterval(readyTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
resolvePromise(result);
}

function detachChild() {
child.stdout?.removeAllListeners?.("data");
child.stderr?.removeAllListeners?.("data");
child.stdout?.destroy?.();
child.stderr?.destroy?.();
child.removeAllListeners?.("error");
child.removeAllListeners?.("close");
child.unref?.();
}

function shouldShowLine(line) {
return (
/^ {2}Building image /.test(line) ||
/^ {2}Step \d+\/\d+ : /.test(line) ||
/^ {2}Context: /.test(line) ||
/^ {2}Gateway: /.test(line) ||
/^Successfully built /.test(line) ||
/^Successfully tagged /.test(line) ||
/^ {2}Built image /.test(line) ||
/^ {2}Pushing image /.test(line) ||
/^\s*\[progress\]/.test(line) ||
/^ {2}Image .*available in the gateway/.test(line) ||
/^Created sandbox: /.test(line) ||
/^✓ /.test(line)
);
}

function flushLine(rawLine) {
const line = rawLine.replace(/\r/g, "").trimEnd();
if (!line) return;
lines.push(line);
lastOutputAt = Date.now();
if (/^ {2}Building image /.test(line) || /^ {2}Step \d+\/\d+ : /.test(line)) {
setPhase("build");
} else if (
/^ {2}Pushing image /.test(line) ||
/^\s*\[progress\]/.test(line) ||
/^ {2}Image .*available in the gateway/.test(line)
) {
setPhase("upload");
} else if (/^Created sandbox: /.test(line)) {
setPhase("create");
}
if (shouldShowLine(line) && line !== lastPrintedLine) {
printProgressLine(line);
sawProgress = true;
}
}

function onChunk(chunk) {
pending += chunk.toString();
const parts = pending.split("\n");
pending = parts.pop();
parts.forEach(flushLine);
}

child.stdout.on("data", onChunk);
child.stderr.on("data", onChunk);

let resolvePromise;
const readyTimer = options.readyCheck
? setInterval(() => {
if (settled || polling) return;
polling = true;
try {
let ready = false;
try {
ready = !!options.readyCheck();
} catch {
return;
}
if (!ready) return;
setPhase("ready");
const detail = "Sandbox reported Ready before create stream exited; continuing.";
lines.push(detail);
printProgressLine(` ${detail}`);
try {
child.kill("SIGTERM");
} catch {
// Best effort only — the child may have already exited.
}
detachChild();
finish({ status: 0, output: lines.join("\n"), sawProgress: true, forcedReady: true });
} finally {
polling = false;
}
}, pollIntervalMs)
: null;
readyTimer?.unref?.();

setPhase("build");
const heartbeatTimer = setInterval(() => {
if (settled) return;
const silentForMs = Date.now() - lastOutputAt;
if (silentForMs < silentPhaseMs) return;
const elapsed = elapsedSeconds();
const bucket = Math.floor(elapsed / 15);
if (currentPhase === lastHeartbeatPhase && bucket === lastHeartbeatBucket) {
return;
}
const heartbeatLine =
currentPhase === "upload"
? ` Still uploading image into OpenShell gateway... (${elapsed}s elapsed)`
: currentPhase === "create"
? ` Still creating sandbox in gateway... (${elapsed}s elapsed)`
: currentPhase === "ready"
? ` Still waiting for sandbox to become ready... (${elapsed}s elapsed)`
: ` Still building sandbox image... (${elapsed}s elapsed)`;
if (trimDisplayLine(heartbeatLine) !== lastPrintedLine) {
printProgressLine(heartbeatLine);
lastHeartbeatPhase = currentPhase;
lastHeartbeatBucket = bucket;
}
}, heartbeatIntervalMs);
heartbeatTimer.unref?.();

return new Promise((resolve) => {
resolvePromise = resolve;
child.on("error", (error) => {
// @ts-expect-error — Node ErrnoException has .code but TS types Error
const code = error && error.code;
const detail = code
? `spawn failed: ${error.message} (${code})`
: `spawn failed: ${error.message}`;
lines.push(detail);
finish({ status: 1, output: lines.join("\n"), sawProgress: false });
});

child.on("close", (code) => {
finish({ status: code ?? 1, output: lines.join("\n"), sawProgress });
});
});
}
const { streamSandboxCreate } = sandboxCreateStream;

function streamGatewayStart(command, env = process.env) {
const child = spawn("bash", ["-lc", command], {
Expand Down
127 changes: 127 additions & 0 deletions src/lib/sandbox-create-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { EventEmitter } from "node:events";

import { afterEach, describe, expect, it, vi } from "vitest";

import { streamSandboxCreate } from "./sandbox-create-stream";

class FakeReadable extends EventEmitter {
destroy() {}
}

class FakeChild extends EventEmitter {
stdout = new FakeReadable();
stderr = new FakeReadable();
kill = vi.fn();
unref = vi.fn();
}

describe("sandbox-create-stream", () => {
afterEach(() => {
vi.useRealTimers();
});

it("prints the initial build banner immediately", async () => {
const child = new FakeChild();
const logLine = vi.fn();
const promise = streamSandboxCreate("echo create", process.env, {
logLine,
spawnImpl: () => child as never,
});

expect(logLine).toHaveBeenCalledWith(" Building sandbox image...");
child.emit("close", 0);
await promise;
});

it("streams visible progress lines and returns the collected output", async () => {
const child = new FakeChild();
const logLine = vi.fn();
const promise = streamSandboxCreate("echo create", process.env, {
logLine,
spawnImpl: () => child as never,
heartbeatIntervalMs: 1_000,
silentPhaseMs: 10_000,
});

child.stdout.emit(
"data",
Buffer.from(" Building image sandbox\n Pushing image layers\nCreated sandbox: demo\n✓ Ready\n"),
);
child.emit("close", 0);

await expect(promise).resolves.toMatchObject({
status: 0,
sawProgress: true,
output: expect.stringContaining("Created sandbox: demo"),
});
expect(logLine).toHaveBeenCalledWith(" Building image sandbox");
expect(logLine).toHaveBeenCalledWith(" Pushing image layers");
expect(logLine).toHaveBeenCalledWith("Created sandbox: demo");
});

it("forces success when the sandbox becomes ready before the stream exits", async () => {
vi.useFakeTimers();

const child = new FakeChild();
let checks = 0;
const promise = streamSandboxCreate("echo create", process.env, {
spawnImpl: () => child as never,
readyCheck: () => {
checks += 1;
return checks >= 2;
},
pollIntervalMs: 5,
heartbeatIntervalMs: 1_000,
silentPhaseMs: 10_000,
logLine: vi.fn(),
});

child.stdout.emit("data", Buffer.from(" Building image sandbox\n"));
await vi.advanceTimersByTimeAsync(12);

await expect(promise).resolves.toMatchObject({
status: 0,
sawProgress: true,
forcedReady: true,
output: expect.stringContaining("Sandbox reported Ready before create stream exited"),
});
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(child.unref).toHaveBeenCalled();
});

it("flushes the final partial line before resolving", async () => {
const child = new FakeChild();
const promise = streamSandboxCreate("echo create", process.env, {
spawnImpl: () => child as never,
logLine: vi.fn(),
});

child.stdout.emit("data", Buffer.from("Created sandbox: demo"));
child.emit("close", 0);

await expect(promise).resolves.toMatchObject({
status: 0,
output: "Created sandbox: demo",
sawProgress: true,
});
});

it("reports spawn errors cleanly", async () => {
const child = new FakeChild();
const promise = streamSandboxCreate("echo create", process.env, {
spawnImpl: () => child as never,
logLine: vi.fn(),
});

child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));

await expect(promise).resolves.toEqual({
status: 1,
output: "spawn failed: ENOENT (ENOENT)",
sawProgress: false,
});
});
});
Loading
Loading