Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
219 changes: 219 additions & 0 deletions src/lib/onboard/machine/runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// 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 {
MACHINE_SNAPSHOT_VERSION,
createSession,
filterSafeUpdates,
normalizeSession,
sanitizeFailure,
type Session,
type SessionUpdates,
} from "../../state/onboard-session";
import { advanceTo, branchTo, completeOnboardMachine, failOnboardMachine, retryTo } from "./result";
import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime";
import {
MissingOnboardStateHandlerError,
OnboardMachineTransitionLimitError,
runOnboardMachine,
type OnboardStateHandlers,
} from "./runner";

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);
Comment on lines +35 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

updateSession drops in-place mutations when mutator returns void.

On Line 36, fallback to session discards mutations made to the cloned argument when mutator returns void. That makes the test double less faithful to the declared (Session | void) contract.

Suggested fix
 const updateSession = (mutator: (value: Session) => Session | void): Session => {
-  const next = mutator(cloneSession(session)) ?? session;
+  const current = cloneSession(session);
+  const next = mutator(current) ?? current;
   session = cloneSession(next);
   return cloneSession(session);
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard/machine/runner.test.ts` around lines 35 - 38, updateSession
currently discards in-place mutations because it calls
mutator(cloneSession(session)) and if the mutator returns void it falls back to
the original session; change it to pass a cloned mutable object to mutator,
capture that mutated object, and use it when mutator returns void: call const
mutated = cloneSession(session); const result = mutator(mutated); const next =
result ?? mutated; then set session = cloneSession(next) and return
cloneSession(session). This preserves in-place mutations while still supporting
mutator returns of Session or void for the updateSession helper.

};
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;
}),
markStepSkipped: () => cloneSession(session),
markStepFailed: (_stepName, message) =>
updateSession((current) => {
current.status = "failed";
current.failure = sanitizeFailure({ step: _stepName, message, recordedAt: "now" });
return current;
}),
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", () => {
it("runs handlers until completion while applying retry and branch transitions", async () => {
const runtime = createRuntime();
const calls: string[] = [];
const handlers: OnboardStateHandlers<RunnerContext> = {
init: () => advanceTo("preflight"),
preflight: () => advanceTo("gateway"),
gateway: () => advanceTo("provider_selection"),
provider_selection: () => advanceTo("inference"),
inference: (context) => {
calls.push(`inference:${context.attempts}`);
return context.attempts === 0 ? retryTo("provider_selection") : advanceTo("sandbox");
},
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", "inference:1"]);
expect(result.context.visited).toEqual([
"init",
"preflight",
"gateway",
"provider_selection",
"inference",
"provider_selection",
"inference",
"sandbox",
"openclaw",
"policies",
"finalizing",
"post_verify",
]);
});

it("stops on failed terminal results", async () => {
const runtime = createRuntime();
const policies = vi.fn(() => advanceTo("finalizing"));

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

expect(result.session).toMatchObject({
status: "failed",
failure: { step: "preflight", message: "preflight failed" },
machine: { state: "failed" },
});
expect(policies).not.toHaveBeenCalled();
});

it("returns immediately for terminal sessions", async () => {
const startedAt = "2026-05-28T00:00:00.000Z";
const completeSession = createSession({
resumable: false,
machine: {
version: MACHINE_SNAPSHOT_VERSION,
state: "complete",
stateEnteredAt: startedAt,
revision: 1,
},
});
completeSession.status = "complete";
const runtime = createRuntime(completeSession);
const init = vi.fn(() => advanceTo("preflight"));

const result = await runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: { init },
});

expect(result.session).toMatchObject({ status: "complete", machine: { state: "complete" } });
expect(init).not.toHaveBeenCalled();
});

it("propagates runtime transition errors without updating context", async () => {
const runtime = createRuntime();
const updateContext = vi.fn(({ context }) => context);

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: { init: () => advanceTo("sandbox") },
updateContext,
}),
).rejects.toThrow("Invalid onboarding machine transition");
expect(updateContext).not.toHaveBeenCalled();
});

it("throws when a non-terminal state has no handler", async () => {
const runtime = createRuntime();

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

it("throws when retry-capable handlers exceed the transition limit", async () => {
const runtime = createRuntime();

await expect(
runOnboardMachine({
context: { attempts: 0, visited: [] } as RunnerContext,
runtime,
handlers: {
init: () => advanceTo("preflight"),
preflight: () => advanceTo("gateway"),
gateway: () => advanceTo("provider_selection"),
provider_selection: () => advanceTo("inference"),
inference: () => retryTo("provider_selection"),
},
maxTransitions: 5,
}),
).rejects.toThrow(OnboardMachineTransitionLimitError);
});
});
100 changes: 100 additions & 0 deletions src/lib/onboard/machine/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { Session } from "../../state/onboard-session";
import type { OnboardStateResult } from "./result";
import { isTerminalOnboardMachineState } from "./transitions";
import type { OnboardMachineState, OnboardNonTerminalMachineState } from "./types";

export type OnboardStateHandler<Context> = (
context: Context,
) => Promise<OnboardStateResult> | OnboardStateResult;

export type OnboardStateHandlers<Context> = Partial<
Record<OnboardNonTerminalMachineState, OnboardStateHandler<Context>>
>;

export interface OnboardMachineRunnerRuntime {
session(): Promise<Session>;
applyResult(result: OnboardStateResult): Promise<Session>;
}

export interface OnboardMachineRunnerOptions<Context> {
context: Context;
runtime: OnboardMachineRunnerRuntime;
handlers: OnboardStateHandlers<Context>;
/**
* Safety valve for retry-capable handlers. Handlers should bound their own
* retry loops, but the runner refuses to apply unbounded transitions.
*/
maxTransitions?: number;
updateContext?(input: {
context: Context;
state: OnboardMachineState;
result: OnboardStateResult;
session: Session;
}): Context | Promise<Context>;
}

export interface OnboardMachineRunnerResult<Context> {
context: Context;
session: Session;
}

export class MissingOnboardStateHandlerError extends Error {
readonly state: OnboardNonTerminalMachineState;

constructor(state: OnboardNonTerminalMachineState) {
super(`Missing onboarding machine handler for state: ${state}`);
this.name = "MissingOnboardStateHandlerError";
this.state = state;
}
}

export class OnboardMachineTransitionLimitError extends Error {
readonly maxTransitions: number;

constructor(maxTransitions: number) {
super(`Onboarding machine exceeded transition limit: ${maxTransitions}`);
this.name = "OnboardMachineTransitionLimitError";
this.maxTransitions = maxTransitions;
}
}

const DEFAULT_MAX_TRANSITIONS = 100;

function normalizeMaxTransitions(value: number | undefined): number {
if (value === undefined) return DEFAULT_MAX_TRANSITIONS;
return Math.max(1, Math.trunc(value));
}
Comment on lines +66 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden transition-limit normalization against non-finite values.

At Line 66-69, Math.trunc + Math.max allows Infinity and yields NaN for NaN; either case can break the safety valve used at Line 84-85 (transitions >= transitionLimit) and effectively remove the cap.

Proposed fix
 const DEFAULT_MAX_TRANSITIONS = 100;
+const MAX_TRANSITIONS_CAP = 10_000;
 
 function normalizeMaxTransitions(value: number | undefined): number {
   if (value === undefined) return DEFAULT_MAX_TRANSITIONS;
-  return Math.max(1, Math.trunc(value));
+  if (!Number.isFinite(value)) return DEFAULT_MAX_TRANSITIONS;
+  return Math.min(MAX_TRANSITIONS_CAP, Math.max(1, Math.trunc(value)));
 }

Also applies to: 81-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard/machine/runner.ts` around lines 66 - 69, The
normalizeMaxTransitions function currently passes Infinity/NaN through
Math.trunc/Math.max which can produce Infinity or NaN and disable the safety
check (transitions >= transitionLimit); update normalizeMaxTransitions to treat
any non-finite input as undefined by first checking Number.isFinite(value) (and
returning DEFAULT_MAX_TRANSITIONS if not finite), then apply Math.trunc and
Math.max(1, ...) so the returned transitionLimit is always a finite integer >=
1; ensure callers that compare transitions >= transitionLimit (the safety valve)
rely on this normalized value.


export async function runOnboardMachine<Context>({
context: initialContext,
runtime,
handlers,
maxTransitions,
updateContext,
}: OnboardMachineRunnerOptions<Context>): Promise<OnboardMachineRunnerResult<Context>> {
let context = initialContext;
let session = await runtime.session();
let transitions = 0;
const transitionLimit = normalizeMaxTransitions(maxTransitions);

while (!isTerminalOnboardMachineState(session.machine.state)) {
if (transitions >= transitionLimit) {
throw new OnboardMachineTransitionLimitError(transitionLimit);
}
const state = session.machine.state;
const handler = handlers[state as OnboardNonTerminalMachineState];
if (!handler) throw new MissingOnboardStateHandlerError(state as OnboardNonTerminalMachineState);

const result = await handler(context);
session = await runtime.applyResult(result);
transitions += 1;
context = updateContext
? await updateContext({ context, state, result, session })
: context;
}

return { context, session };
}
Loading