From 9ee6cfab9b3fd24ff2ad22c06af41871832cebfb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 19:09:44 -0700 Subject: [PATCH 1/5] 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 00000000000..875a0ec45aa --- /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 00000000000..9f23e3895ae --- /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 00000000000..bbba7bd5f6d --- /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/5] 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 00000000000..9a68d3f8997 --- /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 b2c925858fb..5ddd94908dd 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 f05c1116e80..7fe94d8096e 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/5] 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 ae091f7adf8..f6c178f0cfd 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 5ddd94908dd..8e4b9f5cbc2 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 7fe94d8096e..f739f330d2c 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/5] 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 00000000000..becca6028e2 --- /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 00000000000..3e72cd0cccf --- /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 256fda900a901543094a21c1c0a7ba199c736062 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 20 May 2026 17:51:17 -0700 Subject: [PATCH 5/5] fix(cli): align machine snapshot start time --- src/lib/state/onboard-session.test.ts | 7 +++++-- src/lib/state/onboard-session.ts | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 015becd4d22..be35e8f73d1 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -82,7 +82,10 @@ describe("onboard session", () => { }); it("creates and persists a session with restrictive permissions", () => { - const created = session.createSession({ mode: "non-interactive" }); + const created = session.createSession({ + mode: "non-interactive", + startedAt: "2026-01-01T00:00:00.000Z", + }); const saved = session.saveSession(created); const stat = fs.statSync(session.SESSION_FILE); const dirStat = fs.statSync(path.dirname(session.SESSION_FILE)); @@ -93,7 +96,7 @@ describe("onboard session", () => { state: "init", revision: 0, }); - expect(saved.machine.stateEnteredAt).toBeTruthy(); + expect(saved.machine.stateEnteredAt).toBe("2026-01-01T00:00:00.000Z"); expect(fs.existsSync(session.SESSION_FILE)).toBe(true); expect(stat.mode & 0o777).toBe(0o600); expect(dirStat.mode & 0o777).toBe(0o700); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 3fe02f9ce54..26cbf083539 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -449,6 +449,7 @@ function transitionMachineSnapshot(session: Session, state: OnboardMachineState, export function createSession(overrides: Partial = {}): Session { const now = new Date().toISOString(); + const startedAt = overrides.startedAt ?? now; const steps = { ...defaultSteps(), ...(overrides.steps ?? {}), @@ -459,7 +460,7 @@ export function createSession(overrides: Partial = {}): Session { resumable: true, status: "in_progress", mode: overrides.mode ?? "interactive", - startedAt: overrides.startedAt ?? now, + startedAt, updatedAt: overrides.updatedAt ?? now, lastStepStarted: overrides.lastStepStarted ?? null, lastCompletedStep: overrides.lastCompletedStep ?? null, @@ -493,7 +494,7 @@ export function createSession(overrides: Partial = {}): Session { fromDockerfile: overrides.metadata?.fromDockerfile ?? null, }, machine: parseMachineSnapshot(overrides.machine as SessionJsonValue | undefined) ?? - createMachineSnapshot("init", now), + createMachineSnapshot("init", startedAt), steps, }; return session;