Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions docs/adr/0006-fail-closed-journal-compatibility.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions openspec/changes/journal-format-diagnostics/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-08
3 changes: 3 additions & 0 deletions openspec/changes/journal-format-diagnostics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Journal Format Diagnostics

This change adds structured, fail-closed diagnostics for persisted run journal compatibility.
23 changes: 23 additions & 0 deletions openspec/changes/journal-format-diagnostics/design.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions openspec/changes/journal-format-diagnostics/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions openspec/changes/journal-format-diagnostics/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
58 changes: 56 additions & 2 deletions packages/core/src/workbench-run-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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);
});
});
86 changes: 80 additions & 6 deletions packages/core/src/workbench-run-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading