diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d2c07e3e537..47b6659ec3c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -480,7 +480,7 @@ const { restoreDefaultAfterRecreate, }: typeof import("./onboard/cancel-rollback") = require("./onboard/cancel-rollback"); // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. -const { createProviderInferenceOnboardFlowPhase, createSandboxOnboardFlowPhase, runCoreOnboardFlowSlice }: typeof import("./onboard/machine/core-flow-phases") = require("./onboard/machine/core-flow-phases"); +const { createProviderInferenceOnboardFlowPhase, createSandboxOnboardFlowPhase, prepareCoreOnboardFlowContext, prepareFinalOnboardFlowContext, runCoreOnboardFlowSlice }: typeof import("./onboard/machine/core-flow-phases") = require("./onboard/machine/core-flow-phases"); const { createFinalOnboardFlowPhases, runFinalOnboardFlowSlice, @@ -4251,35 +4251,23 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recordRepairEvent, }); - const initialContext = initialFlowResult.context; - if (!initialContext.sandboxGpuConfig) { - throw new Error("Preflight did not produce a sandbox GPU configuration."); - } - session = initialFlowResult.session; - const sandboxGpuConfig = initialContext.sandboxGpuConfig; - const { gpuPassthrough } = initialContext; - const gpu = initialContext.gpu ?? null; - // #2753: for an unfinished sandbox, an explicit requested name precedes // the checkpointed name from the interrupted session. - let sandboxName = - recordedSandboxName || requestedSandboxName || checkpointedSandboxName || null; - if (sandboxName && RESERVED_SANDBOX_NAMES.has(sandboxName)) { - console.error( - ` Reserved name in resumed session: '${sandboxName}' is a ${cliDisplayName()} CLI command.`, - ); - console.error(" Start a fresh onboard with --name to choose a different name."); - process.exit(1); - } - const coreFlowContext: InitialOnboardFlowContext = { - ...initialContext, - session, - sandboxName, + const coreFlowContext = prepareCoreOnboardFlowContext({ + initial: initialFlowResult, + recordedSandboxName, + requestedSandboxName, + checkpointedSandboxName, selectedMessagingChannels, - gpu, - sandboxGpuConfig, - gpuPassthrough, - }; + assertSandboxNameAllowed: (sandboxName) => { + if (!RESERVED_SANDBOX_NAMES.has(sandboxName)) return; + console.error( + ` Reserved name in resumed session: '${sandboxName}' is a ${cliDisplayName()} CLI command.`, + ); + console.error(" Start a fresh onboard with --name to choose a different name."); + process.exit(1); + }, + }); // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, GATEWAY_NAME); // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. @@ -4445,38 +4433,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recordRepairEvent, }); setupInferenceFactory.selectGatewayForFollowupOrExit(GATEWAY_NAME, runOpenshell); - const coreContext = coreFlowResult.context; - session = coreContext.session; - sandboxName = coreContext.sandboxName; - if (!sandboxName || !coreContext.model || !coreContext.provider) { - throw new Error("Onboarding state is incomplete after sandbox setup."); - } - const model = coreContext.model; - const provider = coreContext.provider; - const endpointUrl = coreContext.endpointUrl; - const credentialEnv = coreContext.credentialEnv; - const hermesAuthMethod = coreContext.hermesAuthMethod; - const hermesToolGateways = coreContext.hermesToolGateways; - const nimContainer = coreContext.nimContainer; - let webSearchConfig = coreContext.webSearchConfig as WebSearchConfig | null; - const webSearchSupported = coreContext.webSearchSupported; - - const finalFlowContext: InitialOnboardFlowContext = { - ...coreContext, - session, - sandboxName, - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - nimContainer, - webSearchConfig, - selectedMessagingChannels: coreContext.selectedMessagingChannels, - webSearchSupported, - }; - let liveFinalFlowContext = finalFlowContext; + const finalFlowContext = prepareFinalOnboardFlowContext(coreFlowResult); + let liveFinalFlowContext: InitialOnboardFlowContext = finalFlowContext; const finalFlowPhases = createFinalOnboardFlowPhases< InitialOnboardFlowContext, diff --git a/src/lib/onboard/machine/README.md b/src/lib/onboard/machine/README.md index d31769642ec..bd917cf8116 100644 --- a/src/lib/onboard/machine/README.md +++ b/src/lib/onboard/machine/README.md @@ -19,6 +19,8 @@ The target shape is a machine-driven onboarding runner: 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. +`flow-handoff.ts` validates required data and constructs context at the initial-to-core and core-to-final boundaries. The entrypoint supplies process-bound dependencies and reserved-name output. + The strict runner owns exact `init`, `preflight`, `provider_selection`, `inference`, and `sandbox` entry. If the durable state is later than a slice entry, earlier phases run as evented prerequisite repairs. A repair must return a legal, update-free transition chain and must not change the durable entry state. ## State ownership diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index d137df113fa..b511baf97bf 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -32,6 +32,8 @@ import type { OnboardMachineRunnerResult, OnboardMachineRunnerRuntime } from "./ import { runOnboardSequenceWithRunner, type OnboardSequencePhase } from "./sequence-runner"; import type { OnboardMachineState } from "./types"; +export { prepareCoreOnboardFlowContext, prepareFinalOnboardFlowContext } from "./flow-handoff"; + export interface EndpointProvenanceOptions { endpointSource?: InferenceEndpointSource | null; endpointSourceProvider?: string | null; diff --git a/src/lib/onboard/machine/flow-handoff.test.ts b/src/lib/onboard/machine/flow-handoff.test.ts new file mode 100644 index 00000000000..c090faa49e6 --- /dev/null +++ b/src/lib/onboard/machine/flow-handoff.test.ts @@ -0,0 +1,339 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createSession, MACHINE_SNAPSHOT_VERSION, type Session } from "../../state/onboard-session"; +import type { OnboardFlowContext } from "./flow-context"; +import { prepareCoreOnboardFlowContext, prepareFinalOnboardFlowContext } from "./flow-handoff"; +import type { OnboardMachineState } from "./types"; + +const TRACE_TIME = "2026-08-03T00:00:00.000Z"; + +interface HandoffResultCase { + trace: string; + resume: boolean; + fresh: boolean; + initialState: OnboardMachineState; + coreState: OnboardMachineState; + status: "in_progress" | "failed"; +} + +const handoffResultCases: readonly HandoffResultCase[] = [ + { + trace: "resumed", + resume: true, + fresh: false, + initialState: "provider_selection", + coreState: "openclaw", + status: "in_progress", + }, + { + trace: "failed", + resume: false, + fresh: true, + initialState: "failed", + coreState: "failed", + status: "failed", + }, + { + trace: "paused", + resume: false, + fresh: true, + initialState: "gateway", + coreState: "sandbox", + status: "in_progress", + }, +]; + +function runnerSession( + state: OnboardMachineState, + status: HandoffResultCase["status"], + failure: Session["failure"], +): Session { + const session = createSession({ + failure, + machine: { + version: MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt: TRACE_TIME, + revision: 7, + }, + }); + session.status = status; + return session; +} + +function context(): OnboardFlowContext & { + gpu: string | null; + sandboxGpuConfig: { mode: string } | null; + gpuPassthrough: boolean; +} { + return { + resume: false, + fresh: true, + 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, + compatibleEndpointReasoning: null, + + compatibleEndpointReasoningEffort: null, + nimContainer: null, + webSearchConfig: null, + webSearchSupported: false, + selectedMessagingChannels: [], + gpu: "nvidia", + sandboxGpuConfig: { mode: "cdi" }, + gpuPassthrough: true, + }; +} + +describe("onboard flow handoffs", () => { + it("constructs core context from the initial result and requested name", () => { + const initialContext = context(); + const persisted = createSession(); + const assertSandboxNameAllowed = vi.fn(); + + const result = prepareCoreOnboardFlowContext({ + initial: { context: initialContext, session: persisted }, + recordedSandboxName: null, + requestedSandboxName: "requested", + checkpointedSandboxName: "checkpointed", + selectedMessagingChannels: ["slack"], + assertSandboxNameAllowed, + }); + + expect(result).toMatchObject({ + session: persisted, + sandboxName: "requested", + selectedMessagingChannels: ["slack"], + gpu: "nvidia", + sandboxGpuConfig: { mode: "cdi" }, + gpuPassthrough: true, + }); + expect(assertSandboxNameAllowed).toHaveBeenCalledWith("requested"); + }); + + it.each([ + { + source: "recorded", + recordedSandboxName: "recorded", + requestedSandboxName: "requested", + checkpointedSandboxName: "checkpointed", + }, + { + source: "requested", + recordedSandboxName: null, + requestedSandboxName: "requested", + checkpointedSandboxName: "checkpointed", + }, + { + source: "checkpointed", + recordedSandboxName: null, + requestedSandboxName: null, + checkpointedSandboxName: "checkpointed", + }, + ])("selects the $source sandbox name by precedence", ({ + source, + recordedSandboxName, + requestedSandboxName, + checkpointedSandboxName, + }) => { + const assertSandboxNameAllowed = vi.fn(); + + const result = prepareCoreOnboardFlowContext({ + initial: { context: context(), session: createSession() }, + recordedSandboxName, + requestedSandboxName, + checkpointedSandboxName, + selectedMessagingChannels: [], + assertSandboxNameAllowed, + }); + + expect(result.sandboxName).toBe(source); + expect(assertSandboxNameAllowed).toHaveBeenCalledWith(source); + }); + + it("rejects a missing preflight GPU configuration", () => { + const initialContext = { ...context(), sandboxGpuConfig: null }; + const persisted = createSession(); + + expect(() => + prepareCoreOnboardFlowContext({ + initial: { context: initialContext, session: persisted }, + recordedSandboxName: null, + requestedSandboxName: null, + checkpointedSandboxName: null, + selectedMessagingChannels: [], + assertSandboxNameAllowed: vi.fn(), + }), + ).toThrow("Preflight did not produce a sandbox GPU configuration."); + }); + + it.each( + handoffResultCases, + )("preserves a $trace runner result at the initial-to-core handoff (#7706)", ({ + trace, + resume, + fresh, + initialState, + status, + }) => { + const failure = + status === "failed" + ? { + step: "gateway", + message: "gateway failed", + recordedAt: TRACE_TIME, + } + : null; + const persisted = runnerSession(initialState, status, failure); + const endpointUrl = `https://${trace}.example.test`; + + const result = prepareCoreOnboardFlowContext({ + initial: { + context: { + ...context(), + resume, + fresh, + endpointUrl, + }, + session: persisted, + }, + recordedSandboxName: null, + requestedSandboxName: null, + checkpointedSandboxName: null, + selectedMessagingChannels: ["slack"], + assertSandboxNameAllowed: vi.fn(), + }); + + expect(result.session).toBe(persisted); + expect(result).toMatchObject({ + resume, + fresh, + endpointUrl, + selectedMessagingChannels: ["slack"], + }); + expect(result.session).toMatchObject({ + status, + resumable: true, + failure, + machine: { + state: initialState, + revision: 7, + }, + }); + }); + + it("constructs final context after sandbox identity and inference are complete", () => { + const persisted = createSession(); + const coreContext = { + ...context(), + sandboxName: "ready", + model: "model", + provider: "provider", + endpointUrl: "https://inference.example.test", + selectedMessagingChannels: ["slack"], + }; + + const result = prepareFinalOnboardFlowContext({ + context: coreContext, + session: persisted, + }); + + expect(result.session).toBe(persisted); + expect(result).toMatchObject({ + sandboxName: "ready", + model: "model", + provider: "provider", + endpointUrl: "https://inference.example.test", + selectedMessagingChannels: ["slack"], + }); + }); + + it.each( + handoffResultCases, + )("preserves a $trace runner result at the core-to-final handoff (#7706)", ({ + trace, + resume, + fresh, + coreState, + status, + }) => { + const failure = + status === "failed" + ? { + step: "sandbox", + message: "sandbox failed", + recordedAt: TRACE_TIME, + } + : null; + const persisted = runnerSession(coreState, status, failure); + const endpointUrl = `https://${trace}.example.test`; + + const result = prepareFinalOnboardFlowContext({ + context: { + ...context(), + resume, + fresh, + sandboxName: "ready", + model: "model", + provider: "provider", + endpointUrl, + selectedMessagingChannels: ["slack"], + }, + session: persisted, + }); + + expect(result.session).toBe(persisted); + expect(result).toMatchObject({ + resume, + fresh, + sandboxName: "ready", + model: "model", + provider: "provider", + endpointUrl, + selectedMessagingChannels: ["slack"], + }); + expect(result.session).toMatchObject({ + status, + resumable: true, + failure, + machine: { + state: coreState, + revision: 7, + }, + }); + }); + + it.each([ + "sandboxName", + "model", + "provider", + ] as const)("rejects final context when $field is missing", (field) => { + const coreContext = { + ...context(), + sandboxName: "ready", + model: "model", + provider: "provider", + [field]: null, + }; + + expect(() => + prepareFinalOnboardFlowContext({ + context: coreContext, + session: createSession(), + }), + ).toThrow("Onboarding state is incomplete after sandbox setup."); + }); +}); diff --git a/src/lib/onboard/machine/flow-handoff.ts b/src/lib/onboard/machine/flow-handoff.ts new file mode 100644 index 00000000000..acd607cf48b --- /dev/null +++ b/src/lib/onboard/machine/flow-handoff.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardFlowContext } from "./flow-context"; +import type { OnboardMachineRunnerResult } from "./runner"; + +type InitialHandoffContext = OnboardFlowContext & { + readonly gpu: Gpu | null; + readonly sandboxGpuConfig: SandboxGpuConfig | null; + readonly gpuPassthrough: boolean; +}; + +export function prepareCoreOnboardFlowContext< + Context extends InitialHandoffContext, + Gpu, + SandboxGpuConfig, +>(options: { + initial: OnboardMachineRunnerResult; + recordedSandboxName: string | null; + requestedSandboxName: string | null; + checkpointedSandboxName: string | null; + selectedMessagingChannels: string[]; + assertSandboxNameAllowed(sandboxName: string): void; +}): Context & { sandboxGpuConfig: SandboxGpuConfig } { + const context = options.initial.context; + if (!context.sandboxGpuConfig) { + throw new Error("Preflight did not produce a sandbox GPU configuration."); + } + const sandboxName = + options.recordedSandboxName || + options.requestedSandboxName || + options.checkpointedSandboxName || + null; + if (sandboxName) options.assertSandboxNameAllowed(sandboxName); + return { + ...context, + session: options.initial.session, + sandboxName, + selectedMessagingChannels: options.selectedMessagingChannels, + gpu: context.gpu ?? null, + sandboxGpuConfig: context.sandboxGpuConfig, + gpuPassthrough: context.gpuPassthrough, + }; +} + +export function prepareFinalOnboardFlowContext( + core: OnboardMachineRunnerResult, +): Context & { sandboxName: string; model: string; provider: string } { + const context = core.context; + if (!context.sandboxName || !context.model || !context.provider) { + throw new Error("Onboarding state is incomplete after sandbox setup."); + } + return { + ...context, + session: core.session, + sandboxName: context.sandboxName, + model: context.model, + provider: context.provider, + }; +}