diff --git a/src/lib/onboard/machine/runner.test.ts b/src/lib/onboard/machine/runner.test.ts index 38cbe76c59..55f29d7bcc 100644 --- a/src/lib/onboard/machine/runner.test.ts +++ b/src/lib/onboard/machine/runner.test.ts @@ -51,6 +51,11 @@ function createRuntime(initialSession: Session = createSession()) { Object.assign(current, filterSafeUpdates(updates)); return current; }), + markStepCompleteRecordOnly: (_stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + return current; + }), markStepSkipped: () => cloneSession(session), markStepFailed: (_stepName, message) => updateSession((current) => { @@ -58,6 +63,7 @@ function createRuntime(initialSession: Session = createSession()) { current.failure = sanitizeFailure({ step: _stepName, message, recordedAt: "now" }); return current; }), + markStepFailedRecordOnly: () => cloneSession(session), completeSession: (updates: SessionUpdates = {}) => updateSession((current) => { Object.assign(current, filterSafeUpdates(updates)); diff --git a/src/lib/onboard/machine/runtime.test.ts b/src/lib/onboard/machine/runtime.test.ts index a8bc12feeb..65a2f5ebe2 100644 --- a/src/lib/onboard/machine/runtime.test.ts +++ b/src/lib/onboard/machine/runtime.test.ts @@ -7,9 +7,9 @@ import { createSession, filterSafeUpdates, normalizeSession, - sanitizeFailure, type Session, type SessionUpdates, + sanitizeFailure, } from "../../state/onboard-session"; import type { OnboardMachineEvent } from "./events"; import { @@ -62,6 +62,15 @@ function createHarness(initialSession: Session | null = createSession()) { Object.assign(current, filterSafeUpdates(updates)); return current; }), + markStepCompleteRecordOnly: (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]; @@ -78,6 +87,14 @@ function createHarness(initialSession: Session | null = createSession()) { current.failure = sanitizeFailure({ step: stepName, message, recordedAt: "now" }); return current; }), + markStepFailedRecordOnly: (stepName, message) => + updateSession((current) => { + const step = current.steps[stepName]; + if (!step) return current; + step.status = "failed"; + step.error = message ?? null; + return current; + }), completeSession: (updates: SessionUpdates = {}) => updateSession((current) => { Object.assign(current, filterSafeUpdates(updates)); diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts index 6c6a048d28..ddf38fcf18 100644 --- a/src/lib/onboard/machine/runtime.ts +++ b/src/lib/onboard/machine/runtime.ts @@ -2,8 +2,8 @@ // 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 * as onboardSession from "../../state/onboard-session"; import type { ResumeConfigConflict } from "../resume-config"; import { createOnboardMachineEvent, @@ -25,8 +25,10 @@ export interface OnboardRuntimeDeps { updateSession(mutator: (session: Session) => Session | void): Session; markStepStarted(stepName: string): Session; markStepComplete(stepName: string, updates?: SessionUpdates): Session; + markStepCompleteRecordOnly(stepName: string, updates?: SessionUpdates): Session; markStepSkipped(stepName: string): Session; markStepFailed(stepName: string, message?: string | null): Session; + markStepFailedRecordOnly(stepName: string, message?: string | null): Session; completeSession(updates?: SessionUpdates): Session; filterSafeUpdates(updates: SessionUpdates): Partial; emitEvent(event: OnboardMachineEvent): void; @@ -64,8 +66,10 @@ function defaultDeps(): OnboardRuntimeDeps { updateSession: onboardSession.updateSession, markStepStarted: onboardSession.markStepStarted, markStepComplete: onboardSession.markStepComplete, + markStepCompleteRecordOnly: onboardSession.markStepCompleteRecordOnly, markStepSkipped: onboardSession.markStepSkipped, markStepFailed: onboardSession.markStepFailed, + markStepFailedRecordOnly: onboardSession.markStepFailedRecordOnly, completeSession: onboardSession.completeSession, filterSafeUpdates: onboardSession.filterSafeUpdates, emitEvent: emitOnboardMachineEvent, @@ -120,6 +124,10 @@ export class OnboardRuntime { return this.deps.markStepComplete(stepName, updates); } + async markStepCompleteRecordOnly(stepName: string, updates: SessionUpdates = {}): Promise { + return this.deps.markStepCompleteRecordOnly(stepName, updates); + } + async markStepSkipped(stepName: string): Promise { return this.deps.markStepSkipped(stepName); } @@ -128,6 +136,10 @@ export class OnboardRuntime { return this.deps.markStepFailed(stepName, message); } + async markStepFailedRecordOnly(stepName: string, message: string | null = null): Promise { + return this.deps.markStepFailedRecordOnly(stepName, message); + } + async completeSession(updates: SessionUpdates = {}): Promise { return this.deps.completeSession(updates); } diff --git a/src/lib/onboard/runtime-boundary-record-only.test.ts b/src/lib/onboard/runtime-boundary-record-only.test.ts new file mode 100644 index 0000000000..7b240a7296 --- /dev/null +++ b/src/lib/onboard/runtime-boundary-record-only.test.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + createSession, + filterSafeUpdates, + normalizeSession, + type Session, + type SessionUpdates, +} from "../state/onboard-session"; +import type { OnboardMachineEvent } from "./machine/events"; +import { advanceTo, failOnboardMachine } from "./machine/result"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./machine/runtime"; +import { OnboardRuntimeBoundary } from "./runtime-boundary"; + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function createRuntimeHarness() { + let session: Session = createSession(); + const events: OnboardMachineEvent[] = []; + const updateSession = (mutator: (value: Session) => Session | void): Session => { + session = cloneSession(mutator(cloneSession(session)) ?? session); + return cloneSession(session); + }; + const deps: OnboardRuntimeDeps = { + loadSession: () => cloneSession(session), + createSession, + saveSession: (next) => { + session = cloneSession(next); + return cloneSession(session); + }, + updateSession, + markStepStarted: (stepName) => + updateSession((current) => { + current.steps[stepName].status = "in_progress"; + return current; + }), + markStepComplete: (stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + current.steps[stepName].status = "complete"; + Object.assign(current, filterSafeUpdates(updates)); + return current; + }), + markStepCompleteRecordOnly: (stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + current.steps[stepName].status = "complete"; + Object.assign(current, filterSafeUpdates(updates)); + return current; + }), + markStepSkipped: (stepName) => + updateSession((current) => { + current.steps[stepName].status = "skipped"; + return current; + }), + markStepFailed: (stepName, message) => + updateSession((current) => { + current.steps[stepName].status = "failed"; + current.steps[stepName].error = message ?? null; + current.status = "failed"; + current.failure = { step: stepName, message: message ?? null, recordedAt: "now" }; + return current; + }), + markStepFailedRecordOnly: (stepName, message) => + updateSession((current) => { + current.steps[stepName].status = "failed"; + current.steps[stepName].error = message ?? null; + return current; + }), + completeSession: (updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + current.status = "complete"; + return current; + }), + filterSafeUpdates, + emitEvent: (event) => events.push(event), + now: () => "2026-05-27T00:00:00.000Z", + }; + return { + boundary: new OnboardRuntimeBoundary({ + toSessionUpdates: (updates) => filterSafeUpdates(updates as SessionUpdates) as SessionUpdates, + maybeForceE2eStepFailure: () => undefined, + createRuntime: () => new OnboardRuntime(deps), + }), + events, + getSession: () => cloneSession(session), + }; +} + +describe("OnboardRuntimeBoundary record-only step/result pairing", () => { + it("pairs record-only step completion with an explicit state result", async () => { + const { boundary, events } = createRuntimeHarness(); + + await boundary.recordStateResult(advanceTo("preflight")); + const completed = await boundary.recordStepCompleteWithStateResult( + "preflight", + { sandboxName: "record-only-sb" }, + advanceTo("gateway", { metadata: { state: "preflight" } }), + ); + + expect(completed).toMatchObject({ + sandboxName: "record-only-sb", + machine: { state: "gateway", revision: 2 }, + steps: { preflight: { status: "complete" } }, + }); + expect(events.map((event) => event.type)).toEqual([ + "state.exited", + "state.entered", + "state.exited", + "state.entered", + ]); + }); + + it("pairs record-only step failure with an explicit failure result", async () => { + const { boundary, events } = createRuntimeHarness(); + + await boundary.recordStateResult(advanceTo("preflight")); + const failed = await boundary.recordStepFailedWithStateResult( + "preflight", + "Preflight failed", + failOnboardMachine("Preflight failed", { step: "preflight" }), + ); + + expect(failed).toMatchObject({ + status: "failed", + failure: { step: "preflight", message: "Preflight failed" }, + machine: { state: "failed", revision: 2 }, + steps: { preflight: { status: "failed", error: "Preflight failed" } }, + }); + expect(events.map((event) => event.type)).toEqual([ + "state.exited", + "state.entered", + "state.failed", + "onboard.failed", + ]); + }); + + it("rejects invalid explicit results before persisting record-only step completion", async () => { + const { boundary, getSession } = createRuntimeHarness(); + + await boundary.recordStateResult(advanceTo("preflight")); + await expect( + boundary.recordStepCompleteWithStateResult("preflight", {}, advanceTo("sandbox")), + ).rejects.toThrow("Invalid onboarding machine transition: preflight -> sandbox"); + + expect(getSession()).toMatchObject({ + machine: { state: "preflight", revision: 1 }, + steps: { preflight: { status: "pending" } }, + }); + }); +}); diff --git a/src/lib/onboard/runtime-boundary.test.ts b/src/lib/onboard/runtime-boundary.test.ts index 2261651a80..d9823f74e6 100644 --- a/src/lib/onboard/runtime-boundary.test.ts +++ b/src/lib/onboard/runtime-boundary.test.ts @@ -110,6 +110,7 @@ function createRuntimeHarness(overrides: Partial = {}) { if (nextState) transitionMachine(current, nextState); return current; }), + markStepCompleteRecordOnly: () => cloneSession(session ?? createSession()), markStepSkipped: (stepName) => updateSession((current) => { current.steps[stepName].status = "skipped"; @@ -121,6 +122,7 @@ function createRuntimeHarness(overrides: Partial = {}) { current.failure = { step: stepName, message: message ?? null, recordedAt: "now" }; return current; }), + markStepFailedRecordOnly: () => cloneSession(session ?? createSession()), completeSession: (updates: SessionUpdates = {}) => updateSession((current) => { Object.assign(current, filterSafeUpdates(updates)); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index f955cab215..39c21d5761 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session, SessionUpdates } from "../state/onboard-session"; -import type { OnboardStateResult } from "./machine/result"; +import type { OnboardStateFailedResult, OnboardStateResult } from "./machine/result"; import { OnboardRuntime } from "./machine/runtime"; +import { assertValidOnboardMachineTransition } from "./machine/transitions"; import type { OnboardMachineEventType, OnboardMachineState } from "./machine/types"; import type { ResumeConfigConflict } from "./resume-config"; @@ -51,6 +52,8 @@ export class OnboardRuntimeBoundary { recordRepairEvent: this.recordRepairEvent.bind(this), recordResumeConflict: this.recordResumeConflict.bind(this), recordStateResult: this.recordStateResult.bind(this), + recordStepCompleteWithStateResult: this.recordStepCompleteWithStateResult.bind(this), + recordStepFailedWithStateResult: this.recordStepFailedWithStateResult.bind(this), recordStateResultWithStepCompatibility: this.recordStateResultWithStepCompatibility.bind(this), recordStepFailed: this.recordStepFailed.bind(this), recordPostVerifyStarted: this.recordPostVerifyStarted.bind(this), @@ -102,6 +105,55 @@ export class OnboardRuntimeBoundary { return this.getRuntime().applyResult(result); } + private async assertStateResultWillApply(result: OnboardStateResult): Promise { + const current = await this.getRuntime().session(); + if (result.type === "failed") { + assertValidOnboardMachineTransition(current.machine.state, "failed"); + return; + } + if (result.type === "complete") { + assertValidOnboardMachineTransition(current.machine.state, "complete"); + return; + } + + const sourceState = + result.metadata && typeof result.metadata.state === "string" ? result.metadata.state : null; + if (current.machine.state === result.next) { + throw new Error(`Record-only step result already reached target state: ${result.next}`); + } + if (sourceState && current.machine.state !== sourceState) { + throw new Error( + `Record-only step result source mismatch: ${sourceState} != ${current.machine.state}`, + ); + } + const transition = assertValidOnboardMachineTransition(current.machine.state, result.next); + if (result.transitionKind && transition.kind !== result.transitionKind) { + throw new Error( + `Invalid onboarding machine transition kind: ${current.machine.state} -> ${result.next} expected ${result.transitionKind}, got ${transition.kind}`, + ); + } + } + + async recordStepCompleteWithStateResult( + stepName: string, + updates: SessionUpdates, + result: OnboardStateResult, + ): Promise { + await this.assertStateResultWillApply(result); + await this.getRuntime().markStepCompleteRecordOnly(stepName, updates); + return this.recordStateResultWithStepCompatibility(result); + } + + async recordStepFailedWithStateResult( + stepName: string, + message: string | null, + result: OnboardStateFailedResult, + ): Promise { + await this.assertStateResultWillApply(result); + await this.getRuntime().markStepFailedRecordOnly(stepName, message); + return this.recordStateResult(result); + } + /** * Compatibility bridge for the live onboarding host glue while legacy step helpers remain a * second machine snapshot writer. `markStepStarted()` and `markStepComplete()` still mutate diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index be35e8f73d..2b11ba7d3f 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { createRequire } from "node:module"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const require = createRequire(import.meta.url); const distPath = require.resolve("../../../dist/lib/state/onboard-session"); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 26cbf08353..4b185243f7 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -15,8 +15,8 @@ import { isErrnoException } from "../core/errno"; import type { JsonObject, JsonValue } from "../core/json-types"; import type { WebSearchConfig } from "../inference/web-search"; import { - sanitizeMessagingChannelConfig, type MessagingChannelConfig, + sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import { createOnboardMachineEvent, @@ -26,6 +26,8 @@ import { import { isOnboardMachineState } from "../onboard/machine/transitions"; import type { OnboardMachineState } from "../onboard/machine/types"; import { redactSensitiveText, redactUrl } from "../security/redact"; +import { type StepMutationOptions, shouldUpdateMachine } from "./onboard-step-mutation"; +import { nextMachineStateAfterCompletedStep } from "./onboard-step-state"; export const SESSION_VERSION = 1; export const MACHINE_SNAPSHOT_VERSION = 1; @@ -376,31 +378,6 @@ function createMachineSnapshot( }; } -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"; @@ -1005,7 +982,7 @@ export function updateSession(mutator: (session: Session) => Session | void): Se return saveSession(next); } -export function markStepStarted(stepName: string): Session { +function markStepStartedWithOptions(stepName: string, options: StepMutationOptions = {}): Session { let shouldEmit = false; const updatedSession = updateSession((session) => { const step = session.steps[stepName]; @@ -1019,8 +996,8 @@ export function markStepStarted(stepName: string): Session { session.failure = null; session.status = "in_progress"; const state = machineStateFromOnboardSessionStep(stepName); - if (state) transitionMachineSnapshot(session, state, now); - shouldEmit = true; + shouldEmit = Boolean(state && shouldUpdateMachine(options)); + if (state && shouldEmit) transitionMachineSnapshot(session, state, now); return session; }); if (shouldEmit) { @@ -1031,8 +1008,9 @@ export function markStepStarted(stepName: string): Session { return updatedSession; } -export function markStepComplete(stepName: string, updates: SessionUpdates = {}): Session { +function markStepCompleteWithOptions(stepName: string, updates: SessionUpdates = {}, options: StepMutationOptions = {}): Session { const safeUpdates = filterSafeUpdates(updates); + const hasUpdates = Object.keys(safeUpdates).length > 0; let shouldEmit = false; const updatedSession = updateSession((session) => { const step = session.steps[stepName]; @@ -1045,21 +1023,21 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) session.failure = null; Object.assign(session, safeUpdates); const nextState = nextMachineStateAfterCompletedStep(stepName, session); - if (nextState) transitionMachineSnapshot(session, nextState, now); - shouldEmit = true; + shouldEmit = Boolean(nextState && shouldUpdateMachine(options)); + if (nextState && shouldEmit) transitionMachineSnapshot(session, nextState, now); return session; }); + if (hasUpdates) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "context.updated", + session: updatedSession, + step: stepName, + metadata: { fields: Object.keys(safeUpdates) }, + }), + ); + } 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 }), ); @@ -1067,6 +1045,22 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) return updatedSession; } +export function markStepStarted(stepName: string): Session { + return markStepStartedWithOptions(stepName); +} + +export function markStepStartedRecordOnly(stepName: string): Session { + return markStepStartedWithOptions(stepName, { updateMachine: false }); +} + +export function markStepComplete(stepName: string, updates: SessionUpdates = {}): Session { + return markStepCompleteWithOptions(stepName, updates); +} + +export function markStepCompleteRecordOnly(stepName: string, updates: SessionUpdates = {}): Session { + return markStepCompleteWithOptions(stepName, updates, { updateMachine: false }); +} + export function markStepSkipped(stepName: string): Session { let shouldEmit = false; const updatedSession = updateSession((session) => { @@ -1088,7 +1082,7 @@ export function markStepSkipped(stepName: string): Session { return updatedSession; } -export function markStepFailed(stepName: string, message: string | null = null): Session { +function markStepFailedWithOptions(stepName: string, message: string | null = null, options: StepMutationOptions = {}): Session { let shouldEmit = false; const updatedSession = updateSession((session) => { const step = session.steps[stepName]; @@ -1097,14 +1091,12 @@ export function markStepFailed(stepName: string, message: string | null = null): step.status = "failed"; step.completedAt = null; step.error = redactSensitiveText(message); - session.failure = sanitizeFailure({ - step: stepName, - message, - recordedAt: now, - }); - session.status = "failed"; - transitionMachineSnapshot(session, "failed", now); - shouldEmit = true; + shouldEmit = shouldUpdateMachine(options); + if (shouldEmit) { + session.failure = sanitizeFailure({ step: stepName, message, recordedAt: now }); + session.status = "failed"; + transitionMachineSnapshot(session, "failed", now); + } return session; }); if (shouldEmit) { @@ -1129,6 +1121,14 @@ export function markStepFailed(stepName: string, message: string | null = null): return updatedSession; } +export function markStepFailed(stepName: string, message: string | null = null): Session { + return markStepFailedWithOptions(stepName, message); +} + +export function markStepFailedRecordOnly(stepName: string, message: string | null = null): Session { + return markStepFailedWithOptions(stepName, message, { updateMachine: false }); +} + export function completeSession(updates: SessionUpdates = {}): Session { const safeUpdates = filterSafeUpdates(updates); let wasComplete = false; diff --git a/src/lib/state/onboard-step-mutation.test.ts b/src/lib/state/onboard-step-mutation.test.ts new file mode 100644 index 0000000000..859f7f6105 --- /dev/null +++ b/src/lib/state/onboard-step-mutation.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type * as eventsModule from "../onboard/machine/events"; +import type * as sessionModule from "./onboard-session"; + +const originalHome = process.env.HOME; +let session: typeof sessionModule; +let machineEvents: typeof eventsModule; +let tmpDir: string; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-step-mutation-")); + process.env.HOME = tmpDir; + vi.resetModules(); + session = await import("./onboard-session"); + machineEvents = await import("../onboard/machine/events"); + machineEvents.clearOnboardMachineEventListeners(); + session.clearSession(); + session.releaseOnboardLock(); +}); + +afterEach(() => { + machineEvents.clearOnboardMachineEventListeners(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } +}); + +function requireLoadedSession(loaded: ReturnType) { + expect(loaded).not.toBeNull(); + if (!loaded) throw new Error("Expected onboard session to be present"); + return loaded; +} + +describe("record-only onboard step mutation", () => { + it("persists step status and per-step failure errors without mutating the machine snapshot", () => { + const emitted: eventsModule.OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + session.saveSession(session.createSession()); + + session.markStepStartedRecordOnly("preflight"); + let loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.preflight.status).toBe("in_progress"); + expect(loaded.status).toBe("in_progress"); + expect(loaded.machine).toMatchObject({ state: "init", revision: 0 }); + + session.markStepCompleteRecordOnly("preflight", { sandboxName: "my-assistant" }); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.preflight.status).toBe("complete"); + expect(loaded.sandboxName).toBe("my-assistant"); + expect(loaded.machine).toMatchObject({ state: "init", revision: 0 }); + + session.markStepFailedRecordOnly("gateway", "Gateway failed: NVIDIA_API_KEY=nvapi-secret"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.gateway.status).toBe("failed"); + expect(loaded.steps.gateway.error).toBe("Gateway failed: NVIDIA_API_KEY="); + expect(loaded.steps.gateway.error).not.toContain("nvapi-secret"); + expect(loaded.status).toBe("in_progress"); + expect(loaded.failure).toBeNull(); + expect(loaded.machine).toMatchObject({ state: "init", revision: 0 }); + expect(emitted.map((event) => event.type)).toEqual(["context.updated"]); + }); +}); diff --git a/src/lib/state/onboard-step-mutation.ts b/src/lib/state/onboard-step-mutation.ts new file mode 100644 index 0000000000..68f94e194e --- /dev/null +++ b/src/lib/state/onboard-step-mutation.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface StepMutationOptions { + /** + * Transitional FSM migration escape hatch for fresh-flow slices where the + * runtime applies explicit OnboardStateResult transitions immediately after + * legacy step helpers record status. Production record-only writes should be + * paired with an explicit runtime result through OnboardRuntimeBoundary-owned + * adapters so the runtime remains the durable machine source of truth. Remove + * this option once all live phase bodies return explicit FSM results without + * relying on step helper machine mutation. + */ + updateMachine?: boolean; +} + +export function shouldUpdateMachine(options: StepMutationOptions | undefined): boolean { + return options?.updateMachine !== false; +} diff --git a/src/lib/state/onboard-step-state.ts b/src/lib/state/onboard-step-state.ts new file mode 100644 index 0000000000..1eb0b0536b --- /dev/null +++ b/src/lib/state/onboard-step-state.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardMachineState } from "../onboard/machine/types"; + +export function nextMachineStateAfterCompletedStep( + stepName: string | null | undefined, + session: { agent: string | null }, +): 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; + } +}