Skip to content

Commit 9c97b32

Browse files
author
DavidQ
committed
Enforce single active workspace palette during Workspace V2 export - PR_11_304
1 parent 59722a3 commit 9c97b32

2 files changed

Lines changed: 197 additions & 44 deletions

File tree

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,70 @@
1-
# PR_11_304 Report - Workspace V2 Import/Export Continuation Fix (Saved Session Payload Guard)
1+
# PR_11_304 Report - Workspace V2 Import/Export Continuation (Single Active Palette Ownership)
22

33
## Purpose
4-
Continue/fix PR_11_304 in `tools/workspace-v2/index.js` for Workspace V2 import/export/save/load session handling.
4+
Continue/fix PR_11_304 in `tools/workspace-v2/index.js` to enforce manifest-only export with single active palette ownership at `workspace.tools.palettes`.
55

66
## Scope
7-
- `tools/workspace-v2/index.js` only
8-
- No schema changes
9-
10-
## Issues Fixed
11-
1. Save Session payload source/shape
12-
- Updated `readSessionPayloadForSaveAction(sessionId)` to prioritize current active workspace payload only.
13-
- Removed session-name lookup fallback that could pull unrelated stale payloads.
14-
- Added save-time shape validation using `validateWorkspaceToolSessionPayload(...)` before writing to library.
15-
- Result: new saved Palette Manager sessions are saved as `{ version, toolId, paletteJson }`, not `{ payloadJson }`.
16-
17-
2. Export invalid saved palette sessions with actionable block message
18-
- Added targeted guard in `exportWorkspaceSessionJson()`:
19-
- Detects any saved `palette-manager-v2` session containing `payloadJson`.
20-
- Blocks export with explicit message:
21-
- `Saved session 'session_2' is invalid for palette-manager-v2. Load a valid session and overwrite it, or delete it.`
22-
- No silent conversion/fix during export.
23-
24-
3. Manifest-only textarea after fixture/init/reset
25-
- `loadSelectedFixture()` now normalizes palette fixture session context and syncs manifest textarea via `syncWorkspaceManifestTextarea()`.
26-
- `initializeWorkspaceProducerSession()` creates valid default payload and syncs manifest textarea.
27-
- `fullReset()` now re-initializes producer and writes manifest baseline instead of leaving empty/raw payload state.
28-
- Reset status updated to reflect manifest baseline restoration.
29-
30-
4. Session ID validation UX
31-
- Invalid Session ID message remains exact and visible:
32-
- `Invalid session ID. Use letters, numbers, hyphen, or underscore only.`
33-
- Save remains disabled for invalid IDs via state model.
7+
- `tools/workspace-v2/index.js`
8+
- `docs/dev/reports/PR_11_304_report.md`
9+
- No schema file changes
10+
11+
## Changes Implemented
12+
1. Added strict active palette ownership for export:
13+
- Export now resolves one active palette from the current active `palette-manager-v2` session only.
14+
- Export writes the active palette at:
15+
- `tools.palettes.activePalette`
16+
- Export blocks when active palette is not available with clear error messaging.
17+
- Export blocks ambiguous palette context (multiple palette sessions present with no active palette selected).
18+
19+
2. Kept manifest-only export contract and removed silent no-op behavior:
20+
- Export still validates before download.
21+
- Export continues to reject legacy wrapper shape:
22+
- `workspaceSession`
23+
- `workspaceV2Session`
24+
- `toolSessions`
25+
- `savedSessions` at root
26+
- `exportedAt`
27+
- Export status now surfaces build-time failures using explicit `lastWorkspaceExportBuildErrorMessage`.
28+
29+
3. Tightened palette payload validation paths:
30+
- `palette-manager-v2` payloads continue to require:
31+
- `paletteJson.swatches`
32+
- `paletteJson.colors` remains rejected.
33+
- `payloadJson` remains rejected for `palette-manager-v2`.
34+
- Swatches are validated as lowercase strict fields:
35+
- `symbol`
36+
- `hex`
37+
- `name`
38+
- Extra swatch fields are rejected.
39+
40+
4. Validator now enforces palette tool manifest entry:
41+
- `tools.palettes` is required.
42+
- `tools.palettes.activePalette` is required.
43+
- `tools.palettes.activePalette.swatches` is validated before export/import acceptance.
44+
45+
## Required Rule Coverage
46+
- No `workspaceSession`: enforced
47+
- No `games[]`: enforced in export document shape
48+
- No `palette-manager-v2.payloadJson`: enforced
49+
- Export validates before download: enforced
50+
- Exactly one active exported palette at `workspace.tools.palettes`: enforced
3451

