Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
164 changes: 164 additions & 0 deletions src/lib/onboard/machine/transitions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
103 changes: 103 additions & 0 deletions src/lib/onboard/machine/transitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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_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 const ONBOARD_MACHINE_NEXT_STATES: Readonly<
Record<OnboardMachineState, readonly OnboardMachineState[]>
> = ONBOARD_MACHINE_STATES.reduce(
(nextStates, state) => ({
...nextStates,
[state]: ONBOARD_MACHINE_TRANSITIONS.filter((transition) => transition.from === state).map(
(transition) => transition.to,
),
}),
{} as Record<OnboardMachineState, readonly OnboardMachineState[]>,
);

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;
}
101 changes: 101 additions & 0 deletions src/lib/onboard/machine/types.ts
Original file line number Diff line number Diff line change
@@ -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;
endpointOrigin?: string | null;
credentialEnv?: string | null;
preferredInferenceApi?: string | null;
hermesAuthMethod?: "oauth" | "api_key" | null;
hermesToolGateways?: string[] | null;
policyPresets?: string[] | null;
messagingChannels?: string[] | null;
gpuPassthrough?: boolean;
}
Loading