Skip to content

Commit 0223efe

Browse files
author
DavidQ
committed
Expose runtime repo binding state without serializing browser file handles - PR_26130_005-runtime-handle-state-visibility
1 parent 907a431 commit 0223efe

7 files changed

Lines changed: 435 additions & 13 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ tests/results/
1919
tmp/
2020

2121
# Codex files
22+
docs/dev/codex_commands.md
23+
docs/dev/commit_comment.txt
2224
codex_changed_files.txt
2325
docs/dev/reports/codex_changed_files.txt
2426
codex_review.diff
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# PR_26130_005-runtime-handle-state-visibility
2+
3+
## Summary
4+
5+
- Added serializable runtime binding metadata to Workspace Manager V2 repo references and hydrated tool `workspace` sections: `hasLiveRepoHandle`, `sourceBindingState`, `boundManifestPath`, and `bindingSource`.
6+
- Kept live FileSystem handles out of sessionStorage, persisted toolState context, and Session Inspector JSON; stored metadata only.
7+
- Session Inspector V2 now logs runtime binding status for selected repo/tool entries without rendering a handle object.
8+
- Workspace Manager V2 now logs runtime handle acquired, rebound, lost, and invalidated states.
9+
10+
## Scope
11+
12+
Changed only Workspace Manager V2 runtime binding visibility/status, Session Inspector V2 visibility, and the Workspace Manager V2 Playwright coverage.
13+
14+
No `start_of_day` files were modified.
15+
16+
## Validation
17+
18+
- `node --check tools/workspace-manager-v2/js/WorkspaceManagerV2App.js`
19+
- `node --check tools/workspace-manager-v2/js/services/WorkspaceManagerV2ContextService.js`
20+
- `node --check tools/session-inspector-v2/js/SessionInspectorV2App.js`
21+
- `node --check tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
22+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list -g "loads Gravity Well|restores sessionStorage toolState read-only|rebinds restored session Save"`: 3 passed
23+
- `npm run test:workspace-v2`: 22 passed
24+
- `git diff --check`: passed
25+
26+
Full samples smoke test was skipped because this PR is limited to Workspace Manager V2 runtime handle visibility/status and does not modify shared sample loading, sample manifests, or broad sample runtime behavior.
27+
28+
## Playwright Coverage
29+
30+
Playwright impacted: Yes.
31+
32+
Validated behavior:
33+
34+
- Repo selection stores runtime binding visibility metadata in `workspace.repo.reference`.
35+
- Hydrated tool workspace sessions include save-capable binding metadata while the active workspace manifest remains free of runtime-only fields.
36+
- Session Inspector V2 shows runtime binding status metadata for repo/tool entries and does not render `handle`, `repoHandle`, or `fileSystemHandle` objects.
37+
- Restored sessionStorage toolState without a live repo folder handle reports `hasLiveRepoHandle=false`, keeps Save disabled, and logs the required rebind action.
38+
- Repo folder selection rebinds the restored toolState to the real `game.manifest.json` source and restores save-capable metadata.
39+
40+
Expected pass behavior: runtime metadata is visible, serializable, and accurate; Save remains disabled without a live handle and enabled only for dirty toolState with a live binding.
41+
42+
Expected fail behavior: tests fail if runtime metadata is missing, a live handle object appears in session/tool JSON, Session Inspector omits binding status, or Save becomes available without a live writable binding.
43+
44+
## V8 Coverage
45+
46+
Runtime JavaScript coverage from `npm run test:workspace-v2`:
47+
48+
- `(88%) tools/workspace-manager-v2/js/WorkspaceManagerV2App.js - executed lines 913/913; executed functions 42/48`
49+
- `(90%) tools/workspace-manager-v2/js/services/WorkspaceManagerV2ContextService.js - executed lines 1502/1502; executed functions 140/155`
50+
- `(93%) tools/session-inspector-v2/js/SessionInspectorV2App.js - executed lines 337/337; executed functions 42/45`
51+
52+
Guardrail warnings: none.
53+
54+
## Manual Test
55+
56+
1. Open `tools/workspace-manager-v2/index.html`.
57+
2. Pick the repo folder and open Asteroids.
58+
3. Launch Session Inspector V2 from the Workspace Manager V2 tool tiles.
59+
4. Select `workspace.repo.reference` and `workspace.tools.asset-manager-v2`.
60+
5. Expected: JSON/status show `hasLiveRepoHandle`, `sourceBindingState`, `boundManifestPath`, and `bindingSource`; no live handle object is rendered.
61+
6. Return to Workspace Manager V2.
62+
7. Expected: repo/game/tool enablement and Save/Close dirty-state behavior remain intact.
63+
64+
Out of scope: full sample launch validation. Sample smoke remains skipped until a dedicated sample alignment phase.
65+
66+
## Changed Files
67+
68+
- `docs/dev/codex_commands.md`
69+
- `docs/dev/commit_comment.txt`
70+
- `docs/dev/reports/PR_26130_005-runtime-handle-state-visibility.md`
71+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
72+
- `tools/session-inspector-v2/js/SessionInspectorV2App.js`
73+
- `tools/workspace-manager-v2/js/WorkspaceManagerV2App.js`
74+
- `tools/workspace-manager-v2/js/services/WorkspaceManagerV2ContextService.js`

