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..17d4cbdf664 --- /dev/null +++ b/src/lib/onboard/machine/transitions.ts @@ -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 +> = ONBOARD_MACHINE_STATES.reduce( + (nextStates, state) => ({ + ...nextStates, + [state]: ONBOARD_MACHINE_TRANSITIONS.filter((transition) => transition.from === state).map( + (transition) => transition.to, + ), + }), + {} as Record, +); + +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..e1dca21e725 --- /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; + 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; +}