From 6b753bf4ebf69d157170c477b96a9ae8daa19b16 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 15:18:22 -0700 Subject: [PATCH 1/7] docs(onboard): document FSM migration target Signed-off-by: Carlos Villela --- src/lib/onboard/machine/README.md | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/lib/onboard/machine/README.md diff --git a/src/lib/onboard/machine/README.md b/src/lib/onboard/machine/README.md new file mode 100644 index 00000000000..752e8959f42 --- /dev/null +++ b/src/lib/onboard/machine/README.md @@ -0,0 +1,111 @@ + + + +# Onboard finite-state machine + +This directory contains the transitional onboarding finite-state-machine (FSM) layer. The current implementation records coarse state snapshots and emits machine events while the legacy `src/lib/onboard.ts` entrypoint is split into explicit state handlers. + +## Target architecture + +The target shape is a machine-driven onboarding runner: + +1. Normalize CLI flags, environment, session locking, and consent in `src/lib/onboard.ts`. +2. Build an onboarding context that contains sanitized operator choices, runtime dependencies, and mutable values returned by states. +3. Enter `runOnboardMachine(context)`. +4. Dispatch the current machine state to a handler. +5. Let the handler return an explicit state result such as advance, retry, branch, complete, or failed. +6. Apply the result through `OnboardRuntime`, which validates the transition, updates the persisted session snapshot, and emits redacted machine events. +7. Continue until the machine reaches `complete` or `failed`. + +In that final shape, `src/lib/onboard.ts` should be a thin entrypoint. State handlers should own state-specific prompts, resume validation, repair decisions, and side effects. + +## State ownership + +Machine states are coarse user-visible onboarding phases, not every subprocess or probe inside a phase. The current vocabulary is intentionally limited to major boundaries: + +- `init` +- `preflight` +- `gateway` +- `provider_selection` +- `inference` +- `sandbox` +- `openclaw` or `agent_setup` +- `policies` +- `finalizing` +- `post_verify` +- `complete` or `failed` + +A state handler may perform many smaller operations, but it should expose only stable, redacted state transitions and context updates to the FSM. + +## Session steps versus machine state + +The persisted onboarding session still tracks step-level progress for resumability. Step recording is older than the FSM and is currently used as a compatibility bridge. + +Long term: + +- `OnboardRuntime` should own machine transitions and machine revision increments. +- Session step helpers should record only step status (`pending`, `in_progress`, `complete`, `failed`, `skipped`). +- State handlers should return explicit results instead of implicitly moving the machine by calling step helpers. + +Until that migration completes, step helpers may still infer machine snapshots for compatibility with older sessions and tests. + +## Handler contract + +Each state handler should eventually follow this shape: + +```ts +type OnboardStateHandler = (context: OnboardContext) => Promise; +``` + +A handler should: + +- validate whether the state can be resumed or skipped; +- run state-local repairs before declaring a cached step reusable; +- perform the phase side effects; +- return the next state explicitly; +- keep secrets out of returned metadata and event context. + +A handler should not: + +- mutate the machine snapshot directly; +- jump to states outside the declared transition graph; +- rely on console output as the only observable diagnostic; +- store raw credentials, provider URLs with secrets, or other sensitive values in machine context. + +## Runtime responsibilities + +`OnboardRuntime` is the intended authority for: + +- validating transitions against `transitions.ts`; +- applying safe session context updates; +- marking terminal states; +- emitting redacted lifecycle, state, repair, resume-conflict, and hook events; +- preserving compatibility with normalized older sessions. + +The runtime should reject invalid transitions before they can be persisted. + +## Event semantics + +Machine events are diagnostics and automation hooks. They must be safe to write to JSONL logs and attach to CI/E2E artifacts. + +Event payloads should include only stable, redacted context such as: + +- selected agent; +- sandbox name; +- provider and model names; +- endpoint origin, not full secret-bearing URLs; +- credential environment variable name, not credential value; +- policy presets and messaging channel names. + +Observers and hooks must not change onboarding behavior. A failing hook should emit hook failure diagnostics and let onboarding continue. + +## Migration stages + +The FSM migration is considered complete when: + +1. state metadata is defined once and derived by session, event, progress, and transition code; +2. live onboarding emits `onboard.started`, `onboard.resumed`, `resume.conflict`, terminal, state, skip, repair, and context events consistently; +3. handlers return explicit state results; +4. the runner applies all handler results through `OnboardRuntime`; +5. step helpers no longer implicitly own machine transitions; +6. `src/lib/onboard.ts` contains entrypoint setup and dependency wiring rather than state sequencing. From fb1b32d0a8725934d3c49e77ca375abdcedf2c81 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 15:19:42 -0700 Subject: [PATCH 2/7] refactor(onboard): centralize machine state metadata Signed-off-by: Carlos Villela --- src/lib/onboard/machine/definition.test.ts | 85 ++++++++++++++++ src/lib/onboard/machine/definition.ts | 108 +++++++++++++++++++++ src/lib/onboard/machine/types.ts | 43 +++----- 3 files changed, 208 insertions(+), 28 deletions(-) create mode 100644 src/lib/onboard/machine/definition.test.ts create mode 100644 src/lib/onboard/machine/definition.ts diff --git a/src/lib/onboard/machine/definition.test.ts b/src/lib/onboard/machine/definition.test.ts new file mode 100644 index 00000000000..7fffa49ec5a --- /dev/null +++ b/src/lib/onboard/machine/definition.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + getOnboardMachineStateDefinition, + ONBOARD_MACHINE_NON_TERMINAL_STATE_IDS, + ONBOARD_MACHINE_STATE_DEFINITIONS, + ONBOARD_MACHINE_STATE_IDS, + ONBOARD_MACHINE_TERMINAL_STATE_IDS, +} from "./definition"; + +const expectedStateOrder = [ + "init", + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + "finalizing", + "post_verify", + "complete", + "failed", +]; + +describe("onboard machine definition", () => { + it("is the canonical ordered state catalog", () => { + expect(ONBOARD_MACHINE_STATE_IDS).toEqual(expectedStateOrder); + expect(ONBOARD_MACHINE_STATE_DEFINITIONS.map((definition) => definition.state)).toEqual( + expectedStateOrder, + ); + }); + + it("derives terminal and non-terminal state catalogs from the same vocabulary", () => { + const terminalFromDefinitions = ONBOARD_MACHINE_STATE_DEFINITIONS.filter( + (definition) => definition.terminal, + ).map((definition) => definition.state); + const nonTerminalFromDefinitions = ONBOARD_MACHINE_STATE_DEFINITIONS.filter( + (definition) => !definition.terminal, + ).map((definition) => definition.state); + + expect(ONBOARD_MACHINE_TERMINAL_STATE_IDS).toEqual(terminalFromDefinitions); + expect(ONBOARD_MACHINE_NON_TERMINAL_STATE_IDS).toEqual(nonTerminalFromDefinitions); + }); + + it("keeps resumable step names unique", () => { + const stepNames = ONBOARD_MACHINE_STATE_DEFINITIONS.flatMap((definition) => + "stepName" in definition ? [definition.stepName] : [], + ); + + expect(new Set(stepNames).size).toBe(stepNames.length); + expect(stepNames).toEqual([ + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + ]); + }); + + it("keeps progress metadata attached only to state-backed steps", () => { + for (const definition of ONBOARD_MACHINE_STATE_DEFINITIONS) { + if (!("progress" in definition)) continue; + expect("stepName" in definition).toBe(true); + expect(definition.progress.total).toBe(8); + expect(definition.progress.number).toBeGreaterThanOrEqual(1); + expect(definition.progress.number).toBeLessThanOrEqual(definition.progress.total); + expect(definition.progress.title).not.toHaveLength(0); + } + }); + + it("looks up definitions by state", () => { + expect(getOnboardMachineStateDefinition("gateway")).toMatchObject({ + state: "gateway", + stepName: "gateway", + }); + }); +}); diff --git a/src/lib/onboard/machine/definition.ts b/src/lib/onboard/machine/definition.ts new file mode 100644 index 00000000000..0f873edf0b3 --- /dev/null +++ b/src/lib/onboard/machine/definition.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Canonical metadata for the coarse onboard finite-state machine. + * + * Keep this file free of imports from the rest of the machine package so the + * core state vocabulary can be reused by type, transition, event, session, and + * progress helpers without introducing circular dependencies. + */ + +export const ONBOARD_MACHINE_STATE_DEFINITIONS = [ + { state: "init", terminal: false }, + { + state: "preflight", + terminal: false, + stepName: "preflight", + progress: { number: 1, total: 8, title: "Preflight checks" }, + }, + { + state: "gateway", + terminal: false, + stepName: "gateway", + progress: { number: 2, total: 8, title: "Starting OpenShell gateway" }, + }, + { + state: "provider_selection", + terminal: false, + stepName: "provider_selection", + progress: { number: 3, total: 8, title: "Configuring inference (NIM)" }, + }, + { + state: "inference", + terminal: false, + stepName: "inference", + progress: { number: 4, total: 8, title: "Setting up inference provider" }, + }, + { + state: "sandbox", + terminal: false, + stepName: "sandbox", + progress: { number: 6, total: 8, title: "Creating sandbox" }, + }, + { + state: "agent_setup", + terminal: false, + stepName: "agent_setup", + progress: { number: 7, total: 8, title: "Setting up agent inside sandbox" }, + }, + { + state: "openclaw", + terminal: false, + stepName: "openclaw", + progress: { number: 7, total: 8, title: "Setting up agent inside sandbox" }, + }, + { + state: "policies", + terminal: false, + stepName: "policies", + progress: { number: 8, total: 8, title: "Policy presets" }, + }, + { state: "finalizing", terminal: false }, + { state: "post_verify", terminal: false }, + { state: "complete", terminal: true }, + { state: "failed", terminal: true }, +] as const; + +export const ONBOARD_MACHINE_STATE_IDS = ONBOARD_MACHINE_STATE_DEFINITIONS.map( + (definition) => definition.state, +) as readonly OnboardMachineStateId[]; + +export const ONBOARD_MACHINE_TERMINAL_STATE_IDS = ["complete", "failed"] as const; + +export type OnboardTerminalMachineStateId = (typeof ONBOARD_MACHINE_TERMINAL_STATE_IDS)[number]; + +export type OnboardMachineStateId = (typeof ONBOARD_MACHINE_STATE_DEFINITIONS)[number]["state"]; + +export type OnboardNonTerminalMachineStateId = Exclude< + OnboardMachineStateId, + OnboardTerminalMachineStateId +>; + +export const ONBOARD_MACHINE_NON_TERMINAL_STATE_IDS = ONBOARD_MACHINE_STATE_DEFINITIONS.filter( + (definition): definition is Extract< + (typeof ONBOARD_MACHINE_STATE_DEFINITIONS)[number], + { terminal: false } + > => definition.terminal === false, +).map((definition) => definition.state) as readonly OnboardNonTerminalMachineStateId[]; + +export type OnboardMachineStateDefinition = (typeof ONBOARD_MACHINE_STATE_DEFINITIONS)[number]; + +export type OnboardMachineStateWithStepDefinition = Extract< + OnboardMachineStateDefinition, + { stepName: string } +>; + +export type OnboardMachineStateWithProgressDefinition = Extract< + OnboardMachineStateDefinition, + { progress: { number: number; total: number; title: string } } +>; + +export function getOnboardMachineStateDefinition( + state: OnboardMachineStateId, +): OnboardMachineStateDefinition { + const definition = ONBOARD_MACHINE_STATE_DEFINITIONS.find((entry) => entry.state === state); + if (!definition) throw new Error(`Unknown onboarding machine state: ${state}`); + return definition; +} diff --git a/src/lib/onboard/machine/types.ts b/src/lib/onboard/machine/types.ts index e1dca21e725..d5f00477eef 100644 --- a/src/lib/onboard/machine/types.ts +++ b/src/lib/onboard/machine/types.ts @@ -9,39 +9,26 @@ * 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; +import { + ONBOARD_MACHINE_NON_TERMINAL_STATE_IDS, + ONBOARD_MACHINE_STATE_IDS, + ONBOARD_MACHINE_TERMINAL_STATE_IDS, + type OnboardMachineStateId, + type OnboardNonTerminalMachineStateId, + type OnboardTerminalMachineStateId, +} from "./definition"; + +export const ONBOARD_MACHINE_STATES = ONBOARD_MACHINE_STATE_IDS; -export type OnboardMachineState = (typeof ONBOARD_MACHINE_STATES)[number]; +export type OnboardMachineState = OnboardMachineStateId; -export const ONBOARD_TERMINAL_MACHINE_STATES = ["complete", "failed"] as const; +export const ONBOARD_TERMINAL_MACHINE_STATES = ONBOARD_MACHINE_TERMINAL_STATE_IDS; -export type OnboardTerminalMachineState = - (typeof ONBOARD_TERMINAL_MACHINE_STATES)[number]; +export type OnboardTerminalMachineState = OnboardTerminalMachineStateId; -export type OnboardNonTerminalMachineState = Exclude< - OnboardMachineState, - OnboardTerminalMachineState ->; +export type OnboardNonTerminalMachineState = OnboardNonTerminalMachineStateId; -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_NON_TERMINAL_MACHINE_STATES = ONBOARD_MACHINE_NON_TERMINAL_STATE_IDS; export const ONBOARD_MACHINE_EVENT_TYPES = [ "onboard.started", From c3e4ad63b31738ffad7f7c7bb306dfa8d6eca39a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 15:21:58 -0700 Subject: [PATCH 3/7] refactor(onboard): derive session step mapping from FSM metadata Signed-off-by: Carlos Villela --- src/lib/onboard/machine/definition.test.ts | 11 ++++++++ src/lib/onboard/machine/events.ts | 32 ++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/machine/definition.test.ts b/src/lib/onboard/machine/definition.test.ts index 7fffa49ec5a..6c5d87c6f74 100644 --- a/src/lib/onboard/machine/definition.test.ts +++ b/src/lib/onboard/machine/definition.test.ts @@ -10,6 +10,7 @@ import { ONBOARD_MACHINE_STATE_IDS, ONBOARD_MACHINE_TERMINAL_STATE_IDS, } from "./definition"; +import { ONBOARD_SESSION_STEP_TO_MACHINE_STATE } from "./events"; const expectedStateOrder = [ "init", @@ -65,6 +66,16 @@ describe("onboard machine definition", () => { ]); }); + it("derives the session step mapping from state definitions", () => { + const mappingFromDefinitions = Object.fromEntries( + ONBOARD_MACHINE_STATE_DEFINITIONS.flatMap((definition) => + "stepName" in definition ? [[definition.stepName, definition.state]] : [], + ), + ); + + expect(ONBOARD_SESSION_STEP_TO_MACHINE_STATE).toEqual(mappingFromDefinitions); + }); + it("keeps progress metadata attached only to state-backed steps", () => { for (const definition of ONBOARD_MACHINE_STATE_DEFINITIONS) { if (!("progress" in definition)) continue; diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts index f6b7dca47cb..2ce746167af 100644 --- a/src/lib/onboard/machine/events.ts +++ b/src/lib/onboard/machine/events.ts @@ -4,24 +4,32 @@ import type { JsonObject, JsonValue } from "../../core/json-types"; import { redactSensitiveText, redactUrl } from "../../security/redact"; import type { HermesAuthMethod, Session } from "../../state/onboard-session"; +import { + ONBOARD_MACHINE_STATE_DEFINITIONS, + type OnboardMachineStateWithStepDefinition, +} from "./definition"; 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; +type OnboardSessionStepDefinition = OnboardMachineStateWithStepDefinition; + +export type OnboardSessionStepName = OnboardSessionStepDefinition["stepName"]; + +type OnboardSessionStepToMachineState = { + readonly [StepName in OnboardSessionStepName]: Extract< + OnboardSessionStepDefinition, + { stepName: StepName } + >["state"]; +}; + +export const ONBOARD_SESSION_STEP_TO_MACHINE_STATE = Object.fromEntries( + ONBOARD_MACHINE_STATE_DEFINITIONS.flatMap((definition) => + "stepName" in definition ? [[definition.stepName, definition.state]] : [], + ), +) as OnboardSessionStepToMachineState; export interface OnboardMachineEvent { version: 1; From 603832c0c5d9e2ea2e9a8b27158ee00b8fd9bc93 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 15:24:39 -0700 Subject: [PATCH 4/7] refactor(onboard): derive progress labels from FSM metadata Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 23 +++++--------- src/lib/onboard/machine/definition.ts | 1 - src/lib/onboard/machine/progress.test.ts | 38 ++++++++++++++++++++++++ src/lib/onboard/machine/progress.ts | 38 ++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 src/lib/onboard/machine/progress.test.ts create mode 100644 src/lib/onboard/machine/progress.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 761c3c24540..4fef39b2aa9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -419,6 +419,7 @@ const { handlePoliciesState }: typeof import("./onboard/machine/handlers/policie const { handlePreflightState }: typeof import("./onboard/machine/handlers/preflight") = require("./onboard/machine/handlers/preflight"); const { handleProviderInferenceState }: typeof import("./onboard/machine/handlers/provider-inference") = require("./onboard/machine/handlers/provider-inference"); const { handleSandboxState }: typeof import("./onboard/machine/handlers/sandbox") = require("./onboard/machine/handlers/sandbox"); +const { getOnboardProgressStep }: typeof import("./onboard/machine/progress") = require("./onboard/machine/progress"); const policies: typeof import("./policy") = require("./policy"); const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); @@ -6390,28 +6391,18 @@ const recordRepairEvent = onboardRuntimeBoundary.recordRepairEvent.bind(onboardR const recordPostVerifyStarted = onboardRuntimeBoundary.recordPostVerifyStarted.bind(onboardRuntimeBoundary); const recordSessionComplete = onboardRuntimeBoundary.recordSessionComplete.bind(onboardRuntimeBoundary); -const ONBOARD_STEP_INDEX: Record = { - preflight: { number: 1, title: "Preflight checks" }, - gateway: { number: 2, title: "Starting OpenShell gateway" }, - provider_selection: { number: 3, title: "Configuring inference (NIM)" }, - inference: { number: 4, title: "Setting up inference provider" }, - messaging: { number: 5, title: "Messaging channels" }, - sandbox: { number: 6, title: "Creating sandbox" }, - openclaw: { number: 7, title: "Setting up agent inside sandbox" }, - policies: { number: 8, title: "Policy presets" }, -}; - function skippedStepMessage( stepName: string, detail?: string | null, reason: "resume" | "reuse" = "resume", ): void { - let stepInfo = ONBOARD_STEP_INDEX[stepName]; - if (stepInfo && stepName === "openclaw") { - stepInfo = { ...stepInfo, title: `Setting up ${agentProductName()} inside sandbox` }; - } + const progressStep = getOnboardProgressStep(stepName); + const stepInfo = + progressStep && stepName === "openclaw" + ? { ...progressStep, title: `Setting up ${agentProductName()} inside sandbox` } + : progressStep; if (stepInfo) { - step(stepInfo.number, 8, stepInfo.title); + step(stepInfo.number, stepInfo.total, stepInfo.title); } const prefix = reason === "reuse" ? "[reuse]" : "[resume]"; console.log(` ${prefix} Skipping ${stepName}${detail ? ` (${detail})` : ""}`); diff --git a/src/lib/onboard/machine/definition.ts b/src/lib/onboard/machine/definition.ts index 0f873edf0b3..03903bfb34d 100644 --- a/src/lib/onboard/machine/definition.ts +++ b/src/lib/onboard/machine/definition.ts @@ -45,7 +45,6 @@ export const ONBOARD_MACHINE_STATE_DEFINITIONS = [ state: "agent_setup", terminal: false, stepName: "agent_setup", - progress: { number: 7, total: 8, title: "Setting up agent inside sandbox" }, }, { state: "openclaw", diff --git a/src/lib/onboard/machine/progress.test.ts b/src/lib/onboard/machine/progress.test.ts new file mode 100644 index 00000000000..4c1ee2e99dc --- /dev/null +++ b/src/lib/onboard/machine/progress.test.ts @@ -0,0 +1,38 @@ +// 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_STATE_DEFINITIONS } from "./definition"; +import { getOnboardProgressStep, ONBOARD_PROGRESS_STEPS } from "./progress"; + +describe("onboard progress metadata", () => { + it("derives state-backed progress labels from machine definitions", () => { + for (const definition of ONBOARD_MACHINE_STATE_DEFINITIONS) { + if (!("progress" in definition)) continue; + expect(ONBOARD_PROGRESS_STEPS[definition.stepName]).toEqual(definition.progress); + } + }); + + it("preserves the existing eight-step onboarding labels", () => { + expect(ONBOARD_PROGRESS_STEPS).toEqual({ + preflight: { number: 1, total: 8, title: "Preflight checks" }, + gateway: { number: 2, total: 8, title: "Starting OpenShell gateway" }, + provider_selection: { number: 3, total: 8, title: "Configuring inference (NIM)" }, + inference: { number: 4, total: 8, title: "Setting up inference provider" }, + messaging: { number: 5, total: 8, title: "Messaging channels" }, + sandbox: { number: 6, total: 8, title: "Creating sandbox" }, + openclaw: { number: 7, total: 8, title: "Setting up agent inside sandbox" }, + policies: { number: 8, total: 8, title: "Policy presets" }, + }); + }); + + it("looks up known labels and ignores unknown steps", () => { + expect(getOnboardProgressStep("gateway")).toEqual({ + number: 2, + total: 8, + title: "Starting OpenShell gateway", + }); + expect(getOnboardProgressStep("not-a-step")).toBeNull(); + }); +}); diff --git a/src/lib/onboard/machine/progress.ts b/src/lib/onboard/machine/progress.ts new file mode 100644 index 00000000000..2cf485655ed --- /dev/null +++ b/src/lib/onboard/machine/progress.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ONBOARD_MACHINE_STATE_DEFINITIONS, + type OnboardMachineStateWithProgressDefinition, +} from "./definition"; + +export interface OnboardProgressStep { + number: number; + total: number; + title: string; +} + +export type OnboardMachineProgressStepName = + OnboardMachineStateWithProgressDefinition["stepName"]; + +export type OnboardProgressStepName = OnboardMachineProgressStepName | "messaging"; + +const EXTRA_PROGRESS_STEPS = [ + { + stepName: "messaging", + progress: { number: 5, total: 8, title: "Messaging channels" }, + }, +] as const; + +export const ONBOARD_PROGRESS_STEPS = Object.fromEntries([ + ...ONBOARD_MACHINE_STATE_DEFINITIONS.flatMap((definition) => + "progress" in definition ? [[definition.stepName, definition.progress]] : [], + ), + ...EXTRA_PROGRESS_STEPS.map((definition) => [definition.stepName, definition.progress]), +]) as Readonly>; + +export function getOnboardProgressStep(stepName: string): OnboardProgressStep | null { + return Object.prototype.hasOwnProperty.call(ONBOARD_PROGRESS_STEPS, stepName) + ? ONBOARD_PROGRESS_STEPS[stepName as OnboardProgressStepName] + : null; +} From 4fad8e7cc0461d01f50dae21055a2a1c6d7232f5 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 17:02:42 -0700 Subject: [PATCH 5/7] fix(onboard): emit lifecycle events for onboarding start Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 3 + src/lib/onboard/runtime-boundary.test.ts | 94 ++++++++++++++++++++++++ src/lib/onboard/runtime-boundary.ts | 10 ++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/lib/onboard/runtime-boundary.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4fef39b2aa9..a05c9f16da6 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6382,6 +6382,7 @@ const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ maybeForceE2eStepFailure, }); +const recordOnboardStarted = onboardRuntimeBoundary.recordOnboardStarted.bind(onboardRuntimeBoundary); const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); @@ -6675,6 +6676,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { ); } + await recordOnboardStarted(resume); + // Backstop for the resume path: a session may exist (so the early guard // skipped because resume === true) but never have recorded a sandboxName // — sandbox creation could have failed before that step ran. Without a diff --git a/src/lib/onboard/runtime-boundary.test.ts b/src/lib/onboard/runtime-boundary.test.ts new file mode 100644 index 00000000000..d81116ed86b --- /dev/null +++ b/src/lib/onboard/runtime-boundary.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + createSession, + filterSafeUpdates, + normalizeSession, + type Session, + type SessionUpdates, +} from "../state/onboard-session"; +import type { OnboardMachineEvent } from "./machine/events"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./machine/runtime"; +import { OnboardRuntimeBoundary } from "./runtime-boundary"; + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function createRuntimeHarness() { + let session: Session | null = createSession(); + const events: OnboardMachineEvent[] = []; + const updateSession = (mutator: (value: Session) => Session | void): Session => { + const current = session ? cloneSession(session) : createSession(); + session = cloneSession(mutator(current) ?? current); + return cloneSession(session); + }; + const deps: OnboardRuntimeDeps = { + loadSession: () => (session ? cloneSession(session) : null), + createSession, + saveSession: (next) => { + session = cloneSession(next); + return cloneSession(session); + }, + updateSession, + markStepStarted: (stepName) => + updateSession((current) => { + current.steps[stepName].status = "in_progress"; + return current; + }), + markStepComplete: (stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + current.steps[stepName].status = "complete"; + Object.assign(current, filterSafeUpdates(updates)); + return current; + }), + markStepSkipped: (stepName) => + updateSession((current) => { + current.steps[stepName].status = "skipped"; + return current; + }), + markStepFailed: (stepName, message) => + updateSession((current) => { + current.steps[stepName].status = "failed"; + current.failure = { step: stepName, message: message ?? null, recordedAt: "now" }; + return current; + }), + completeSession: (updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + current.status = "complete"; + return current; + }), + filterSafeUpdates, + emitEvent: (event) => events.push(event), + now: () => "2026-05-27T00:00:00.000Z", + }; + return { + createRuntime: () => new OnboardRuntime(deps), + events, + }; +} + +describe("OnboardRuntimeBoundary", () => { + it("records started and resumed lifecycle events through the runtime", async () => { + const harness = createRuntimeHarness(); + const boundary = new OnboardRuntimeBoundary({ + toSessionUpdates: (updates) => filterSafeUpdates(updates as SessionUpdates) as SessionUpdates, + maybeForceE2eStepFailure: () => undefined, + createRuntime: harness.createRuntime, + }); + + await boundary.recordOnboardStarted(false); + await boundary.recordOnboardStarted(true); + + expect(harness.events.map((event) => event.type)).toEqual([ + "onboard.started", + "onboard.resumed", + ]); + expect(harness.events[0]).toMatchObject({ state: "init" }); + expect(harness.events[1]).toMatchObject({ state: "init" }); + }); +}); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index daa8a13367a..e90166e17b3 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -8,6 +8,7 @@ import type { OnboardMachineEventType, OnboardMachineState } from "./machine/typ export interface OnboardRuntimeBoundaryOptions { toSessionUpdates(updates: Record): SessionUpdates; maybeForceE2eStepFailure(stepName: string): void; + createRuntime?(): OnboardRuntime; } export class OnboardRuntimeBoundary { @@ -16,7 +17,7 @@ export class OnboardRuntimeBoundary { constructor(private readonly options: OnboardRuntimeBoundaryOptions) {} reset(): void { - this.runtime = new OnboardRuntime(); + this.runtime = this.options.createRuntime?.() ?? new OnboardRuntime(); } clear(): void { @@ -24,12 +25,13 @@ export class OnboardRuntimeBoundary { } getRuntime(): OnboardRuntime { - if (!this.runtime) this.runtime = new OnboardRuntime(); + if (!this.runtime) this.runtime = this.options.createRuntime?.() ?? new OnboardRuntime(); return this.runtime; } recorders() { return { + recordOnboardStarted: this.recordOnboardStarted.bind(this), startRecordedStep: this.startRecordedStep.bind(this), recordStepComplete: this.recordStepComplete.bind(this), recordStepSkipped: this.recordStepSkipped.bind(this), @@ -41,6 +43,10 @@ export class OnboardRuntimeBoundary { }; } + async recordOnboardStarted(resumed: boolean): Promise { + return this.getRuntime().start({ resumed }); + } + async startRecordedStep( stepName: string, updates: { From f98631d6cd959433c740727123c638de54dc5d8f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 1 Jun 2026 12:44:25 -0700 Subject: [PATCH 6/7] test(onboard): cover entrypoint lifecycle events Signed-off-by: Carlos Villela --- test/onboard-lifecycle.test.ts | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/onboard-lifecycle.test.ts diff --git a/test/onboard-lifecycle.test.ts b/test/onboard-lifecycle.test.ts new file mode 100644 index 00000000000..6676c8a1acc --- /dev/null +++ b/test/onboard-lifecycle.test.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, it } from "vitest"; + +type LifecyclePayload = { + calls: Array<{ + resumed: boolean; + sessionBeforeExists: boolean; + mode: string | null; + sandboxName: string | null; + }>; + events: Array<{ type: string; state: string | null; step: string | null }>; +}; + +function runLifecycleEntrypoint(mode: "fresh" | "resume"): LifecyclePayload { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-lifecycle-")); + const scriptPath = path.join(tmpDir, `onboard-lifecycle-${mode}.cjs`); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runtimeBoundaryPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "runtime-boundary.js"), + ); + const eventsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "machine", "events.js"), + ); + + fs.writeFileSync( + scriptPath, + ` +const { OnboardRuntimeBoundary } = require(${runtimeBoundaryPath}); +const eventsModule = require(${eventsPath}); +const emittedEvents = []; +eventsModule.addOnboardMachineEventListener((event) => emittedEvents.push(event)); + +const sentinel = new Error("stop after onboard lifecycle event"); +const originalRecordOnboardStarted = OnboardRuntimeBoundary.prototype.recordOnboardStarted; +const calls = []; +OnboardRuntimeBoundary.prototype.recordOnboardStarted = async function(resumed) { + const onboardSession = require(${onboardPath}).onboardSession; + const sessionBefore = onboardSession.loadSession(); + calls.push({ + resumed, + sessionBeforeExists: sessionBefore !== null, + mode: sessionBefore?.mode ?? null, + sandboxName: sessionBefore?.sandboxName ?? null, + }); + await originalRecordOnboardStarted.call(this, resumed); + throw sentinel; +}; + +const onboardModule = require(${onboardPath}); +if (${JSON.stringify(mode)} === "resume") { + onboardModule.onboardSession.saveSession( + onboardModule.onboardSession.createSession({ + mode: "non-interactive", + sandboxName: "resume-lifecycle", + metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + }), + ); +} + +const options = { + resume: ${JSON.stringify(mode)} === "resume", + nonInteractive: true, + acceptThirdPartySoftware: true, + sandboxName: "fresh-lifecycle", + noGpu: true, +}; + +onboardModule.onboard(options).then( + () => { + throw new Error("expected lifecycle spy to abort onboarding"); + }, + (error) => { + if (error !== sentinel && error?.message !== sentinel.message) { + console.error(error?.stack || error); + process.exit(1); + } + console.log(JSON.stringify({ + calls, + events: emittedEvents.map((event) => ({ + type: event.type, + state: event.state, + step: event.step, + })), + })); + }, +); +`, + ); + + try { + const env: Record = { ...process.env, HOME: tmpDir }; + delete env.NEMOCLAW_NON_INTERACTIVE; + delete env.NEMOCLAW_SANDBOX_NAME; + delete env.NEMOCLAW_FROM_DOCKERFILE; + delete env.NEMOCLAW_PROVIDER; + delete env.NEMOCLAW_MODEL; + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + const line = result.stdout.trim().split("\n").pop(); + assert.ok(line, `expected JSON payload in stdout:\n${result.stdout}`); + return JSON.parse(line) as LifecyclePayload; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("onboard entrypoint lifecycle events", () => { + it("emits onboard.started after creating a fresh session", () => { + const payload = runLifecycleEntrypoint("fresh"); + + assert.deepEqual(payload.calls, [ + { + resumed: false, + sessionBeforeExists: true, + mode: "non-interactive", + sandboxName: null, + }, + ]); + assert.deepEqual(payload.events, [{ type: "onboard.started", state: "init", step: null }]); + }); + + it("emits onboard.resumed after loading a resumable session", () => { + const payload = runLifecycleEntrypoint("resume"); + + assert.deepEqual(payload.calls, [ + { + resumed: true, + sessionBeforeExists: true, + mode: "non-interactive", + sandboxName: "resume-lifecycle", + }, + ]); + assert.deepEqual(payload.events, [{ type: "onboard.resumed", state: "init", step: null }]); + }); +}); From 8be4acda4e8c054dba29fc2814f2251586b4432a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 1 Jun 2026 12:51:45 -0700 Subject: [PATCH 7/7] fix(onboard): keep lifecycle hook budget neutral Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8d67e3b831d..1ba235d239d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6326,7 +6326,6 @@ const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ maybeForceE2eStepFailure, }); -const recordOnboardStarted = onboardRuntimeBoundary.recordOnboardStarted.bind(onboardRuntimeBoundary); const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); @@ -6619,9 +6618,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { }), ); } - - await recordOnboardStarted(resume); - + await onboardRuntimeBoundary.recordOnboardStarted(resume); // Backstop for the resume path: a session may exist (so the early guard // skipped because resume === true) but never have recorded a sandboxName // — sandbox creation could have failed before that step ran. Without a