Skip to content
Merged
Changes from all commits
Commits
Show all changes
103 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
727ac69
refactor(onboard): add sequence runner adapter
cv May 29, 2026
75f82f7
refactor(onboard): support FSM runner stop states
cv May 29, 2026
59deee6
refactor(onboard): define FSM flow context
cv May 29, 2026
25c5abf
refactor(onboard): extract preflight and gateway FSM phases
cv May 29, 2026
4d9cc9f
refactor(onboard): extract provider and sandbox FSM phases
cv May 29, 2026
8a5b54a
refactor(onboard): extract agent policy finalization FSM phases
cv May 29, 2026
bec7ef8
refactor(onboard): assemble FSM phase sequence
cv May 29, 2026
0e168b3
refactor(onboard): add initial FSM flow slice
cv May 29, 2026
708ecb6
refactor(onboard): add core FSM flow slice
cv May 29, 2026
b2ecb37
refactor(onboard): add final FSM flow slice
cv May 29, 2026
4e84a09
refactor(onboard): run initial phases through FSM slice
cv May 29, 2026
2fb2488
refactor(onboard): run core phases through FSM slice
cv May 29, 2026
dfcdf9b
refactor(onboard): run final phases through FSM slice
cv May 29, 2026
26b0b43
refactor(onboard): extract live FSM slice runner helper
cv May 29, 2026
68e254d
test(onboard): cover live FSM slice boundaries
cv May 29, 2026
a1af752
merge(onboard): sync FSM stop states with main
cv Jun 9, 2026
5900708
merge(onboard): sync flow context with stop states
cv Jun 9, 2026
8576e5f
merge(onboard): sync preflight phases with flow context
cv Jun 9, 2026
42eeb90
merge(onboard): sync provider sandbox phases with preflight phases
cv Jun 9, 2026
dc8c463
test(onboard): cover sandbox branch metadata passthrough
cv Jun 9, 2026
5c76573
merge(onboard): sync finalization phases with provider sandbox phases
cv Jun 9, 2026
68722ed
test(onboard): run finalization phases through sequence runner
cv Jun 9, 2026
1775dc2
merge(onboard): sync flow sequence with finalization phases
cv Jun 9, 2026
3de7633
test(onboard): validate assembled provider sequence
cv Jun 9, 2026
8a893ff
merge(onboard): sync initial slice with flow sequence
cv Jun 9, 2026
ab55336
fix(onboard): handle init in initial FSM slice
cv Jun 9, 2026
6823bd5
merge(onboard): sync core slice with initial slice
cv Jun 9, 2026
9cc7d0c
merge(onboard): sync final slice with core slice
cv Jun 9, 2026
5c7b7ee
Merge branch 'main' into stack/onboard-fsm-flow-context
jyaunches Jun 9, 2026
83c1412
Merge branch 'main' into stack/onboard-fsm-flow-context
cv Jun 9, 2026
6abbbb4
Merge branch 'stack/onboard-fsm-flow-context' into stack/onboard-fsm-…
cv Jun 9, 2026
c07f0dd
Merge branch 'stack/onboard-fsm-preflight-gateway-phases' into stack/…
cv Jun 9, 2026
cf6c2a9
Merge branch 'stack/onboard-fsm-provider-sandbox-phases' into stack/o…
cv Jun 9, 2026
4a297d0
Merge branch 'stack/onboard-fsm-agent-policy-finalization-phases' int…
cv Jun 9, 2026
cfdaf52
Merge branch 'stack/onboard-fsm-flow-sequence' into stack/onboard-fsm…
cv Jun 9, 2026
848ed51
Merge branch 'stack/onboard-fsm-initial-sequence-slice' into stack/on…
cv Jun 9, 2026
3629605
Merge branch 'stack/onboard-fsm-core-sequence-slice' into stack/onboa…
cv Jun 9, 2026
23e8577
Merge branch 'main' into stack/onboard-fsm-flow-context
cv Jun 9, 2026
abbd8c4
chore: apply static formatting for FSM flow stack
cv Jun 9, 2026
b592eac
Merge remote-tracking branch 'origin/stack/onboard-fsm-flow-context' …
cv Jun 9, 2026
adbbdfa
chore(onboard): format preflight gateway FSM phase
cv Jun 9, 2026
8a6bca2
Merge remote-tracking branch 'origin/stack/onboard-fsm-preflight-gate…
cv Jun 9, 2026
baeab37
chore(onboard): format provider sandbox FSM phase
cv Jun 9, 2026
5118bd7
Merge remote-tracking branch 'origin/stack/onboard-fsm-provider-sandb…
cv Jun 9, 2026
a64242b
chore(onboard): format finalization FSM phases
cv Jun 9, 2026
32059c9
Merge remote-tracking branch 'origin/stack/onboard-fsm-agent-policy-f…
cv Jun 9, 2026
3562942
chore(onboard): format FSM flow sequence
cv Jun 9, 2026
c2bb6a9
Merge remote-tracking branch 'origin/stack/onboard-fsm-flow-sequence'…
cv Jun 9, 2026
ac0a73c
Merge remote-tracking branch 'origin/stack/onboard-fsm-initial-sequen…
cv Jun 9, 2026
b193221
chore(onboard): format core FSM flow slice
cv Jun 9, 2026
3f8a02d
Merge remote-tracking branch 'origin/stack/onboard-fsm-core-sequence-…
cv Jun 9, 2026
da474e4
Merge remote-tracking branch 'origin/stack/onboard-fsm-final-sequence…
cv Jun 9, 2026
69d537d
Merge branch 'main' into stack/onboard-fsm-initial-sequence-slice
cv Jun 9, 2026
90fedc3
Merge branch 'stack/onboard-fsm-initial-sequence-slice' into stack/on…
cv Jun 9, 2026
e251bdf
Merge branch 'stack/onboard-fsm-core-sequence-slice' into stack/onboa…
cv Jun 9, 2026
05cac16
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-initial-sl…
cv Jun 9, 2026
a4d6fe2
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-core-slice…
cv Jun 9, 2026
b6e3eb6
Merge remote-tracking branch 'origin/main' into stack/onboard-fsm-cor…
cv Jun 9, 2026
95f65c4
Merge remote-tracking branch 'origin/stack/onboard-fsm-core-sequence-…
cv Jun 9, 2026
7de4d13
Merge remote-tracking branch 'origin/stack/onboard-fsm-final-sequence…
cv Jun 9, 2026
fba2f8c
refactor(onboard): extract initial FSM flow phases
cv Jun 9, 2026
a6406c0
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-initial-sl…
cv Jun 9, 2026
d948c12
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-core-slice…
cv Jun 9, 2026
55d6244
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-final-slic…
cv Jun 9, 2026
dc8119c
chore(onboard): format live flow slice helper
cv Jun 9, 2026
66b2ba3
Merge remote-tracking branch 'origin/stack/onboard-fsm-live-slice-hel…
cv Jun 9, 2026
211b379
chore(onboard): format live slice boundary test
cv Jun 9, 2026
7be0d7b
Merge remote-tracking branch 'origin/main' into stack/onboard-fsm-fin…
cv Jun 9, 2026
455c664
Merge remote-tracking branch 'origin/stack/onboard-fsm-final-sequence…
cv Jun 9, 2026
89de2fa
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-initial-sl…
cv Jun 9, 2026
a4a6773
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-core-slice…
cv Jun 9, 2026
c0dd16a
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-final-slic…
cv Jun 9, 2026
e36d763
Merge remote-tracking branch 'origin/stack/onboard-fsm-live-slice-hel…
cv Jun 9, 2026
02dcc8c
fix(onboard): enter initial FSM slice from init state
cv Jun 9, 2026
725fb30
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-initial-sl…
cv Jun 9, 2026
0b54044
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-core-slice…
cv Jun 9, 2026
f4910a8
Merge remote-tracking branch 'origin/stack/onboard-fsm-use-final-slic…
cv Jun 9, 2026
354a3da
Merge remote-tracking branch 'origin/stack/onboard-fsm-live-slice-hel…
cv Jun 9, 2026
ed8b7e4
test(onboard): mirror initial FSM transitions in live probe
cv Jun 9, 2026
1c17956
merge(onboard): sync live slice boundary tests with main
cv Jun 10, 2026
15bd587
test(onboard): enforce live slice dist coverage
cv Jun 10, 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
354 changes: 354 additions & 0 deletions test/onboard-fsm-live-slices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { type SpawnSyncReturns, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, describe, it } from "vitest";

