diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 26a7b9231fc..bc9656961dc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6615,7 +6615,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { }), ); } - + 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 diff --git a/src/lib/onboard/runtime-boundary.test.ts b/src/lib/onboard/runtime-boundary.test.ts new file mode 100644 index 00000000000..d81116ed86b --- /dev/null +++ b/src/lib/onboard/runtime-boundary.test.ts @@ -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" }); + }); +}); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index daa8a13367a..e90166e17b3 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -8,6 +8,7 @@ import type { OnboardMachineEventType, OnboardMachineState } from "./machine/typ export interface OnboardRuntimeBoundaryOptions { toSessionUpdates(updates: Record): SessionUpdates; maybeForceE2eStepFailure(stepName: string): void; + createRuntime?(): OnboardRuntime; } export class OnboardRuntimeBoundary { @@ -16,7 +17,7 @@ export class OnboardRuntimeBoundary { constructor(private readonly options: OnboardRuntimeBoundaryOptions) {} reset(): void { - this.runtime = new OnboardRuntime(); + this.runtime = this.options.createRuntime?.() ?? new OnboardRuntime(); } clear(): void { @@ -24,12 +25,13 @@ export class OnboardRuntimeBoundary { } 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), @@ -41,6 +43,10 @@ export class OnboardRuntimeBoundary { }; } + async recordOnboardStarted(resumed: boolean): Promise { + return this.getRuntime().start({ resumed }); + } + async startRecordedStep( stepName: string, updates: { diff --git a/test/onboard-lifecycle.test.ts b/test/onboard-lifecycle.test.ts new file mode 100644 index 00000000000..6676c8a1acc --- /dev/null +++ b/test/onboard-lifecycle.test.ts @@ -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 = { ...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 }]); + }); +});