Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
cd6e5f7
refactor(onboard): plumb step mutation options through runtime
cv May 28, 2026
e266e3b
refactor(onboard): add record-only FSM runner adapter
cv May 28, 2026
bf4da0b
refactor(onboard): return ordered provider FSM results
cv May 28, 2026
212ff4d
refactor(onboard): run live sequence with record-only steps
cv May 28, 2026
f69f60a
refactor(onboard): let FSM handlers return result sequences
cv May 29, 2026
eb8c5ce
merge(onboard): resolve runner sequence conflicts
cv Jun 8, 2026
1ff1105
refactor(onboard): enforce FSM result sequence ownership
cv Jun 8, 2026
7c379a1
test(onboard): split FSM runner sequence specs
cv Jun 8, 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
12 changes: 11 additions & 1 deletion src/lib/onboard/machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ Until that migration completes, step helpers may still infer machine snapshots f
Each state handler should eventually follow this shape:

```ts
type OnboardStateHandler = (context: OnboardContext) => Promise<OnboardStateResult>;
type OnboardStateHandler = (
context: OnboardContext,
) => Promise<OnboardStateResult | readonly OnboardStateResult[]>;
```

A handler should:
Expand All @@ -72,6 +74,14 @@ A handler should not:
- rely on console output as the only observable diagnostic;
- store raw credentials, provider URLs with secrets, or other sensitive values in machine context.

Handlers may return a result sequence only when one composite handler deliberately owns the
covered state transitions, such as provider selection plus inference retry. Every result in a
sequence must declare its source state in `metadata.state`, and that source must match the
machine's current state when the result is applied. The runner also checks the handler's sequence
ownership allowlist; add a new entry in `DEFAULT_SEQUENCE_OWNERSHIP` before introducing another
composite handler that crosses into a later state. Terminal results (`complete` or `failed`) end
the sequence immediately.

## Runtime responsibilities

`OnboardRuntime` is the intended authority for:
Expand Down
318 changes: 318 additions & 0 deletions src/lib/onboard/machine/runner-sequence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
// 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,
filterSafeUpdates,
MACHINE_SNAPSHOT_VERSION,
normalizeSession,
type Session,
type SessionUpdates,
sanitizeFailure,
} from "../../state/onboard-session";
import { advanceTo, branchTo, completeOnboardMachine, failOnboardMachine, retryTo } from "./result";
import {
EmptyOnboardStateHandlerResultError,
OnboardMachineResultSequenceOwnershipError,
OnboardMachineResultSequenceSourceError,
OnboardMachineTransitionLimitError,
type OnboardStateHandlers,
runOnboardMachine,
} from "./runner";
import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime";

interface RunnerContext {
attempts: number;
visited: 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 => {
const next = mutator(cloneSession(session)) ?? session;
session = cloneSession(next);
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-28T00:00:00.000Z",
};
return new OnboardRuntime(deps);
}

describe("runOnboardMachine result sequences", () => {
it("runs handlers until completion while applying multiple results in order", async () => {
const runtime = createRuntime();
const calls: string[] = [];
const handlers: OnboardStateHandlers<RunnerContext> = {
init: () => advanceTo("preflight"),
preflight: () => advanceTo("gateway"),
gateway: () => advanceTo("provider_selection"),
provider_selection: (context) => {
if (context.attempts === 0) return advanceTo("inference");
return [
advanceTo("inference", { metadata: { state: "provider_selection" } }),
advanceTo("sandbox", { metadata: { state: "inference" } }),
];
},
inference: (context) => {
calls.push(`inference:${context.attempts}`);
return retryTo("provider_selection");
},
sandbox: () => branchTo("openclaw"),
openclaw: () => advanceTo("policies"),
policies: () => advanceTo("finalizing"),
finalizing: () => advanceTo("post_verify"),
post_verify: () => completeOnboardMachine({ sandboxName: "my-assistant" }),
};

const result = await runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers,
updateContext: ({ context, state }) => ({
attempts: state === "inference" ? context.attempts + 1 : context.attempts,
visited: [...context.visited, state],
}),
});

expect(result.session).toMatchObject({
status: "complete",
sandboxName: "my-assistant",
machine: { state: "complete" },
});
expect(calls).toEqual(["inference:0"]);
expect(result.context.visited).toEqual([
"init",
"preflight",
"gateway",
"provider_selection",
"inference",
"provider_selection",
"inference",
"sandbox",
"openclaw",
"policies",
"finalizing",
"post_verify",
]);
});