const repoRoot = path.join(import.meta.dirname, "..");
const probeTimeoutMs = 10_000;

type SliceName = "initial" | "core" | "final";
type ProbeMode = "fresh" | "resume-initial" | "ahead-core";

interface ProbeOptions {
slice: SliceName;
mode?: ProbeMode;
}

interface DistArtifact {
label: string;
sourcePath: string;
distPath: string;
}

const requiredDistArtifacts: readonly DistArtifact[] = [
{
label: "onboard dispatcher",
sourcePath: path.join(repoRoot, "src", "lib", "onboard.ts"),
distPath: path.join(repoRoot, "dist", "lib", "onboard.js"),
},
{
label: "flow slices",
sourcePath: path.join(repoRoot, "src", "lib", "onboard", "machine", "flow-slices.ts"),
distPath: path.join(repoRoot, "dist", "lib", "onboard", "machine", "flow-slices.js"),
},
{
label: "state results",
sourcePath: path.join(repoRoot, "src", "lib", "onboard", "machine", "result.ts"),
distPath: path.join(repoRoot, "dist", "lib", "onboard", "machine", "result.js"),
},
{
label: "session persistence",
sourcePath: path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"),
distPath: path.join(repoRoot, "dist", "lib", "state", "onboard-session.js"),
},
{
label: "preflight handler",
sourcePath: path.join(repoRoot, "src", "lib", "onboard", "machine", "handlers", "preflight.ts"),
distPath: path.join(repoRoot, "dist", "lib", "onboard", "machine", "handlers", "preflight.js"),
},
{
label: "provider inference handler",
sourcePath: path.join(
repoRoot,
"src",
"lib",
"onboard",
"machine",
"handlers",
"provider-inference.ts",
),
distPath: path.join(
repoRoot,
"dist",
"lib",
"onboard",
"machine",
"handlers",
"provider-inference.js",
),
},
];

