diff --git a/src/lib/onboard/machine/flow-phases/agent-policy-finalization.test.ts b/src/lib/onboard/machine/flow-phases/agent-policy-finalization.test.ts new file mode 100644 index 0000000000..749b57c4a2 --- /dev/null +++ b/src/lib/onboard/machine/flow-phases/agent-policy-finalization.test.ts @@ -0,0 +1,182 @@ +// 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, + MACHINE_SNAPSHOT_VERSION, + normalizeSession, + sanitizeFailure, + type Session, + type SessionUpdates, +} from "../../../state/onboard-session"; +import type { OnboardFlowContext } from "../flow-context"; +import { advanceTo, completeOnboardMachine } from "../result"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "../runtime"; +import { runOnboardSequenceWithRunner } from "../sequence-runner"; +import { + createAgentSetupPhase, + createFinalizationPhase, + createOpenclawSetupPhase, + createPoliciesPhase, + createPostVerifyPhase, +} from "./agent-policy-finalization"; + +function context(): OnboardFlowContext { + return { + resume: false, + fresh: false, + session: createSession(), + agent: null, + recordedSandboxName: null, + requestedSandboxName: null, + sandboxName: "my-assistant", + fromDockerfile: null, + model: "model", + provider: "provider", + endpointUrl: null, + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: null, + nimContainer: null, + webSearchConfig: null, + webSearchSupported: true, + selectedMessagingChannels: [], + gpu: null, + sandboxGpuConfig: null, + gpuPassthrough: false, + }; +} + +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); +} + +describe("agent/policy/finalization phases", () => { + it("creates branch-specific setup phases", async () => { + const agentPhase = createAgentSetupPhase(async () => ({ result: advanceTo("policies") })); + const openclawPhase = createOpenclawSetupPhase(async () => ({ result: advanceTo("policies") })); + + expect(agentPhase.state).toBe("agent_setup"); + expect(openclawPhase.state).toBe("openclaw"); + await expect(agentPhase.run(context())).resolves.toMatchObject({ + result: { next: "policies" }, + }); + await expect(openclawPhase.run(context())).resolves.toMatchObject({ + result: { next: "policies" }, + }); + }); + + it("maps policies context updates", async () => { + const phase = createPoliciesPhase(async () => ({ + context: { selectedMessagingChannels: ["slack"] }, + result: advanceTo("finalizing"), + })); + + const result = await phase.run(context()); + + expect(phase.state).toBe("policies"); + expect(result.context.selectedMessagingChannels).toEqual(["slack"]); + expect(result.result).toMatchObject({ next: "finalizing" }); + }); + + it("creates finalization and post-verify phases", async () => { + const finalizing = createFinalizationPhase(async () => ({ result: advanceTo("post_verify") })); + const postVerify = createPostVerifyPhase(async () => ({ + result: completeOnboardMachine({ sandboxName: "my-assistant" }), + })); + + expect(finalizing.state).toBe("finalizing"); + expect(postVerify.state).toBe("post_verify"); + await expect(finalizing.run(context())).resolves.toMatchObject({ + result: { next: "post_verify" }, + }); + await expect(postVerify.run(context())).resolves.toMatchObject({ + result: { type: "complete" }, + }); + }); + + it("runs branch-to-completion phases through the strict FSM runner", async () => { + const initialSession = createSession({ + machine: { + version: MACHINE_SNAPSHOT_VERSION, + state: "openclaw", + stateEnteredAt: "2026-05-29T00:00:00.000Z", + revision: 0, + }, + }); + + const result = await runOnboardSequenceWithRunner({ + context: context(), + runtime: createRuntime(initialSession), + phases: [ + createOpenclawSetupPhase(async () => ({ result: advanceTo("policies") })), + createPoliciesPhase(async () => ({ + context: { selectedMessagingChannels: ["slack"] }, + result: advanceTo("finalizing"), + })), + createFinalizationPhase(async () => ({ result: advanceTo("post_verify") })), + createPostVerifyPhase(async () => ({ + result: completeOnboardMachine({ sandboxName: "my-assistant" }), + })), + ], + }); + + expect(result.session).toMatchObject({ + status: "complete", + sandboxName: "my-assistant", + machine: { state: "complete" }, + }); + expect(result.context.selectedMessagingChannels).toEqual(["slack"]); + }); +}); diff --git a/src/lib/onboard/machine/flow-phases/agent-policy-finalization.ts b/src/lib/onboard/machine/flow-phases/agent-policy-finalization.ts new file mode 100644 index 0000000000..95ff78a95f --- /dev/null +++ b/src/lib/onboard/machine/flow-phases/agent-policy-finalization.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardFlowContext, OnboardFlowPhaseResult } from "../flow-context"; +import { mergeOnboardFlowContext, onboardFlowPhaseResult } from "../flow-context"; +import type { OnboardSequencePhase } from "../sequence-runner"; + +type FlowPhaseHandler = (context: Context) => Promise<{ + context?: Partial; + result: OnboardFlowPhaseResult["result"]; +}>; + +function createFlowPhase( + state: OnboardSequencePhase["state"], + runPhase: FlowPhaseHandler, +): OnboardSequencePhase { + return { + state, + async run(context) { + const result = await runPhase(context); + return onboardFlowPhaseResult( + result.context ? mergeOnboardFlowContext(context, result.context) : context, + result.result, + ); + }, + }; +} + +export function createAgentSetupPhase( + runAgentSetup: FlowPhaseHandler, +): OnboardSequencePhase { + return createFlowPhase("agent_setup", runAgentSetup); +} + +export function createOpenclawSetupPhase( + runOpenclawSetup: FlowPhaseHandler, +): OnboardSequencePhase { + return createFlowPhase("openclaw", runOpenclawSetup); +} + +export function createPoliciesPhase( + runPolicies: FlowPhaseHandler, +): OnboardSequencePhase { + return createFlowPhase("policies", runPolicies); +} + +export function createFinalizationPhase( + runFinalization: FlowPhaseHandler, +): OnboardSequencePhase { + return createFlowPhase("finalizing", runFinalization); +} + +export function createPostVerifyPhase( + runPostVerify: FlowPhaseHandler, +): OnboardSequencePhase { + return createFlowPhase("post_verify", runPostVerify); +} diff --git a/src/lib/onboard/machine/flow-sequence.test.ts b/src/lib/onboard/machine/flow-sequence.test.ts new file mode 100644 index 0000000000..9d0c30c88a --- /dev/null +++ b/src/lib/onboard/machine/flow-sequence.test.ts @@ -0,0 +1,204 @@ +// 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, + MACHINE_SNAPSHOT_VERSION, + normalizeSession, + sanitizeFailure, + type Session, + type SessionUpdates, +} from "../../state/onboard-session"; +import type { OnboardFlowContext, OnboardFlowPhaseResult } from "./flow-context"; +import { onboardFlowPhaseResult } from "./flow-context"; +import { buildOnboardFlowPhaseSequence } from "./flow-sequence"; +import { advanceTo, branchTo, completeOnboardMachine } from "./result"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; +import { runOnboardSequenceWithRunner } from "./sequence-runner"; + +type Context = OnboardFlowContext; + +function context(): Context { + return { + resume: false, + fresh: false, + session: createSession(), + agent: null, + recordedSandboxName: null, + requestedSandboxName: null, + sandboxName: null, + fromDockerfile: null, + model: null, + provider: null, + endpointUrl: null, + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: null, + nimContainer: null, + webSearchConfig: null, + webSearchSupported: false, + selectedMessagingChannels: [], + gpu: null, + sandboxGpuConfig: { mode: "0" }, + gpuPassthrough: false, + }; +} + +function result( + ctx: Context, + next: ReturnType["next"], +): OnboardFlowPhaseResult { + return onboardFlowPhaseResult(ctx, advanceTo(next)); +} + +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); +} + +describe("onboard flow phase sequence", () => { + it("assembles phases in machine order", () => { + const phases = buildOnboardFlowPhaseSequence({ + preflight: async (ctx) => + result({ ...ctx, gpu: { type: "nvidia" }, gpuPassthrough: true }, "gateway"), + gateway: async (ctx) => result(ctx, "provider_selection"), + providerInference: async (ctx) => + result({ ...ctx, provider: "nvidia", model: "model" }, "sandbox"), + sandbox: async (ctx) => + onboardFlowPhaseResult({ ...ctx, sandboxName: "my-assistant" }, branchTo("openclaw")), + openclaw: async (ctx) => result(ctx, "policies"), + agentSetup: async (ctx) => result(ctx, "policies"), + policies: async (ctx) => result(ctx, "finalizing"), + finalization: async (ctx) => result(ctx, "post_verify"), + postVerify: async (ctx) => onboardFlowPhaseResult(ctx, completeOnboardMachine()), + }); + + expect(phases.map((phase) => phase.state)).toEqual([ + "preflight", + "gateway", + "provider_selection", + "sandbox", + "openclaw", + "agent_setup", + "policies", + "finalizing", + "post_verify", + ]); + }); + + it("delegates phase execution to supplied handlers", async () => { + const phases = buildOnboardFlowPhaseSequence({ + preflight: async (ctx) => + result({ ...ctx, gpu: { type: "nvidia" }, gpuPassthrough: true }, "gateway"), + gateway: async (ctx) => result(ctx, "provider_selection"), + providerInference: async (ctx) => result(ctx, "sandbox"), + sandbox: async (ctx) => onboardFlowPhaseResult(ctx, branchTo("openclaw")), + openclaw: async (ctx) => result(ctx, "policies"), + agentSetup: async (ctx) => result(ctx, "policies"), + policies: async (ctx) => result(ctx, "finalizing"), + finalization: async (ctx) => result(ctx, "post_verify"), + postVerify: async (ctx) => onboardFlowPhaseResult(ctx, completeOnboardMachine()), + }); + + const preflight = await phases[0].run(context()); + + expect(preflight.context.gpu).toEqual({ type: "nvidia" }); + expect(preflight.result).toMatchObject({ next: "gateway" }); + }); + + it("runs ordered provider results through runtime transition validation", async () => { + const initialSession = createSession({ + machine: { + version: MACHINE_SNAPSHOT_VERSION, + state: "preflight", + stateEnteredAt: "2026-05-29T00:00:00.000Z", + revision: 0, + }, + }); + const phases = buildOnboardFlowPhaseSequence({ + preflight: async (ctx) => + result({ ...ctx, gpu: { type: "nvidia" }, gpuPassthrough: true }, "gateway"), + gateway: async (ctx) => result(ctx, "provider_selection"), + providerInference: async (ctx) => + onboardFlowPhaseResult({ ...ctx, provider: "nvidia", model: "model" }, [ + advanceTo("inference", { metadata: { state: "provider_selection" } }), + advanceTo("sandbox", { metadata: { state: "inference" } }), + ]), + sandbox: async (ctx) => + onboardFlowPhaseResult({ ...ctx, sandboxName: "my-assistant" }, branchTo("openclaw")), + openclaw: async (ctx) => result(ctx, "policies"), + agentSetup: async (ctx) => result(ctx, "policies"), + policies: async (ctx) => result(ctx, "finalizing"), + finalization: async (ctx) => result(ctx, "post_verify"), + postVerify: async (ctx) => + onboardFlowPhaseResult(ctx, completeOnboardMachine({ sandboxName: "my-assistant" })), + }); + + const run = await runOnboardSequenceWithRunner({ + context: context(), + runtime: createRuntime(initialSession), + phases, + }); + + expect(run.session).toMatchObject({ + status: "complete", + sandboxName: "my-assistant", + machine: { state: "complete" }, + }); + expect(run.context).toMatchObject({ + provider: "nvidia", + model: "model", + sandboxName: "my-assistant", + }); + }); +}); diff --git a/src/lib/onboard/machine/flow-sequence.ts b/src/lib/onboard/machine/flow-sequence.ts new file mode 100644 index 0000000000..d3cf2d1b56 --- /dev/null +++ b/src/lib/onboard/machine/flow-sequence.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardFlowContext, OnboardFlowPhaseResult } from "./flow-context"; +import { + createAgentSetupPhase, + createFinalizationPhase, + createOpenclawSetupPhase, + createPoliciesPhase, + createPostVerifyPhase, +} from "./flow-phases/agent-policy-finalization"; +import { createGatewayPhase, createPreflightPhase } from "./flow-phases/preflight-gateway"; +import { createProviderInferencePhase, createSandboxPhase } from "./flow-phases/provider-sandbox"; +import type { OnboardSequencePhase } from "./sequence-runner"; + +export interface OnboardFlowPhaseHandlers { + preflight(context: Context): Promise>; + gateway(context: Context): Promise>; + providerInference(context: Context): Promise>; + sandbox(context: Context): Promise>; + openclaw(context: Context): Promise>; + agentSetup(context: Context): Promise>; + policies(context: Context): Promise>; + finalization(context: Context): Promise>; + postVerify(context: Context): Promise>; +} + +export function buildOnboardFlowPhaseSequence( + handlers: OnboardFlowPhaseHandlers, +): OnboardSequencePhase[] { + return [ + createPreflightPhase(async (context) => { + const result = await handlers.preflight(context); + return { + session: result.context.session, + gpu: result.context.gpu, + sandboxGpuConfig: result.context.sandboxGpuConfig as NonNullable< + Context["sandboxGpuConfig"] + >, + gpuPassthrough: result.context.gpuPassthrough, + result: result.result, + }; + }), + createGatewayPhase(async (context) => { + const result = await handlers.gateway(context); + return { session: result.context.session, result: result.result }; + }), + createProviderInferencePhase((context) => handlers.providerInference(context)), + createSandboxPhase((context) => handlers.sandbox(context)), + createOpenclawSetupPhase((context) => handlers.openclaw(context)), + createAgentSetupPhase((context) => handlers.agentSetup(context)), + createPoliciesPhase((context) => handlers.policies(context)), + createFinalizationPhase((context) => handlers.finalization(context)), + createPostVerifyPhase((context) => handlers.postVerify(context)), + ]; +}