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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,10 @@ release artifact. Current release versions are:

| Package | Version | Release role |
| --- | ---: | --- |
| `@openspec-ui/core` | 0.15.0 | Shared behavior and persistence contract |
| `openspec-ui-vscode` | 0.9.0 | VS Code delivery |
| `@openspec-ui/server` | 1.6.0 | Standalone server delivery |
| `@openspec-ui/webui` | 1.7.0 | Shared browser UI |
| `@openspec-ui/core` | 0.20.2 | Shared behavior and persistence contract |
| `openspec-ui-vscode` | 0.16.2 | VS Code delivery |
| `@openspec-ui/server` | 1.8.0 | Standalone server delivery |
| `@openspec-ui/webui` | 1.9.0 | Shared browser UI |
| `@openspec-ui/cli` | 0.1.0 | CI merge-gate delivery |

`@vscode/vsce` (the extension's packager) already names the built
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Context

Checkpoint capture currently excludes selected JavaScript build and dependency
directories, but not common Python generated state. Persisted checkpoints may
also contain paths that become excluded after an extension upgrade.

## Decisions

### Delegate repository ignore semantics to Git

For a Git workspace, checkpoint capture asks Git for tracked and untracked
non-ignored files using `git ls-files --cached --others --exclude-standard`.
This honors root and nested `.gitignore` files, negated patterns,
`.git/info/exclude`, and the user's global Git excludes. Tracked files remain
covered even when they match a later ignore rule. If the workspace is not in a
Git repository, capture falls back to the existing filesystem traversal and
the mandatory exact-name exclusions.

Git only returns paths; checkpoint code still validates each path, skips
symbolic links, enforces size limits, and reads file contents itself.

### Exclude generated state by exact basename

Directory exclusions use exact basenames for established generated locations:
`.cache`, `__pycache__`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`,
`.hypothesis`, `.tox`, `.nox`, `.venv`, and `venv`. File exclusions use exact
basenames for `.env` and `.eslintcache`. Exact matching avoids treating
application source directories containing words such as `cache` as generated
state.

### Sanitize checkpoints during deserialization

Deserialization filters excluded paths from `before`, `after`, and `delta`.
Both extension and standalone recovery already persist deserialized sessions,
so loading an existing journal rewrites it without excluded content. Workspace
files are not removed. Rollback intentionally stops covering excluded paths.

The serialized checkpoint version remains `1` because the representation does
not change and older version-1 documents remain readable.

## Risks

- A user who intentionally keeps source in an exactly named excluded directory
cannot restore it through workbench rollback. The exclusions are limited to
conventional generated-state names to reduce this risk.
- Historical `.env` data remains on disk until the upgraded extension activates
for that workspace and successfully rewrites its journal.
- Git-ignored files are intentionally outside rollback coverage. Teams should
not ignore source files that must be recoverable through workbench rollback.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

Workbench checkpoints scan the entire workspace before mutating operations.
Python virtual environments and generated caches can exceed checkpoint limits,
while `.env` contents can persist secrets that must never be stored in run
history.

## What Changes

- Exclude `.env`, generated cache directories, cache files, and local virtual
environments from new workbench checkpoints.
- Use the workspace repository's Git ignore rules so language- and
project-specific generated files are omitted without a hard-coded catalog.
- Remove those paths from checkpoints restored from existing run journals and
persist the sanitized journal without deleting workspace files.
- Cover new capture and migration behavior with core tests.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `persistent-workbench-runs`: Checkpoint capture and recovery omit sensitive
environment files and generated local state.

## Impact

- `packages/core/src/checkpoint.ts`
- `packages/core/src/checkpoint.test.ts`
- `packages/extension/CHANGELOG.md`
- `README.md` release version table
- Core and extension patch versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## ADDED Requirements

### Requirement: Checkpoint persistence excludes sensitive and generated state

The workbench SHALL omit sensitive environment files, generated caches, and
local virtual environments from checkpoint capture and rollback data.

#### Scenario: Capture a workspace containing generated local state

- **WHEN** a checkpoint is captured for a workspace containing `.env`, a local
virtual environment, or a supported generated cache directory
- **THEN** those paths are not read into the checkpoint
- **AND** ordinary workspace files remain covered by rollback

#### Scenario: Restore a historical checkpoint

- **WHEN** a version-1 run journal contains paths that are excluded by the
current checkpoint policy
- **THEN** recovery removes those paths from the checkpoint snapshots and delta
- **AND** the sanitized journal is persisted without deleting workspace files

#### Scenario: Capture a Git workspace with project-specific ignores

- **WHEN** a checkpoint is captured in a Git workspace with root or nested
`.gitignore` rules
- **THEN** untracked paths ignored by Git are omitted from the checkpoint
- **AND** negated untracked paths and tracked files remain covered

#### Scenario: Capture a workspace outside Git

- **WHEN** Git cannot enumerate files for the workspace
- **THEN** checkpoint capture falls back to filesystem traversal
- **AND** mandatory sensitive and generated-state exclusions still apply
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Checkpoint exclusions

- [x] 1.1 Exclude sensitive environment and generated cache paths before file
contents are read.
- [x] 1.2 Sanitize excluded paths from deserialized historical checkpoints.
- [x] 1.3 Add focused capture and migration tests.
- [x] 1.4 Honor repository Git ignore rules, including nested and negated
patterns, while retaining a non-Git fallback.
- [x] 1.5 Add focused Git-ignore checkpoint tests.

## 2. Release and verification

- [x] 2.1 Bump affected package patch versions.
- [x] 2.2 Run focused core tests and typecheck.
- [x] 2.3 Validate this OpenSpec change strictly.
- [x] 2.4 Bump core and extension patch versions for Git-ignore support.
- [x] 2.5 Re-run repository verification and strict change validation.
32 changes: 32 additions & 0 deletions openspec/specs/persistent-workbench-runs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,35 @@ in the setting's own description.
- **THEN** processes created before the cutoff, and their checkpoint
sessions, are removed together

### Requirement: Checkpoint persistence excludes sensitive and generated state

The workbench SHALL omit sensitive environment files, generated caches, and
local virtual environments from checkpoint capture and rollback data.

#### Scenario: Capture a workspace containing generated local state

- **WHEN** a checkpoint is captured for a workspace containing `.env`, a local
virtual environment, or a supported generated cache directory
- **THEN** those paths are not read into the checkpoint
- **AND** ordinary workspace files remain covered by rollback

#### Scenario: Restore a historical checkpoint

- **WHEN** a version-1 run journal contains paths that are excluded by the
current checkpoint policy
- **THEN** recovery removes those paths from the checkpoint snapshots and delta
- **AND** the sanitized journal is persisted without deleting workspace files

#### Scenario: Capture a Git workspace with project-specific ignores

- **WHEN** a checkpoint is captured in a Git workspace with root or nested
`.gitignore` rules
- **THEN** untracked paths ignored by Git are omitted from the checkpoint
- **AND** negated untracked paths and tracked files remain covered

#### Scenario: Capture a workspace outside Git

- **WHEN** Git cannot enumerate files for the workspace
- **THEN** checkpoint capture falls back to filesystem traversal
- **AND** mandatory sensitive and generated-state exclusions still apply

4 changes: 2 additions & 2 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions 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.20.0",
"version": "0.20.2",
"type": "module",
"main": "src/index.ts",
"exports": {
Expand All @@ -21,4 +21,4 @@
"@types/node": "^26.2.0",
"@types/cross-spawn": "^6.0.6"
}
}
}
87 changes: 87 additions & 0 deletions packages/core/src/checkpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import simpleGit from "simple-git";
import { afterEach, describe, expect, it } from "vitest";
import {
captureCheckpoint,
Expand Down Expand Up @@ -78,6 +79,67 @@ describe("Workbench checkpoints", () => {
expect(checkpoint.coverage.excludedDirectories).toContain("node_modules");
});

it("excludes environment files, virtual environments, and generated caches", async () => {
const root = await temporaryRoot();
await mkdir(path.join(root, ".venv"), { recursive: true });
await mkdir(path.join(root, ".mypy_cache"), { recursive: true });
await mkdir(path.join(root, "src", "__pycache__"), { recursive: true });
await writeFile(path.join(root, ".env"), "SECRET=value");
await writeFile(path.join(root, ".eslintcache"), "generated");
await writeFile(path.join(root, ".venv", "dependency.py"), "ignored");
await writeFile(path.join(root, ".mypy_cache", "state.json"), "ignored");
await writeFile(path.join(root, "src", "__pycache__", "module.pyc"), "ignored");
await writeFile(path.join(root, "tracked.txt"), "tracked");

const checkpoint = await captureCheckpoint(root);

expect([...checkpoint.before.keys()]).toEqual(["tracked.txt"]);
expect(checkpoint.coverage.skippedFiles).toEqual([".env", ".eslintcache"]);
expect(checkpoint.coverage.excludedDirectories).toEqual(expect.arrayContaining([
".mypy_cache",
".venv",
"__pycache__",
]));
});

it("honors nested Git ignore rules, negation, and tracked files", async () => {
const root = await temporaryRoot();
await mkdir(path.join(root, "src"), { recursive: true });
await writeFile(path.join(root, ".gitignore"), "*.cache\nsrc/generated/\n!keep.cache\n");
await writeFile(path.join(root, "ignored.cache"), "ignored");
await writeFile(path.join(root, "keep.cache"), "included");
await writeFile(path.join(root, "tracked.cache"), "tracked");
await writeFile(path.join(root, "src", ".gitignore"), "*.tmp\n!keep.tmp\n");
await writeFile(path.join(root, "src", "ignored.tmp"), "ignored");
await writeFile(path.join(root, "src", "keep.tmp"), "included");
await mkdir(path.join(root, "src", "generated"), { recursive: true });
await writeFile(path.join(root, "src", "generated", "output.bin"), "ignored");
const git = simpleGit(root);
await git.init();
await git.add([".gitignore", "src/.gitignore"]);
await git.raw(["add", "-f", "tracked.cache"]);

const checkpoint = await captureCheckpoint(root);

expect([...checkpoint.before.keys()]).toEqual([
".gitignore",
"keep.cache",
"src/.gitignore",
"src/keep.tmp",
"tracked.cache",
]);
});

it("falls back to filesystem traversal outside a Git repository", async () => {
const root = await temporaryRoot();
await writeFile(path.join(root, ".gitignore"), "ignored.txt\n");
await writeFile(path.join(root, "ignored.txt"), "included without Git");

const checkpoint = await captureCheckpoint(root);

expect([...checkpoint.before.keys()]).toEqual([".gitignore", "ignored.txt"]);
});

it("round-trips persisted checkpoints and rejects unsafe paths", async () => {
const root = await temporaryRoot();
await writeFile(path.join(root, "work.txt"), "before");
Expand All @@ -93,6 +155,31 @@ describe("Workbench checkpoints", () => {
serialized.before[0]!.path = "../outside.txt";
expect(() => deserializeCheckpoint(serialized)).toThrow("Invalid checkpoint path");
});

it("removes newly excluded paths from historical checkpoints", async () => {
const root = await temporaryRoot();
await writeFile(path.join(root, "tracked.txt"), "before");
const checkpoint = await captureCheckpoint(root);
await writeFile(path.join(root, "tracked.txt"), "after");
await finalizeCheckpoint(checkpoint);
const serialized = serializeCheckpoint(checkpoint);
const snapshot = serialized.before[0]!;
const afterSnapshot = serialized.after![0]!;
serialized.before.push({ ...snapshot, path: ".env" });
serialized.before.push({ ...snapshot, path: ".mypy_cache/cache.json" });
serialized.after!.push({ ...afterSnapshot, path: ".env" });
serialized.after!.push({ ...afterSnapshot, path: ".mypy_cache/cache.json" });
serialized.delta!.push({ path: ".env", kind: "modified" });
serialized.delta!.push({ path: ".mypy_cache/cache.json", kind: "modified" });

const restored = deserializeCheckpoint(serialized);

expect([...restored.before.keys()]).toEqual(["tracked.txt"]);
expect([...restored.after!.keys()]).toEqual(["tracked.txt"]);
expect(restored.delta?.map((item) => item.path)).toEqual(["tracked.txt"]);
expect(restored.coverage.skippedFiles).toContain(".env");
expect(restored.coverage.excludedDirectories).toContain(".mypy_cache");
});
});

describe("rollbackChangeCheckpoints", () => {
Expand Down
Loading
Loading