playwright.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ module.exports = {
2121
use: {
2222
headless: false,
2323
launchOptions: {
24-
slowMo: 100
24+
slowMo: 50
2525
},
2626
trace: "on"
2727
}

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,23 @@ async function readWorkspaceSessionHydration(page) {
288288
});
289289
}
290290

291+
function expectRuntimeBindingMetadata(metadata, {
292+
bindingSource = "game.manifest.json",
293+
boundManifestPath = "/games/Asteroids/game.manifest.json",
294+
hasLiveRepoHandle = true,
295+
sourceBindingState = "bound"
296+
} = {}) {
297+
expect(metadata).toMatchObject({
298+
bindingSource,
299+
boundManifestPath,
300+
hasLiveRepoHandle,
301+
sourceBindingState
302+
});
303+
expect(metadata.handle).toBeUndefined();
304+
expect(metadata.repoHandle).toBeUndefined();
305+
expect(metadata.fileSystemHandle).toBeUndefined();
306+
}
307+
291308
async function dirtyPaletteToolState(page, swatch) {
292309
await page.evaluate((nextSwatch) => {
293310
const app = window.__workspaceManagerV2App;
@@ -1491,14 +1508,21 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14911508
tiles.every((tile) => Array.from(tile.querySelectorAll(".workspace-manager-v2__tool-tile-action"), (action) => action.textContent.trim()).join("|") === "How To Use|Read Me")
14921509
))).toBe(true);
14931510
await selectMockRepo(page);
1494-
expect(await readWorkspaceSessionHydration(page)).toMatchObject({
1511+
const selectedRepoHydration = await readWorkspaceSessionHydration(page);
1512+
expect(selectedRepoHydration).toMatchObject({
14951513
repoReference: {
14961514
displayName: "HTML-JavaScript-Gaming",
14971515
handleName: "HTML-JavaScript-Gaming",
14981516
kind: "file-system-directory-handle-reference"
14991517
},
15001518
toolKeys: []
15011519
});
1520+
expectRuntimeBindingMetadata(selectedRepoHydration.repoReference, {
1521+
bindingSource: "showDirectoryPicker",
1522+
boundManifestPath: "",
1523+
hasLiveRepoHandle: true,
1524+
sourceBindingState: "repo-handle-acquired"
1525+
});
15021526
const compactCenterLayout = await page.evaluate(() => {
15031527
const getRect = (selector) => {
15041528
const element = document.querySelector(selector);
@@ -1542,6 +1566,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
15421566
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/"toolId"/);
15431567
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/"workspaceManifest"/);
15441568
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/"workspaceMetadata"/);
1569+
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/"hasLiveRepoHandle"/);
1570+
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/"sourceBindingState"/);
15451571
await expect(page.locator("#workspaceContextOutput")).not.toHaveValue(/samples\//);
15461572
await expect(page.locator("#pickRepoBtn")).toBeDisabled();
15471573
await expect(page.locator("#activeGameSelect")).toBeDisabled();
@@ -1550,6 +1576,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
15501576
await expect(page.locator("#cancelWorkspaceButton")).toBeEnabled();
15511577
await expect(page.locator("#activeGameContent button")).toHaveCount(0);
15521578
const selectedGameHydration = await readWorkspaceSessionHydration(page);
1579+
expectRuntimeBindingMetadata(selectedGameHydration.repoReference);
15531580
expect(selectedGameHydration.toolKeys).toEqual([
15541581
"workspace.tools.asset-manager-v2",
15551582
"workspace.tools.palette-manager-v2",
@@ -1603,7 +1630,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
16031630
assetsPath: "games/Asteroids/assets",
16041631
repoReferenceKey: "workspace.repo.reference"
16051632
});
1633+
expectRuntimeBindingMetadata(selectedGameHydration.workspaceByTool["asset-manager-v2"]);
1634+
expectRuntimeBindingMetadata(selectedGameHydration.workspaceByTool["palette-manager-v2"]);
1635+
expectRuntimeBindingMetadata(selectedGameHydration.workspaceByTool["preview-generator-v2"]);
1636+
expectRuntimeBindingMetadata(selectedGameHydration.workspaceByTool["session-inspector-v2"]);
16061637
expect(selectedGameHydration.toolSessions["asset-manager-v2"].state).toBeUndefined();
1638+
expect(JSON.stringify(selectedGameHydration.toolSessions)).not.toMatch(/getDirectoryHandle|createWritable|FileSystemDirectoryHandle/);
16071639
expect(Object.keys(selectedGameHydration.dataByTool["asset-manager-v2"].assets)).toHaveLength(14);
16081640
expect(selectedGameHydration.toolSessions["templates-v2"]).toBeUndefined();
16091641
expect(Object.values(selectedGameHydration.dirtyByTool)).toEqual([
@@ -1677,6 +1709,21 @@ test.describe("Workspace Manager V2 bootstrap", () => {
16771709
await expect(page.locator("#sessionInspectorV2EntryList [data-session-inspector-v2-entry-id='sessionStorage:workspace.tools.preview-generator-v2']")).toHaveCount(1);
16781710
await expect(page.locator("#sessionInspectorV2EntryList [data-session-inspector-v2-entry-id='sessionStorage:workspace.tools.session-inspector-v2']")).toHaveCount(1);
16791711
await expect(page.locator("#sessionInspectorV2EntryList [data-session-inspector-v2-entry-id='sessionStorage:workspace.tools.templates-v2']")).toHaveCount(0);
1712+
await page.locator('[data-session-inspector-v2-entry-id="sessionStorage:workspace.tools.asset-manager-v2"]').click();
1713+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"hasLiveRepoHandle": true');
1714+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"sourceBindingState": "bound"');
1715+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"boundManifestPath": "/games/Asteroids/game.manifest.json"');
1716+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"bindingSource": "game.manifest.json"');
1717+
await expect(page.locator("#sessionInspectorV2JsonOutput")).not.toContainText('"handle":');
1718+
await expect(page.locator("#sessionInspectorV2JsonOutput")).not.toContainText('"repoHandle":');
1719+
await expect(page.locator("#statusLog")).toHaveValue(/INFO Runtime binding status for sessionStorage:workspace\.tools\.asset-manager-v2: hasLiveRepoHandle=true; sourceBindingState=bound; boundManifestPath=\/games\/Asteroids\/game\.manifest\.json; bindingSource=game\.manifest\.json\./);
1720+
await page.locator('[data-session-inspector-v2-entry-id="sessionStorage:workspace.repo.reference"]').click();
1721+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"hasLiveRepoHandle": true');
1722+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"sourceBindingState": "bound"');
1723+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"boundManifestPath": "/games/Asteroids/game.manifest.json"');
1724+
await expect(page.locator("#sessionInspectorV2JsonOutput")).toContainText('"bindingSource": "game.manifest.json"');
1725+
await expect(page.locator("#sessionInspectorV2JsonOutput")).not.toContainText('"handle":');
1726+
await expect(page.locator("#statusLog")).toHaveValue(/INFO Runtime binding status for sessionStorage:workspace\.repo\.reference: hasLiveRepoHandle=true; sourceBindingState=bound; boundManifestPath=\/games\/Asteroids\/game\.manifest\.json; bindingSource=game\.manifest\.json\./);
16801727
await page.locator("#returnToWorkspaceButton").click();
16811728
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
16821729
await expectWorkspaceReturnedFromTool(page);
@@ -1760,6 +1807,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
17601807
expect(storedContext.toolId).toBeUndefined();
17611808
expect(storedContext.activePalette).toBeUndefined();
17621809
expect(storedContext.workspaceManifest).toBeUndefined();
1810+
expect(storedContext.hasLiveRepoHandle).toBeUndefined();
1811+
expect(storedContext.sourceBindingState).toBeUndefined();
17631812
expect(storedContext.gameId).toBe("Asteroids");
17641813
expect(storedContext.gameRoot).toBe("games/Asteroids/");
17651814
expect(storedContext.assetsPath).toBe("games/Asteroids/assets");
@@ -2291,6 +2340,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
22912340
const restoredState = await page.evaluate(() => ({
22922341
activeGameManifestPath: window.__workspaceManagerV2App.activeGame.manifestPath,
22932342
hasRepoHandle: Boolean(window.__workspaceManagerV2App.activeRepoHandle),
2343+
repoReference: JSON.parse(sessionStorage.getItem("workspace.repo.reference")),
22942344
requiresRepoHandle: window.__workspaceManagerV2App.activeToolStateRequiresRepoHandle,
22952345
writes: JSON.parse(sessionStorage.getItem("workspace.repo.manifestWrites") || "[]")
22962346
}));
@@ -2300,6 +2350,13 @@ test.describe("Workspace Manager V2 bootstrap", () => {
23002350
writes: []
23012351
});
23022352
expect(restoredState.activeGameManifestPath).toBe(`sessionStorage:${hostContextId}`);
2353+
expectRuntimeBindingMetadata(restoredState.repoReference, {
2354+
bindingSource: "sessionStorage restore",
2355+
boundManifestPath: `sessionStorage:${hostContextId}`,
2356+
hasLiveRepoHandle: false,
2357+
sourceBindingState: "missing-live-repo-handle"
2358+
});
2359+
await expect(page.locator("#statusLog")).toHaveValue(new RegExp(`WARN Runtime handle lost: hasLiveRepoHandle=false; sourceBindingState=missing-live-repo-handle; boundManifestPath=sessionStorage:${hostContextId}; bindingSource=sessionStorage restore\\. Required action: Pick Repo Folder to rebind game\\.manifest\\.json before Save or tool launch\\.`));
23032360

23042361
await page.evaluate(() => window.__workspaceManagerV2App.saveWorkspaceSession());
23052362
await expect(page.locator("#statusLog")).toHaveValue(new RegExp(`FAIL Save blocked: missing live repo folder handle for active toolState; active game source=sessionStorage:${hostContextId}; context\\.gameId=Asteroids; context\\.gameRoot=games/Asteroids/\\. Required action: Pick Repo Folder to rebind game\\.manifest\\.json before Save\\.`));
@@ -2310,14 +2367,17 @@ test.describe("Workspace Manager V2 bootstrap", () => {
23102367
activeGameManifestKind: window.__workspaceManagerV2App.activeGame.manifestKind,
23112368
activeGameManifestPath: window.__workspaceManagerV2App.activeGame.manifestPath,
23122369
hasRepoHandle: Boolean(window.__workspaceManagerV2App.activeRepoHandle),
2370+
repoReference: JSON.parse(sessionStorage.getItem("workspace.repo.reference")),
23132371
requiresRepoHandle: window.__workspaceManagerV2App.activeToolStateRequiresRepoHandle
23142372
}));
2315-
expect(reboundState).toEqual({
2373+
expect(reboundState).toMatchObject({
23162374
activeGameManifestKind: "game-manifest",
23172375
activeGameManifestPath: "/games/Asteroids/game.manifest.json",
23182376
hasRepoHandle: true,
23192377
requiresRepoHandle: false
23202378
});
2379+
expectRuntimeBindingMetadata(reboundState.repoReference);
2380+
await expect(page.locator("#statusLog")).toHaveValue(/OK Runtime handle rebound: hasLiveRepoHandle=true; sourceBindingState=bound; boundManifestPath=\/games\/Asteroids\/game\.manifest\.json; bindingSource=repo-folder-rebind\./);
23212381

23222382
await page.locator("#saveWorkspaceButton").click();
23232383
await expect(page.locator("#statusLog")).toHaveValue(/OK Save source binding: \/games\/Asteroids\/game\.manifest\.json\./);
@@ -2327,9 +2387,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
23272387
await expect(page.locator("#statusLog")).toHaveValue(/OK Save dirty\/clean validation: 1 dirty toolState payload persisted; 1 toolState key marked clean\./);
23282388
const saveState = await page.evaluate((contextId) => ({
23292389
paletteSession: JSON.parse(sessionStorage.getItem("workspace.tools.palette-manager-v2")),
2390+
repoReference: JSON.parse(sessionStorage.getItem("workspace.repo.reference")),
23302391
savedContext: JSON.parse(sessionStorage.getItem(contextId)),
23312392
writes: JSON.parse(sessionStorage.getItem("workspace.repo.manifestWrites") || "[]")
23322393
}), hostContextId);
2394+
expectRuntimeBindingMetadata(saveState.repoReference);
2395+
expectRuntimeBindingMetadata(saveState.paletteSession.workspace);
23332396
expect(saveState.paletteSession.dirty).toEqual({
23342397
isDirty: false,
23352398
reason: null,

tools/session-inspector-v2/js/SessionInspectorV2App.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,36 @@ export class SessionInspectorV2App {
9494
this.dirty.render(entry);
9595
if (entry) {
9696
this.statusLog.info(`Selected ${entry.storageType}:${entry.key}.`);
97+
const runtimeBinding = this.runtimeBindingForEntry(entry);
98+
if (runtimeBinding) {
99+
this.statusLog.info(`Runtime binding status for ${entry.storageType}:${entry.key}: hasLiveRepoHandle=${runtimeBinding.hasLiveRepoHandle}; sourceBindingState=${runtimeBinding.sourceBindingState}; boundManifestPath=${runtimeBinding.boundManifestPath || "(none)"}; bindingSource=${runtimeBinding.bindingSource || "(none)"}.`);
100+
}
101+
}
102+
}
103+
104+
runtimeBindingForEntry(entry) {
105+
const value = entry?.parseOk ? entry.parsedValue : null;
106+
if (!value || typeof value !== "object" || Array.isArray(value)) {
107+
return null;
108+
}
109+
const candidate = Object.prototype.hasOwnProperty.call(value, "hasLiveRepoHandle")
110+
? value
111+
: value.workspace;
112+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
113+
return null;
114+
}
115+
if (!Object.prototype.hasOwnProperty.call(candidate, "hasLiveRepoHandle")
116+
|| !Object.prototype.hasOwnProperty.call(candidate, "sourceBindingState")
117+
|| !Object.prototype.hasOwnProperty.call(candidate, "boundManifestPath")
118+
|| !Object.prototype.hasOwnProperty.call(candidate, "bindingSource")) {
119+
return null;
97120
}
121+
return {
122+
hasLiveRepoHandle: candidate.hasLiveRepoHandle === true,
123+
sourceBindingState: String(candidate.sourceBindingState || "unknown"),
124+
boundManifestPath: String(candidate.boundManifestPath || ""),
125+
bindingSource: String(candidate.bindingSource || "")
126+
};
98127
}
99128

100129
deleteEntry(entryId) {

0 commit comments

Comments
 (0)