fix(canvas): start board state fresh in the session workspace

Drop the legacy extension-folder state import; boards now start empty in session.workspacePath.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 545df520-6557-4211-a110-53c60f4b9f3a
This commit is contained in:
Aaron Powell
2026-10-12 13:37:10 +11:00
co-authored by Copilot App
parent f4319559ab
commit 758f4382db
7 changed files with 28 additions and 79 deletions
@@ -30,10 +30,8 @@ This is Copilot's session-state workspace, not the repository checkout. It store
metadata, AI reviews, manual bucket overrides, and a history of decisions made from the board.
State survives extension reloads and resuming the same session; a new session has its own board.
Infinite sessions must be enabled to provide the workspace. Keeping writes outside the extension
folder avoids invalidating its safety fingerprint.
If no session state exists, the board imports the legacy `state/board.json` next to this file when
present, without modifying it. Existing session state always takes precedence.
folder avoids invalidating its safety fingerprint. State previously stored in the extension folder is
not imported; boards start fresh.
## Requirements
@@ -47,4 +45,4 @@ present, without modifying it. Existing session state always takes precedence.
## Regression tests
Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` from the repository root
to check both canvases' session-scoped persistence and legacy imports without accessing GitHub.
to check both canvases' session-scoped persistence without accessing GitHub.
@@ -358,4 +358,4 @@ if (!session.workspacePath) {
store = await new BoardStore(
path.join(session.workspacePath, "extensions", CANVAS_ID, "state", "board.json"),
REPO,
).load({ legacyFile: path.join(EXT_DIR, "state", "board.json") });
).load();
@@ -41,25 +41,14 @@ export class BoardStore {
this.writeChain = Promise.resolve();
}
async load({ legacyFile } = {}) {
let raw;
let imported = false;
async load() {
try {
raw = await readFile(this.filePath, "utf8");
const raw = await readFile(this.filePath, "utf8");
const parsed = JSON.parse(raw);
this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo };
} catch (error) {
if (error.code !== "ENOENT") throw error;
if (!legacyFile) return this;
try {
raw = await readFile(legacyFile, "utf8");
imported = true;
} catch (error) {
if (error.code !== "ENOENT") throw error;
return this;
}
}
const parsed = JSON.parse(raw);
this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo };
if (imported) await this.commit();
return this;
}
+2 -5
View File
@@ -52,10 +52,7 @@ file per repository. This is Copilot's session-state workspace, not the reposito
checkout. State survives extension reloads and resuming the same session; a new
session has its own board. Infinite sessions must be enabled to provide the workspace.
Keeping writes outside the extension folder avoids invalidating its safety fingerprint.
If no session state exists, the board imports the legacy
`.github/extensions/open-pr-board/state/<owner>__<repo>.json` file when present,
without modifying it. Existing session state always takes precedence.
State previously stored in the extension folder is not imported; boards start fresh.
Use **Reset board** (or the `reset_board` action) to clear reviews and history without
touching GitHub.
@@ -88,4 +85,4 @@ Results arriving after a manual move, reset, or replacement review are rejected.
Run `node --test eng/open-pr-board.test.mjs` from the repository root. GitHub writes
are tested with injected in-process fakes, never the authenticated CLI.
Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` to check
both canvases' session-scoped persistence and legacy imports without accessing GitHub.
both canvases' session-scoped persistence without accessing GitHub.
@@ -43,8 +43,7 @@ async function getStore(repo) {
// The repo is validated against REPO_PATTERN above, so it cannot escape the state directory.
const filename = `${repo.replace("/", "__")}.json`;
const file = path.join(session.workspacePath, "extensions", CANVAS_ID, "state", filename);
const legacyFile = path.join(EXT_DIR, "state", filename);
store = await new BoardStore({ file, repo }).load({ legacyFile });
store = await new BoardStore({ file, repo }).load();
stores.set(repo, store);
}
return store;
+9 -20
View File
@@ -68,30 +68,19 @@ export class BoardStore {
return () => this.listeners.delete(listener);
}
async load({ legacyFile } = {}) {
let raw;
let imported = false;
async load() {
try {
raw = await readFile(this.file, "utf8");
const raw = await readFile(this.file, "utf8");
const parsed = JSON.parse(raw);
this.state = {
repo: this.repo,
updatedAt: parsed.updatedAt ?? null,
items: parsed.items && typeof parsed.items === "object" ? parsed.items : {},
history: Array.isArray(parsed.history) ? parsed.history : [],
};
} catch (error) {
if (error.code !== "ENOENT") throw error;
if (!legacyFile) return this;
try {
raw = await readFile(legacyFile, "utf8");
imported = true;
} catch (error) {
if (error.code !== "ENOENT") throw error;
return this;
}
}
const parsed = JSON.parse(raw);
this.state = {
repo: this.repo,
updatedAt: parsed.updatedAt ?? null,
items: parsed.items && typeof parsed.items === "object" ? parsed.items : {},
history: Array.isArray(parsed.history) ? parsed.history : [],
};
if (imported) await this.persist();
return this;
}
+8 -31
View File
@@ -116,22 +116,17 @@ for (const id of ["open-pr-board", "external-plugin-review-board"]) {
assert.deepEqual(await fingerprint(f.extensionDir), before);
});
test(`${id} imports legacy state without changing it and prefers session state thereafter`, async (t) => {
test(`${id} starts fresh and ignores state stored in the extension folder`, async (t) => {
const f = await fixture(t, id);
const legacy = {
await writeJSON(f.legacyFile, {
version: 1, repo, items: { "1": { ...item, manualColumn: column, reviewStatus: "reviewed" } },
history: [{ number: 1, comment: "Preserved history" }],
};
await writeJSON(f.legacyFile, legacy);
history: [{ number: 1, comment: "Old history" }],
});
const before = await fingerprint(f.extensionDir);
const act = await loadCanvas(f);
assert.equal((await act("get_board")).items[0].manualOverride, column);
const imported = JSON.parse(await readFile(f.stateFile, "utf8"));
assert.deepEqual(imported.items, legacy.items);
assert.deepEqual(imported.history, legacy.history);
await act("move_item", { number: 1, column: "unreviewed" });
const reloaded = await loadCanvas(f);
assert.equal((await reloaded("get_board")).items[0].manualOverride, null);
assert.equal((await act("get_board")).items.length, 0);
await act("refresh");
assert.equal((await act("get_board")).items[0].manualOverride, null);
assert.deepEqual(await fingerprint(f.extensionDir), before);
});
@@ -142,9 +137,8 @@ for (const id of ["open-pr-board", "external-plugin-review-board"]) {
assert.deepEqual(await fingerprint(f.extensionDir), before);
});
test(`${id} surfaces corrupt session state instead of falling back to legacy data`, async (t) => {
test(`${id} surfaces corrupt session state`, async (t) => {
const f = await fixture(t, id);
await writeJSON(f.legacyFile, { repo, items: {}, history: [] });
await mkdir(path.dirname(f.stateFile), { recursive: true });
await writeFile(f.stateFile, "not json", "utf8");
await assert.rejects(async () => {
@@ -152,23 +146,6 @@ for (const id of ["open-pr-board", "external-plugin-review-board"]) {
await act("get_board");
}, /JSON|Unexpected token/);
});
test(`${id} surfaces legacy parse and migration write failures`, async (t) => {
const f = await fixture(t, id);
await mkdir(path.dirname(f.legacyFile), { recursive: true });
await writeFile(f.legacyFile, "not json", "utf8");
await assert.rejects(async () => {
const act = await loadCanvas(f);
await act("get_board");
}, /JSON|Unexpected token/);
await writeJSON(f.legacyFile, { repo, items: {}, history: [] });
await writeFile(f.workspacePath, "not a directory", "utf8");
await assert.rejects(async () => {
const act = await loadCanvas(f);
await act("get_board");
}, /ENOTDIR|ENOENT|EEXIST/);
});
}
test("both canvases keep independent boards in the same session workspace", async (t) => {