From f4319559ab6f0f2c1727b8bb3ba91a5736e77e2c Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 12 Oct 2026 12:19:43 +1100 Subject: [PATCH 1/3] fix(canvas): persist board state in the session workspace Keep runtime writes outside extension folders to avoid invalidating their safety fingerprints. Import legacy board state without modifying it and document session isolation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 545df520-6557-4211-a110-53c60f4b9f3a --- .../external-plugin-review-board/README.md | 15 +- .../extension.mjs | 15 +- .../lib/state.mjs | 19 +- .github/extensions/open-pr-board/README.md | 17 +- .../extensions/open-pr-board/extension.mjs | 15 +- .../extensions/open-pr-board/lib/state.mjs | 29 ++- eng/board-state-storage.test.mjs | 187 ++++++++++++++++++ 7 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 eng/board-state-storage.test.mjs diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index 5b3121fae..3524265a0 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -24,8 +24,16 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external ## State -Board state lives in `state/board.json` next to this file and is gitignored. It stores the synced issue +Board state lives in +`/extensions/external-plugin-review-board/state/board.json`. +This is Copilot's session-state workspace, not the repository checkout. It stores the synced issue 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. ## Requirements @@ -35,3 +43,8 @@ metadata, AI reviews, manual bucket overrides, and a history of decisions made f ## Agent actions `get_board`, `refresh`, `start_review`, `start_rereview`, `record_review`, `move_item`, `get_issue`. + +## 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. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 7a839704e..89b0ed4e6 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -1,7 +1,7 @@ // Extension: external-plugin-review-board // Kanban board for triaging external plugin submissions with AI review, -// quick approve/reject, and an issue detail view. Board state is persisted to -// ./state/board.json (gitignored) so it survives reloads and new sessions. +// quick approve/reject, and an issue detail view. Board state is persisted under +// session.workspacePath so writes do not change the extension fingerprint. import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,7 +18,7 @@ const PUBLIC_DIR = path.join(EXT_DIR, "public"); const GUIDANCE_PATH = path.join(EXT_DIR, "review-guidance.md"); const DETAIL_TTL_MS = 2 * 60 * 1000; -const store = await new BoardStore(path.join(EXT_DIR, "state", "board.json"), REPO).load(); +let store; const servers = new Map(); const detailCache = new Map(); let refreshInFlight = null; @@ -330,6 +330,7 @@ session = await joinSession({ }, }, }); + servers.set(ctx.instanceId, entry); } if (!store.state.lastRefreshedAt) { @@ -350,3 +351,11 @@ session = await joinSession({ }), ], }); + +if (!session.workspacePath) { + throw new Error("External Plugin Review Board requires a session workspace for persistent state. Enable infinite sessions."); +} +store = await new BoardStore( + path.join(session.workspacePath, "extensions", CANVAS_ID, "state", "board.json"), + REPO, +).load({ legacyFile: path.join(EXT_DIR, "state", "board.json") }); diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index f42a99521..ca1e2e21d 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -41,14 +41,25 @@ export class BoardStore { this.writeChain = Promise.resolve(); } - async load() { + async load({ legacyFile } = {}) { + let raw; + let imported = false; try { - const raw = await readFile(this.filePath, "utf8"); - const parsed = JSON.parse(raw); - this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo }; + raw = await readFile(this.filePath, "utf8"); } 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; } diff --git a/.github/extensions/open-pr-board/README.md b/.github/extensions/open-pr-board/README.md index ebe6e972a..8299398d5 100644 --- a/.github/extensions/open-pr-board/README.md +++ b/.github/extensions/open-pr-board/README.md @@ -46,9 +46,18 @@ branch-protection rules still apply. ## State -Board state lives in `.github/extensions/open-pr-board/state/__.json`, one -file per repository, and is git-ignored. Use **Reset board** (or the `reset_board` -action) to clear reviews and history without touching GitHub. +Board state lives in +`/extensions/open-pr-board/state/__.json`, one +file per repository. This is Copilot's session-state workspace, not the repository +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/__.json` file when present, +without modifying it. Existing session state always takes precedence. +Use **Reset board** (or the `reset_board` action) to clear reviews and history without +touching GitHub. ## Agent actions @@ -78,3 +87,5 @@ 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. diff --git a/.github/extensions/open-pr-board/extension.mjs b/.github/extensions/open-pr-board/extension.mjs index cce764687..11f7f02fb 100644 --- a/.github/extensions/open-pr-board/extension.mjs +++ b/.github/extensions/open-pr-board/extension.mjs @@ -2,8 +2,8 @@ // Review board for open pull requests: AI review with persistent state tracking, // approve / request-changes / needs-insight / close recommendations, visible labels, // and GitHub actions. Decisions are applied through the authenticated gh CLI and -// recorded on the board. Board state is persisted to -// ./state/__.json (gitignored) so it survives reloads and new sessions. +// recorded on the board. Board state is persisted under session.workspacePath +// so writes do not change the extension fingerprint. import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,8 +41,10 @@ async function getStore(repo) { let store = stores.get(repo); if (!store) { // The repo is validated against REPO_PATTERN above, so it cannot escape the state directory. - const file = path.join(EXT_DIR, "state", `${repo.replace("/", "__")}.json`); - store = await new BoardStore({ file, repo }).load(); + 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 }); stores.set(repo, store); } return store; @@ -399,6 +401,7 @@ session = await joinSession({ }, }, }); + entry = { repo, ...server }; servers.set(ctx.instanceId, entry); } @@ -421,3 +424,7 @@ session = await joinSession({ }), ], }); + +if (!session.workspacePath) { + throw new Error("Open PR Board requires a session workspace for persistent state. Enable infinite sessions."); +} diff --git a/.github/extensions/open-pr-board/lib/state.mjs b/.github/extensions/open-pr-board/lib/state.mjs index 7a0f3f498..12ea86b07 100644 --- a/.github/extensions/open-pr-board/lib/state.mjs +++ b/.github/extensions/open-pr-board/lib/state.mjs @@ -68,19 +68,30 @@ export class BoardStore { return () => this.listeners.delete(listener); } - async load() { + async load({ legacyFile } = {}) { + let raw; + let imported = false; try { - 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 : [], - }; + raw = await readFile(this.file, "utf8"); } 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; } diff --git a/eng/board-state-storage.test.mjs b/eng/board-state-storage.test.mjs new file mode 100644 index 000000000..e5f2cac50 --- /dev/null +++ b/eng/board-state-storage.test.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import vm from "node:vm"; +import { test } from "node:test"; + +const extensionsDir = fileURLToPath(new URL("../.github/extensions/", import.meta.url)); +const repo = "github/awesome-copilot"; +const item = { + number: 1, title: "Example", author: "contributor", labels: [], + url: "https://github.com/github/awesome-copilot/issues/1", +}; + +async function fixture(t, id) { + const root = await mkdtemp(path.join(os.tmpdir(), "board-state-storage-")); + t.after(() => rm(root, { recursive: true, force: true })); + const extensionDir = path.join(root, id); + await cp(path.join(extensionsDir, id), extensionDir, { recursive: true }); + const workspacePath = path.join(root, "session"); + const filename = id === "open-pr-board" ? "github__awesome-copilot.json" : "board.json"; + return { + id, root, extensionDir, workspacePath, + legacyFile: path.join(extensionDir, "state", filename), + stateFile: path.join(workspacePath, "extensions", id, "state", filename), + }; +} + +async function fingerprint(dir) { + const files = []; + for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(dir, entry.name); + files.push([entry.name, entry.isDirectory() + ? await fingerprint(file) + : createHash("sha256").update(await readFile(file)).digest("hex")]); + } + return files; +} + +async function loadCanvas({ id, extensionDir, workspacePath }) { + let canvas; + const context = vm.createContext({ setTimeout }); + const sdk = { + createCanvas: (definition) => definition, + joinSession: async (options) => { + [canvas] = options.canvases; + return { workspacePath, log() {}, send: async () => {} }; + }, + CanvasError: class CanvasError extends Error { + constructor(code, message) { super(message); this.code = code; } + }, + }; + const filename = path.join(extensionDir, "extension.mjs"); + const module = new vm.SourceTextModule(await readFile(filename, "utf8"), { + context, + initializeImportMeta(meta) { meta.url = pathToFileURL(filename).href; }, + }); + await module.link(async (specifier) => { + let exports; + if (specifier === "@github/copilot-sdk/extension") { + exports = sdk; + } else if (specifier === "./lib/github.mjs") { + exports = { + listOpenPullRequests: async () => [item], + listReadyIssues: async () => [item], + }; + } else { + exports = await import(specifier.startsWith(".") + ? pathToFileURL(path.join(extensionsDir, id, specifier)).href + : specifier); + } + return new vm.SyntheticModule(Object.keys(exports), function () { + for (const [name, value] of Object.entries(exports)) this.setExport(name, value); + }, { context }); + }); + await module.evaluate(); + return (name, input = {}) => { + const action = canvas.actions.find((entry) => entry.name === name); + assert.ok(action, `Missing canvas action ${name}`); + return action.handler({ instanceId: "test-board", input }); + }; +} + +async function writeJSON(file, value) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value), "utf8"); +} + +for (const id of ["open-pr-board", "external-plugin-review-board"]) { + const column = id === "open-pr-board" ? "needs-insight" : "needs-review"; + + test(`${id} writes only in its session namespace and survives reloads`, async (t) => { + const f = await fixture(t, id); + const before = await fingerprint(f.extensionDir); + const act = await loadCanvas(f); + await act("refresh"); + await act("move_item", { number: 1, column }); + assert.equal(JSON.parse(await readFile(f.stateFile, "utf8")).items["1"].manualColumn, column); + assert.deepEqual(await fingerprint(f.extensionDir), before); + + const reloaded = await loadCanvas(f); + assert.equal((await reloaded("get_board")).items[0].manualOverride, column); + const otherSession = await loadCanvas({ ...f, workspacePath: path.join(f.root, "other-session") }); + assert.equal((await otherSession("get_board")).items.length, 0); + + if (id === "open-pr-board") { + await act("refresh", { repo: "example/other" }); + const otherFile = path.join(path.dirname(f.stateFile), "example__other.json"); + assert.equal(JSON.parse(await readFile(otherFile, "utf8")).repo, "example/other"); + await act("reset_board"); + const afterReset = await loadCanvas(f); + assert.equal((await afterReset("get_board")).items[0].manualOverride, null); + } + assert.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} imports legacy state without changing it and prefers session state thereafter`, async (t) => { + const f = await fixture(t, id); + const legacy = { + version: 1, repo, items: { "1": { ...item, manualColumn: column, reviewStatus: "reviewed" } }, + history: [{ number: 1, comment: "Preserved history" }], + }; + await writeJSON(f.legacyFile, legacy); + 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.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} rejects a missing session workspace instead of writing to the extension`, async (t) => { + const f = await fixture(t, id); + const before = await fingerprint(f.extensionDir); + await assert.rejects(loadCanvas({ ...f, workspacePath: undefined }), /requires a session workspace/); + assert.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} surfaces corrupt session state instead of falling back to legacy data`, 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 () => { + const act = await loadCanvas(f); + 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) => { + const pr = await fixture(t, "open-pr-board"); + const plugin = { ...await fixture(t, "external-plugin-review-board"), workspacePath: pr.workspacePath }; + const prBoard = await loadCanvas(pr); + const pluginBoard = await loadCanvas(plugin); + await prBoard("refresh"); + await pluginBoard("refresh"); + await prBoard("move_item", { number: 1, column: "needs-insight" }); + await pluginBoard("move_item", { number: 1, column: "accept" }); + const reloadedPR = await loadCanvas(pr); + const reloadedPlugin = await loadCanvas(plugin); + assert.equal((await reloadedPR("get_board")).items[0].manualOverride, "needs-insight"); + assert.equal((await reloadedPlugin("get_board")).items[0].manualOverride, "accept"); +}); From 758f4382db2cf112e79c4cc5017ba84f57e6878b Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 12 Oct 2026 13:37:10 +1100 Subject: [PATCH 2/3] 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 --- .../external-plugin-review-board/README.md | 8 ++-- .../extension.mjs | 2 +- .../lib/state.mjs | 19 ++------- .github/extensions/open-pr-board/README.md | 7 +--- .../extensions/open-pr-board/extension.mjs | 3 +- .../extensions/open-pr-board/lib/state.mjs | 29 +++++--------- eng/board-state-storage.test.mjs | 39 ++++--------------- 7 files changed, 28 insertions(+), 79 deletions(-) diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index 3524265a0..478324ec8 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -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. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 89b0ed4e6..ad1006c35 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -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(); diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index ca1e2e21d..f42a99521 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -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; } diff --git a/.github/extensions/open-pr-board/README.md b/.github/extensions/open-pr-board/README.md index 8299398d5..225d2874e 100644 --- a/.github/extensions/open-pr-board/README.md +++ b/.github/extensions/open-pr-board/README.md @@ -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/__.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. diff --git a/.github/extensions/open-pr-board/extension.mjs b/.github/extensions/open-pr-board/extension.mjs index 11f7f02fb..b51eb98e5 100644 --- a/.github/extensions/open-pr-board/extension.mjs +++ b/.github/extensions/open-pr-board/extension.mjs @@ -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; diff --git a/.github/extensions/open-pr-board/lib/state.mjs b/.github/extensions/open-pr-board/lib/state.mjs index 12ea86b07..7a0f3f498 100644 --- a/.github/extensions/open-pr-board/lib/state.mjs +++ b/.github/extensions/open-pr-board/lib/state.mjs @@ -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; } diff --git a/eng/board-state-storage.test.mjs b/eng/board-state-storage.test.mjs index e5f2cac50..29f0d8ef0 100644 --- a/eng/board-state-storage.test.mjs +++ b/eng/board-state-storage.test.mjs @@ -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) => { From 01678e94897a410905dbbdf5f3a25db063416e0d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 12 Oct 2026 13:49:34 +1100 Subject: [PATCH 3/3] fix(canvas): handle board startup requests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 545df520-6557-4211-a110-53c60f4b9f3a --- .../extension.mjs | 2 ++ eng/board-state-storage.test.mjs | 35 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index ad1006c35..c65797e17 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -213,6 +213,7 @@ function routesFor(instanceId) { function wrap(fn) { return async (ctx) => { try { + if (!store) throw new CanvasError("not_ready", "The extension is still starting. Retry the action."); return await fn(ctx); } catch (error) { if (error instanceof CanvasError) throw error; @@ -316,6 +317,7 @@ session = await joinSession({ }, ], open: async (ctx) => { + if (!store) throw new CanvasError("not_ready", "The extension is still starting. Retry opening it."); let entry = servers.get(ctx.instanceId); if (!entry) { entry = await startBoardServer({ diff --git a/eng/board-state-storage.test.mjs b/eng/board-state-storage.test.mjs index 29f0d8ef0..3db0f568b 100644 --- a/eng/board-state-storage.test.mjs +++ b/eng/board-state-storage.test.mjs @@ -39,13 +39,29 @@ async function fingerprint(dir) { return files; } -async function loadCanvas({ id, extensionDir, workspacePath }) { +async function loadCanvas({ id, extensionDir, workspacePath, triggerDuringJoin = false }) { let canvas; + let startupResults; const context = vm.createContext({ setTimeout }); const sdk = { createCanvas: (definition) => definition, joinSession: async (options) => { [canvas] = options.canvases; + if (triggerDuringJoin) { + const ctx = { instanceId: "startup-test", input: {} }; + const requests = [ + canvas.actions.find((entry) => entry.name === "get_board").handler(ctx), + canvas.open(ctx), + ]; + startupResults = Promise.all(requests.map(async (request) => { + try { + await request; + return { ok: true }; + } catch (error) { + return { ok: false, code: error.code, message: error.message }; + } + })); + } return { workspacePath, log() {}, send: async () => {} }; }, CanvasError: class CanvasError extends Error { @@ -76,11 +92,13 @@ async function loadCanvas({ id, extensionDir, workspacePath }) { }, { context }); }); await module.evaluate(); - return (name, input = {}) => { + const act = (name, input = {}) => { const action = canvas.actions.find((entry) => entry.name === name); assert.ok(action, `Missing canvas action ${name}`); return action.handler({ instanceId: "test-board", input }); }; + act.startupResults = startupResults; + return act; } async function writeJSON(file, value) { @@ -148,6 +166,19 @@ for (const id of ["open-pr-board", "external-plugin-review-board"]) { }); } +test("external-plugin-review-board returns retryable not_ready errors during startup", async (t) => { + const f = await fixture(t, "external-plugin-review-board"); + const act = await loadCanvas({ ...f, triggerDuringJoin: true }); + const results = await act.startupResults; + assert.equal(results.length, 2); + for (const result of results) { + assert.equal(result.ok, false); + assert.equal(result.code, "not_ready"); + assert.match(result.message, /still starting/i); + } + assert.equal((await act("get_board")).items.length, 0); +}); + test("both canvases keep independent boards in the same session workspace", async (t) => { const pr = await fixture(t, "open-pr-board"); const plugin = { ...await fixture(t, "external-plugin-review-board"), workspacePath: pr.workspacePath };