diff --git a/src/lib/onboard/machine/sequence-runner.test.ts b/src/lib/onboard/machine/sequence-runner.test.ts new file mode 100644 index 0000000000..6791168566 --- /dev/null +++ b/src/lib/onboard/machine/sequence-runner.test.ts @@ -0,0 +1,212 @@ +// 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, + sanitizeFailure, + type Session, + type SessionUpdates, +} from "../../state/onboard-session"; +import { advanceTo, branchTo, completeOnboardMachine, retryTo } from "./result"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; +import { + buildOnboardSequenceHandlers, + DuplicateOnboardSequencePhaseError, + runOnboardSequenceWithRunner, + type OnboardSequencePhase, +} from "./sequence-runner"; + +interface SequenceContext { + attempt: number; + log: string[]; +} + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function createRuntime(initialSession: Session = createSession()) { + let session = cloneSession(initialSession); + 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: () => cloneSession(session), + markStepComplete: (_stepName, updates: SessionUpdates = {}) => + updateSession((current) => { + 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) => { + current.status = "failed"; + current.failure = sanitizeFailure({ step: stepName, message, recordedAt: "now" }); + return current; + }), + markStepFailedRecordOnly: () => cloneSession(session), + completeSession: (updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + current.status = "complete"; + current.resumable = false; + return current; + }), + filterSafeUpdates, + emitEvent: () => undefined, + now: () => "2026-05-29T00:00:00.000Z", + }; + return new OnboardRuntime(deps); +} + +function phase( + state: OnboardSequencePhase["state"], + run: OnboardSequencePhase["run"], +): OnboardSequencePhase { + return { state, run }; +} + +describe("onboard sequence runner", () => { + it("runs sequence phases through the strict FSM runner", async () => { + const phases: OnboardSequencePhase[] = [ + phase("init", (context) => ({ + context: { ...context, log: [...context.log, "init"] }, + result: advanceTo("preflight"), + })), + phase("preflight", (context) => ({ + context: { ...context, log: [...context.log, "preflight"] }, + result: advanceTo("gateway"), + })), + phase("gateway", (context) => ({ + context: { ...context, log: [...context.log, "gateway"] }, + result: advanceTo("provider_selection"), + })), + phase("provider_selection", (context) => { + if (context.attempt === 0) { + return { + context: { attempt: 1, log: [...context.log, "provider:first"] }, + result: [ + advanceTo("inference", { metadata: { state: "provider_selection" } }), + retryTo("provider_selection", { metadata: { state: "inference" } }), + ], + }; + } + return { + context: { ...context, log: [...context.log, "provider:second"] }, + result: [ + advanceTo("inference", { metadata: { state: "provider_selection" } }), + advanceTo("sandbox", { metadata: { state: "inference" } }), + ], + }; + }), + phase("sandbox", (context) => ({ + context: { ...context, log: [...context.log, "sandbox"] }, + result: branchTo("openclaw"), + })), + phase("openclaw", (context) => ({ + context: { ...context, log: [...context.log, "openclaw"] }, + result: advanceTo("policies"), + })), + phase("policies", (context) => ({ + context: { ...context, log: [...context.log, "policies"] }, + result: advanceTo("finalizing"), + })), + phase("finalizing", (context) => ({ + context: { ...context, log: [...context.log, "finalizing"] }, + result: advanceTo("post_verify"), + })), + phase("post_verify", (context) => ({ + context: { ...context, log: [...context.log, "post_verify"] }, + result: completeOnboardMachine({ sandboxName: "my-assistant" }), + })), + ]; + + const result = await runOnboardSequenceWithRunner({ + context: { attempt: 0, log: [] }, + runtime: createRuntime(), + phases, + }); + + expect(result.session).toMatchObject({ + status: "complete", + sandboxName: "my-assistant", + machine: { state: "complete" }, + }); + expect(result.context).toEqual({ + attempt: 1, + log: [ + "init", + "preflight", + "gateway", + "provider:first", + "provider:second", + "sandbox", + "openclaw", + "policies", + "finalizing", + "post_verify", + ], + }); + }); + + it("passes custom sequence ownership through to the runner", async () => { + const result = await runOnboardSequenceWithRunner({ + context: { attempt: 0, log: [] }, + runtime: createRuntime( + createSession({ + machine: { + version: 1, + state: "finalizing", + stateEnteredAt: "2026-05-29T00:00:00.000Z", + revision: 0, + }, + }), + ), + sequenceOwnership: { finalizing: ["post_verify"] }, + phases: [ + phase("finalizing", (context) => ({ + context: { ...context, log: ["finalizing"] }, + result: [ + advanceTo("post_verify", { metadata: { state: "finalizing" } }), + completeOnboardMachine({}, { state: "post_verify" }), + ], + })), + ], + }); + + expect(result.session).toMatchObject({ + status: "complete", + machine: { state: "complete" }, + }); + expect(result.context.log).toEqual(["finalizing"]); + }); + + it("rejects duplicate phases before running", () => { + expect(() => + buildOnboardSequenceHandlers( + [ + phase("preflight", (context) => ({ context, result: advanceTo("gateway") })), + phase("preflight", (context) => ({ context, result: advanceTo("gateway") })), + ], + () => undefined, + ), + ).toThrow(DuplicateOnboardSequencePhaseError); + }); +}); diff --git a/src/lib/onboard/machine/sequence-runner.ts b/src/lib/onboard/machine/sequence-runner.ts new file mode 100644 index 0000000000..ecda077c93 --- /dev/null +++ b/src/lib/onboard/machine/sequence-runner.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardMachineRunnerOptions, OnboardStateHandlerResult } from "./runner"; +import { runOnboardMachine, type OnboardMachineRunnerRuntime, type OnboardStateHandlers } from "./runner"; +import type { OnboardNonTerminalMachineState } from "./types"; + +export interface OnboardSequencePhaseResult { + context: Context; + result: OnboardStateHandlerResult; +} + +export interface OnboardSequencePhase { + state: OnboardNonTerminalMachineState; + run(context: Context): Promise> | OnboardSequencePhaseResult; +} + +export interface OnboardSequenceRunnerOptions { + context: Context; + runtime: OnboardMachineRunnerRuntime; + phases: readonly OnboardSequencePhase[]; + maxTransitions?: OnboardMachineRunnerOptions["maxTransitions"]; + sequenceOwnership?: OnboardMachineRunnerOptions["sequenceOwnership"]; +} + +export class DuplicateOnboardSequencePhaseError extends Error { + readonly state: OnboardNonTerminalMachineState; + + constructor(state: OnboardNonTerminalMachineState) { + super(`Duplicate onboarding sequence phase for state: ${state}`); + this.name = "DuplicateOnboardSequencePhaseError"; + this.state = state; + } +} + +export function buildOnboardSequenceHandlers( + phases: readonly OnboardSequencePhase[], + setPendingContext: (context: Context) => void, +): OnboardStateHandlers { + const handlers: OnboardStateHandlers = {}; + for (const phase of phases) { + if (handlers[phase.state]) throw new DuplicateOnboardSequencePhaseError(phase.state); + handlers[phase.state] = async (context) => { + const phaseResult = await phase.run(context); + setPendingContext(phaseResult.context); + return phaseResult.result; + }; + } + return handlers; +} + +/** + * Adapter for migrating the existing manual onboard sequence onto the strict + * FSM runner. + * + * Each phase can keep constructing its rich next context while returning one or + * more explicit FSM results. The generic runner remains responsible for + * applying those results and validating transitions. + */ +export async function runOnboardSequenceWithRunner({ + context: initialContext, + runtime, + phases, + maxTransitions, + sequenceOwnership, +}: OnboardSequenceRunnerOptions) { + let pendingContext = initialContext; + return runOnboardMachine({ + context: initialContext, + runtime, + maxTransitions, + sequenceOwnership, + handlers: buildOnboardSequenceHandlers(phases, (context) => { + pendingContext = context; + }), + updateContext: () => pendingContext, + }); +}