Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6b753bf
docs(onboard): document FSM migration target
cv May 27, 2026
fb1b32d
refactor(onboard): centralize machine state metadata
cv May 27, 2026
c3e4ad6
refactor(onboard): derive session step mapping from FSM metadata
cv May 27, 2026
603832c
refactor(onboard): derive progress labels from FSM metadata
cv May 27, 2026
4fad8e7
fix(onboard): emit lifecycle events for onboarding start
cv May 28, 2026
f99e9cb
fix(onboard): emit machine events for resume conflicts
cv May 28, 2026
2b60df4
refactor(onboard): introduce explicit state result types
cv May 28, 2026
30341b0
refactor(onboard): apply explicit state results through runtime
cv May 28, 2026
d4ad2d9
refactor(onboard): make finalization return FSM result
cv May 28, 2026
356c947
refactor(onboard): make agent setup return FSM result
cv May 28, 2026
2296519
refactor(onboard): make policy setup return FSM result
cv May 28, 2026
67a9a1e
refactor(onboard): make preflight and gateway return FSM results
cv May 28, 2026
46f4a49
refactor(onboard): make sandbox return branch FSM result
cv May 28, 2026
9cc15f5
refactor(onboard): return FSM results from provider inference
cv May 28, 2026
dbbb273
refactor(onboard): add FSM runner shell
cv May 28, 2026
6b27a0b
refactor(onboard): consume handler FSM results compatibly
cv May 28, 2026
44009ad
refactor(onboard): allow step recording without machine transitions
cv May 28, 2026
748bda6
merge(onboard): sync stack base
cv Jun 4, 2026
796ed7b
refactor(onboard): address FSM runner review feedback
cv Jun 4, 2026
c782eac
merge(onboard): sync stack base
cv Jun 4, 2026
a5e53d9
refactor(onboard): address FSM result compatibility feedback
cv Jun 4, 2026
5ab1f50
merge(onboard): sync stack base
cv Jun 4, 2026
758367a
refactor(onboard): align record-only step semantics
cv Jun 4, 2026
cc637fd
refactor(onboard): keep result recording entrypoint neutral
cv Jun 4, 2026
4fdab5b
merge(onboard): sync stack base
cv Jun 4, 2026
3e4fcf7
merge(onboard): sync stack base
cv Jun 4, 2026
a373ddd
merge(onboard): sync stack base
cv Jun 4, 2026
a4d3fe0
merge(onboard): sync stack base
cv Jun 4, 2026
f03adbc
Merge remote-tracking branch 'origin/main' into stack/onboard-fsm-ste…
cv Jun 5, 2026
13745bd
fix(onboard): constrain record-only step mutation
cv Jun 5, 2026
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
6 changes: 6 additions & 0 deletions src/lib/onboard/machine/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,19 @@ function createRuntime(initialSession: Session = createSession()) {
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));
Expand Down
19 changes: 18 additions & 1 deletion src/lib/onboard/machine/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
createSession,
filterSafeUpdates,
normalizeSession,
sanitizeFailure,
type Session,
type SessionUpdates,
sanitizeFailure,
} from "../../state/onboard-session";
import type { OnboardMachineEvent } from "./events";
import {
Expand Down Expand Up @@ -62,6 +62,15 @@ function createHarness(initialSession: Session | null = createSession()) {
Object.assign(current, filterSafeUpdates(updates));
return current;
}),
markStepCompleteRecordOnly: (stepName, updates: SessionUpdates = {}) =>
updateSession((current) => {
const step = current.steps[stepName];
if (!step) return current;
step.status = "complete";
current.lastCompletedStep = stepName;
Object.assign(current, filterSafeUpdates(updates));
return current;
}),
markStepSkipped: (stepName) =>
updateSession((current) => {
const step = current.steps[stepName];
Expand All @@ -78,6 +87,14 @@ function createHarness(initialSession: Session | null = createSession()) {
current.failure = sanitizeFailure({ step: stepName, message, recordedAt: "now" });
return current;
}),
markStepFailedRecordOnly: (stepName, message) =>
updateSession((current) => {
const step = current.steps[stepName];
if (!step) return current;
step.status = "failed";
step.error = message ?? null;
return current;
}),
completeSession: (updates: SessionUpdates = {}) =>
updateSession((current) => {
Object.assign(current, filterSafeUpdates(updates));
Expand Down
14 changes: 13 additions & 1 deletion src/lib/onboard/machine/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import type { JsonObject } from "../../core/json-types";
import * as onboardSession from "../../state/onboard-session";
import type { Session, SessionUpdates } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
import type { ResumeConfigConflict } from "../resume-config";
import {
createOnboardMachineEvent,
Expand All @@ -25,8 +25,10 @@ export interface OnboardRuntimeDeps {
updateSession(mutator: (session: Session) => Session | void): Session;
markStepStarted(stepName: string): Session;
markStepComplete(stepName: string, updates?: SessionUpdates): Session;
markStepCompleteRecordOnly(stepName: string, updates?: SessionUpdates): Session;
markStepSkipped(stepName: string): Session;
markStepFailed(stepName: string, message?: string | null): Session;
markStepFailedRecordOnly(stepName: string, message?: string | null): Session;
completeSession(updates?: SessionUpdates): Session;
filterSafeUpdates(updates: SessionUpdates): Partial<Session>;
emitEvent(event: OnboardMachineEvent): void;
Expand Down Expand Up @@ -64,8 +66,10 @@ function defaultDeps(): OnboardRuntimeDeps {
updateSession: onboardSession.updateSession,
markStepStarted: onboardSession.markStepStarted,
markStepComplete: onboardSession.markStepComplete,
markStepCompleteRecordOnly: onboardSession.markStepCompleteRecordOnly,
markStepSkipped: onboardSession.markStepSkipped,
markStepFailed: onboardSession.markStepFailed,
markStepFailedRecordOnly: onboardSession.markStepFailedRecordOnly,
completeSession: onboardSession.completeSession,
filterSafeUpdates: onboardSession.filterSafeUpdates,
emitEvent: emitOnboardMachineEvent,
Expand Down Expand Up @@ -120,6 +124,10 @@ export class OnboardRuntime {
return this.deps.markStepComplete(stepName, updates);
}

async markStepCompleteRecordOnly(stepName: string, updates: SessionUpdates = {}): Promise<Session> {
return this.deps.markStepCompleteRecordOnly(stepName, updates);
}

async markStepSkipped(stepName: string): Promise<Session> {
return this.deps.markStepSkipped(stepName);
}
Expand All @@ -128,6 +136,10 @@ export class OnboardRuntime {
return this.deps.markStepFailed(stepName, message);
}

async markStepFailedRecordOnly(stepName: string, message: string | null = null): Promise<Session> {
return this.deps.markStepFailedRecordOnly(stepName, message);
}

async completeSession(updates: SessionUpdates = {}): Promise<Session> {
return this.deps.completeSession(updates);
}
Expand Down
155 changes: 155 additions & 0 deletions src/lib/onboard/runtime-boundary-record-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// 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,
type Session,
type SessionUpdates,
} from "../state/onboard-session";
import type { OnboardMachineEvent } from "./machine/events";
import { advanceTo, failOnboardMachine } from "./machine/result";
import { OnboardRuntime, type OnboardRuntimeDeps } from "./machine/runtime";
import { OnboardRuntimeBoundary } from "./runtime-boundary";

function cloneSession(session: Session): Session {
return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session;
}

function createRuntimeHarness() {
let session: Session = createSession();
const events: OnboardMachineEvent[] = [];
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: (stepName) =>
updateSession((current) => {
current.steps[stepName].status = "in_progress";
return current;
}),
markStepComplete: (stepName, updates: SessionUpdates = {}) =>
updateSession((current) => {
current.steps[stepName].status = "complete";
Object.assign(current, filterSafeUpdates(updates));
return current;
}),
markStepCompleteRecordOnly: (stepName, updates: SessionUpdates = {}) =>
updateSession((current) => {
current.steps[stepName].status = "complete";
Object.assign(current, filterSafeUpdates(updates));
return current;
}),
markStepSkipped: (stepName) =>
updateSession((current) => {
current.steps[stepName].status = "skipped";
return current;
}),
markStepFailed: (stepName, message) =>
updateSession((current) => {
current.steps[stepName].status = "failed";
current.steps[stepName].error = message ?? null;
current.status = "failed";
current.failure = { step: stepName, message: message ?? null, recordedAt: "now" };
return current;
}),
markStepFailedRecordOnly: (stepName, message) =>
updateSession((current) => {
current.steps[stepName].status = "failed";
current.steps[stepName].error = message ?? null;
return current;
}),
completeSession: (updates: SessionUpdates = {}) =>
updateSession((current) => {
Object.assign(current, filterSafeUpdates(updates));
current.status = "complete";
return current;
}),
filterSafeUpdates,
emitEvent: (event) => events.push(event),
now: () => "2026-05-27T00:00:00.000Z",
};
return {
boundary: new OnboardRuntimeBoundary({
toSessionUpdates: (updates) => filterSafeUpdates(updates as SessionUpdates) as SessionUpdates,
maybeForceE2eStepFailure: () => undefined,
createRuntime: () => new OnboardRuntime(deps),
}),
events,
getSession: () => cloneSession(session),
};
}

describe("OnboardRuntimeBoundary record-only step/result pairing", () => {
it("pairs record-only step completion with an explicit state result", async () => {
const { boundary, events } = createRuntimeHarness();

await boundary.recordStateResult(advanceTo("preflight"));
const completed = await boundary.recordStepCompleteWithStateResult(
"preflight",
{ sandboxName: "record-only-sb" },
advanceTo("gateway", { metadata: { state: "preflight" } }),
);

expect(completed).toMatchObject({
sandboxName: "record-only-sb",
machine: { state: "gateway", revision: 2 },
steps: { preflight: { status: "complete" } },
});
expect(events.map((event) => event.type)).toEqual([
"state.exited",
"state.entered",
"state.exited",
"state.entered",
]);
});

it("pairs record-only step failure with an explicit failure result", async () => {
const { boundary, events } = createRuntimeHarness();

await boundary.recordStateResult(advanceTo("preflight"));
const failed = await boundary.recordStepFailedWithStateResult(
"preflight",
"Preflight failed",
failOnboardMachine("Preflight failed", { step: "preflight" }),
);

expect(failed).toMatchObject({
status: "failed",
failure: { step: "preflight", message: "Preflight failed" },
machine: { state: "failed", revision: 2 },
steps: { preflight: { status: "failed", error: "Preflight failed" } },
});
expect(events.map((event) => event.type)).toEqual([
"state.exited",
"state.entered",
"state.failed",
"onboard.failed",
]);
});

it("rejects invalid explicit results before persisting record-only step completion", async () => {
const { boundary, getSession } = createRuntimeHarness();

await boundary.recordStateResult(advanceTo("preflight"));
await expect(
boundary.recordStepCompleteWithStateResult("preflight", {}, advanceTo("sandbox")),
).rejects.toThrow("Invalid onboarding machine transition: preflight -> sandbox");

expect(getSession()).toMatchObject({
machine: { state: "preflight", revision: 1 },
steps: { preflight: { status: "pending" } },
});
});
});
2 changes: 2 additions & 0 deletions src/lib/onboard/runtime-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function createRuntimeHarness(overrides: Partial<Session> = {}) {
if (nextState) transitionMachine(current, nextState);
return current;
}),
markStepCompleteRecordOnly: () => cloneSession(session ?? createSession()),
markStepSkipped: (stepName) =>
updateSession((current) => {
current.steps[stepName].status = "skipped";
Expand All @@ -121,6 +122,7 @@ function createRuntimeHarness(overrides: Partial<Session> = {}) {
current.failure = { step: stepName, message: message ?? null, recordedAt: "now" };
return current;
}),
markStepFailedRecordOnly: () => cloneSession(session ?? createSession()),
completeSession: (updates: SessionUpdates = {}) =>
updateSession((current) => {
Object.assign(current, filterSafeUpdates(updates));
Expand Down
54 changes: 53 additions & 1 deletion src/lib/onboard/runtime-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0

import type { Session, SessionUpdates } from "../state/onboard-session";
import type { OnboardStateResult } from "./machine/result";
import type { OnboardStateFailedResult, OnboardStateResult } from "./machine/result";
import { OnboardRuntime } from "./machine/runtime";
import { assertValidOnboardMachineTransition } from "./machine/transitions";
import type { OnboardMachineEventType, OnboardMachineState } from "./machine/types";
import type { ResumeConfigConflict } from "./resume-config";

Expand Down Expand Up @@ -51,6 +52,8 @@ export class OnboardRuntimeBoundary {
recordRepairEvent: this.recordRepairEvent.bind(this),
recordResumeConflict: this.recordResumeConflict.bind(this),
recordStateResult: this.recordStateResult.bind(this),
recordStepCompleteWithStateResult: this.recordStepCompleteWithStateResult.bind(this),
recordStepFailedWithStateResult: this.recordStepFailedWithStateResult.bind(this),
recordStateResultWithStepCompatibility: this.recordStateResultWithStepCompatibility.bind(this),
recordStepFailed: this.recordStepFailed.bind(this),
recordPostVerifyStarted: this.recordPostVerifyStarted.bind(this),
Expand Down Expand Up @@ -102,6 +105,55 @@ export class OnboardRuntimeBoundary {
return this.getRuntime().applyResult(result);
}

private async assertStateResultWillApply(result: OnboardStateResult): Promise<void> {
const current = await this.getRuntime().session();
if (result.type === "failed") {
assertValidOnboardMachineTransition(current.machine.state, "failed");
return;
}
if (result.type === "complete") {
assertValidOnboardMachineTransition(current.machine.state, "complete");
return;
}

const sourceState =
result.metadata && typeof result.metadata.state === "string" ? result.metadata.state : null;
if (current.machine.state === result.next) {
throw new Error(`Record-only step result already reached target state: ${result.next}`);
}
if (sourceState && current.machine.state !== sourceState) {
throw new Error(
`Record-only step result source mismatch: ${sourceState} != ${current.machine.state}`,
);
}
const transition = assertValidOnboardMachineTransition(current.machine.state, result.next);
if (result.transitionKind && transition.kind !== result.transitionKind) {
throw new Error(
`Invalid onboarding machine transition kind: ${current.machine.state} -> ${result.next} expected ${result.transitionKind}, got ${transition.kind}`,
);
}
}

async recordStepCompleteWithStateResult(
stepName: string,
updates: SessionUpdates,
result: OnboardStateResult,
): Promise<Session> {
await this.assertStateResultWillApply(result);
await this.getRuntime().markStepCompleteRecordOnly(stepName, updates);
return this.recordStateResultWithStepCompatibility(result);
}

async recordStepFailedWithStateResult(
stepName: string,
message: string | null,
result: OnboardStateFailedResult,
): Promise<Session> {
await this.assertStateResultWillApply(result);
await this.getRuntime().markStepFailedRecordOnly(stepName, message);
return this.recordStateResult(result);
}

/**
* Compatibility bridge for the live onboarding host glue while legacy step helpers remain a
* second machine snapshot writer. `markStepStarted()` and `markStepComplete()` still mutate
Expand Down
4 changes: 2 additions & 2 deletions src/lib/state/onboard-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const require = createRequire(import.meta.url);
const distPath = require.resolve("../../../dist/lib/state/onboard-session");
Expand Down
Loading
Loading