3552
## Validation Commands Run
3653
1. `node --check tools/workspace-v2/index.js`
37-
2. Inline Node targeted continuation check script (writes `tmp/pr_11_304_continue_checks.json`)
38-
3. Inline Node saved-session validation scenario check script
54+
2. `node --check tests/runtime/V2CurrentSessionExport.test.mjs`
55+
3. `node tests/runtime/V2CurrentSessionExport.test.mjs`
56+
4. Inline targeted executable check script:
57+
- writes `tmp/pr_11_304_palette_ownership_checks.json`
58+
- validates presence of single-active-palette export and guard strings in `tools/workspace-v2/index.js`
3959

4060
## Validation Results
4161
- Command 1: PASS
42-
- Command 2: PASS (`PR_11_304 continuation checks: ok`)
43-
- Command 3: PASS (`saved session validation scenario check: ok`)
44-
45-
## Acceptance Mapping
46-
- Load Fixture -> manifest textarea contains `tools.workspace-v2.activeSession.paletteJson.swatches`: PASS
47-
- Export blocks stale invalid saved palette session `session_2` with actionable message: PASS
48-
- After deleting/overwriting invalid saved session, export path is unblocked: PASS (guard behavior validated)
49-
- New saved palette sessions use `paletteJson`, not `payloadJson`: PASS
50-
- Export never emits `workspaceSession` and never emits `games[]`: PASS (manifest builder/validator path)
51-
- Invalid New Session ID message is actionable and Save stays disabled: PASS
62+
- Command 2: PASS
63+
- Command 3: FAIL (existing legacy expectation unrelated to this scoped change):
64+
- test asserts `workspace.schema.json` root `workspaceSession`, which conflicts with current manifest-only direction
65+
- Command 4: PASS
5266

5367
## Full Samples Smoke Decision
5468
- Full samples smoke test skipped.
55-
- Reason: change is scoped to one file and validated via targeted syntax + executable continuation checks.
69+
- Reason: change is scoped to one tool JS file with targeted syntax/executable checks only; no shared sample framework changes.
70+

