diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a05c9f16da..98a49eea55 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6389,6 +6389,7 @@ const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardR const recordStepFailed = onboardRuntimeBoundary.recordStepFailed.bind(onboardRuntimeBoundary); const recordStateSkipped = onboardRuntimeBoundary.recordStateSkipped.bind(onboardRuntimeBoundary); const recordRepairEvent = onboardRuntimeBoundary.recordRepairEvent.bind(onboardRuntimeBoundary); +const recordResumeConflict = onboardRuntimeBoundary.recordResumeConflict.bind(onboardRuntimeBoundary); const recordPostVerifyStarted = onboardRuntimeBoundary.recordPostVerifyStarted.bind(onboardRuntimeBoundary); const recordSessionComplete = onboardRuntimeBoundary.recordSessionComplete.bind(onboardRuntimeBoundary); @@ -6598,6 +6599,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); if (resumeConflicts.length > 0) { for (const conflict of resumeConflicts) { + await recordResumeConflict(conflict); if (conflict.field === "sandbox") { console.error( ` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.`, diff --git a/src/lib/onboard/machine/runtime.test.ts b/src/lib/onboard/machine/runtime.test.ts index f098ba0dc3..d48da85e0a 100644 --- a/src/lib/onboard/machine/runtime.test.ts +++ b/src/lib/onboard/machine/runtime.test.ts @@ -209,6 +209,24 @@ describe("OnboardRuntime", () => { expect(events[1]).toMatchObject({ state: "post_verify" }); }); + it("emits redacted resume conflict events without mutating durable state", async () => { + const { runtime, events, getSession } = createHarness(sessionInState("provider_selection")); + + await runtime.emitResumeConflict({ + field: "fromDockerfile", + recorded: "/workspace/Dockerfile", + requested: "/tmp/Dockerfile", + metadata: { endpoint: "https://alice:secret@example.com/v1?token=super-secret" }, + }); + + expect(getSession().machine.state).toBe("provider_selection"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "resume.conflict", state: "provider_selection" }); + expect(events[0].metadata.field).toBe("fromDockerfile"); + expect(JSON.stringify(events)).not.toContain("super-secret"); + expect(JSON.stringify(events)).not.toContain("alice:secret"); + }); + it("emits skipped and repair events without mutating durable state", async () => { const { runtime, events, getSession } = createHarness(sessionInState("provider_selection")); diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts index 2e5d584f3b..65516c3212 100644 --- a/src/lib/onboard/machine/runtime.ts +++ b/src/lib/onboard/machine/runtime.ts @@ -243,6 +243,25 @@ export class OnboardRuntime { return session; } + async emitResumeConflict(options: { + field: string; + recorded?: unknown; + requested?: unknown; + metadata?: Record | null; + }): Promise { + const session = this.ensureSession(); + this.emit("resume.conflict", session, { + state: session.machine.state, + metadata: { + ...eventMetadata(options.metadata), + field: options.field, + recorded: options.recorded ?? null, + requested: options.requested ?? null, + }, + }); + return session; + } + async emitRepairEvent( type: Extract< OnboardMachineEventType, diff --git a/src/lib/onboard/runtime-boundary.test.ts b/src/lib/onboard/runtime-boundary.test.ts index d81116ed86..21d6f1083e 100644 --- a/src/lib/onboard/runtime-boundary.test.ts +++ b/src/lib/onboard/runtime-boundary.test.ts @@ -91,4 +91,25 @@ describe("OnboardRuntimeBoundary", () => { expect(harness.events[0]).toMatchObject({ state: "init" }); expect(harness.events[1]).toMatchObject({ state: "init" }); }); + + it("records resume conflict diagnostics through the runtime", async () => { + const harness = createRuntimeHarness(); + const boundary = new OnboardRuntimeBoundary({ + toSessionUpdates: (updates) => filterSafeUpdates(updates as SessionUpdates) as SessionUpdates, + maybeForceE2eStepFailure: () => undefined, + createRuntime: harness.createRuntime, + }); + + await boundary.recordResumeConflict({ + field: "sandbox", + recorded: "old-sandbox", + requested: "new-sandbox", + }); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0]).toMatchObject({ + type: "resume.conflict", + metadata: { field: "sandbox", recorded: "old-sandbox", requested: "new-sandbox" }, + }); + }); }); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index e90166e17b..e2306e3ce5 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -37,6 +37,7 @@ export class OnboardRuntimeBoundary { recordStepSkipped: this.recordStepSkipped.bind(this), recordStateSkipped: this.recordStateSkipped.bind(this), recordRepairEvent: this.recordRepairEvent.bind(this), + recordResumeConflict: this.recordResumeConflict.bind(this), recordStepFailed: this.recordStepFailed.bind(this), recordPostVerifyStarted: this.recordPostVerifyStarted.bind(this), recordSessionComplete: this.recordSessionComplete.bind(this), @@ -83,6 +84,15 @@ export class OnboardRuntimeBoundary { return this.getRuntime().markSkipped(state, metadata); } + async recordResumeConflict(conflict: { + field: string; + recorded?: unknown; + requested?: unknown; + metadata?: Record | null; + }): Promise { + return this.getRuntime().emitResumeConflict(conflict); + } + async recordRepairEvent( type: Extract< OnboardMachineEventType,