function distArtifactStatus(): { ok: true } | { ok: false; reason: string } {
for (const artifact of requiredDistArtifacts) {
if (!fs.existsSync(artifact.distPath)) {
return {
ok: false,
reason: `${artifact.label} is missing at ${path.relative(repoRoot, artifact.distPath)}`,
};
}
if (!fs.existsSync(artifact.sourcePath)) continue;
const sourceMtime = fs.statSync(artifact.sourcePath).mtimeMs;
const distMtime = fs.statSync(artifact.distPath).mtimeMs;
if (sourceMtime > distMtime + 1000) {
return {
ok: false,
reason: `${artifact.label} is older than ${path.relative(repoRoot, artifact.sourcePath)}`,
};
}
}
return { ok: true };
}

function assertFreshDistArtifacts(): void {
const status = distArtifactStatus();
if (status.ok) return;
throw new Error(
`Live onboard FSM slice boundary tests require fresh compiled CLI artifacts: ${status.reason}. Run npm run build:cli before this test.`,
);
}

function probeEnvironment(tmpDir: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
HOME: tmpDir,
TMPDIR: tmpDir,
PATH: process.env.PATH || "/usr/bin:/bin",
NODE_ENV: "test",
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_SANDBOX_NAME: "fsm-sandbox",
NEMOCLAW_YES: "1",
NO_COLOR: "1",
};
for (const key of ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]) {
if (process.env[key]) env[key] = process.env[key];
}
return env;
}

function redactProbeOutput(value: string): string {
return value
.replace(/(authorization:\s*bearer\s+)[^\s]+/gi, "$1<redacted>")
.replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1<redacted>")
.replace(/((?:api[_-]?key|token|password|secret)=)[^\s]+/gi, "$1<redacted>")
.replace(/(https?:\/\/)[^@\s]+@/gi, "$1<redacted>@")
.slice(0, 4000);
}

