Skip to content
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6615,7 +6615,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
}),
);
}

await onboardRuntimeBoundary.recordOnboardStarted(resume);
// Backstop for the resume path: a session may exist (so the early guard
// skipped because resume === true) but never have recorded a sandboxName
// — sandbox creation could have failed before that step ran. Without a
Expand Down
94 changes: 94 additions & 0 deletions src/lib/onboard/runtime-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
createSession,
filterSafeUpdates,
normalizeSession,
type Session,
type SessionUpdates,
} from "../state/onboard-session";
import type { OnboardMachineEvent } from "./machine/events";
import { OnboardRuntime, type OnboardRuntimeDeps } from "./machine/runtime";
import { OnboardRuntimeBoundary } from "./runtime-boundary";

function cloneSession(session: Session): Session {
return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session;
}

function createRuntimeHarness() {
let session: Session | null = createSession();
const events: OnboardMachineEvent[] = [];
const updateSession = (mutator: (value: Session) => Session | void): Session => {
const current = session ? cloneSession(session) : createSession();
session = cloneSession(mutator(current) ?? current);
return cloneSession(session);
};
const deps: OnboardRuntimeDeps = {
loadSession: () => (session ? cloneSession(session) : null),
createSession,
saveSession: (next) => {
session = cloneSession(next);
return cloneSession(session);
},
updateSession,
markStepStarted: (stepName) =>
updateSession((current) => {
current.steps[stepName].status = "in_progress";
return current;
}),
markStepComplete: (stepName, updates: SessionUpdates = {}) =>
updateSession((current) => {
current.steps[stepName].status = "complete";
Object.assign(current, filterSafeUpdates(updates));
return current;
}),
markStepSkipped: (stepName) =>
updateSession((current) => {
current.steps[stepName].status = "skipped";
return current;
}),
markStepFailed: (stepName, message) =>
updateSession((current) => {
current.steps[stepName].status = "failed";
current.failure = { step: stepName, message: message ?? null, recordedAt: "now" };
return current;
}),
completeSession: (updates: SessionUpdates = {}) =>
updateSession((current) => {
Object.assign(current, filterSafeUpdates(updates));
current.status = "complete";
return current;
}),
filterSafeUpdates,
emitEvent: (event) => events.push(event),
now: () => "2026-05-27T00:00:00.000Z",
};
return {
createRuntime: () => new OnboardRuntime(deps),
events,
};
}

describe("OnboardRuntimeBoundary", () => {
it("records started and resumed lifecycle events through the runtime", async () => {
const harness = createRuntimeHarness();
const boundary = new OnboardRuntimeBoundary({
toSessionUpdates: (updates) => filterSafeUpdates(updates as SessionUpdates) as SessionUpdates,
maybeForceE2eStepFailure: () => undefined,
createRuntime: harness.createRuntime,
});

await boundary.recordOnboardStarted(false);
await boundary.recordOnboardStarted(true);

expect(harness.events.map((event) => event.type)).toEqual([
"onboard.started",
"onboard.resumed",
]);
expect(harness.events[0]).toMatchObject({ state: "init" });
expect(harness.events[1]).toMatchObject({ state: "init" });
});
});
10 changes: 8 additions & 2 deletions src/lib/onboard/runtime-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { OnboardMachineEventType, OnboardMachineState } from "./machine/typ
export interface OnboardRuntimeBoundaryOptions {
toSessionUpdates(updates: Record<string, unknown>): SessionUpdates;
maybeForceE2eStepFailure(stepName: string): void;
createRuntime?(): OnboardRuntime;
}