tools/workspace-v2/index.js

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class WorkspaceV2SessionProducer {
8585
this.currentSessionPayload = null;
8686
this.currentSessionSource = "";
8787
this.currentHostContextId = "";
88+
this.lastWorkspaceExportBuildErrorMessage = "";
8889
this.workspaceTransitionState = "idle";
8990
this.pendingMergePreview = null;
9091
this.lastMergedSessionResult = null;
@@ -905,6 +906,90 @@ class WorkspaceV2SessionProducer {
905906
return "";
906907
}
907908

909+
validatePaletteSwatchesForWorkspaceExport(swatches, swatchesPath) {
910+
if (!Array.isArray(swatches)) {
911+
return { ok: false, message: `${swatchesPath} must be an array.` };
912+
}
913+
for (let index = 0; index < swatches.length; index += 1) {
914+
const swatchPath = `${swatchesPath}[${index}]`;
915+
const swatch = swatches[index];
916+
if (!swatch || typeof swatch !== "object" || Array.isArray(swatch)) {
917+
return { ok: false, message: `${swatchPath} must be an object.` };
918+
}
919+
const swatchKeys = Object.keys(swatch);
920+
const allowedSwatchKeys = new Set(["symbol", "hex", "name"]);
921+
for (const swatchKey of swatchKeys) {
922+
if (!allowedSwatchKeys.has(swatchKey)) {
923+
return { ok: false, message: `${swatchPath}.${swatchKey} is not allowed.` };
924+
}
925+
}
926+
if (typeof swatch.symbol !== "string" || swatch.symbol.length !== 1) {
927+
return { ok: false, message: `${swatchPath}.symbol must be exactly one character.` };
928+
}
929+
if (typeof swatch.hex !== "string" || !/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(swatch.hex)) {
930+
return { ok: false, message: `${swatchPath}.hex must be #RRGGBB or #RRGGBBAA.` };
931+
}
932+
if (typeof swatch.name !== "string" || !swatch.name.trim()) {
933+
return { ok: false, message: `${swatchPath}.name is required.` };
934+
}
935+
}
936+
return { ok: true, message: "" };
937+
}
938+
939+
resolveActivePaletteForWorkspaceExport(activePayload, library) {
940+
let savedPaletteCount = 0;
941+
if (library && typeof library === "object" && !Array.isArray(library)) {
942+
for (const sessionId of Object.keys(library)) {
943+
const savedPayload = library[sessionId];
944+
if (!savedPayload || typeof savedPayload !== "object" || Array.isArray(savedPayload)) {
945+
continue;
946+
}
947+
if (savedPayload.toolId !== "palette-manager-v2") {
948+
continue;
949+
}
950+
if (!savedPayload.paletteJson || typeof savedPayload.paletteJson !== "object" || Array.isArray(savedPayload.paletteJson)) {
951+
continue;
952+
}
953+
if (!Array.isArray(savedPayload.paletteJson.swatches)) {
954+
continue;
955+
}
956+
savedPaletteCount += 1;
957+
}
958+
}
959+
if (!activePayload || typeof activePayload !== "object" || Array.isArray(activePayload)) {
960+
return { ok: false, message: "Active session payload could not be resolved for export." };
961+
}
962+
if (activePayload.toolId !== "palette-manager-v2") {
963+
if (savedPaletteCount > 1) {
964+
return {
965+
ok: false,
966+
message: "Multiple palettes are present. Select one active palette session before export."
967+
};
968+
}
969+
return {
970+
ok: false,
971+
message: "Workspace export requires one active palette in the current palette-manager-v2 session."
972+
};
973+
}
974+
const activePaletteValidation = this.validateWorkspaceToolSessionPayload(activePayload, "tools.workspace-v2.activeSession");
975+
if (!activePaletteValidation.ok) {
976+
return { ok: false, message: activePaletteValidation.message };
977+
}
978+
const swatchValidation = this.validatePaletteSwatchesForWorkspaceExport(
979+
activePayload.paletteJson.swatches,
980+
"tools.workspace-v2.activeSession.paletteJson.swatches"
981+
);
982+
if (!swatchValidation.ok) {
983+
return { ok: false, message: swatchValidation.message };
984+
}
985+
return {
986+
ok: true,
987+
activePalette: {
988+
swatches: this.cloneSessionValue(activePayload.paletteJson.swatches)
989+
}
990+
};
991+
}
992+
908993
selectedMergedSessionId() {
909994
return typeof this.mergedSessionIdNode.value === "string" ? this.mergedSessionIdNode.value.trim() : "";
910995
}
@@ -3048,16 +3133,25 @@ class WorkspaceV2SessionProducer {
30483133
}
30493134

30503135
buildWorkspaceSchemaDocument() {
3136+
this.lastWorkspaceExportBuildErrorMessage = "";
30513137
const activePayload = this.resolveActiveSessionPayloadForWorkspaceManifest();
30523138
if (!this.isValidSessionPayload(activePayload)) {
3139+
this.lastWorkspaceExportBuildErrorMessage = "Export error: No active Workspace V2 session is available to export.";
30533140
return null;
30543141
}
30553142
const library = this.readSessionLibrary();
30563143
if (library === null) {
3144+
this.lastWorkspaceExportBuildErrorMessage = "Export error: Session library could not be read.";
3145+
return null;
3146+
}
3147+
const activePaletteResolution = this.resolveActivePaletteForWorkspaceExport(activePayload, library);
3148+
if (!activePaletteResolution.ok) {
3149+
this.lastWorkspaceExportBuildErrorMessage = `Export error: ${activePaletteResolution.message}`;
30573150
return null;
30583151
}
30593152
const activeToolId = typeof activePayload.toolId === "string" && activePayload.toolId.trim() ? activePayload.toolId.trim() : "";
30603153
if (!activeToolId) {
3154+
this.lastWorkspaceExportBuildErrorMessage = "Export error: Active session toolId is missing.";
30613155
return null;
30623156
}
30633157
let activeHostContextId = typeof this.currentHostContextId === "string" ? this.currentHostContextId.trim() : "";
@@ -3086,6 +3180,9 @@ class WorkspaceV2SessionProducer {
30863180
id: `workspace-v2-${activeHostContextId}`,
30873181
name: `Workspace V2 Session ${activeToolId}`,
30883182
tools: {
3183+
palettes: {
3184+
activePalette: this.cloneSessionValue(activePaletteResolution.activePalette)
3185+
},
30893186
"workspace-v2": {
30903187
schema: "html-js-gaming.workspace-v2-session/1",
30913188
game: workspaceGame,
@@ -3104,7 +3201,7 @@ class WorkspaceV2SessionProducer {
31043201
this.setImportExportStatus("Export clicked");
31053202
const workspaceSchemaDocument = this.buildWorkspaceSchemaDocument();
31063203
if (!workspaceSchemaDocument) {
3107-
this.setImportExportStatus("Export error: No active Workspace V2 session is available to export.");
3204+
this.setImportExportStatus(this.lastWorkspaceExportBuildErrorMessage || "Export error: No active Workspace V2 session is available to export.");
31083205
return;
31093206
}
31103207
const invalidSavedSessionId = this.readInvalidPaletteSavedSessionId(workspaceSchemaDocument.tools["workspace-v2"].savedSessions);
@@ -3159,6 +3256,13 @@ class WorkspaceV2SessionProducer {
31593256
if (!Array.isArray(sessionPayload.paletteJson.swatches)) {
31603257
return { ok: false, message: `${sessionPath}.paletteJson.swatches must be an array.` };
31613258
}
3259+
const swatchValidation = this.validatePaletteSwatchesForWorkspaceExport(
3260+
sessionPayload.paletteJson.swatches,
3261+
`${sessionPath}.paletteJson.swatches`
3262+
);
3263+
if (!swatchValidation.ok) {
3264+
return swatchValidation;
3265+
}
31623266
if (Object.prototype.hasOwnProperty.call(sessionPayload.paletteJson, "colors")) {
31633267
return { ok: false, message: `${sessionPath}.paletteJson.colors is not supported. Use paletteJson.swatches.` };
31643268
}
@@ -3205,12 +3309,46 @@ class WorkspaceV2SessionProducer {
32053309
return { ok: false, message: "tools must be an object." };
32063310
}
32073311
const toolsKeys = Object.keys(workspaceDocument.tools);
3208-
const allowedToolsKeys = new Set(["workspace-v2"]);
3312+
const allowedToolsKeys = new Set(["palettes", "workspace-v2"]);
32093313
for (const key of toolsKeys) {
32103314
if (!allowedToolsKeys.has(key)) {
32113315
return { ok: false, message: `tools.${key} is not allowed.` };
32123316
}
32133317
}
3318+
if (!Object.prototype.hasOwnProperty.call(workspaceDocument.tools, "palettes")) {
3319+
return { ok: false, message: "tools.palettes is required." };
3320+
}
3321+
const palettesTool = workspaceDocument.tools.palettes;
3322+
if (!palettesTool || typeof palettesTool !== "object" || Array.isArray(palettesTool)) {
3323+
return { ok: false, message: "tools.palettes must be an object." };
3324+
}
3325+
const palettesToolKeys = Object.keys(palettesTool);
3326+
const allowedPalettesToolKeys = new Set(["activePalette"]);
3327+
for (const key of palettesToolKeys) {
3328+
if (!allowedPalettesToolKeys.has(key)) {
3329+
return { ok: false, message: `tools.palettes.${key} is not allowed.` };
3330+
}
3331+
}
3332+
if (!Object.prototype.hasOwnProperty.call(palettesTool, "activePalette")) {
3333+
return { ok: false, message: "tools.palettes.activePalette is required." };
3334+
}
3335+
if (!palettesTool.activePalette || typeof palettesTool.activePalette !== "object" || Array.isArray(palettesTool.activePalette)) {
3336+
return { ok: false, message: "tools.palettes.activePalette must be an object." };
3337+
}
3338+
const activePaletteKeys = Object.keys(palettesTool.activePalette);
3339+
const allowedActivePaletteKeys = new Set(["swatches"]);
3340+
for (const key of activePaletteKeys) {
3341+
if (!allowedActivePaletteKeys.has(key)) {
3342+
return { ok: false, message: `tools.palettes.activePalette.${key} is not allowed.` };
3343+
}
3344+
}
3345+
const activePaletteSwatchValidation = this.validatePaletteSwatchesForWorkspaceExport(
3346+
palettesTool.activePalette.swatches,
3347+
"tools.palettes.activePalette.swatches"
3348+
);
3349+
if (!activePaletteSwatchValidation.ok) {
3350+
return activePaletteSwatchValidation;
3351+
}
32143352
if (!Object.prototype.hasOwnProperty.call(workspaceDocument.tools, "workspace-v2")) {
32153353
return { ok: false, message: "tools.workspace-v2 is required." };
32163354
}

0 commit comments

Comments
 (0)