function probeFailureMessage(result: SpawnSyncReturns<string>): string {
const details = [
`slice probe exited with status ${result.status ?? "null"}${result.signal ? ` and signal ${result.signal}` : ""}`,
result.error ? `error: ${redactProbeOutput(result.error.message)}` : null,
result.stderr ? `stderr:\n${redactProbeOutput(result.stderr)}` : null,
result.stdout ? `stdout:\n${redactProbeOutput(result.stdout)}` : null,
].filter(Boolean);
return details.join("\n\n");
}

function runSliceProbe(options: ProbeOptions) {
const scenario = { mode: options.mode ?? "fresh", slice: options.slice };
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `nemoclaw-onboard-fsm-${scenario.mode}-${scenario.slice}-`),
);
const scriptPath = path.join(tmpDir, `probe-${scenario.mode}-${scenario.slice}.js`);
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const flowSlicesPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "machine", "flow-slices.js"),
);
const resultPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "machine", "result.js"),
);
const sessionPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "state", "onboard-session.js"),
);
const preflightHandlerPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "machine", "handlers", "preflight.js"),
);
const providerHandlerPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "onboard", "machine", "handlers", "provider-inference.js"),
);

fs.writeFileSync(
scriptPath,
`
const scenario = ${JSON.stringify(scenario)};
const flowSlices = require(${flowSlicesPath});
const { advanceTo, branchTo } = require(${resultPath});
const onboardSession = require(${sessionPath});
const preflightHandlers = require(${preflightHandlerPath});
const providerHandlers = require(${providerHandlerPath});
const called = [];
const sentinel = new Error("slice-called");

function machine(state, revision = 1) {
return { version: 1, state, stateEnteredAt: null, revision };
}

function seedResumeSession(state) {
onboardSession.saveSession(onboardSession.createSession({
mode: "non-interactive",
sandboxName: "fsm-sandbox",
machine: machine(state),
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
}));
}

function baseContext(context, overrides = {}) {
return {
...context,
session: overrides.session ?? context.session ?? null,
sandboxName: overrides.sandboxName ?? context.sandboxName ?? "fsm-sandbox",
model: overrides.model ?? context.model ?? "model",
provider: overrides.provider ?? context.provider ?? "provider",
endpointUrl: overrides.endpointUrl ?? context.endpointUrl ?? null,
credentialEnv: overrides.credentialEnv ?? context.credentialEnv ?? null,
hermesAuthMethod: overrides.hermesAuthMethod ?? context.hermesAuthMethod ?? null,
hermesToolGateways: overrides.hermesToolGateways ?? context.hermesToolGateways ?? [],
preferredInferenceApi: overrides.preferredInferenceApi ?? context.preferredInferenceApi ?? null,
nimContainer: overrides.nimContainer ?? context.nimContainer ?? null,
webSearchConfig: overrides.webSearchConfig ?? context.webSearchConfig ?? null,
webSearchSupported: overrides.webSearchSupported ?? context.webSearchSupported ?? false,
selectedMessagingChannels: overrides.selectedMessagingChannels ?? context.selectedMessagingChannels ?? [],
gpu: overrides.gpu ?? context.gpu ?? null,
sandboxGpuConfig: overrides.sandboxGpuConfig ?? context.sandboxGpuConfig ?? { sandboxGpuEnabled: false, mode: "0" },
gpuPassthrough: overrides.gpuPassthrough ?? context.gpuPassthrough ?? false,
resumeHasResolvedGpuIntent: false,
requestedGpuPassthrough: false,
};
}

preflightHandlers.handlePreflightState = async () => {
if (scenario.mode !== "resume-initial") {
throw new Error("unexpected preflight compatibility handler");
}
called.push("preflight-compat");
throw sentinel;
};

providerHandlers.handleProviderInferenceState = async () => {
if (scenario.mode !== "ahead-core") {
throw new Error("unexpected provider compatibility handler");
}
called.push("provider-compat");
throw sentinel;
};

flowSlices.runInitialOnboardFlowSequence = async ({ context, runtime }) => {
called.push("initial");
if (scenario.mode === "resume-initial") {
throw new Error("strict initial runner should not run on resume");
}
if (scenario.slice === "initial") throw sentinel;
const initialSession = await runtime.session();
if (initialSession.machine?.state === "init") {
await runtime.applyResult(advanceTo("preflight"));
}
await runtime.applyResult(advanceTo("gateway", { metadata: { state: "preflight" } }));
await runtime.applyResult(advanceTo("provider_selection", { metadata: { state: "gateway" } }));
if (scenario.mode === "ahead-core") {
await runtime.applyResult(advanceTo("inference", { metadata: { state: "provider_selection" } }));
}
const session = await runtime.session();
return { context: baseContext(context, { session }), session };
};

flowSlices.runCoreOnboardFlowSequence = async ({ context, runtime }) => {
called.push("core");
if (scenario.mode === "ahead-core") {
throw new Error("strict core runner should not run after an ahead-state handoff");
}
if (scenario.slice === "core") throw sentinel;
await runtime.applyResult(advanceTo("inference", { metadata: { state: "provider_selection" } }));
await runtime.applyResult(advanceTo("sandbox", { metadata: { state: "inference" } }));
await runtime.applyResult(branchTo("openclaw", { metadata: { state: "sandbox" } }));
const session = await runtime.session();
return { context: baseContext(context, { session }), session };
};

flowSlices.runFinalOnboardFlowSequence = async ({ context }) => {
called.push("final");
if (scenario.slice === "final") throw sentinel;
throw new Error("unexpected final slice fallthrough");
};

if (scenario.mode === "resume-initial") {
seedResumeSession("preflight");
}

const { onboard } = require(${onboardPath});

(async () => {
try {
await onboard({
nonInteractive: true,
autoYes: true,
acceptThirdPartySoftware: true,
noGpu: true,
sandboxName: "fsm-sandbox",
resume: scenario.mode === "resume-initial",
});
throw new Error("expected slice sentinel");
} catch (error) {
if (error === sentinel || error?.message === sentinel.message) {
console.log(JSON.stringify({ called }));
return;
}
console.error(error);
process.exit(1);
}
})();
`,
);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: probeEnvironment(tmpDir),
timeout: probeTimeoutMs,
});
try {
assert.equal(result.status, 0, probeFailureMessage(result));
const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
const payload = JSON.parse(lines.at(-1) || "{}") as { called?: string[] };
assert.ok(
Array.isArray(payload.called),
`slice probe did not return called slices\n${probeFailureMessage(result)}`,
);
return payload.called as string[];
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

describe("live onboard FSM slice boundaries", () => {
/*
* The live dispatcher is still loaded from compiled CommonJS:
* src/lib/onboard.ts captures these helpers through require-time bindings,
* and a source-level Vitest import cannot replace them without adding a
* production-only injection seam. Keep the monkeypatch in a short-lived
* child process, with a minimal environment and a timeout, until onboard's
* dispatcher exposes an explicit test hook or moves to source-testable ESM.
*/
beforeAll(() => {
assertFreshDistArtifacts();
});

it("enters the initial slice on fresh onboard runs", () => {
assert.deepEqual(runSliceProbe({ slice: "initial" }), ["initial"]);
});

it("enters the core slice after the initial slice reaches provider selection", () => {
assert.deepEqual(runSliceProbe({ slice: "core" }), ["initial", "core"]);
});

it("enters the final slice after the core slice reaches the branch state", () => {
assert.deepEqual(runSliceProbe({ slice: "final" }), ["initial", "core", "final"]);
});

it("bypasses the strict initial runner on resume and reaches compatibility phases", () => {
assert.deepEqual(runSliceProbe({ slice: "initial", mode: "resume-initial" }), [
"preflight-compat",
]);
});

it("bypasses the strict core runner when fresh state is already past the core entry", () => {
assert.deepEqual(runSliceProbe({ slice: "core", mode: "ahead-core" }), [
"initial",
"provider-compat",
]);
});
});
Loading