From 9ee6cfab9b3fd24ff2ad22c06af41871832cebfb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 19:09:44 -0700 Subject: [PATCH 1/7] refactor(cli): add onboard FSM transition types --- src/lib/onboard/machine/transitions.test.ts | 164 ++++++++++++++++++++ src/lib/onboard/machine/transitions.ts | 107 +++++++++++++ src/lib/onboard/machine/types.ts | 101 ++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/lib/onboard/machine/transitions.test.ts create mode 100644 src/lib/onboard/machine/transitions.ts create mode 100644 src/lib/onboard/machine/types.ts diff --git a/src/lib/onboard/machine/transitions.test.ts b/src/lib/onboard/machine/transitions.test.ts new file mode 100644 index 0000000000..875a0ec45a --- /dev/null +++ b/src/lib/onboard/machine/transitions.test.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + ONBOARD_MACHINE_EVENT_TYPES, + ONBOARD_MACHINE_STATES, + ONBOARD_NON_TERMINAL_MACHINE_STATES, +} from "./types"; +import { + assertValidOnboardMachineTransition, + canTransitionOnboardMachineState, + getNextOnboardMachineStates, + getOnboardMachineTransition, + InvalidOnboardMachineTransitionError, + isOnboardMachineState, + isTerminalOnboardMachineState, + ONBOARD_MACHINE_DIRECT_TRANSITIONS, + ONBOARD_MACHINE_NEXT_STATES, + ONBOARD_MACHINE_TRANSITIONS, +} from "./transitions"; + +const canonicalDirectTransitions = [ + ["init", "preflight", "advance"], + ["preflight", "gateway", "advance"], + ["gateway", "provider_selection", "advance"], + ["provider_selection", "inference", "advance"], + ["inference", "provider_selection", "retry"], + ["inference", "sandbox", "advance"], + ["sandbox", "openclaw", "branch"], + ["sandbox", "agent_setup", "branch"], + ["openclaw", "policies", "advance"], + ["agent_setup", "policies", "advance"], + ["policies", "finalizing", "advance"], + ["finalizing", "post_verify", "advance"], + ["post_verify", "complete", "advance"], +] as const; + +describe("onboard machine vocabulary", () => { + it("defines the initial coarse state vocabulary from issue #3802", () => { + expect(ONBOARD_MACHINE_STATES).toEqual([ + "init", + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + "finalizing", + "post_verify", + "complete", + "failed", + ]); + }); + + it("defines the initial observe-only event vocabulary from issue #3802", () => { + expect(ONBOARD_MACHINE_EVENT_TYPES).toEqual([ + "onboard.started", + "onboard.resumed", + "onboard.completed", + "onboard.failed", + "state.entered", + "state.exited", + "state.skipped", + "state.completed", + "state.failed", + "state.repair.started", + "state.repair.completed", + "state.repair.failed", + "context.updated", + "resume.conflict", + "hook.started", + "hook.completed", + "hook.failed", + ]); + }); + + it("recognizes valid machine state names", () => { + expect(isOnboardMachineState("preflight")).toBe(true); + expect(isOnboardMachineState("messaging")).toBe(false); + expect(isOnboardMachineState(null)).toBe(false); + }); +}); + +describe("onboard machine transitions", () => { + it("encodes the canonical direct transition graph", () => { + expect(ONBOARD_MACHINE_DIRECT_TRANSITIONS).toEqual( + canonicalDirectTransitions.map(([from, to, kind]) => ({ from, to, kind })), + ); + }); + + it("allows every non-terminal state to fail", () => { + for (const state of ONBOARD_NON_TERMINAL_MACHINE_STATES) { + expect(canTransitionOnboardMachineState(state, "failed")).toBe(true); + expect(getOnboardMachineTransition(state, "failed")?.kind).toBe("failure"); + } + }); + + it("keeps terminal states terminal", () => { + expect(isTerminalOnboardMachineState("complete")).toBe(true); + expect(isTerminalOnboardMachineState("failed")).toBe(true); + expect(getNextOnboardMachineStates("complete")).toEqual([]); + expect(getNextOnboardMachineStates("failed")).toEqual([]); + expect(canTransitionOnboardMachineState("complete", "failed")).toBe(false); + expect(canTransitionOnboardMachineState("failed", "init")).toBe(false); + }); + + it("exposes next states in deterministic order", () => { + expect(ONBOARD_MACHINE_NEXT_STATES).toEqual({ + init: ["preflight", "failed"], + preflight: ["gateway", "failed"], + gateway: ["provider_selection", "failed"], + provider_selection: ["inference", "failed"], + inference: ["provider_selection", "sandbox", "failed"], + sandbox: ["openclaw", "agent_setup", "failed"], + agent_setup: ["policies", "failed"], + openclaw: ["policies", "failed"], + policies: ["finalizing", "failed"], + finalizing: ["post_verify", "failed"], + post_verify: ["complete", "failed"], + complete: [], + failed: [], + }); + }); + + it("classifies retry and branch transitions", () => { + expect(assertValidOnboardMachineTransition("inference", "provider_selection")).toMatchObject({ + kind: "retry", + }); + expect(assertValidOnboardMachineTransition("sandbox", "openclaw")).toMatchObject({ + kind: "branch", + }); + expect(assertValidOnboardMachineTransition("sandbox", "agent_setup")).toMatchObject({ + kind: "branch", + }); + }); + + it("rejects transitions outside the graph", () => { + expect(() => assertValidOnboardMachineTransition("init", "sandbox")).toThrow( + InvalidOnboardMachineTransitionError, + ); + expect(() => assertValidOnboardMachineTransition("complete", "failed")).toThrow( + "complete -> failed", + ); + }); + + it("keeps the next-state map aligned with the transition list", () => { + for (const state of ONBOARD_MACHINE_STATES) { + expect( + ONBOARD_MACHINE_TRANSITIONS.filter((transition) => transition.from === state).map( + (transition) => transition.to, + ), + ).toEqual(getNextOnboardMachineStates(state)); + } + }); + + it("does not contain duplicate transition edges", () => { + const edges = ONBOARD_MACHINE_TRANSITIONS.map(({ from, to }) => `${from}->${to}`); + expect(new Set(edges).size).toBe(edges.length); + }); +}); diff --git a/src/lib/onboard/machine/transitions.ts b/src/lib/onboard/machine/transitions.ts new file mode 100644 index 0000000000..9f23e3895a --- /dev/null +++ b/src/lib/onboard/machine/transitions.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardMachineState, OnboardMachineTransition } from "./types"; +import { + ONBOARD_MACHINE_STATES, + ONBOARD_NON_TERMINAL_MACHINE_STATES, + ONBOARD_TERMINAL_MACHINE_STATES, +} from "./types"; + +export const ONBOARD_MACHINE_NEXT_STATES = { + init: ["preflight", "failed"], + preflight: ["gateway", "failed"], + gateway: ["provider_selection", "failed"], + provider_selection: ["inference", "failed"], + inference: ["provider_selection", "sandbox", "failed"], + sandbox: ["openclaw", "agent_setup", "failed"], + agent_setup: ["policies", "failed"], + openclaw: ["policies", "failed"], + policies: ["finalizing", "failed"], + finalizing: ["post_verify", "failed"], + post_verify: ["complete", "failed"], + complete: [], + failed: [], +} as const satisfies Readonly>; + +export const ONBOARD_MACHINE_DIRECT_TRANSITIONS = [ + { from: "init", to: "preflight", kind: "advance" }, + { from: "preflight", to: "gateway", kind: "advance" }, + { from: "gateway", to: "provider_selection", kind: "advance" }, + { from: "provider_selection", to: "inference", kind: "advance" }, + { from: "inference", to: "provider_selection", kind: "retry" }, + { from: "inference", to: "sandbox", kind: "advance" }, + { from: "sandbox", to: "openclaw", kind: "branch" }, + { from: "sandbox", to: "agent_setup", kind: "branch" }, + { from: "openclaw", to: "policies", kind: "advance" }, + { from: "agent_setup", to: "policies", kind: "advance" }, + { from: "policies", to: "finalizing", kind: "advance" }, + { from: "finalizing", to: "post_verify", kind: "advance" }, + { from: "post_verify", to: "complete", kind: "advance" }, +] as const satisfies readonly OnboardMachineTransition[]; + +export const ONBOARD_MACHINE_FAILURE_TRANSITIONS = ONBOARD_NON_TERMINAL_MACHINE_STATES.map( + (from) => ({ from, to: "failed" as const, kind: "failure" as const }), +) satisfies readonly OnboardMachineTransition[]; + +export const ONBOARD_MACHINE_TRANSITIONS = [ + ...ONBOARD_MACHINE_DIRECT_TRANSITIONS, + ...ONBOARD_MACHINE_FAILURE_TRANSITIONS, +] as const satisfies readonly OnboardMachineTransition[]; + +export class InvalidOnboardMachineTransitionError extends Error { + readonly from: OnboardMachineState; + readonly to: OnboardMachineState; + + constructor(from: OnboardMachineState, to: OnboardMachineState) { + super(`Invalid onboarding machine transition: ${from} -> ${to}`); + this.name = "InvalidOnboardMachineTransitionError"; + this.from = from; + this.to = to; + } +} + +export function isOnboardMachineState(value: unknown): value is OnboardMachineState { + return typeof value === "string" && ONBOARD_MACHINE_STATES.includes(value as OnboardMachineState); +} + +export function isTerminalOnboardMachineState( + state: OnboardMachineState, +): state is "complete" | "failed" { + return ONBOARD_TERMINAL_MACHINE_STATES.includes(state as "complete" | "failed"); +} + +export function getNextOnboardMachineStates( + from: OnboardMachineState, +): readonly OnboardMachineState[] { + return ONBOARD_MACHINE_NEXT_STATES[from]; +} + +export function canTransitionOnboardMachineState( + from: OnboardMachineState, + to: OnboardMachineState, +): boolean { + return getNextOnboardMachineStates(from).includes(to); +} + +export function getOnboardMachineTransition( + from: OnboardMachineState, + to: OnboardMachineState, +): OnboardMachineTransition | null { + return ( + ONBOARD_MACHINE_TRANSITIONS.find( + (transition) => transition.from === from && transition.to === to, + ) ?? null + ); +} + +export function assertValidOnboardMachineTransition( + from: OnboardMachineState, + to: OnboardMachineState, +): OnboardMachineTransition { + const transition = getOnboardMachineTransition(from, to); + if (!transition) { + throw new InvalidOnboardMachineTransitionError(from, to); + } + return transition; +} diff --git a/src/lib/onboard/machine/types.ts b/src/lib/onboard/machine/types.ts new file mode 100644 index 0000000000..bbba7bd5f6 --- /dev/null +++ b/src/lib/onboard/machine/types.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Coarse onboarding finite-state-machine vocabulary. + * + * These types intentionally model only major step boundaries. Mid-operation + * resume inside gateway startup, sandbox creation, credential upserts, model + * probes, or policy application is out of scope for the initial FSM shell. + */ + +export const ONBOARD_MACHINE_STATES = [ + "init", + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + "finalizing", + "post_verify", + "complete", + "failed", +] as const; + +export type OnboardMachineState = (typeof ONBOARD_MACHINE_STATES)[number]; + +export const ONBOARD_TERMINAL_MACHINE_STATES = ["complete", "failed"] as const; + +export type OnboardTerminalMachineState = + (typeof ONBOARD_TERMINAL_MACHINE_STATES)[number]; + +export type OnboardNonTerminalMachineState = Exclude< + OnboardMachineState, + OnboardTerminalMachineState +>; + +export const ONBOARD_NON_TERMINAL_MACHINE_STATES: readonly OnboardNonTerminalMachineState[] = + ONBOARD_MACHINE_STATES.filter( + (state): state is OnboardNonTerminalMachineState => + !ONBOARD_TERMINAL_MACHINE_STATES.includes(state as OnboardTerminalMachineState), + ); + +export const ONBOARD_MACHINE_EVENT_TYPES = [ + "onboard.started", + "onboard.resumed", + "onboard.completed", + "onboard.failed", + "state.entered", + "state.exited", + "state.skipped", + "state.completed", + "state.failed", + "state.repair.started", + "state.repair.completed", + "state.repair.failed", + "context.updated", + "resume.conflict", + "hook.started", + "hook.completed", + "hook.failed", +] as const; + +export type OnboardMachineEventType = (typeof ONBOARD_MACHINE_EVENT_TYPES)[number]; + +export type OnboardMachineTransitionKind = + | "advance" + | "retry" + | "branch" + | "failure"; + +export interface OnboardMachineTransition { + from: OnboardMachineState; + to: OnboardMachineState; + kind: OnboardMachineTransitionKind; +} + +/** + * Stable, redacted context keys that machine events may expose. + * + * Do not add raw secrets or unredacted URLs here. Runtime-derived topology + * decisions such as Docker/WSL reachability, Ollama proxy necessity, or live + * gateway health should be recomputed during execution rather than stored as + * durable FSM context. + */ +export interface OnboardMachineContext { + agent?: string | null; + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + endpointUrl?: string | null; + credentialEnv?: string | null; + preferredInferenceApi?: string | null; + hermesAuthMethod?: "oauth" | "api_key" | null; + hermesToolGateways?: string[] | null; + policyPresets?: string[] | null; + messagingChannels?: string[] | null; + gpuPassthrough?: boolean; +} From b9e4545e44066975dab7945a93b580b366ec82c2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 19:27:06 -0700 Subject: [PATCH 2/7] refactor(cli): emit onboard session machine events --- src/lib/onboard/machine/events.ts | 166 ++++++++++++++++++++++++++ src/lib/state/onboard-session.test.ts | 90 ++++++++++++++ src/lib/state/onboard-session.ts | 94 +++++++++++++-- 3 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 src/lib/onboard/machine/events.ts diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts new file mode 100644 index 0000000000..9a68d3f899 --- /dev/null +++ b/src/lib/onboard/machine/events.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonObject, JsonValue } from "../../core/json-types"; +import { redactSensitiveText, redactUrl } from "../../security/redact"; +import type { HermesAuthMethod, Session } from "../../state/onboard-session"; +import type { + OnboardMachineContext, + OnboardMachineEventType, + OnboardMachineState, +} from "./types"; + +export const ONBOARD_SESSION_STEP_TO_MACHINE_STATE = { + preflight: "preflight", + gateway: "gateway", + provider_selection: "provider_selection", + inference: "inference", + sandbox: "sandbox", + agent_setup: "agent_setup", + openclaw: "openclaw", + policies: "policies", +} as const satisfies Readonly>; + +export type OnboardSessionStepName = keyof typeof ONBOARD_SESSION_STEP_TO_MACHINE_STATE; + +export interface OnboardMachineEvent { + version: 1; + type: OnboardMachineEventType; + occurredAt: string; + sessionId: string | null; + state: OnboardMachineState | null; + step: OnboardSessionStepName | null; + context: OnboardMachineContext; + error: string | null; + metadata: JsonObject; +} + +export type OnboardMachineEventListener = (event: OnboardMachineEvent) => void; + +const listeners = new Set(); + +export function addOnboardMachineEventListener( + listener: OnboardMachineEventListener, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function clearOnboardMachineEventListeners(): void { + listeners.clear(); +} + +export function isOnboardSessionStepName(value: string): value is OnboardSessionStepName { + return Object.prototype.hasOwnProperty.call(ONBOARD_SESSION_STEP_TO_MACHINE_STATE, value); +} + +export function machineStateFromOnboardSessionStep( + stepName: string | null | undefined, +): OnboardMachineState | null { + if (!stepName || !isOnboardSessionStepName(stepName)) return null; + return ONBOARD_SESSION_STEP_TO_MACHINE_STATE[stepName]; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function hermesAuthMethod(value: unknown): HermesAuthMethod | null { + return value === "oauth" || value === "api_key" ? value : null; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function sanitizeJsonValue(value: unknown): JsonValue { + if (typeof value === "string") return redactUrl(value) ?? redactSensitiveText(value) ?? ""; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean" || value === null) return value; + if (Array.isArray(value)) return value.map((entry) => sanitizeJsonValue(entry)); + if (typeof value !== "object" || value === null) return String(value); + + const result: JsonObject = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = sanitizeJsonValue(entry); + } + return result; +} + +export function sanitizeOnboardMachineEventMetadata( + metadata: Record | null | undefined, +): JsonObject { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {}; + const sanitized: JsonObject = {}; + for (const [key, value] of Object.entries(metadata)) { + sanitized[key] = sanitizeJsonValue(value); + } + return sanitized; +} + +export function buildOnboardMachineContext(session: Session): OnboardMachineContext { + const endpointUrl = redactUrl(session.endpointUrl); + return { + agent: nullableString(session.agent), + sandboxName: nullableString(session.sandboxName), + provider: nullableString(session.provider), + model: nullableString(session.model), + endpointUrl, + credentialEnv: nullableString(session.credentialEnv), + preferredInferenceApi: nullableString(session.preferredInferenceApi), + hermesAuthMethod: hermesAuthMethod(session.hermesAuthMethod), + hermesToolGateways: stringArray(session.hermesToolGateways), + policyPresets: stringArray(session.policyPresets), + messagingChannels: stringArray(session.messagingChannels), + gpuPassthrough: booleanValue(session.gpuPassthrough), + }; +} + +export function createOnboardMachineEvent({ + type, + session, + step, + state, + error = null, + metadata = {}, +}: { + type: OnboardMachineEventType; + session: Session; + step?: string | null; + state?: OnboardMachineState | null; + error?: string | null; + metadata?: Record | null; +}): OnboardMachineEvent { + const normalizedStep = step && isOnboardSessionStepName(step) ? step : null; + return { + version: 1, + type, + occurredAt: new Date().toISOString(), + sessionId: nullableString(session.sessionId), + state: state ?? machineStateFromOnboardSessionStep(normalizedStep), + step: normalizedStep, + context: buildOnboardMachineContext(session), + error: redactSensitiveText(error), + metadata: sanitizeOnboardMachineEventMetadata(metadata), + }; +} + +export function emitOnboardMachineEvent(event: OnboardMachineEvent): void { + if (listeners.size === 0) return; + for (const listener of listeners) { + try { + listener(event); + } catch { + // Event observers are diagnostics only. A broken observer must not + // change onboarding behavior; hook failure events are introduced by the + // later observe-only hook API. + } + } +} diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index b2c925858f..5ddd94908d 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -9,11 +9,15 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const distPath = require.resolve("../../../dist/lib/state/onboard-session"); +const eventsDistPath = require.resolve("../../../dist/lib/onboard/machine/events"); const originalHome = process.env.HOME; type OnboardSessionModule = typeof import("../../../dist/lib/state/onboard-session"); +type OnboardMachineEventsModule = typeof import("../../../dist/lib/onboard/machine/events"); +type OnboardMachineEvent = import("../../../dist/lib/onboard/machine/events").OnboardMachineEvent; type LoadedSession = NonNullable>; type DebugSummary = NonNullable>; let session: OnboardSessionModule; +let machineEvents: OnboardMachineEventsModule; let tmpDir: string; function requireLoadedSession( @@ -44,13 +48,18 @@ beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-session-")); process.env.HOME = tmpDir; delete require.cache[distPath]; + delete require.cache[eventsDistPath]; session = require("../../../dist/lib/state/onboard-session"); + machineEvents = require("../../../dist/lib/onboard/machine/events"); + machineEvents.clearOnboardMachineEventListeners(); session.clearSession(); session.releaseOnboardLock(); }); afterEach(() => { + machineEvents.clearOnboardMachineEventListeners(); delete require.cache[distPath]; + delete require.cache[eventsDistPath]; fs.rmSync(tmpDir, { recursive: true, force: true }); if (originalHome === undefined) { delete process.env.HOME; @@ -117,6 +126,87 @@ describe("onboard session", () => { expect(loaded.failure.message).toMatch(/Sandbox creation failed/); }); + it("emits redacted structured machine events for session step mutations", () => { + const emitted: OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + + session.saveSession(session.createSession({ sessionId: "session-1" })); + session.markStepStarted("gateway"); + session.markStepComplete("gateway", { + sandboxName: "my-assistant", + endpointUrl: + "https://alice:super-secret-token@example.com/v1?token=super-secret-token&keep=yes#token=super-secret-token", + credentialEnv: "NVIDIA_API_KEY", + }); + session.markStepSkipped("openclaw"); + session.markStepFailed("sandbox", "NVIDIA_API_KEY=super-secret-token"); + session.completeSession({ provider: "ollama-local", credentialEnv: null }); + + expect(emitted.map((event) => event.type)).toEqual([ + "state.entered", + "context.updated", + "state.completed", + "state.skipped", + "state.failed", + "onboard.failed", + "context.updated", + "onboard.completed", + ]); + expect(emitted[0]).toMatchObject({ + version: 1, + sessionId: "session-1", + state: "gateway", + step: "gateway", + error: null, + }); + expect(emitted[1].context).toMatchObject({ + sandboxName: "my-assistant", + credentialEnv: "NVIDIA_API_KEY", + }); + expect(emitted[1].context.endpointUrl).toBe( + "https://example.com/v1?token=%3CREDACTED%3E&keep=yes", + ); + expect(emitted[1].metadata.fields).toEqual([ + "sandboxName", + "endpointUrl", + "credentialEnv", + ]); + expect(emitted[4]).toMatchObject({ + type: "state.failed", + state: "sandbox", + step: "sandbox", + error: "NVIDIA_API_KEY=", + }); + expect(emitted[5]).toMatchObject({ type: "onboard.failed", state: "failed" }); + expect(emitted.at(-1)).toMatchObject({ type: "onboard.completed", state: "complete" }); + expect(JSON.stringify(emitted)).not.toContain("super-secret-token"); + + const persisted = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")); + expect(persisted.events).toBeUndefined(); + }); + + it("keeps event observer failures from changing session mutation behavior", () => { + machineEvents.addOnboardMachineEventListener(() => { + throw new Error("observer failed"); + }); + + session.saveSession(session.createSession()); + expect(() => session.markStepStarted("preflight")).not.toThrow(); + + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.preflight.status).toBe("in_progress"); + }); + + it("does not emit machine events for unknown session step names", () => { + const emitted: OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + + session.saveSession(session.createSession()); + session.markStepStarted("not_a_real_step"); + + expect(emitted).toEqual([]); + }); + it("persists safe provider metadata without persisting secrets", () => { session.saveSession(session.createSession()); const unsafeProviderUpdate: Parameters[1] & { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index f05c1116e8..7fe94d8096 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -18,6 +18,10 @@ import { sanitizeMessagingChannelConfig, type MessagingChannelConfig, } from "../messaging-channel-config"; +import { + createOnboardMachineEvent, + emitOnboardMachineEvent, +} from "../onboard/machine/events"; import { redactSensitiveText, redactUrl } from "../security/redact"; export const SESSION_VERSION = 1; @@ -883,7 +887,8 @@ export function updateSession(mutator: (session: Session) => Session | void): Se } export function markStepStarted(stepName: string): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "in_progress"; @@ -893,12 +898,21 @@ export function markStepStarted(stepName: string): Session { session.lastStepStarted = stepName; session.failure = null; session.status = "in_progress"; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.entered", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepComplete(stepName: string, updates: SessionUpdates = {}): Session { - return updateSession((session) => { + const safeUpdates = filterSafeUpdates(updates); + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "complete"; @@ -906,13 +920,31 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) step.error = null; session.lastCompletedStep = stepName; session.failure = null; - Object.assign(session, filterSafeUpdates(updates)); + Object.assign(session, safeUpdates); + shouldEmit = true; return session; }); + if (shouldEmit) { + if (Object.keys(safeUpdates).length > 0) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "context.updated", + session: updatedSession, + step: stepName, + metadata: { fields: Object.keys(safeUpdates) }, + }), + ); + } + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.completed", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepSkipped(stepName: string): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; if (step.status === "complete" || step.status === "failed") return session; @@ -920,12 +952,20 @@ export function markStepSkipped(stepName: string): Session { step.startedAt = null; step.completedAt = null; step.error = null; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.skipped", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepFailed(stepName: string, message: string | null = null): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "failed"; @@ -937,18 +977,58 @@ export function markStepFailed(stepName: string, message: string | null = null): recordedAt: new Date().toISOString(), }); session.status = "failed"; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "state.failed", + session: updatedSession, + step: stepName, + error: message, + }), + ); + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "onboard.failed", + session: updatedSession, + state: "failed", + step: stepName, + error: message, + }), + ); + } + return updatedSession; } export function completeSession(updates: SessionUpdates = {}): Session { - return updateSession((session) => { - Object.assign(session, filterSafeUpdates(updates)); + const safeUpdates = filterSafeUpdates(updates); + const updatedSession = updateSession((session) => { + Object.assign(session, safeUpdates); session.status = "complete"; session.resumable = false; session.failure = null; return session; }); + if (Object.keys(safeUpdates).length > 0) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "context.updated", + session: updatedSession, + state: "complete", + metadata: { fields: Object.keys(safeUpdates) }, + }), + ); + } + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "onboard.completed", + session: updatedSession, + state: "complete", + }), + ); + return updatedSession; } export function summarizeForDebug( From 651e2a07c3f34bd38cf942d08ad350e5d6b5eb86 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 21:47:57 -0700 Subject: [PATCH 3/7] refactor(cli): persist onboard machine snapshots --- src/lib/actions/inference-set.test.ts | 8 +- src/lib/state/onboard-session.test.ts | 115 ++++++++++++++++++++ src/lib/state/onboard-session.ts | 145 ++++++++++++++++++++++++-- 3 files changed, 259 insertions(+), 9 deletions(-) diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index ae091f7adf..f6c178f0cf 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -86,9 +86,15 @@ function baseSession(overrides: Partial = {}): Session { telegramConfig: null, wechatConfig: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + machine: { + version: 1, + state: "complete", + stateEnteredAt: "2026-05-11T00:00:00.000Z", + revision: 0, + }, steps: {}, ...overrides, - }; + } as Session; } function createDeps(options: { diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 5ddd94908d..8e4b9f5cbc 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -40,6 +40,14 @@ function requireDebugSummary( return summary; } +function normalizeLegacySession( + legacy: unknown, +): ReturnType { + return session.normalizeSession( + legacy as Parameters[0], + ); +} + beforeEach(() => { // Recreate tmpDir per test so lock artifacts (and any other on-disk state) // from a previous test cannot leak into this one. Without this, malformed @@ -80,6 +88,12 @@ describe("onboard session", () => { const dirStat = fs.statSync(path.dirname(session.SESSION_FILE)); expect(saved.mode).toBe("non-interactive"); + expect(saved.machine).toMatchObject({ + version: 1, + state: "init", + revision: 0, + }); + expect(saved.machine.stateEnteredAt).toBeTruthy(); expect(fs.existsSync(session.SESSION_FILE)).toBe(true); expect(stat.mode & 0o777).toBe(0o600); expect(dirStat.mode & 0o777).toBe(0o700); @@ -124,6 +138,107 @@ describe("onboard session", () => { } expect(loaded.failure.step).toBe("sandbox"); expect(loaded.failure.message).toMatch(/Sandbox creation failed/); + expect(loaded.machine.state).toBe("failed"); + }); + + it("persists a compact machine snapshot across step boundaries", () => { + session.saveSession(session.createSession()); + let loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "init", revision: 0 }); + + session.markStepStarted("preflight"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "preflight", revision: 1 }); + expect(loaded.machine.stateEnteredAt).toBe(loaded.steps.preflight.startedAt); + + session.markStepComplete("preflight"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "gateway", revision: 2 }); + expect(loaded.machine.stateEnteredAt).toBe(loaded.steps.preflight.completedAt); + + session.markStepComplete("gateway"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "provider_selection", revision: 3 }); + + session.completeSession(); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "complete", revision: 4 }); + expect(requireDebugSummary(session.summarizeForDebug()).machine).toEqual(loaded.machine); + }); + + it("normalizes old sessions without machine snapshots", () => { + type LegacySession = Omit, "machine"> & { + machine?: unknown; + }; + const legacy = session.createSession({ + sessionId: "legacy-session", + startedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:05:00.000Z", + }) as unknown as LegacySession; + delete legacy.machine; + legacy.steps.gateway.status = "in_progress"; + legacy.steps.gateway.startedAt = "2026-01-01T00:02:00.000Z"; + legacy.lastStepStarted = "gateway"; + + let normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "gateway", + stateEnteredAt: "2026-01-01T00:02:00.000Z", + revision: 0, + }); + + legacy.steps.gateway.status = "complete"; + legacy.steps.gateway.completedAt = "2026-01-01T00:03:00.000Z"; + legacy.lastCompletedStep = "gateway"; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "provider_selection", + stateEnteredAt: "2026-01-01T00:03:00.000Z", + revision: 0, + }); + + legacy.status = "failed"; + legacy.failure = { + step: "gateway", + message: "boom", + recordedAt: "2026-01-01T00:04:00.000Z", + }; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "failed", + stateEnteredAt: "2026-01-01T00:04:00.000Z", + revision: 0, + }); + + legacy.status = "complete"; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine.state).toBe("complete"); + }); + + it("normalizes invalid machine snapshots from old sessions", () => { + type LegacySession = Omit, "machine"> & { + machine?: unknown; + }; + const legacy = session.createSession({ lastCompletedStep: "policies" }) as unknown as LegacySession; + legacy.steps.policies.status = "complete"; + legacy.steps.policies.completedAt = "2026-01-01T00:08:00.000Z"; + legacy.machine = { + version: 1, + state: "not-a-state", + stateEnteredAt: "2026-01-01T00:09:00.000Z", + revision: -1, + }; + + const normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "finalizing", + stateEnteredAt: "2026-01-01T00:08:00.000Z", + revision: 0, + }); }); it("emits redacted structured machine events for session step mutations", () => { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7fe94d8096..f739f330d2 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -21,10 +21,14 @@ import { import { createOnboardMachineEvent, emitOnboardMachineEvent, + machineStateFromOnboardSessionStep, } from "../onboard/machine/events"; +import { isOnboardMachineState } from "../onboard/machine/transitions"; +import type { OnboardMachineState } from "../onboard/machine/types"; import { redactSensitiveText, redactUrl } from "../security/redact"; export const SESSION_VERSION = 1; +export const MACHINE_SNAPSHOT_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); @@ -64,6 +68,13 @@ export interface SessionMetadata { fromDockerfile: string | null; } +export interface OnboardMachineSnapshot { + version: typeof MACHINE_SNAPSHOT_VERSION; + state: OnboardMachineState; + stateEnteredAt: string | null; + revision: number; +} + export interface Session { version: number; sessionId: string; @@ -115,6 +126,7 @@ export interface Session { telegramConfig: TelegramConfig | null; wechatConfig: WechatConfig | null; metadata: SessionMetadata; + machine: OnboardMachineSnapshot; steps: Record; } @@ -198,6 +210,7 @@ export interface DebugSessionSummary { lastStepStarted: string | null; lastCompletedStep: string | null; failure: SessionFailure | null; + machine: OnboardMachineSnapshot; steps: Record; } @@ -240,6 +253,10 @@ function readPositiveInteger(value: SessionJsonValue | undefined): number | null return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; } +function readNonNegativeInteger(value: SessionJsonValue | undefined): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; +} + function readStringArray(value: SessionJsonValue | undefined): string[] | null { if (!Array.isArray(value)) return null; return value.filter((entry): entry is string => typeof entry === "string"); @@ -308,6 +325,17 @@ function parseStepState(value: SessionJsonValue | undefined): StepState | null { }; } +function parseMachineSnapshot(value: SessionJsonValue | undefined): OnboardMachineSnapshot | null { + if (!isObject(value) || value.version !== MACHINE_SNAPSHOT_VERSION) return null; + if (!isOnboardMachineState(value.state)) return null; + return { + version: MACHINE_SNAPSHOT_VERSION, + state: value.state, + stateEnteredAt: readString(value.stateEnteredAt), + revision: readNonNegativeInteger(value.revision) ?? 0, + }; +} + function parseLockInfo(value: SessionJsonValue | undefined): LockInfo | null { if (!isObject(value) || typeof value.pid !== "number") return null; return { @@ -335,9 +363,97 @@ export function sanitizeFailure( // ── Session CRUD ───────────────────────────────────────────────── +function createMachineSnapshot( + state: OnboardMachineState, + stateEnteredAt: string | null, + revision = 0, +): OnboardMachineSnapshot { + return { + version: MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt, + revision: Math.max(0, Math.trunc(revision)), + }; +} + +function nextMachineStateAfterCompletedStep( + stepName: string | null | undefined, + session: Pick, +): OnboardMachineState | null { + switch (stepName) { + case "preflight": + return "gateway"; + case "gateway": + return "provider_selection"; + case "provider_selection": + return "inference"; + case "inference": + return "sandbox"; + case "sandbox": + return session.agent ? "agent_setup" : "openclaw"; + case "openclaw": + case "agent_setup": + return "policies"; + case "policies": + return "finalizing"; + default: + return null; + } +} + +function inferMachineState(session: Session): OnboardMachineState { + if (session.status === "complete") return "complete"; + if (session.status === "failed") return "failed"; + + const startedState = machineStateFromOnboardSessionStep(session.lastStepStarted); + const startedStep = session.lastStepStarted ? session.steps[session.lastStepStarted] : null; + if (startedState && startedStep?.status === "in_progress") return startedState; + + return nextMachineStateAfterCompletedStep(session.lastCompletedStep, session) ?? "init"; +} + +function inferMachineStateEnteredAt(session: Session, state: OnboardMachineState): string | null { + if (state === "failed") return session.failure?.recordedAt ?? session.updatedAt; + if (state === "complete") return session.updatedAt; + + const startedState = machineStateFromOnboardSessionStep(session.lastStepStarted); + const startedStep = session.lastStepStarted ? session.steps[session.lastStepStarted] : null; + if (state === startedState && startedStep?.status === "in_progress") { + return startedStep.startedAt ?? session.updatedAt; + } + + if (nextMachineStateAfterCompletedStep(session.lastCompletedStep, session) === state) { + const completedStep = session.lastCompletedStep ? session.steps[session.lastCompletedStep] : null; + return completedStep?.completedAt ?? session.updatedAt; + } + + return session.startedAt; +} + +function inferMachineSnapshot(session: Session): OnboardMachineSnapshot { + const state = inferMachineState(session); + return createMachineSnapshot(state, inferMachineStateEnteredAt(session, state)); +} + +function transitionMachineSnapshot(session: Session, state: OnboardMachineState, now: string): void { + const current = session.machine ?? createMachineSnapshot("init", session.startedAt); + if (current.state === state) { + session.machine = { + ...current, + stateEnteredAt: current.stateEnteredAt ?? now, + }; + return; + } + session.machine = createMachineSnapshot(state, now, current.revision + 1); +} + export function createSession(overrides: Partial = {}): Session { const now = new Date().toISOString(); - return { + const steps = { + ...defaultSteps(), + ...(overrides.steps ?? {}), + }; + const session: Session = { version: SESSION_VERSION, sessionId: overrides.sessionId ?? `${Date.now()}-${randomUUID()}`, resumable: true, @@ -376,11 +492,11 @@ export function createSession(overrides: Partial = {}): Session { gatewayName: overrides.metadata?.gatewayName ?? "nemoclaw", fromDockerfile: overrides.metadata?.fromDockerfile ?? null, }, - steps: { - ...defaultSteps(), - ...(overrides.steps ?? {}), - }, + machine: parseMachineSnapshot(overrides.machine as SessionJsonValue | undefined) ?? + createMachineSnapshot("init", now), + steps, }; + return session; } export function normalizeSession(data: Session | SessionJsonValue | undefined): Session | null { @@ -429,6 +545,8 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): } } + normalized.machine = parseMachineSnapshot(data.machine) ?? inferMachineSnapshot(normalized); + return normalized; } @@ -891,13 +1009,16 @@ export function markStepStarted(stepName: string): Session { const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "in_progress"; - step.startedAt = new Date().toISOString(); + step.startedAt = now; step.completedAt = null; step.error = null; session.lastStepStarted = stepName; session.failure = null; session.status = "in_progress"; + const state = machineStateFromOnboardSessionStep(stepName); + if (state) transitionMachineSnapshot(session, state, now); shouldEmit = true; return session; }); @@ -915,12 +1036,15 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "complete"; - step.completedAt = new Date().toISOString(); + step.completedAt = now; step.error = null; session.lastCompletedStep = stepName; session.failure = null; Object.assign(session, safeUpdates); + const nextState = nextMachineStateAfterCompletedStep(stepName, session); + if (nextState) transitionMachineSnapshot(session, nextState, now); shouldEmit = true; return session; }); @@ -968,15 +1092,17 @@ export function markStepFailed(stepName: string, message: string | null = null): const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "failed"; step.completedAt = null; step.error = redactSensitiveText(message); session.failure = sanitizeFailure({ step: stepName, message, - recordedAt: new Date().toISOString(), + recordedAt: now, }); session.status = "failed"; + transitionMachineSnapshot(session, "failed", now); shouldEmit = true; return session; }); @@ -1005,10 +1131,12 @@ export function markStepFailed(stepName: string, message: string | null = null): export function completeSession(updates: SessionUpdates = {}): Session { const safeUpdates = filterSafeUpdates(updates); const updatedSession = updateSession((session) => { + const now = new Date().toISOString(); Object.assign(session, safeUpdates); session.status = "complete"; session.resumable = false; session.failure = null; + transitionMachineSnapshot(session, "complete", now); return session; }); if (Object.keys(safeUpdates).length > 0) { @@ -1057,6 +1185,7 @@ export function summarizeForDebug( lastStepStarted: session.lastStepStarted, lastCompletedStep: session.lastCompletedStep, failure: sanitizeFailure(session.failure), + machine: session.machine, steps: Object.fromEntries( Object.entries(session.steps).map(([name, step]) => [ name, From f756907b5c07a0bb2d09049ab6b4fa7cda681709 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 22:12:25 -0700 Subject: [PATCH 4/7] refactor(cli): add onboard runtime shell --- src/lib/onboard/machine/runtime.test.ts | 184 +++++++++++++++++ src/lib/onboard/machine/runtime.ts | 263 ++++++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/lib/onboard/machine/runtime.test.ts create mode 100644 src/lib/onboard/machine/runtime.ts diff --git a/src/lib/onboard/machine/runtime.test.ts b/src/lib/onboard/machine/runtime.test.ts new file mode 100644 index 0000000000..becca6028e --- /dev/null +++ b/src/lib/onboard/machine/runtime.test.ts @@ -0,0 +1,184 @@ +// 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, +} from "../../state/onboard-session"; +import type { OnboardMachineEvent } from "./events"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; +import { InvalidOnboardMachineTransitionError } from "./transitions"; + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function createHarness(initialSession: Session | null = createSession()) { + let session = initialSession ? cloneSession(initialSession) : null; + const events: OnboardMachineEvent[] = []; + let tick = 0; + const deps: OnboardRuntimeDeps = { + loadSession: () => (session ? cloneSession(session) : null), + createSession: (overrides) => createSession(overrides), + saveSession: (next) => { + session = cloneSession(next); + return cloneSession(session); + }, + updateSession: (mutator) => { + const current = session ? cloneSession(session) : createSession(); + const next = mutator(current) ?? current; + session = cloneSession(next); + return cloneSession(session); + }, + filterSafeUpdates, + emitEvent: (event) => events.push(event), + now: () => `2026-05-19T00:00:${String(tick++).padStart(2, "0")}.000Z`, + }; + return { + runtime: new OnboardRuntime(deps), + events, + getSession: () => { + if (!session) throw new Error("Expected runtime session"); + return cloneSession(session); + }, + }; +} + +function sessionInState(state: Session["machine"]["state"]): Session { + const session = createSession(); + session.machine = { + version: 1, + state, + stateEnteredAt: "2026-05-19T00:00:00.000Z", + revision: 7, + }; + return session; +} + +describe("OnboardRuntime", () => { + it("starts a session and emits started/resumed lifecycle events", async () => { + const { runtime, events, getSession } = createHarness(null); + + const started = await runtime.start(); + expect(started.machine.state).toBe("init"); + expect(getSession().machine.state).toBe("init"); + expect(events[0]).toMatchObject({ type: "onboard.started", state: "init" }); + + await runtime.start({ resumed: true }); + expect(events[1]).toMatchObject({ type: "onboard.resumed", state: "init" }); + }); + + it("validates and persists explicit transitions", async () => { + const { runtime, events, getSession } = createHarness(); + + await runtime.transition("preflight"); + + expect(getSession().machine).toEqual({ + version: 1, + state: "preflight", + stateEnteredAt: "2026-05-19T00:00:00.000Z", + revision: 1, + }); + expect(events.map((event) => event.type)).toEqual(["state.exited", "state.entered"]); + expect(events[0]).toMatchObject({ state: "init" }); + expect(events[1]).toMatchObject({ state: "preflight" }); + + await expect(runtime.transition("sandbox")).rejects.toThrow( + InvalidOnboardMachineTransitionError, + ); + expect(getSession().machine.state).toBe("preflight"); + }); + + it("applies only safe context updates and emits redacted context events", async () => { + const { runtime, events, getSession } = createHarness(); + + await runtime.updateContext({ + provider: "nvidia-prod", + endpointUrl: "https://alice:secret@example.com/v1?token=super-secret&keep=yes#token=frag", + credentialEnv: "NVIDIA_API_KEY", + apiKey: "super-secret", + } as Parameters[0] & { apiKey: string }); + + expect(getSession()).toMatchObject({ + provider: "nvidia-prod", + endpointUrl: "https://example.com/v1?token=%3CREDACTED%3E&keep=yes", + credentialEnv: "NVIDIA_API_KEY", + }); + expect("apiKey" in getSession()).toBe(false); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "context.updated", state: "init" }); + expect(events[0].metadata.fields).toEqual(["provider", "endpointUrl", "credentialEnv"]); + expect(JSON.stringify(events)).not.toContain("super-secret"); + }); + + it("fails non-terminal sessions with redacted failure events", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("gateway")); + + await runtime.fail("NVIDIA_API_KEY=super-secret", { step: "gateway" }); + + expect(getSession()).toMatchObject({ + status: "failed", + failure: { step: "gateway", message: "NVIDIA_API_KEY=" }, + machine: { state: "failed", revision: 8 }, + }); + expect(events.map((event) => event.type)).toEqual(["state.failed", "onboard.failed"]); + expect(events[0]).toMatchObject({ state: "gateway", step: "gateway" }); + expect(events[1]).toMatchObject({ state: "failed", step: "gateway" }); + expect(JSON.stringify(events)).not.toContain("super-secret"); + }); + + it("rejects terminal-state failure and invalid completion transitions", async () => { + const completeHarness = createHarness(sessionInState("complete")); + await expect(completeHarness.runtime.fail("boom")).rejects.toThrow("complete -> failed"); + expect(completeHarness.getSession().machine.state).toBe("complete"); + + const policiesHarness = createHarness(sessionInState("policies")); + await expect(policiesHarness.runtime.complete()).rejects.toThrow("policies -> complete"); + expect(policiesHarness.getSession().machine.state).toBe("policies"); + }); + + it("completes from post_verify and emits completion events", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("post_verify")); + + await runtime.complete({ sandboxName: "my-assistant" }); + + expect(getSession()).toMatchObject({ + status: "complete", + resumable: false, + sandboxName: "my-assistant", + machine: { state: "complete", revision: 8 }, + }); + expect(events.map((event) => event.type)).toEqual([ + "context.updated", + "state.completed", + "state.entered", + "onboard.completed", + ]); + }); + + it("emits skipped and repair events without mutating durable state", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("provider_selection")); + + await runtime.markSkipped("provider_selection", { reason: "resume" }); + await runtime.emitRepairEvent("state.repair.started", { + state: "provider_selection", + metadata: { action: "ollama-systemd" }, + }); + await runtime.emitRepairEvent("state.repair.completed", { state: "provider_selection" }); + + expect(getSession().machine.state).toBe("provider_selection"); + expect(events.map((event) => event.type)).toEqual([ + "state.skipped", + "state.repair.started", + "state.repair.completed", + ]); + expect(events[0].metadata.reason).toBe("resume"); + await expect(runtime.markSkipped("complete")).rejects.toThrow( + "Terminal onboarding state cannot be skipped", + ); + }); +}); diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts new file mode 100644 index 0000000000..3e72cd0ccc --- /dev/null +++ b/src/lib/onboard/machine/runtime.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonObject } from "../../core/json-types"; +import * as onboardSession from "../../state/onboard-session"; +import type { Session, SessionUpdates } from "../../state/onboard-session"; +import { + createOnboardMachineEvent, + emitOnboardMachineEvent, + type OnboardMachineEvent, +} from "./events"; +import { + assertValidOnboardMachineTransition, + canTransitionOnboardMachineState, + isTerminalOnboardMachineState, +} from "./transitions"; +import type { OnboardMachineEventType, OnboardMachineState } from "./types"; + +export interface OnboardRuntimeDeps { + loadSession(): Session | null; + createSession(overrides?: Partial): Session; + saveSession(session: Session): Session; + updateSession(mutator: (session: Session) => Session | void): Session; + filterSafeUpdates(updates: SessionUpdates): Partial; + emitEvent(event: OnboardMachineEvent): void; + now(): string; +} + +export type OnboardRuntimeTransitionOptions = { + metadata?: Record | null; +}; + +export type OnboardRuntimeUpdateOptions = { + state?: OnboardMachineState | null; + metadata?: Record | null; +}; + +export type OnboardRuntimeFailureOptions = { + step?: string | null; + metadata?: Record | null; +}; + +function defaultDeps(): OnboardRuntimeDeps { + return { + loadSession: onboardSession.loadSession, + createSession: onboardSession.createSession, + saveSession: onboardSession.saveSession, + updateSession: onboardSession.updateSession, + filterSafeUpdates: onboardSession.filterSafeUpdates, + emitEvent: emitOnboardMachineEvent, + now: () => new Date().toISOString(), + }; +} + +function eventMetadata(metadata: Record | null | undefined): JsonObject { + return metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as JsonObject) + : {}; +} + +function snapshotFor( + state: OnboardMachineState, + stateEnteredAt: string | null, + revision: number, +): onboardSession.OnboardMachineSnapshot { + return { + version: onboardSession.MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt, + revision: Math.max(0, Math.trunc(revision)), + }; +} + +export class OnboardRuntime { + private readonly deps: OnboardRuntimeDeps; + + constructor(deps: Partial = {}) { + this.deps = { ...defaultDeps(), ...deps }; + } + + async session(): Promise { + return this.ensureSession(); + } + + async start(options: { resumed?: boolean; metadata?: Record | null } = {}): Promise { + const session = this.ensureSession(); + this.emit(options.resumed === true ? "onboard.resumed" : "onboard.started", session, { + state: session.machine.state, + metadata: options.metadata, + }); + return session; + } + + async transition( + to: OnboardMachineState, + options: OnboardRuntimeTransitionOptions = {}, + ): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + assertValidOnboardMachineTransition(from, to); + + const enteredAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + session.machine = snapshotFor(to, enteredAt, session.machine.revision + 1); + if (to === "failed") { + session.status = "failed"; + } else if (to === "complete") { + session.status = "complete"; + session.resumable = false; + session.failure = null; + } else if (session.status !== "failed") { + session.status = "in_progress"; + } + return session; + }); + + this.emit("state.exited", updated, { state: from, metadata: options.metadata }); + this.emit("state.entered", updated, { state: to, metadata: options.metadata }); + return updated; + } + + async updateContext( + updates: SessionUpdates, + options: OnboardRuntimeUpdateOptions = {}, + ): Promise { + const safeUpdates = this.deps.filterSafeUpdates(updates); + const fields = Object.keys(safeUpdates); + const updated = this.deps.updateSession((session) => { + Object.assign(session, safeUpdates); + return session; + }); + if (fields.length > 0) { + this.emit("context.updated", updated, { + state: options.state ?? updated.machine.state, + metadata: { ...eventMetadata(options.metadata), fields }, + }); + } + return updated; + } + + async complete(updates: SessionUpdates = {}): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + assertValidOnboardMachineTransition(from, "complete"); + + const safeUpdates = this.deps.filterSafeUpdates(updates); + const fields = Object.keys(safeUpdates); + const enteredAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + Object.assign(session, safeUpdates); + session.status = "complete"; + session.resumable = false; + session.failure = null; + session.machine = snapshotFor("complete", enteredAt, session.machine.revision + 1); + return session; + }); + + if (fields.length > 0) { + this.emit("context.updated", updated, { + state: "complete", + metadata: { fields }, + }); + } + this.emit("state.completed", updated, { state: from }); + this.emit("state.entered", updated, { state: "complete" }); + this.emit("onboard.completed", updated, { state: "complete" }); + return updated; + } + + async fail(message: string | null, options: OnboardRuntimeFailureOptions = {}): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + if (!canTransitionOnboardMachineState(from, "failed")) { + assertValidOnboardMachineTransition(from, "failed"); + } + + const recordedAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + session.status = "failed"; + session.failure = onboardSession.sanitizeFailure({ + step: options.step ?? null, + message, + recordedAt, + }); + session.machine = snapshotFor("failed", recordedAt, session.machine.revision + 1); + return session; + }); + + this.emit("state.failed", updated, { + state: from, + step: options.step, + error: message, + metadata: options.metadata, + }); + this.emit("onboard.failed", updated, { + state: "failed", + step: options.step, + error: message, + metadata: options.metadata, + }); + return updated; + } + + async markSkipped( + state: OnboardMachineState, + metadata: Record | null = null, + ): Promise { + const session = this.ensureSession(); + if (isTerminalOnboardMachineState(state)) { + throw new Error(`Terminal onboarding state cannot be skipped: ${state}`); + } + this.emit("state.skipped", session, { state, metadata }); + return session; + } + + async emitRepairEvent( + type: Extract< + OnboardMachineEventType, + "state.repair.started" | "state.repair.completed" | "state.repair.failed" + >, + options: { + state?: OnboardMachineState | null; + error?: string | null; + metadata?: Record | null; + } = {}, + ): Promise { + const session = this.ensureSession(); + this.emit(type, session, { + state: options.state ?? session.machine.state, + error: options.error ?? null, + metadata: options.metadata, + }); + return session; + } + + private ensureSession(): Session { + const existing = this.deps.loadSession(); + if (existing) return existing; + return this.deps.saveSession(this.deps.createSession()); + } + + private emit( + type: OnboardMachineEventType, + session: Session, + options: { + state?: OnboardMachineState | null; + step?: string | null; + error?: string | null; + metadata?: Record | null; + } = {}, + ): void { + this.deps.emitEvent( + createOnboardMachineEvent({ + type, + session, + state: options.state ?? session.machine.state, + step: options.step ?? null, + error: options.error ?? null, + metadata: options.metadata, + }), + ); + } +} From 702454b2d9c3547a95a4c51f4f4ec9a6c5780ca0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 22:26:42 -0700 Subject: [PATCH 5/7] refactor(cli): route onboard step boundaries through runtime --- src/lib/agent/onboard.ts | 4 +- src/lib/onboard.ts | 87 +++++++++++++++---------- src/lib/onboard/machine/runtime.test.ts | 56 ++++++++++++++-- src/lib/onboard/machine/runtime.ts | 30 +++++++++ 4 files changed, 135 insertions(+), 42 deletions(-) diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 2446108910..f08c32b9c6 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -31,7 +31,7 @@ export interface OnboardContext { buildSandboxConfigSyncScript: (config: LooseObject) => string; writeSandboxConfigSyncFile: (script: string) => string; cleanupTempDir: (file: string, prefix: string) => void; - startRecordedStep: (stepName: string, updates: LooseObject) => void; + startRecordedStep: (stepName: string, updates: LooseObject) => Promise; skippedStepMessage: (stepName: string, sandboxName: string) => void; } @@ -424,7 +424,7 @@ export async function handleAgentSetup( } } - startRecordedStep("agent_setup", { sandboxName, provider, model }); + await startRecordedStep("agent_setup", { sandboxName, provider, model }); step(7, 8, `Setting up ${agent.displayName} inside sandbox`); const binaryAvailability = verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bc231df3a5..470639b346 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -279,6 +279,7 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); +const { OnboardRuntime }: typeof import("./onboard/machine/runtime") = require("./onboard/machine/runtime"); const policies: typeof import("./policy") = require("./policy"); const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); @@ -409,6 +410,7 @@ const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; +let ONBOARD_RUNTIME: import("./onboard/machine/runtime").OnboardRuntime | null = null; const GATEWAY_NAME = "nemoclaw"; const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; type HermesAuthMethod = "oauth" | "api_key"; @@ -9017,7 +9019,12 @@ function toSessionUpdates( return normalized; } -function startRecordedStep( +function getOnboardRuntime(): import("./onboard/machine/runtime").OnboardRuntime { + if (!ONBOARD_RUNTIME) ONBOARD_RUNTIME = new OnboardRuntime(); + return ONBOARD_RUNTIME; +} + +async function startRecordedStep( stepName: string, updates: { sandboxName?: string | null; @@ -9025,20 +9032,30 @@ function startRecordedStep( model?: string | null; policyPresets?: string[] | null; } = {}, -): void { - onboardSession.markStepStarted(stepName); +): Promise { + const runtime = getOnboardRuntime(); + await runtime.markStepStarted(stepName); if (Object.keys(updates).length > 0) { - onboardSession.updateSession((session: Session) => { - if (updates.sandboxName !== undefined) session.sandboxName = updates.sandboxName; - if (updates.provider !== undefined) session.provider = updates.provider; - if (updates.model !== undefined) session.model = updates.model; - if (updates.policyPresets !== undefined) session.policyPresets = updates.policyPresets; - return session; - }); + await runtime.updateContext(toSessionUpdates(updates)); } maybeForceE2eStepFailure(stepName); } +async function recordStepComplete( + stepName: string, + updates: SessionUpdates = {}, +): Promise { + return getOnboardRuntime().markStepComplete(stepName, updates); +} + +async function recordStepSkipped(stepName: string): Promise { + return getOnboardRuntime().markStepSkipped(stepName); +} + +async function recordSessionComplete(updates: SessionUpdates = {}): Promise { + return getOnboardRuntime().completeSession(updates); +} + const ONBOARD_STEP_INDEX: Record = { preflight: { number: 1, title: "Preflight checks" }, gateway: { number: 2, title: "Starting OpenShell gateway" }, @@ -9074,6 +9091,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; _preflightDashboardPort = opts.controlUiPort || null; + ONBOARD_RUNTIME = new OnboardRuntime(); delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; const fresh = opts.fresh === true; @@ -9422,9 +9440,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { }), ); } else { - startRecordedStep("preflight"); + await startRecordedStep("preflight"); gpu = await preflight({ ...opts, optedOutGpuPassthrough: opts.noGpu === true }); - onboardSession.markStepComplete("preflight"); + await recordStepComplete("preflight"); } const sandboxGpuConfig = resolveSandboxGpuConfig(gpu, { flag: effectiveSandboxGpuFlag, @@ -9560,11 +9578,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { resume && session?.steps?.gateway?.status === "complete" && canReuseHealthyGateway; if (resumeGateway) { skippedStepMessage("gateway", "running"); - onboardSession.markStepComplete("gateway"); + await recordStepComplete("gateway"); } else if (!resume && canReuseHealthyGateway) { skippedStepMessage("gateway", "running", "reuse"); note(" Reusing healthy NemoClaw gateway."); - onboardSession.markStepComplete("gateway"); + await recordStepComplete("gateway"); } else { if (resume && session?.steps?.gateway?.status === "complete") { if (gatewayReuseState === "active-unnamed") { @@ -9582,9 +9600,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { retireLegacyGatewayForDockerDriverUpgrade(); gatewayReuseState = "missing"; } - startRecordedStep("gateway"); + await startRecordedStep("gateway"); await startGateway(gpu, { gpuPassthrough }); - onboardSession.markStepComplete("gateway"); + await recordStepComplete("gateway"); } // #2753: prefer requestedSandboxName over an unconfirmed session name. @@ -9635,7 +9653,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { // below). A SIGINT between any earlier step and createSandbox would // otherwise leave a phantom that `nemoclaw list` resurrects until // manually destroyed. - startRecordedStep("provider_selection"); + await startRecordedStep("provider_selection"); const selection = await setupNim(gpu, sandboxName, agent); model = selection.model; provider = selection.provider; @@ -9645,7 +9663,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { hermesToolGateways = selection.hermesToolGateways; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; - onboardSession.markStepComplete( + await recordStepComplete( "provider_selection", toSessionUpdates({ provider, @@ -9678,7 +9696,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (!sandboxName) { sandboxName = await promptValidatedSandboxName(agent); } - startRecordedStep("inference", { provider, model }); + await startRecordedStep("inference", { provider, model }); const inferenceResult = await setupInference( sandboxName, model, @@ -9692,7 +9710,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { forceProviderSelection = true; continue; } - onboardSession.markStepComplete( + await recordStepComplete( "inference", toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); @@ -9712,7 +9730,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (nimContainer && sandboxName) { registry.updateSandbox(sandboxName, { nimContainer }); } - onboardSession.markStepComplete( + await recordStepComplete( "inference", toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); @@ -9751,7 +9769,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } } - startRecordedStep("inference", { provider, model }); + await startRecordedStep("inference", { provider, model }); const inferenceResult = await setupInference( sandboxName, model, @@ -9769,7 +9787,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (nimContainer && sandboxName) { registry.updateSandbox(sandboxName, { nimContainer }); } - onboardSession.markStepComplete( + await recordStepComplete( "inference", toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); @@ -9906,7 +9924,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } else { nextWebSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); } - startRecordedStep("sandbox", { provider, model }); + await startRecordedStep("sandbox", { provider, model }); const recordedMessagingChannels = getRecordedMessagingChannelsForResume(resume, session, sandboxName); if (recordedMessagingChannels) { selectedMessagingChannels = recordedMessagingChannels; @@ -9960,7 +9978,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { ...getSandboxAgentRegistryFields(agent, !fromDockerfile), }); registry.setDefault(sandboxName); - onboardSession.markStepComplete( + await recordStepComplete( "sandbox", toSessionUpdates({ sandboxName, @@ -9996,24 +10014,24 @@ async function onboard(opts: OnboardOptions = {}): Promise { skippedStepMessage, }); ensureAgentDashboardForward(sandboxName, agent); - onboardSession.markStepSkipped("openclaw"); + await recordStepSkipped("openclaw"); } else { const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { skippedStepMessage("openclaw", sandboxName); - onboardSession.markStepComplete( + await recordStepComplete( "openclaw", toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } else { - startRecordedStep("openclaw", { sandboxName, provider, model }); + await startRecordedStep("openclaw", { sandboxName, provider, model }); await setupOpenclaw(sandboxName, model, provider); - onboardSession.markStepComplete( + await recordStepComplete( "openclaw", toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } - onboardSession.markStepSkipped("agent_setup"); + await recordStepSkipped("agent_setup"); } const latestSession = onboardSession.loadSession(); @@ -10066,7 +10084,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); if (resumePolicies) { skippedStepMessage("policies", recordedPolicyPresetsForSupport.join(", ")); - onboardSession.markStepComplete( + await recordStepComplete( "policies", toSessionUpdates({ sandboxName, @@ -10076,7 +10094,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { }), ); } else { - startRecordedStep("policies", { + await startRecordedStep("policies", { sandboxName, provider, model, @@ -10102,7 +10120,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); }, }); - onboardSession.markStepComplete( + await recordStepComplete( "policies", toSessionUpdates({ sandboxName, provider, model, policyPresets: appliedPolicyPresets }), ); @@ -10112,7 +10130,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { ensureAgentDashboardForward(sandboxName, agent); } - onboardSession.completeSession( + await recordSessionComplete( toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); completed = true; @@ -10192,6 +10210,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { printDashboard(sandboxName, model, provider, nimContainer, agent); } finally { releaseOnboardLock(); + ONBOARD_RUNTIME = null; } } diff --git a/src/lib/onboard/machine/runtime.test.ts b/src/lib/onboard/machine/runtime.test.ts index becca6028e..7b26269541 100644 --- a/src/lib/onboard/machine/runtime.test.ts +++ b/src/lib/onboard/machine/runtime.test.ts @@ -7,7 +7,9 @@ import { createSession, filterSafeUpdates, normalizeSession, + sanitizeFailure, type Session, + type SessionUpdates, } from "../../state/onboard-session"; import type { OnboardMachineEvent } from "./events"; import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; @@ -21,6 +23,12 @@ function createHarness(initialSession: Session | null = createSession()) { let session = initialSession ? cloneSession(initialSession) : null; const events: OnboardMachineEvent[] = []; let tick = 0; + const updateSession = (mutator: (value: Session) => Session | void): Session => { + const current = session ? cloneSession(session) : createSession(); + const next = mutator(current) ?? current; + session = cloneSession(next); + return cloneSession(session); + }; const deps: OnboardRuntimeDeps = { loadSession: () => (session ? cloneSession(session) : null), createSession: (overrides) => createSession(overrides), @@ -28,12 +36,48 @@ function createHarness(initialSession: Session | null = createSession()) { session = cloneSession(next); return cloneSession(session); }, - updateSession: (mutator) => { - const current = session ? cloneSession(session) : createSession(); - const next = mutator(current) ?? current; - session = cloneSession(next); - return cloneSession(session); - }, + updateSession, + markStepStarted: (stepName) => + updateSession((current) => { + const step = current.steps[stepName]; + if (!step) return current; + step.status = "in_progress"; + current.lastStepStarted = stepName; + current.status = "in_progress"; + return current; + }), + markStepComplete: (stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + const step = current.steps[stepName]; + if (!step) return current; + step.status = "complete"; + current.lastCompletedStep = stepName; + Object.assign(current, filterSafeUpdates(updates)); + return current; + }), + markStepSkipped: (stepName) => + updateSession((current) => { + const step = current.steps[stepName]; + if (!step) return current; + step.status = "skipped"; + return current; + }), + markStepFailed: (stepName, message) => + updateSession((current) => { + const step = current.steps[stepName]; + if (!step) return current; + step.status = "failed"; + current.status = "failed"; + current.failure = sanitizeFailure({ step: stepName, message, recordedAt: "now" }); + return current; + }), + completeSession: (updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + current.status = "complete"; + current.resumable = false; + return current; + }), filterSafeUpdates, emitEvent: (event) => events.push(event), now: () => `2026-05-19T00:00:${String(tick++).padStart(2, "0")}.000Z`, diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts index 3e72cd0ccc..2e5d584f3b 100644 --- a/src/lib/onboard/machine/runtime.ts +++ b/src/lib/onboard/machine/runtime.ts @@ -21,6 +21,11 @@ export interface OnboardRuntimeDeps { createSession(overrides?: Partial): Session; saveSession(session: Session): Session; updateSession(mutator: (session: Session) => Session | void): Session; + markStepStarted(stepName: string): Session; + markStepComplete(stepName: string, updates?: SessionUpdates): Session; + markStepSkipped(stepName: string): Session; + markStepFailed(stepName: string, message?: string | null): Session; + completeSession(updates?: SessionUpdates): Session; filterSafeUpdates(updates: SessionUpdates): Partial; emitEvent(event: OnboardMachineEvent): void; now(): string; @@ -46,6 +51,11 @@ function defaultDeps(): OnboardRuntimeDeps { createSession: onboardSession.createSession, saveSession: onboardSession.saveSession, updateSession: onboardSession.updateSession, + markStepStarted: onboardSession.markStepStarted, + markStepComplete: onboardSession.markStepComplete, + markStepSkipped: onboardSession.markStepSkipped, + markStepFailed: onboardSession.markStepFailed, + completeSession: onboardSession.completeSession, filterSafeUpdates: onboardSession.filterSafeUpdates, emitEvent: emitOnboardMachineEvent, now: () => new Date().toISOString(), @@ -91,6 +101,26 @@ export class OnboardRuntime { return session; } + async markStepStarted(stepName: string): Promise { + return this.deps.markStepStarted(stepName); + } + + async markStepComplete(stepName: string, updates: SessionUpdates = {}): Promise { + return this.deps.markStepComplete(stepName, updates); + } + + async markStepSkipped(stepName: string): Promise { + return this.deps.markStepSkipped(stepName); + } + + async markStepFailed(stepName: string, message: string | null = null): Promise { + return this.deps.markStepFailed(stepName, message); + } + + async completeSession(updates: SessionUpdates = {}): Promise { + return this.deps.completeSession(updates); + } + async transition( to: OnboardMachineState, options: OnboardRuntimeTransitionOptions = {}, From f555d6114e81353eaccd4cf5e402d027c3e41e99 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 22 May 2026 10:04:44 -0700 Subject: [PATCH 6/7] refactor(cli): extract onboard runtime boundary glue --- src/lib/onboard.ts | 51 ++++++------------------- src/lib/onboard/runtime-boundary.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 src/lib/onboard/runtime-boundary.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 93c10fc67d..94ff24b15b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -293,7 +293,7 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); -const { OnboardRuntime }: typeof import("./onboard/machine/runtime") = require("./onboard/machine/runtime"); +const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary"); const policies: typeof import("./policy") = require("./policy"); const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); @@ -430,7 +430,6 @@ const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; -let ONBOARD_RUNTIME: import("./onboard/machine/runtime").OnboardRuntime | null = null; const GATEWAY_NAME = "nemoclaw"; const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; type HermesAuthMethod = "oauth" | "api_key"; @@ -8917,42 +8916,14 @@ function toSessionUpdates( return normalized; } -function getOnboardRuntime(): import("./onboard/machine/runtime").OnboardRuntime { - if (!ONBOARD_RUNTIME) ONBOARD_RUNTIME = new OnboardRuntime(); - return ONBOARD_RUNTIME; -} - -async function startRecordedStep( - stepName: string, - updates: { - sandboxName?: string | null; - provider?: string | null; - model?: string | null; - policyPresets?: string[] | null; - } = {}, -): Promise { - const runtime = getOnboardRuntime(); - await runtime.markStepStarted(stepName); - if (Object.keys(updates).length > 0) { - await runtime.updateContext(toSessionUpdates(updates)); - } - maybeForceE2eStepFailure(stepName); -} - -async function recordStepComplete( - stepName: string, - updates: SessionUpdates = {}, -): Promise { - return getOnboardRuntime().markStepComplete(stepName, updates); -} - -async function recordStepSkipped(stepName: string): Promise { - return getOnboardRuntime().markStepSkipped(stepName); -} - -async function recordSessionComplete(updates: SessionUpdates = {}): Promise { - return getOnboardRuntime().completeSession(updates); -} +const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ + toSessionUpdates, + maybeForceE2eStepFailure, +}); +const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); +const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); +const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); +const recordSessionComplete = onboardRuntimeBoundary.recordSessionComplete.bind(onboardRuntimeBoundary); const ONBOARD_STEP_INDEX: Record = { preflight: { number: 1, title: "Preflight checks" }, @@ -8989,7 +8960,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; _preflightDashboardPort = opts.controlUiPort || null; - ONBOARD_RUNTIME = new OnboardRuntime(); + onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; const fresh = opts.fresh === true; @@ -10143,7 +10114,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { printDashboard(sandboxName, model, provider, nimContainer, agent); } finally { releaseOnboardLock(); - ONBOARD_RUNTIME = null; + onboardRuntimeBoundary.clear(); } } diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts new file mode 100644 index 0000000000..b1935e7214 --- /dev/null +++ b/src/lib/onboard/runtime-boundary.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session, SessionUpdates } from "../state/onboard-session"; +import { OnboardRuntime } from "./machine/runtime"; + +export interface OnboardRuntimeBoundaryOptions { + toSessionUpdates(updates: Record): SessionUpdates; + maybeForceE2eStepFailure(stepName: string): void; +} + +export class OnboardRuntimeBoundary { + private runtime: OnboardRuntime | null = null; + + constructor(private readonly options: OnboardRuntimeBoundaryOptions) {} + + reset(): void { + this.runtime = new OnboardRuntime(); + } + + clear(): void { + this.runtime = null; + } + + getRuntime(): OnboardRuntime { + if (!this.runtime) this.runtime = new OnboardRuntime(); + return this.runtime; + } + + async startRecordedStep( + stepName: string, + updates: { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + policyPresets?: string[] | null; + } = {}, + ): Promise { + const runtime = this.getRuntime(); + await runtime.markStepStarted(stepName); + if (Object.keys(updates).length > 0) { + await runtime.updateContext(this.options.toSessionUpdates(updates)); + } + this.options.maybeForceE2eStepFailure(stepName); + } + + async recordStepComplete(stepName: string, updates: SessionUpdates = {}): Promise { + return this.getRuntime().markStepComplete(stepName, updates); + } + + async recordStepSkipped(stepName: string): Promise { + return this.getRuntime().markStepSkipped(stepName); + } + + async recordSessionComplete(updates: SessionUpdates = {}): Promise { + return this.getRuntime().completeSession(updates); + } +} From 83afec4c98323b3f969c4e4a511232fe0b58cfe9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 22 May 2026 10:45:15 -0700 Subject: [PATCH 7/7] refactor(cli): route agent setup through runtime boundary --- src/lib/agent/onboard.ts | 22 ++++++++++++++-------- src/lib/onboard.ts | 3 +++ src/lib/onboard/runtime-boundary.ts | 4 ++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 8768e7facf..db4c3f5794 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -13,7 +13,6 @@ import { dockerBuild, dockerImageInspect } from "../adapters/docker"; import { getAgentBranding } from "../cli/branding"; import { getProviderSelectionConfig } from "../inference/config"; import type { JsonObject as LooseObject } from "../core/json-types"; -import * as onboardSession from "../state/onboard-session"; import { runSandboxConfigSync } from "../onboard/config-sync"; import { ROOT, redact, run, shellQuote } from "../runner"; import { @@ -30,6 +29,8 @@ export interface OnboardContext { openshellShellCommand: (args: string[], options?: { openshellBinary?: string }) => string; openshellBinary: string; startRecordedStep: (stepName: string, updates: LooseObject) => Promise; + recordStepComplete: (stepName: string, updates: LooseObject) => Promise; + recordStepFailed: (stepName: string, message: string | null) => Promise; skippedStepMessage: (stepName: string, sandboxName: string) => void; } @@ -348,13 +349,14 @@ export function collectHermesStartupDiagnostics( /** * Record and print an agent setup failure before exiting the onboarding flow. */ -function failAgentSetup( +async function failAgentSetup( sandboxName: string, agent: AgentDefinition, message: string, + recordStepFailed: OnboardContext["recordStepFailed"], details: string[] = [], -): never { - onboardSession.markStepFailed( +): Promise { + await recordStepFailed( "agent_setup", details.length > 0 ? `${message}\n${details.join("\n")}` : message, ); @@ -401,6 +403,8 @@ export async function handleAgentSetup( runCaptureOpenshell, openshellBinary: openshellBin, startRecordedStep, + recordStepComplete, + recordStepFailed, skippedStepMessage, } = ctx; @@ -433,7 +437,7 @@ export async function handleAgentSetup( // to the Dockerfile's zero-byte placeholder. Mirrors the OpenClaw // path in src/lib/onboard.ts. Fixes #3999 for non-OpenClaw agents. syncNemoClawConfig(); - onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model }); + await recordStepComplete("agent_setup", { sandboxName, provider, model }); return; } } @@ -444,10 +448,11 @@ export async function handleAgentSetup( const binaryAvailability = verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell); if (!binaryAvailability.available) { - failAgentSetup( + await failAgentSetup( sandboxName, agent, describeAgentBinaryFailure(sandboxName, agent, binaryAvailability), + recordStepFailed, ); } @@ -478,10 +483,11 @@ export async function handleAgentSetup( agent.name === "hermes" ? collectHermesStartupDiagnostics(sandboxName, runCaptureOpenshell) : []; - failAgentSetup( + await failAgentSetup( sandboxName, agent, `${agent.displayName} gateway did not respond within ${timeoutSecs}s`, + recordStepFailed, diagnostics, ); } @@ -489,7 +495,7 @@ export async function handleAgentSetup( console.log(` \u2713 ${agent.displayName} configured inside sandbox`); } - onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model }); + await recordStepComplete("agent_setup", { sandboxName, provider, model }); } /** diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 94ff24b15b..0bd183d995 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8923,6 +8923,7 @@ const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); +const recordStepFailed = onboardRuntimeBoundary.recordStepFailed.bind(onboardRuntimeBoundary); const recordSessionComplete = onboardRuntimeBoundary.recordSessionComplete.bind(onboardRuntimeBoundary); const ONBOARD_STEP_INDEX: Record = { @@ -9904,6 +9905,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { openshellShellCommand, openshellBinary: getOpenshellBinary(), startRecordedStep, + recordStepComplete, + recordStepFailed, skippedStepMessage, }); ensureAgentDashboardForward(sandboxName, agent); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index b1935e7214..0fbd52f256 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -52,6 +52,10 @@ export class OnboardRuntimeBoundary { return this.getRuntime().markStepSkipped(stepName); } + async recordStepFailed(stepName: string, message: string | null): Promise { + return this.getRuntime().markStepFailed(stepName, message); + } + async recordSessionComplete(updates: SessionUpdates = {}): Promise { return this.getRuntime().completeSession(updates); }