diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index ae091f7adf8..f6c178f0cfd 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -86,9 +86,15 @@ function baseSession(overrides: Partial = {}): Session { telegramConfig: null, wechatConfig: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + machine: { + version: 1, + state: "complete", + stateEnteredAt: "2026-05-11T00:00:00.000Z", + revision: 0, + }, steps: {}, ...overrides, - }; + } as Session; } function createDeps(options: { diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 825ad6937f7..be35e8f73d1 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -40,6 +40,14 @@ function requireDebugSummary( return summary; } +function normalizeLegacySession( + legacy: unknown, +): ReturnType { + return session.normalizeSession( + legacy as Parameters[0], + ); +} + beforeEach(() => { // Recreate tmpDir per test so lock artifacts (and any other on-disk state) // from a previous test cannot leak into this one. Without this, malformed @@ -74,12 +82,21 @@ describe("onboard session", () => { }); it("creates and persists a session with restrictive permissions", () => { - const created = session.createSession({ mode: "non-interactive" }); + const created = session.createSession({ + mode: "non-interactive", + startedAt: "2026-01-01T00:00:00.000Z", + }); const saved = session.saveSession(created); const stat = fs.statSync(session.SESSION_FILE); const dirStat = fs.statSync(path.dirname(session.SESSION_FILE)); expect(saved.mode).toBe("non-interactive"); + expect(saved.machine).toMatchObject({ + version: 1, + state: "init", + revision: 0, + }); + expect(saved.machine.stateEnteredAt).toBe("2026-01-01T00:00:00.000Z"); expect(fs.existsSync(session.SESSION_FILE)).toBe(true); expect(stat.mode & 0o777).toBe(0o600); expect(dirStat.mode & 0o777).toBe(0o700); @@ -124,6 +141,107 @@ describe("onboard session", () => { } expect(loaded.failure.step).toBe("sandbox"); expect(loaded.failure.message).toMatch(/Sandbox creation failed/); + expect(loaded.machine.state).toBe("failed"); + }); + + it("persists a compact machine snapshot across step boundaries", () => { + session.saveSession(session.createSession()); + let loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "init", revision: 0 }); + + session.markStepStarted("preflight"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "preflight", revision: 1 }); + expect(loaded.machine.stateEnteredAt).toBe(loaded.steps.preflight.startedAt); + + session.markStepComplete("preflight"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "gateway", revision: 2 }); + expect(loaded.machine.stateEnteredAt).toBe(loaded.steps.preflight.completedAt); + + session.markStepComplete("gateway"); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "provider_selection", revision: 3 }); + + session.completeSession(); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.machine).toMatchObject({ state: "complete", revision: 4 }); + expect(requireDebugSummary(session.summarizeForDebug()).machine).toEqual(loaded.machine); + }); + + it("normalizes old sessions without machine snapshots", () => { + type LegacySession = Omit, "machine"> & { + machine?: unknown; + }; + const legacy = session.createSession({ + sessionId: "legacy-session", + startedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:05:00.000Z", + }) as unknown as LegacySession; + delete legacy.machine; + legacy.steps.gateway.status = "in_progress"; + legacy.steps.gateway.startedAt = "2026-01-01T00:02:00.000Z"; + legacy.lastStepStarted = "gateway"; + + let normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "gateway", + stateEnteredAt: "2026-01-01T00:02:00.000Z", + revision: 0, + }); + + legacy.steps.gateway.status = "complete"; + legacy.steps.gateway.completedAt = "2026-01-01T00:03:00.000Z"; + legacy.lastCompletedStep = "gateway"; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "provider_selection", + stateEnteredAt: "2026-01-01T00:03:00.000Z", + revision: 0, + }); + + legacy.status = "failed"; + legacy.failure = { + step: "gateway", + message: "boom", + recordedAt: "2026-01-01T00:04:00.000Z", + }; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "failed", + stateEnteredAt: "2026-01-01T00:04:00.000Z", + revision: 0, + }); + + legacy.status = "complete"; + normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine.state).toBe("complete"); + }); + + it("normalizes invalid machine snapshots from old sessions", () => { + type LegacySession = Omit, "machine"> & { + machine?: unknown; + }; + const legacy = session.createSession({ lastCompletedStep: "policies" }) as unknown as LegacySession; + legacy.steps.policies.status = "complete"; + legacy.steps.policies.completedAt = "2026-01-01T00:08:00.000Z"; + legacy.machine = { + version: 1, + state: "not-a-state", + stateEnteredAt: "2026-01-01T00:09:00.000Z", + revision: -1, + }; + + const normalized = requireLoadedSession(normalizeLegacySession(legacy)); + expect(normalized.machine).toEqual({ + version: 1, + state: "finalizing", + stateEnteredAt: "2026-01-01T00:08:00.000Z", + revision: 0, + }); }); it("emits redacted structured machine events for session step mutations", () => { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index a74602db39f..26cbf083539 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -21,10 +21,14 @@ import { import { createOnboardMachineEvent, emitOnboardMachineEvent, + machineStateFromOnboardSessionStep, } from "../onboard/machine/events"; +import { isOnboardMachineState } from "../onboard/machine/transitions"; +import type { OnboardMachineState } from "../onboard/machine/types"; import { redactSensitiveText, redactUrl } from "../security/redact"; export const SESSION_VERSION = 1; +export const MACHINE_SNAPSHOT_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); @@ -64,6 +68,13 @@ export interface SessionMetadata { fromDockerfile: string | null; } +export interface OnboardMachineSnapshot { + version: typeof MACHINE_SNAPSHOT_VERSION; + state: OnboardMachineState; + stateEnteredAt: string | null; + revision: number; +} + export interface Session { version: number; sessionId: string; @@ -115,6 +126,7 @@ export interface Session { telegramConfig: TelegramConfig | null; wechatConfig: WechatConfig | null; metadata: SessionMetadata; + machine: OnboardMachineSnapshot; steps: Record; } @@ -198,6 +210,7 @@ export interface DebugSessionSummary { lastStepStarted: string | null; lastCompletedStep: string | null; failure: SessionFailure | null; + machine: OnboardMachineSnapshot; steps: Record; } @@ -240,6 +253,10 @@ function readPositiveInteger(value: SessionJsonValue | undefined): number | null return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; } +function readNonNegativeInteger(value: SessionJsonValue | undefined): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; +} + function readStringArray(value: SessionJsonValue | undefined): string[] | null { if (!Array.isArray(value)) return null; return value.filter((entry): entry is string => typeof entry === "string"); @@ -308,6 +325,17 @@ function parseStepState(value: SessionJsonValue | undefined): StepState | null { }; } +function parseMachineSnapshot(value: SessionJsonValue | undefined): OnboardMachineSnapshot | null { + if (!isObject(value) || value.version !== MACHINE_SNAPSHOT_VERSION) return null; + if (!isOnboardMachineState(value.state)) return null; + return { + version: MACHINE_SNAPSHOT_VERSION, + state: value.state, + stateEnteredAt: readString(value.stateEnteredAt), + revision: readNonNegativeInteger(value.revision) ?? 0, + }; +} + function parseLockInfo(value: SessionJsonValue | undefined): LockInfo | null { if (!isObject(value) || typeof value.pid !== "number") return null; return { @@ -335,15 +363,104 @@ export function sanitizeFailure( // ── Session CRUD ───────────────────────────────────────────────── +function createMachineSnapshot( + state: OnboardMachineState, + stateEnteredAt: string | null, + revision = 0, +): OnboardMachineSnapshot { + return { + version: MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt, + revision: Math.max(0, Math.trunc(revision)), + }; +} + +function nextMachineStateAfterCompletedStep( + stepName: string | null | undefined, + session: Pick, +): OnboardMachineState | null { + switch (stepName) { + case "preflight": + return "gateway"; + case "gateway": + return "provider_selection"; + case "provider_selection": + return "inference"; + case "inference": + return "sandbox"; + case "sandbox": + return session.agent ? "agent_setup" : "openclaw"; + case "openclaw": + case "agent_setup": + return "policies"; + case "policies": + return "finalizing"; + default: + return null; + } +} + +function inferMachineState(session: Session): OnboardMachineState { + if (session.status === "complete") return "complete"; + if (session.status === "failed") return "failed"; + + const startedState = machineStateFromOnboardSessionStep(session.lastStepStarted); + const startedStep = session.lastStepStarted ? session.steps[session.lastStepStarted] : null; + if (startedState && startedStep?.status === "in_progress") return startedState; + + return nextMachineStateAfterCompletedStep(session.lastCompletedStep, session) ?? "init"; +} + +function inferMachineStateEnteredAt(session: Session, state: OnboardMachineState): string | null { + if (state === "failed") return session.failure?.recordedAt ?? session.updatedAt; + if (state === "complete") return session.updatedAt; + + const startedState = machineStateFromOnboardSessionStep(session.lastStepStarted); + const startedStep = session.lastStepStarted ? session.steps[session.lastStepStarted] : null; + if (state === startedState && startedStep?.status === "in_progress") { + return startedStep.startedAt ?? session.updatedAt; + } + + if (nextMachineStateAfterCompletedStep(session.lastCompletedStep, session) === state) { + const completedStep = session.lastCompletedStep ? session.steps[session.lastCompletedStep] : null; + return completedStep?.completedAt ?? session.updatedAt; + } + + return session.startedAt; +} + +function inferMachineSnapshot(session: Session): OnboardMachineSnapshot { + const state = inferMachineState(session); + return createMachineSnapshot(state, inferMachineStateEnteredAt(session, state)); +} + +function transitionMachineSnapshot(session: Session, state: OnboardMachineState, now: string): void { + const current = session.machine ?? createMachineSnapshot("init", session.startedAt); + if (current.state === state) { + session.machine = { + ...current, + stateEnteredAt: current.stateEnteredAt ?? now, + }; + return; + } + session.machine = createMachineSnapshot(state, now, current.revision + 1); +} + export function createSession(overrides: Partial = {}): Session { const now = new Date().toISOString(); - return { + const startedAt = overrides.startedAt ?? now; + const steps = { + ...defaultSteps(), + ...(overrides.steps ?? {}), + }; + const session: Session = { version: SESSION_VERSION, sessionId: overrides.sessionId ?? `${Date.now()}-${randomUUID()}`, resumable: true, status: "in_progress", mode: overrides.mode ?? "interactive", - startedAt: overrides.startedAt ?? now, + startedAt, updatedAt: overrides.updatedAt ?? now, lastStepStarted: overrides.lastStepStarted ?? null, lastCompletedStep: overrides.lastCompletedStep ?? null, @@ -376,11 +493,11 @@ export function createSession(overrides: Partial = {}): Session { gatewayName: overrides.metadata?.gatewayName ?? "nemoclaw", fromDockerfile: overrides.metadata?.fromDockerfile ?? null, }, - steps: { - ...defaultSteps(), - ...(overrides.steps ?? {}), - }, + machine: parseMachineSnapshot(overrides.machine as SessionJsonValue | undefined) ?? + createMachineSnapshot("init", startedAt), + steps, }; + return session; } export function normalizeSession(data: Session | SessionJsonValue | undefined): Session | null { @@ -429,6 +546,8 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): } } + normalized.machine = parseMachineSnapshot(data.machine) ?? inferMachineSnapshot(normalized); + return normalized; } @@ -891,13 +1010,16 @@ export function markStepStarted(stepName: string): Session { const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "in_progress"; - step.startedAt = new Date().toISOString(); + step.startedAt = now; step.completedAt = null; step.error = null; session.lastStepStarted = stepName; session.failure = null; session.status = "in_progress"; + const state = machineStateFromOnboardSessionStep(stepName); + if (state) transitionMachineSnapshot(session, state, now); shouldEmit = true; return session; }); @@ -915,12 +1037,15 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "complete"; - step.completedAt = new Date().toISOString(); + step.completedAt = now; step.error = null; session.lastCompletedStep = stepName; session.failure = null; Object.assign(session, safeUpdates); + const nextState = nextMachineStateAfterCompletedStep(stepName, session); + if (nextState) transitionMachineSnapshot(session, nextState, now); shouldEmit = true; return session; }); @@ -968,15 +1093,17 @@ export function markStepFailed(stepName: string, message: string | null = null): const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; + const now = new Date().toISOString(); step.status = "failed"; step.completedAt = null; step.error = redactSensitiveText(message); session.failure = sanitizeFailure({ step: stepName, message, - recordedAt: new Date().toISOString(), + recordedAt: now, }); session.status = "failed"; + transitionMachineSnapshot(session, "failed", now); shouldEmit = true; return session; }); @@ -1006,11 +1133,13 @@ export function completeSession(updates: SessionUpdates = {}): Session { const safeUpdates = filterSafeUpdates(updates); let wasComplete = false; const updatedSession = updateSession((session) => { + const now = new Date().toISOString(); wasComplete = session.status === "complete"; Object.assign(session, safeUpdates); session.status = "complete"; session.resumable = false; session.failure = null; + transitionMachineSnapshot(session, "complete", now); return session; }); if (Object.keys(safeUpdates).length > 0) { @@ -1061,6 +1190,7 @@ export function summarizeForDebug( lastStepStarted: session.lastStepStarted, lastCompletedStep: session.lastCompletedStep, failure: sanitizeFailure(session.failure), + machine: session.machine, steps: Object.fromEntries( Object.entries(session.steps).map(([name, step]) => [ name,