Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/lib/onboard/machine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# 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<OnboardStateResult>;
```

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.
85 changes: 85 additions & 0 deletions src/lib/onboard/machine/definition.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
108 changes: 108 additions & 0 deletions src/lib/onboard/machine/definition.ts
Original file line number Diff line number Diff line change
@@ -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];
Comment thread
cv marked this conversation as resolved.
Resolved

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;
}
43 changes: 15 additions & 28 deletions src/lib/onboard/machine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down