From a89595e5faca3361d006e5d47f6aec8f18ba0dff Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Thu, 13 Aug 2026 14:57:17 +0300 Subject: [PATCH] Add fail-closed journal diagnostics --- README.md | 6 +- .../0006-fail-closed-journal-compatibility.md | 53 ++++++++++++ docs/adr/README.md | 1 + .../journal-format-diagnostics/.openspec.yaml | 2 + .../journal-format-diagnostics/README.md | 3 + .../journal-format-diagnostics/design.md | 23 +++++ .../journal-format-diagnostics/proposal.md | 24 ++++++ .../specs/persistent-workbench-runs/spec.md | 24 ++++++ .../journal-format-diagnostics/tasks.md | 17 ++++ package-lock.json | 6 +- packages/core/package.json | 2 +- .../core/src/workbench-run-journal.test.ts | 58 ++++++++++++- packages/core/src/workbench-run-journal.ts | 86 +++++++++++++++++-- packages/extension/CHANGELOG.md | 5 ++ packages/extension/package.json | 2 +- packages/extension/src/extension.ts | 3 +- .../src/recovery-diagnostics.test.ts | 19 ++++ .../extension/src/recovery-diagnostics.ts | 4 + packages/server/package.json | 2 +- packages/server/src/server.test.ts | 22 ++++- 20 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0006-fail-closed-journal-compatibility.md create mode 100644 openspec/changes/journal-format-diagnostics/.openspec.yaml create mode 100644 openspec/changes/journal-format-diagnostics/README.md create mode 100644 openspec/changes/journal-format-diagnostics/design.md create mode 100644 openspec/changes/journal-format-diagnostics/proposal.md create mode 100644 openspec/changes/journal-format-diagnostics/specs/persistent-workbench-runs/spec.md create mode 100644 openspec/changes/journal-format-diagnostics/tasks.md create mode 100644 packages/extension/src/recovery-diagnostics.test.ts create mode 100644 packages/extension/src/recovery-diagnostics.ts diff --git a/README.md b/README.md index 7eaa30c5..48bc6dd9 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ release artifact. Current release versions are: | Package | Version | Release role | | --- | ---: | --- | -| `@openspec-ui/core` | 0.9.0 | Shared behavior and persistence contract | -| `openspec-ui-vscode` | 0.4.1 | VS Code delivery | -| `@openspec-ui/server` | 1.2.0 | Standalone server delivery | +| `@openspec-ui/core` | 0.10.0 | Shared behavior and persistence contract | +| `openspec-ui-vscode` | 0.4.2 | VS Code delivery | +| `@openspec-ui/server` | 1.2.1 | Standalone server delivery | | `@openspec-ui/webui` | 1.2.1 | Shared browser UI | ## Delivery Capability Matrix diff --git a/docs/adr/0006-fail-closed-journal-compatibility.md b/docs/adr/0006-fail-closed-journal-compatibility.md new file mode 100644 index 00000000..551686b1 --- /dev/null +++ b/docs/adr/0006-fail-closed-journal-compatibility.md @@ -0,0 +1,53 @@ +# 0006: Fail-Closed Journal Compatibility + +Status: Accepted + +Date: 2026-08-08 + +## Context + +ADR 0004 introduced a versioned run journal and serialized checkpoints. A +workspace can be opened by standalone and VS Code deliveries with different +bundled core versions. An older delivery may therefore encounter a newer +journal or checkpoint format that it cannot safely interpret. + +A generic load error preserves data but does not distinguish corruption from +an upgrade requirement. Moving an unsupported journal aside would let the +older delivery create a fresh journal at the original path, splitting process +history and making later recovery ambiguous. + +## Decision + +1. Unknown journal and checkpoint versions fail closed before recovery state is used. +2. Compatibility failures never rename, rewrite, quarantine, or delete the persisted journal. +3. Core exposes structured load diagnostics with a stable code, journal path, + discovered version, and supported version where applicable. +4. Human-readable diagnostics state whether the user should upgrade the delivery + or inspect a corrupted journal. Hosts may adapt presentation but do not infer + compatibility from error-message text. +5. A future schema change must add an explicit migration path before core writes + a newer version. Migration must be covered by fixtures for every supported + source version and preserve the last valid source until replacement succeeds. + +## Rejected Alternatives + +### Rename future journals to a quarantine path + +Rejected because the older host could then create a new journal and split the +workspace's process history across two files. + +### Best-effort parsing of unknown versions + +Rejected because recovery and rollback are destructive capabilities. Missing or +reinterpreted fields must not silently change their behavior. + +### Treat every load error as corruption + +Rejected because a valid future version requires an upgrade, not repair or deletion. + +## Consequences + +- Older deliveries preserve future journal data and provide actionable diagnostics. +- Recovery remains unavailable until a compatible delivery is used. +- Schema evolution requires deliberate migrations instead of permissive parsing. +- Hosts can test and render compatibility failures without matching message text. \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md index dd8b95c3..3782fb34 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,5 +12,6 @@ Alternatives / Consequences. | [0003](0003-native-vscode-openspec-workbench.md) | Native VS Code OpenSpec Workbench | Accepted | | [0004](0004-persistent-workbench-runs.md) | Persistent Workbench runs and delivery parity | Accepted | | [0005](0005-authenticated-local-transport.md) | Authenticated local REST and WebSocket transport | Accepted | +| [0006](0006-fail-closed-journal-compatibility.md) | Fail-closed journal compatibility | Accepted | New architecture-impacting changes must add an ADR and reference it from the related OpenSpec change. \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/.openspec.yaml b/openspec/changes/journal-format-diagnostics/.openspec.yaml new file mode 100644 index 00000000..0907676c --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-08 \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/README.md b/openspec/changes/journal-format-diagnostics/README.md new file mode 100644 index 00000000..1fe0ee90 --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/README.md @@ -0,0 +1,3 @@ +# Journal Format Diagnostics + +This change adds structured, fail-closed diagnostics for persisted run journal compatibility. \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/design.md b/openspec/changes/journal-format-diagnostics/design.md new file mode 100644 index 00000000..dc35b7dc --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/design.md @@ -0,0 +1,23 @@ +## Context + +The journal contains recovery and rollback state shared by delivery targets. +Compatibility errors must be machine-readable while retaining safe default +behavior for callers that only display `Error.message`. + +## Decisions + +- Export `WorkbenchJournalLoadError` from core with stable diagnostic codes. +- Include the journal path on every load diagnostic. +- Include discovered and supported versions for format compatibility failures. +- Validate journal and checkpoint version discriminators before deserialization. +- Preserve the existing missing-file behavior: a missing journal is an empty journal. +- Do not mutate the journal in any load failure path. + +## Trade-offs + +- Recovery remains disabled for unknown versions rather than attempting partial reads. +- Structured diagnostics add a public core type that must remain backward compatible. + +## Architecture + +ADR 0006 defines the fail-closed and no-rewrite policy. \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/proposal.md b/openspec/changes/journal-format-diagnostics/proposal.md new file mode 100644 index 00000000..775f5413 --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/proposal.md @@ -0,0 +1,24 @@ +## Why + +Older deliveries can encounter valid journals or checkpoints written by a newer +core. Today those failures are generic, so hosts cannot distinguish an upgrade +requirement from malformed persisted data without parsing error text. + +## What Changes + +- Define a fail-closed journal compatibility policy in ADR 0006. +- Add typed core diagnostics for invalid JSON, invalid shape, future journal + versions, future checkpoint versions, and workspace mismatches. +- Preserve incompatible journal bytes and path without quarantine or rewriting. +- Surface actionable diagnostics through existing standalone and extension error paths. + +## Impact + +- `packages/core`: structured journal load errors and compatibility tests. +- `packages/server`: ships improved recovery diagnostics from core. +- `packages/extension`: ships improved recovery diagnostics from core. +- `docs/adr`: persisted-format evolution policy. + +## Architecture + +This change is governed by ADR 0006. \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/specs/persistent-workbench-runs/spec.md b/openspec/changes/journal-format-diagnostics/specs/persistent-workbench-runs/spec.md new file mode 100644 index 00000000..be823fc6 --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/specs/persistent-workbench-runs/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Run recovery fails closed for incompatible persisted formats +The system SHALL reject unsupported journal and checkpoint versions without +moving, rewriting, or deleting the persisted journal. + +#### Scenario: Older delivery opens a future journal version +- **WHEN** the persisted journal version is newer than the bundled core supports +- **THEN** recovery fails with an upgrade-required diagnostic +- **AND** the journal remains byte-for-byte unchanged at its original path + +#### Scenario: Journal contains a future checkpoint version +- **WHEN** the journal version is supported but a checkpoint version is not +- **THEN** recovery fails with a checkpoint compatibility diagnostic +- **AND** the journal remains byte-for-byte unchanged + +#### Scenario: Journal JSON is malformed +- **WHEN** the journal cannot be parsed as JSON +- **THEN** recovery fails with a corruption diagnostic distinct from version compatibility + +#### Scenario: Host presents a recovery failure +- **WHEN** core rejects persisted recovery state +- **THEN** the host displays the actionable core diagnostic +- **AND** does not infer compatibility by parsing human-readable error text \ No newline at end of file diff --git a/openspec/changes/journal-format-diagnostics/tasks.md b/openspec/changes/journal-format-diagnostics/tasks.md new file mode 100644 index 00000000..d519d53c --- /dev/null +++ b/openspec/changes/journal-format-diagnostics/tasks.md @@ -0,0 +1,17 @@ +## 1. Core Diagnostics + +- [x] 1.1 Add stable journal load diagnostic codes and metadata. +- [x] 1.2 Detect unsupported journal and checkpoint versions before deserialization. +- [x] 1.3 Test corruption, future versions, workspace mismatch, and byte preservation. + +## 2. Delivery Behavior + +- [x] 2.1 Confirm standalone recovery returns actionable core diagnostics. +- [x] 2.2 Confirm the extension recovery-disabled path displays actionable diagnostics. +- [x] 2.3 Bump affected package versions and update release documentation. + +## 3. Verification + +- [x] 3.1 Run focused core, server, and extension tests. +- [x] 3.2 Run workspace verify/build and strict OpenSpec validation. +- [x] 3.3 Run live future-journal diagnostics smoke through the standalone server. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 47de11c3..d5462949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10773,7 +10773,7 @@ }, "packages/core": { "name": "@openspec-ui/core", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "cross-spawn": "^7.0.6", "simple-git": "^3.27.0" @@ -10785,7 +10785,7 @@ }, "packages/extension": { "name": "openspec-ui-vscode", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "@openspec-ui/core": "*", @@ -11240,7 +11240,7 @@ }, "packages/server": { "name": "@openspec-ui/server", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@openspec-ui/core": "*", "ws": "^8.21.0" diff --git a/packages/core/package.json b/packages/core/package.json index c37659fd..0f8605bb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.9.0", + "version": "0.10.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/workbench-run-journal.test.ts b/packages/core/src/workbench-run-journal.test.ts index ef6e6d42..0d15ccbb 100644 --- a/packages/core/src/workbench-run-journal.test.ts +++ b/packages/core/src/workbench-run-journal.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { captureCheckpoint, serializeCheckpoint } from "./checkpoint.js"; -import { WorkbenchRunJournal } from "./workbench-run-journal.js"; +import { + WorkbenchJournalLoadError, + WorkbenchRunJournal, +} from "./workbench-run-journal.js"; const roots: string[] = []; @@ -81,7 +84,58 @@ describe("WorkbenchRunJournal", () => { const unsupported = JSON.stringify({ version: 99, processes: [], checkpointSessions: [] }); await writeFile(journal.filePath, unsupported, "utf8"); - await expect(journal.load()).rejects.toThrow("Unsupported Workbench run journal version: 99"); + const error = await journal.load().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(WorkbenchJournalLoadError); + expect(error).toMatchObject({ + code: "unsupported-journal-version", + journalPath: journal.filePath, + foundVersion: 99, + supportedVersion: 1, + }); + expect((error as Error).message).toContain("Upgrade OpenSpec UI"); + expect(await readFile(journal.filePath, "utf8")).toBe(unsupported); + }); + + it("distinguishes unsupported checkpoint versions without replacing the journal", async () => { + const root = await temporaryRoot(); + const checkpoint = serializeCheckpoint(await captureCheckpoint(root)); + const journal = new WorkbenchRunJournal(root); + await journal.save({ processes: [], checkpointSessions: [] }); + const unsupported = JSON.stringify({ + version: 1, + processes: [{ id: "run-1", operation: "implement", mutating: true, state: "interrupted" }], + checkpointSessions: [{ + processId: "run-1", + checkpoint: { ...checkpoint, version: 99 }, + }], + }); + await writeFile(journal.filePath, unsupported, "utf8"); + + const error = await journal.load().catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: "unsupported-checkpoint-version", + journalPath: journal.filePath, + foundVersion: 99, + supportedVersion: 1, + }); expect(await readFile(journal.filePath, "utf8")).toBe(unsupported); }); + + it("reports workspace mismatches as structured diagnostics without replacing the journal", async () => { + const root = await temporaryRoot(); + const otherRoot = await temporaryRoot(); + const checkpoint = serializeCheckpoint(await captureCheckpoint(otherRoot)); + const journal = new WorkbenchRunJournal(root); + await journal.save({ processes: [], checkpointSessions: [] }); + const mismatched = JSON.stringify({ + version: 1, + processes: [{ id: "run-1", operation: "implement", mutating: true, state: "interrupted" }], + checkpointSessions: [{ processId: "run-1", checkpoint }], + }); + await writeFile(journal.filePath, mismatched, "utf8"); + + const error = await journal.load().catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: "workspace-mismatch", journalPath: journal.filePath }); + expect(await readFile(journal.filePath, "utf8")).toBe(mismatched); + }); }); diff --git a/packages/core/src/workbench-run-journal.ts b/packages/core/src/workbench-run-journal.ts index 1d601124..ef4e9550 100644 --- a/packages/core/src/workbench-run-journal.ts +++ b/packages/core/src/workbench-run-journal.ts @@ -28,6 +28,38 @@ export interface WorkbenchRunJournalOptions { maxProcesses?: number; } +export type WorkbenchJournalLoadErrorCode = + | "invalid-json" + | "invalid-shape" + | "unsupported-journal-version" + | "unsupported-checkpoint-version" + | "invalid-checkpoint" + | "workspace-mismatch"; + +export interface WorkbenchJournalLoadErrorDetails { + code: WorkbenchJournalLoadErrorCode; + journalPath: string; + foundVersion?: unknown; + supportedVersion?: number; + cause?: unknown; +} + +export class WorkbenchJournalLoadError extends Error { + readonly code: WorkbenchJournalLoadErrorCode; + readonly journalPath: string; + readonly foundVersion?: unknown; + readonly supportedVersion?: number; + + constructor(message: string, details: WorkbenchJournalLoadErrorDetails) { + super(message, { cause: details.cause }); + this.name = "WorkbenchJournalLoadError"; + this.code = details.code; + this.journalPath = details.journalPath; + this.foundVersion = details.foundVersion; + this.supportedVersion = details.supportedVersion; + } +} + function emptyJournal(): WorkbenchRunJournalData { return { processes: [], checkpointSessions: [] }; } @@ -58,21 +90,63 @@ export class WorkbenchRunJournal { let document: WorkbenchRunJournalDocument; try { document = JSON.parse(source) as WorkbenchRunJournalDocument; - } catch { - throw new Error(`Workbench run journal is not valid JSON: ${this.filePath}`); + } catch (error) { + throw new WorkbenchJournalLoadError( + `Workbench run journal is not valid JSON. Inspect or restore ${this.filePath}.`, + { code: "invalid-json", journalPath: this.filePath, cause: error }, + ); } if (document.version !== WORKBENCH_RUN_JOURNAL_VERSION) { - throw new Error(`Unsupported Workbench run journal version: ${String(document.version)}`); + throw new WorkbenchJournalLoadError( + `Workbench run journal version ${String(document.version)} is not supported by this OpenSpec UI version. Upgrade OpenSpec UI to recover runs.`, + { + code: "unsupported-journal-version", + journalPath: this.filePath, + foundVersion: document.version, + supportedVersion: WORKBENCH_RUN_JOURNAL_VERSION, + }, + ); } if (!Array.isArray(document.processes) || !Array.isArray(document.checkpointSessions)) { - throw new Error(`Workbench run journal has an invalid shape: ${this.filePath}`); + throw new WorkbenchJournalLoadError( + `Workbench run journal has an invalid shape. Inspect or restore ${this.filePath}.`, + { code: "invalid-shape", journalPath: this.filePath }, + ); } const resolvedRoot = path.resolve(this.root); for (const session of document.checkpointSessions) { - const checkpoint = deserializeCheckpoint(session.checkpoint); + if (!session || typeof session !== "object" || !session.checkpoint || typeof session.checkpoint !== "object") { + throw new WorkbenchJournalLoadError( + `Workbench run journal contains an invalid checkpoint session. Inspect or restore ${this.filePath}.`, + { code: "invalid-shape", journalPath: this.filePath }, + ); + } + if (session.checkpoint.version !== 1) { + throw new WorkbenchJournalLoadError( + `Workbench checkpoint version ${String(session.checkpoint.version)} is not supported by this OpenSpec UI version. Upgrade OpenSpec UI to recover runs.`, + { + code: "unsupported-checkpoint-version", + journalPath: this.filePath, + foundVersion: session.checkpoint.version, + supportedVersion: 1, + }, + ); + } + let checkpoint; + try { + checkpoint = deserializeCheckpoint(session.checkpoint); + } catch (error) { + throw new WorkbenchJournalLoadError( + `Workbench run journal contains an invalid checkpoint. Inspect or restore ${this.filePath}.`, + { code: "invalid-checkpoint", journalPath: this.filePath, cause: error }, + ); + } if (checkpoint.root !== resolvedRoot) { - throw new Error(`Checkpoint root does not match journal workspace: ${checkpoint.root}`); + throw new WorkbenchJournalLoadError( + `Checkpoint root ${checkpoint.root} does not match journal workspace ${resolvedRoot}. Open the journal from its original workspace.`, + { code: "workspace-mismatch", journalPath: this.filePath }, + ); } } return { diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 5da3a8d7..2236a8e6 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.4.2 + +- Added actionable, fail-closed diagnostics when persisted run journals or + checkpoints require a newer OpenSpec UI version. + ## 0.4.1 - Authenticated optional local-server sessions with an ephemeral token passed diff --git a/packages/extension/package.json b/packages/extension/package.json index b3a9ea20..6b68f663 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,7 +3,7 @@ "displayName": "OpenSpec Workbench", "description": "Native OpenSpec workflow with lifecycle controls, VS Code Chat, persistent processes, checkpoints, and conflict-safe rollback.", "publisher": "openspec-ui", - "version": "0.4.1", + "version": "0.4.2", "icon": "media/icon.png", "license": "MIT", "repository": { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 96f120da..1a8d1edd 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -17,6 +17,7 @@ import { registerOpenSpecChatParticipant } from "./chat-participant.js"; import { AiPanel } from "./webview/ai-panel.js"; import type { AiPanelContext } from "./webview/ai-panel.js"; import { OptionalServerManager } from "./optional-server.js"; +import { recoveryDisabledMessage } from "./recovery-diagnostics.js"; let runners: Map | undefined; let optionalServer: OptionalServerManager | undefined; @@ -45,7 +46,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { + it("presents actionable core compatibility diagnostics without parsing message text", () => { + const error = new WorkbenchJournalLoadError( + "Workbench run journal version 2 is not supported. Upgrade OpenSpec UI to recover runs.", + { + code: "unsupported-journal-version", + journalPath: "/workspace/.openspec-ui/workbench-runs.json", + foundVersion: 2, + supportedVersion: 1, + }, + ); + + expect(recoveryDisabledMessage(error)).toContain("Upgrade OpenSpec UI to recover runs"); + }); +}); \ No newline at end of file diff --git a/packages/extension/src/recovery-diagnostics.ts b/packages/extension/src/recovery-diagnostics.ts new file mode 100644 index 00000000..c84615f0 --- /dev/null +++ b/packages/extension/src/recovery-diagnostics.ts @@ -0,0 +1,4 @@ +export function recoveryDisabledMessage(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return `OpenSpec UI: run recovery disabled (${detail}).`; +} \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 4afd715f..80e9be43 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/server", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/server/src/server.test.ts b/packages/server/src/server.test.ts index 22acbc4a..7fade811 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -5,7 +5,7 @@ // за отдельным живым smoke-тестом с реальным агентом). import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import WebSocket from "ws"; @@ -357,6 +357,26 @@ describe("server — REST /api/status", () => { expect(await cleaned.json()).toEqual({ removed: 1, retained: 0 }); expect((await new WorkbenchRunJournal(cwd).load()).processes).toEqual([]); }); + + it("returns actionable diagnostics without replacing a future run journal", async () => { + const cwd = await createTempWorkspace(); + const journal = new WorkbenchRunJournal(cwd); + await journal.save({ processes: [], checkpointSessions: [] }); + const futureJournal = JSON.stringify({ version: 99, processes: [], checkpointSessions: [] }); + await writeFile(journal.filePath, futureJournal, "utf8"); + + const response = await fetch(`${baseUrl}/api/processes/list`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd }), + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: expect.stringContaining("Upgrade OpenSpec UI to recover runs"), + }); + expect(await readFile(journal.filePath, "utf8")).toBe(futureJournal); + }); }); describe("server — WebSocket /api/ws", () => {