it("allows explicit sequence ownership extensions for custom composite handlers", async () => {
const runtime = createRuntime();

const result = await runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
sequenceOwnership: { init: ["preflight"] },
handlers: {
init: () => [
advanceTo("preflight", { metadata: { state: "init" } }),
advanceTo("gateway", { metadata: { state: "preflight" } }),
],
gateway: () => advanceTo("provider_selection"),
provider_selection: () => advanceTo("inference"),
inference: () => advanceTo("sandbox"),
sandbox: () => branchTo("openclaw"),
openclaw: () => advanceTo("policies"),
policies: () => advanceTo("finalizing"),
finalizing: () => advanceTo("post_verify"),
post_verify: () => completeOnboardMachine({ sandboxName: "my-assistant" }),
},
});

expect(result.session).toMatchObject({
status: "complete",
sandboxName: "my-assistant",
machine: { state: "complete" },
});
});

it("rejects handlers that return an empty result list", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: { init: () => [] },
}),
).rejects.toThrow(EmptyOnboardStateHandlerResultError);
});

it("requires source-state metadata for multi-result handler sequences", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => [
advanceTo("preflight"),
advanceTo("gateway", { metadata: { state: "preflight" } }),
],
},
}),
).rejects.toThrow(OnboardMachineResultSequenceSourceError);
});

it("rejects multi-result handler sequences with stale source-state metadata", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => [
advanceTo("preflight", { metadata: { state: "init" } }),
advanceTo("gateway", { metadata: { state: "init" } }),
],
},
}),
).rejects.toThrow(OnboardMachineResultSequenceSourceError);
await expect(runtime.session()).resolves.toMatchObject({ machine: { state: "preflight" } });
});

it("rejects multi-result handler sequences that cross states outside the handler ownership", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => [
advanceTo("preflight", { metadata: { state: "init" } }),
advanceTo("gateway", { metadata: { state: "preflight" } }),
],
},
}),
).rejects.toThrow(OnboardMachineResultSequenceOwnershipError);
await expect(runtime.session()).resolves.toMatchObject({ machine: { state: "preflight" } });
});

it("propagates invalid transitions after earlier sequence results apply", async () => {
const runtime = createRuntime(
createSession({
machine: {
version: MACHINE_SNAPSHOT_VERSION,
state: "provider_selection",
stateEnteredAt: "2026-05-28T00:00:00.000Z",
revision: 1,
},
}),
);
const updateContext = vi.fn(({ context, state }) => ({
...context,
visited: [...context.visited, state],
}));

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
provider_selection: () => [
advanceTo("inference", { metadata: { state: "provider_selection" } }),
advanceTo("policies", { metadata: { state: "inference" } }),
],
},
updateContext,
}),
).rejects.toThrow("Invalid onboarding machine transition");
expect(updateContext).toHaveBeenCalledOnce();
await expect(runtime.session()).resolves.toMatchObject({ machine: { state: "inference" } });
});

it("stops applying handler sequences after terminal results", async () => {
const runtime = createRuntime();
const updateContext = vi.fn(({ context, state }) => ({
...context,
visited: [...context.visited, state],
}));

const result = await runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => [
failOnboardMachine("init failed", {
step: "init",
metadata: { state: "init" },
}),
advanceTo("preflight", { metadata: { state: "failed" } }),
],
},
updateContext,
});

expect(result.session).toMatchObject({
status: "failed",
failure: { step: "init", message: "init failed" },
machine: { state: "failed" },
});
expect(result.context.visited).toEqual(["init"]);
expect(updateContext).toHaveBeenCalledOnce();
});

it("counts each handler sequence result toward the transition limit", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => [
advanceTo("preflight", { metadata: { state: "init" } }),
advanceTo("gateway", { metadata: { state: "preflight" } }),
],
},
maxTransitions: 1,
}),
).rejects.toThrow(OnboardMachineTransitionLimitError);
await expect(runtime.session()).resolves.toMatchObject({ machine: { state: "preflight" } });
});
});
Loading