export class OnboardRuntimeBoundary {
Expand All @@ -16,20 +17,21 @@ export class OnboardRuntimeBoundary {
constructor(private readonly options: OnboardRuntimeBoundaryOptions) {}

reset(): void {
this.runtime = new OnboardRuntime();
this.runtime = this.options.createRuntime?.() ?? new OnboardRuntime();
}

clear(): void {
this.runtime = null;
}

getRuntime(): OnboardRuntime {
if (!this.runtime) this.runtime = new OnboardRuntime();
if (!this.runtime) this.runtime = this.options.createRuntime?.() ?? new OnboardRuntime();
return this.runtime;
}

recorders() {
return {
recordOnboardStarted: this.recordOnboardStarted.bind(this),
startRecordedStep: this.startRecordedStep.bind(this),
recordStepComplete: this.recordStepComplete.bind(this),
recordStepSkipped: this.recordStepSkipped.bind(this),
Expand All @@ -41,6 +43,10 @@ export class OnboardRuntimeBoundary {
};
}

async recordOnboardStarted(resumed: boolean): Promise<Session> {
return this.getRuntime().start({ resumed });
}

async startRecordedStep(
stepName: string,
updates: {
Expand Down
150 changes: 150 additions & 0 deletions test/onboard-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, it } from "vitest";

type LifecyclePayload = {
calls: Array<{
resumed: boolean;
sessionBeforeExists: boolean;
mode: string | null;
sandboxName: string | null;
}>;
events: Array<{ type: string; state: string | null; step: string | null }>;
};

function runLifecycleEntrypoint(mode: "fresh" | "resume"): LifecyclePayload {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-lifecycle-"));
const scriptPath = path.join(tmpDir, `onboard-lifecycle-${mode}.cjs`);
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const runtimeBoundaryPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "runtime-boundary.js"),
);
const eventsPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "machine", "events.js"),
);

fs.writeFileSync(
scriptPath,
`
const { OnboardRuntimeBoundary } = require(${runtimeBoundaryPath});
const eventsModule = require(${eventsPath});
const emittedEvents = [];
eventsModule.addOnboardMachineEventListener((event) => emittedEvents.push(event));

const sentinel = new Error("stop after onboard lifecycle event");
const originalRecordOnboardStarted = OnboardRuntimeBoundary.prototype.recordOnboardStarted;
const calls = [];
OnboardRuntimeBoundary.prototype.recordOnboardStarted = async function(resumed) {
const onboardSession = require(${onboardPath}).onboardSession;
const sessionBefore = onboardSession.loadSession();
calls.push({
resumed,
sessionBeforeExists: sessionBefore !== null,
mode: sessionBefore?.mode ?? null,
sandboxName: sessionBefore?.sandboxName ?? null,
});
await originalRecordOnboardStarted.call(this, resumed);
throw sentinel;
};

const onboardModule = require(${onboardPath});
if (${JSON.stringify(mode)} === "resume") {
onboardModule.onboardSession.saveSession(
onboardModule.onboardSession.createSession({
mode: "non-interactive",
sandboxName: "resume-lifecycle",
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
}),
);
}

const options = {
resume: ${JSON.stringify(mode)} === "resume",
nonInteractive: true,
acceptThirdPartySoftware: true,
sandboxName: "fresh-lifecycle",
noGpu: true,
};

onboardModule.onboard(options).then(
() => {
throw new Error("expected lifecycle spy to abort onboarding");
},
(error) => {
if (error !== sentinel && error?.message !== sentinel.message) {
console.error(error?.stack || error);
process.exit(1);
}
console.log(JSON.stringify({
calls,
events: emittedEvents.map((event) => ({
type: event.type,
state: event.state,
step: event.step,
})),
}));
},
);
`,
);

try {
const env: Record<string, string | undefined> = { ...process.env, HOME: tmpDir };
delete env.NEMOCLAW_NON_INTERACTIVE;
delete env.NEMOCLAW_SANDBOX_NAME;
delete env.NEMOCLAW_FROM_DOCKERFILE;
delete env.NEMOCLAW_PROVIDER;
delete env.NEMOCLAW_MODEL;

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env,
});

assert.equal(result.status, 0, result.stderr);
const line = result.stdout.trim().split("\n").pop();
assert.ok(line, `expected JSON payload in stdout:\n${result.stdout}`);
return JSON.parse(line) as LifecyclePayload;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

describe("onboard entrypoint lifecycle events", () => {
it("emits onboard.started after creating a fresh session", () => {
const payload = runLifecycleEntrypoint("fresh");

assert.deepEqual(payload.calls, [
{
resumed: false,
sessionBeforeExists: true,
mode: "non-interactive",
sandboxName: null,
},
]);
assert.deepEqual(payload.events, [{ type: "onboard.started", state: "init", step: null }]);
});

it("emits onboard.resumed after loading a resumable session", () => {
const payload = runLifecycleEntrypoint("resume");

assert.deepEqual(payload.calls, [
{
resumed: true,
sessionBeforeExists: true,
mode: "non-interactive",
sandboxName: "resume-lifecycle",
},
]);
assert.deepEqual(payload.events, [{ type: "onboard.resumed", state: "init", step: null }]);
});
});
Loading