diff --git a/src/lib/onboard/machine/runtime.test.ts b/src/lib/onboard/machine/runtime.test.ts new file mode 100644 index 0000000000..becca6028e --- /dev/null +++ b/src/lib/onboard/machine/runtime.test.ts @@ -0,0 +1,184 @@ +// 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, +} from "../../state/onboard-session"; +import type { OnboardMachineEvent } from "./events"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; +import { InvalidOnboardMachineTransitionError } from "./transitions"; + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function createHarness(initialSession: Session | null = createSession()) { + let session = initialSession ? cloneSession(initialSession) : null; + const events: OnboardMachineEvent[] = []; + let tick = 0; + const deps: OnboardRuntimeDeps = { + loadSession: () => (session ? cloneSession(session) : null), + createSession: (overrides) => createSession(overrides), + saveSession: (next) => { + session = cloneSession(next); + return cloneSession(session); + }, + updateSession: (mutator) => { + const current = session ? cloneSession(session) : createSession(); + const next = mutator(current) ?? current; + session = cloneSession(next); + return cloneSession(session); + }, + filterSafeUpdates, + emitEvent: (event) => events.push(event), + now: () => `2026-05-19T00:00:${String(tick++).padStart(2, "0")}.000Z`, + }; + return { + runtime: new OnboardRuntime(deps), + events, + getSession: () => { + if (!session) throw new Error("Expected runtime session"); + return cloneSession(session); + }, + }; +} + +function sessionInState(state: Session["machine"]["state"]): Session { + const session = createSession(); + session.machine = { + version: 1, + state, + stateEnteredAt: "2026-05-19T00:00:00.000Z", + revision: 7, + }; + return session; +} + +describe("OnboardRuntime", () => { + it("starts a session and emits started/resumed lifecycle events", async () => { + const { runtime, events, getSession } = createHarness(null); + + const started = await runtime.start(); + expect(started.machine.state).toBe("init"); + expect(getSession().machine.state).toBe("init"); + expect(events[0]).toMatchObject({ type: "onboard.started", state: "init" }); + + await runtime.start({ resumed: true }); + expect(events[1]).toMatchObject({ type: "onboard.resumed", state: "init" }); + }); + + it("validates and persists explicit transitions", async () => { + const { runtime, events, getSession } = createHarness(); + + await runtime.transition("preflight"); + + expect(getSession().machine).toEqual({ + version: 1, + state: "preflight", + stateEnteredAt: "2026-05-19T00:00:00.000Z", + revision: 1, + }); + expect(events.map((event) => event.type)).toEqual(["state.exited", "state.entered"]); + expect(events[0]).toMatchObject({ state: "init" }); + expect(events[1]).toMatchObject({ state: "preflight" }); + + await expect(runtime.transition("sandbox")).rejects.toThrow( + InvalidOnboardMachineTransitionError, + ); + expect(getSession().machine.state).toBe("preflight"); + }); + + it("applies only safe context updates and emits redacted context events", async () => { + const { runtime, events, getSession } = createHarness(); + + await runtime.updateContext({ + provider: "nvidia-prod", + endpointUrl: "https://alice:secret@example.com/v1?token=super-secret&keep=yes#token=frag", + credentialEnv: "NVIDIA_API_KEY", + apiKey: "super-secret", + } as Parameters[0] & { apiKey: string }); + + expect(getSession()).toMatchObject({ + provider: "nvidia-prod", + endpointUrl: "https://example.com/v1?token=%3CREDACTED%3E&keep=yes", + credentialEnv: "NVIDIA_API_KEY", + }); + expect("apiKey" in getSession()).toBe(false); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "context.updated", state: "init" }); + expect(events[0].metadata.fields).toEqual(["provider", "endpointUrl", "credentialEnv"]); + expect(JSON.stringify(events)).not.toContain("super-secret"); + }); + + it("fails non-terminal sessions with redacted failure events", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("gateway")); + + await runtime.fail("NVIDIA_API_KEY=super-secret", { step: "gateway" }); + + expect(getSession()).toMatchObject({ + status: "failed", + failure: { step: "gateway", message: "NVIDIA_API_KEY=" }, + machine: { state: "failed", revision: 8 }, + }); + expect(events.map((event) => event.type)).toEqual(["state.failed", "onboard.failed"]); + expect(events[0]).toMatchObject({ state: "gateway", step: "gateway" }); + expect(events[1]).toMatchObject({ state: "failed", step: "gateway" }); + expect(JSON.stringify(events)).not.toContain("super-secret"); + }); + + it("rejects terminal-state failure and invalid completion transitions", async () => { + const completeHarness = createHarness(sessionInState("complete")); + await expect(completeHarness.runtime.fail("boom")).rejects.toThrow("complete -> failed"); + expect(completeHarness.getSession().machine.state).toBe("complete"); + + const policiesHarness = createHarness(sessionInState("policies")); + await expect(policiesHarness.runtime.complete()).rejects.toThrow("policies -> complete"); + expect(policiesHarness.getSession().machine.state).toBe("policies"); + }); + + it("completes from post_verify and emits completion events", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("post_verify")); + + await runtime.complete({ sandboxName: "my-assistant" }); + + expect(getSession()).toMatchObject({ + status: "complete", + resumable: false, + sandboxName: "my-assistant", + machine: { state: "complete", revision: 8 }, + }); + expect(events.map((event) => event.type)).toEqual([ + "context.updated", + "state.completed", + "state.entered", + "onboard.completed", + ]); + }); + + it("emits skipped and repair events without mutating durable state", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("provider_selection")); + + await runtime.markSkipped("provider_selection", { reason: "resume" }); + await runtime.emitRepairEvent("state.repair.started", { + state: "provider_selection", + metadata: { action: "ollama-systemd" }, + }); + await runtime.emitRepairEvent("state.repair.completed", { state: "provider_selection" }); + + expect(getSession().machine.state).toBe("provider_selection"); + expect(events.map((event) => event.type)).toEqual([ + "state.skipped", + "state.repair.started", + "state.repair.completed", + ]); + expect(events[0].metadata.reason).toBe("resume"); + await expect(runtime.markSkipped("complete")).rejects.toThrow( + "Terminal onboarding state cannot be skipped", + ); + }); +}); diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts new file mode 100644 index 0000000000..3e72cd0ccc --- /dev/null +++ b/src/lib/onboard/machine/runtime.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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 { + createOnboardMachineEvent, + emitOnboardMachineEvent, + type OnboardMachineEvent, +} from "./events"; +import { + assertValidOnboardMachineTransition, + canTransitionOnboardMachineState, + isTerminalOnboardMachineState, +} from "./transitions"; +import type { OnboardMachineEventType, OnboardMachineState } from "./types"; + +export interface OnboardRuntimeDeps { + loadSession(): Session | null; + createSession(overrides?: Partial): Session; + saveSession(session: Session): Session; + updateSession(mutator: (session: Session) => Session | void): Session; + filterSafeUpdates(updates: SessionUpdates): Partial; + emitEvent(event: OnboardMachineEvent): void; + now(): string; +} + +export type OnboardRuntimeTransitionOptions = { + metadata?: Record | null; +}; + +export type OnboardRuntimeUpdateOptions = { + state?: OnboardMachineState | null; + metadata?: Record | null; +}; + +export type OnboardRuntimeFailureOptions = { + step?: string | null; + metadata?: Record | null; +}; + +function defaultDeps(): OnboardRuntimeDeps { + return { + loadSession: onboardSession.loadSession, + createSession: onboardSession.createSession, + saveSession: onboardSession.saveSession, + updateSession: onboardSession.updateSession, + filterSafeUpdates: onboardSession.filterSafeUpdates, + emitEvent: emitOnboardMachineEvent, + now: () => new Date().toISOString(), + }; +} + +function eventMetadata(metadata: Record | null | undefined): JsonObject { + return metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as JsonObject) + : {}; +} + +function snapshotFor( + state: OnboardMachineState, + stateEnteredAt: string | null, + revision: number, +): onboardSession.OnboardMachineSnapshot { + return { + version: onboardSession.MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt, + revision: Math.max(0, Math.trunc(revision)), + }; +} + +export class OnboardRuntime { + private readonly deps: OnboardRuntimeDeps; + + constructor(deps: Partial = {}) { + this.deps = { ...defaultDeps(), ...deps }; + } + + async session(): Promise { + return this.ensureSession(); + } + + async start(options: { resumed?: boolean; metadata?: Record | null } = {}): Promise { + const session = this.ensureSession(); + this.emit(options.resumed === true ? "onboard.resumed" : "onboard.started", session, { + state: session.machine.state, + metadata: options.metadata, + }); + return session; + } + + async transition( + to: OnboardMachineState, + options: OnboardRuntimeTransitionOptions = {}, + ): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + assertValidOnboardMachineTransition(from, to); + + const enteredAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + session.machine = snapshotFor(to, enteredAt, session.machine.revision + 1); + if (to === "failed") { + session.status = "failed"; + } else if (to === "complete") { + session.status = "complete"; + session.resumable = false; + session.failure = null; + } else if (session.status !== "failed") { + session.status = "in_progress"; + } + return session; + }); + + this.emit("state.exited", updated, { state: from, metadata: options.metadata }); + this.emit("state.entered", updated, { state: to, metadata: options.metadata }); + return updated; + } + + async updateContext( + updates: SessionUpdates, + options: OnboardRuntimeUpdateOptions = {}, + ): Promise { + const safeUpdates = this.deps.filterSafeUpdates(updates); + const fields = Object.keys(safeUpdates); + const updated = this.deps.updateSession((session) => { + Object.assign(session, safeUpdates); + return session; + }); + if (fields.length > 0) { + this.emit("context.updated", updated, { + state: options.state ?? updated.machine.state, + metadata: { ...eventMetadata(options.metadata), fields }, + }); + } + return updated; + } + + async complete(updates: SessionUpdates = {}): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + assertValidOnboardMachineTransition(from, "complete"); + + const safeUpdates = this.deps.filterSafeUpdates(updates); + const fields = Object.keys(safeUpdates); + const enteredAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + Object.assign(session, safeUpdates); + session.status = "complete"; + session.resumable = false; + session.failure = null; + session.machine = snapshotFor("complete", enteredAt, session.machine.revision + 1); + return session; + }); + + if (fields.length > 0) { + this.emit("context.updated", updated, { + state: "complete", + metadata: { fields }, + }); + } + this.emit("state.completed", updated, { state: from }); + this.emit("state.entered", updated, { state: "complete" }); + this.emit("onboard.completed", updated, { state: "complete" }); + return updated; + } + + async fail(message: string | null, options: OnboardRuntimeFailureOptions = {}): Promise { + const current = this.ensureSession(); + const from = current.machine.state; + if (!canTransitionOnboardMachineState(from, "failed")) { + assertValidOnboardMachineTransition(from, "failed"); + } + + const recordedAt = this.deps.now(); + const updated = this.deps.updateSession((session) => { + session.status = "failed"; + session.failure = onboardSession.sanitizeFailure({ + step: options.step ?? null, + message, + recordedAt, + }); + session.machine = snapshotFor("failed", recordedAt, session.machine.revision + 1); + return session; + }); + + this.emit("state.failed", updated, { + state: from, + step: options.step, + error: message, + metadata: options.metadata, + }); + this.emit("onboard.failed", updated, { + state: "failed", + step: options.step, + error: message, + metadata: options.metadata, + }); + return updated; + } + + async markSkipped( + state: OnboardMachineState, + metadata: Record | null = null, + ): Promise { + const session = this.ensureSession(); + if (isTerminalOnboardMachineState(state)) { + throw new Error(`Terminal onboarding state cannot be skipped: ${state}`); + } + this.emit("state.skipped", session, { state, metadata }); + return session; + } + + async emitRepairEvent( + type: Extract< + OnboardMachineEventType, + "state.repair.started" | "state.repair.completed" | "state.repair.failed" + >, + options: { + state?: OnboardMachineState | null; + error?: string | null; + metadata?: Record | null; + } = {}, + ): Promise { + const session = this.ensureSession(); + this.emit(type, session, { + state: options.state ?? session.machine.state, + error: options.error ?? null, + metadata: options.metadata, + }); + return session; + } + + private ensureSession(): Session { + const existing = this.deps.loadSession(); + if (existing) return existing; + return this.deps.saveSession(this.deps.createSession()); + } + + private emit( + type: OnboardMachineEventType, + session: Session, + options: { + state?: OnboardMachineState | null; + step?: string | null; + error?: string | null; + metadata?: Record | null; + } = {}, + ): void { + this.deps.emitEvent( + createOnboardMachineEvent({ + type, + session, + state: options.state ?? session.machine.state, + step: options.step ?? null, + error: options.error ?? null, + metadata: options.metadata, + }), + ); + } +}