Skip to content

Commit 27baaca

Browse files
author
DavidQ
committed
Enforce single-source-of-truth for merge state and eliminate UI/data desync - PR_11_262
1 parent c94cd4e commit 27baaca

5 files changed

Lines changed: 274 additions & 20 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# PR_11_262 Merge-State Single Source Of Truth Report
2+
3+
## Scope
4+
Workspace V2 session merge state only.
5+
6+
## Files Changed
7+
- tools/workspace-v2/index.js
8+
- tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
9+
- docs/pr/PLAN_PR_11_262_WORKSPACE_V2_MERGE_STATE_SINGLE_SOURCE_OF_TRUTH_ENFORCEMENT.md
10+
- docs/pr/BUILD_PR_11_262_WORKSPACE_V2_MERGE_STATE_SINGLE_SOURCE_OF_TRUTH_ENFORCEMENT.md
11+
12+
## Implementation Summary
13+
- Enforced one authoritative source path for last successful merge state:
14+
- storage key `v2-last-merged`
15+
- validated through `resolveAuthoritativeLastMergedHostContextId()` against:
16+
- recent sessions (`v2-session-history`)
17+
- sessionStorage payload presence
18+
- merged-result metadata (`mergeResultMeta.isMergedResult === true`)
19+
- Removed cached `lastMergedHostContextId` property dependence.
20+
- Undo enable state now derives only from authoritative resolver.
21+
- Undo action now consumes only authoritative resolver output.
22+
- Stale authoritative record auto-clears and emits non-user-visible diagnostics:
23+
- `[WorkspaceV2UndoLastMerge] stale_authoritative_merge_record`
24+
- Merge preview remains transient and recompute-only by selection flow.
25+
- Existing stale merge text clearing from PR_11_261 remains active.
26+
27+
## Validation Commands
28+
1. `node --check tools/workspace-v2/index.js`
29+
- PASS
30+
2. `node --check tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs`
31+
- PASS
32+
3. `node tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs`
33+
- PASS
34+
- Results file: `tmp/v2-merge-state-ssot-results.json`
35+
- Failures: `[]`
36+
37+
## Targeted Validation Coverage
38+
- initial load: undo disabled with no authoritative record
39+
- after apply: undo enabled when authoritative record + recent + sessionStorage + merged metadata are valid
40+
- after undo: authoritative record cleared, undo disabled
41+
- after merged session deletion: authoritative record invalidates, undo disabled
42+
- stale storage path: authoritative record invalidates, undo disabled
43+
- stale non-merged metadata path: authoritative record invalidates, undo disabled
44+
- refresh recompute clears stale authoritative record
45+
46+
## Full Samples Smoke Decision
47+
- Skipped full samples smoke test.
48+
- Reason: PR scope is limited to Workspace V2 merge-state model wiring and a dedicated runtime test only.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# BUILD_PR_11_262_WORKSPACE_V2_MERGE_STATE_SINGLE_SOURCE_OF_TRUTH_ENFORCEMENT
2+
3+
## Purpose
4+
Implement authoritative merge-state model enforcement for Workspace V2 session merge/undo state.
5+
6+
## Files
7+
- tools/workspace-v2/index.js
8+
- tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
9+
- docs/dev/reports/PR_11_262_merge_state_ssot_report.md
10+
11+
## Implementation
12+
1. Introduce authoritative resolver:
13+
- `resolveAuthoritativeLastMergedHostContextId()`
14+
- validates merge record from storage against recent session history + sessionStorage + merged metadata
15+
2. Route undo enable-state and undo execution through resolver only.
16+
3. Remove cached last-merge property dependence.
17+
4. Preserve merge preview as transient (selection-change recompute only).
18+
5. Keep stale record diagnostics non-user-visible via `console.debug`.
19+
20+
## Acceptance
21+
- Undo is enabled only when authoritative record is valid against live data.
22+
- Stale authoritative record is cleared automatically.
23+
- Load/refresh recomputes from data, not cached flags.
24+
25+
## Validation
26+
- node --check tools/workspace-v2/index.js
27+
- node --check tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
28+
- node tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# PLAN_PR_11_262_WORKSPACE_V2_MERGE_STATE_SINGLE_SOURCE_OF_TRUTH_ENFORCEMENT
2+
3+
## Purpose
4+
Enforce one authoritative data source for Workspace V2 last-merge and undo availability state.
5+
6+
## Scope
7+
- tools/workspace-v2/index.js
8+
- tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
9+
- PR docs/report only
10+
11+
## Goals
12+
- Remove cached/duplicated last-merge flag dependence
13+
- Derive undo state from authoritative merge record validated against live data
14+
- Recompute on load/refresh from data
15+
- Auto-clear stale authoritative record when merged entry is missing/invalid
16+
17+
## Out of Scope
18+
- No merge algorithm changes
19+
- No UI redesign
20+
- No unrelated files
21+
22+
## Validation
23+
- node --check tools/workspace-v2/index.js
24+
- node --check tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
25+
- node tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const jsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
11+
const resultsPath = path.join(repoRoot, "tmp", "v2-merge-state-ssot-results.json");
12+
13+
function checkSyntax(filePath) {
14+
try {
15+
execFileSync(process.execPath, ["--check", filePath], {
16+
cwd: repoRoot,
17+
stdio: ["ignore", "pipe", "pipe"]
18+
});
19+
return { ok: true, error: "" };
20+
} catch (error) {
21+
return { ok: false, error: (error?.stderr || error?.stdout || error?.message || "").toString().trim() };
22+
}
23+
}
24+
25+
function resolveAuthoritativeLastMergedHostContextId(lastMergedHostContextId, history, sessionStorageMap) {
26+
if (!lastMergedHostContextId) {
27+
return "";
28+
}
29+
const mergedRecentEntry = history.find((entry) => entry.hostContextId === lastMergedHostContextId);
30+
const existsInRecent = Boolean(mergedRecentEntry);
31+
const existsInSessionStorage = Object.prototype.hasOwnProperty.call(sessionStorageMap, lastMergedHostContextId);
32+
const existsAsMergedRecent = Boolean(
33+
mergedRecentEntry &&
34+
mergedRecentEntry.payload &&
35+
typeof mergedRecentEntry.payload === "object" &&
36+
!Array.isArray(mergedRecentEntry.payload) &&
37+
mergedRecentEntry.payload.mergeResultMeta &&
38+
typeof mergedRecentEntry.payload.mergeResultMeta === "object" &&
39+
mergedRecentEntry.payload.mergeResultMeta.isMergedResult === true
40+
);
41+
return existsInRecent && existsInSessionStorage && existsAsMergedRecent ? lastMergedHostContextId : "";
42+
}
43+
44+
function undoEnabled(lastMergedHostContextId, history, sessionStorageMap) {
45+
return Boolean(resolveAuthoritativeLastMergedHostContextId(lastMergedHostContextId, history, sessionStorageMap));
46+
}
47+
48+
export function run() {
49+
const failures = [];
50+
const jsExists = fs.existsSync(jsPath);
51+
const js = jsExists ? fs.readFileSync(jsPath, "utf8") : "";
52+
const jsSyntax = checkSyntax(jsPath);
53+
const testSyntax = checkSyntax(path.join(repoRoot, "tests", "runtime", "V2MergeStateSingleSourceOfTruth.test.mjs"));
54+
55+
if (!jsExists) failures.push("Missing tools/workspace-v2/index.js.");
56+
if (!jsSyntax.ok) failures.push("tools/workspace-v2/index.js failed syntax check.");
57+
if (!testSyntax.ok) failures.push("tests/runtime/V2MergeStateSingleSourceOfTruth.test.mjs failed syntax check.");
58+
59+
const requiredTokens = [
60+
"resolveAuthoritativeLastMergedHostContextId()",
61+
"const mergedHostContextId = this.resolveAuthoritativeLastMergedHostContextId();",
62+
"existsAsMergedRecent",
63+
"this.writeLastMergedHostContextId(\"\");",
64+
"console.debug(\"[WorkspaceV2UndoLastMerge] stale_authoritative_merge_record\", {",
65+
"const lastMergedId = this.resolveAuthoritativeLastMergedHostContextId();"
66+
];
67+
requiredTokens.forEach((token) => {
68+
if (!js.includes(token)) failures.push(`Missing merge-state SSoT token/text: ${token}`);
69+
});
70+
71+
const mergedId = "asset-browser-v2-merged-1777777777777-abcd1234";
72+
const validMergedPayload = {
73+
version: "v2",
74+
toolId: "asset-browser-v2",
75+
mergeResultMeta: { isMergedResult: true }
76+
};
77+
78+
const initialHistory = [{ hostContextId: "asset-browser-v2-regular", payload: { version: "v2", toolId: "asset-browser-v2" } }];
79+
const initialStorage = { "asset-browser-v2-regular": "{\"version\":\"v2\",\"toolId\":\"asset-browser-v2\"}" };
80+
if (undoEnabled("", initialHistory, initialStorage)) failures.push("Undo must be disabled on initial load.");
81+
82+
const applyHistory = [
83+
{ hostContextId: mergedId, payload: validMergedPayload },
84+
...initialHistory
85+
];
86+
const applyStorage = {
87+
...initialStorage,
88+
[mergedId]: JSON.stringify(validMergedPayload)
89+
};
90+
if (!undoEnabled(mergedId, applyHistory, applyStorage)) failures.push("Undo must be enabled immediately after successful merge apply.");
91+
92+
const undoHistory = initialHistory;
93+
const undoStorage = initialStorage;
94+
if (undoEnabled("", undoHistory, undoStorage)) failures.push("Undo must be disabled immediately after undo clears authoritative record.");
95+
96+
const deletedMergedHistory = initialHistory;
97+
const deletedMergedStorage = applyStorage;
98+
if (undoEnabled(mergedId, deletedMergedHistory, deletedMergedStorage)) failures.push("Undo must be disabled if merged recent entry is deleted.");
99+
100+
const staleStorageHistory = applyHistory;
101+
const staleStorage = initialStorage;
102+
if (undoEnabled(mergedId, staleStorageHistory, staleStorage)) failures.push("Undo must be disabled if merged session storage entry is missing.");
103+
104+
const staleMetaHistory = [
105+
{ hostContextId: mergedId, payload: { version: "v2", toolId: "asset-browser-v2" } },
106+
...initialHistory
107+
];
108+
if (undoEnabled(mergedId, staleMetaHistory, applyStorage)) failures.push("Undo must be disabled if authoritative recent entry is not a merged result.");
109+
110+
const refreshRecompute = resolveAuthoritativeLastMergedHostContextId(mergedId, deletedMergedHistory, deletedMergedStorage);
111+
if (refreshRecompute !== "") failures.push("Refresh recompute should clear stale authoritative merge record.");
112+
113+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
114+
fs.writeFileSync(resultsPath, `${JSON.stringify({
115+
generatedAt: new Date().toISOString(),
116+
failures,
117+
checks: { jsExists, jsSyntax, testSyntax },
118+
scenarios: {
119+
initialUndoEnabled: undoEnabled("", initialHistory, initialStorage),
120+
applyUndoEnabled: undoEnabled(mergedId, applyHistory, applyStorage),
121+
postUndoUndoEnabled: undoEnabled("", undoHistory, undoStorage),
122+
deletedMergedUndoEnabled: undoEnabled(mergedId, deletedMergedHistory, deletedMergedStorage),
123+
staleStorageUndoEnabled: undoEnabled(mergedId, staleStorageHistory, staleStorage),
124+
staleMetaUndoEnabled: undoEnabled(mergedId, staleMetaHistory, applyStorage),
125+
refreshRecompute
126+
}
127+
}, null, 2)}\n`, "utf8");
128+
129+
console.log(`v2 merge-state-ssot results: ${resultsPath}`);
130+
assert.equal(failures.length, 0, `V2 merge-state-ssot failures: ${failures.join(" | ")}`);
131+
return { failures };
132+
}
133+
134+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
135+
try {
136+
const summary = run();
137+
console.log(JSON.stringify(summary, null, 2));
138+
} catch (error) {
139+
console.error(error);
140+
process.exitCode = 1;
141+
}
142+
}

tools/workspace-v2/index.js

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ class WorkspaceV2SessionProducer {
8484
this.currentHostContextId = "";
8585
this.pendingMergePreview = null;
8686
this.lastMergedSessionResult = null;
87-
this.lastMergedHostContextId = "";
8887
this.mergeOutputSelectionKey = "";
8988
this.recentSessionInventory = [];
9089
this.loadFixtureButton.addEventListener("click", () => {
@@ -195,7 +194,6 @@ class WorkspaceV2SessionProducer {
195194
}
196195
});
197196
this.decodeSessionParamFromUrl();
198-
this.lastMergedHostContextId = this.readLastMergedHostContextId();
199197
this.registerSnapshotHook();
200198
this.renderSessionLibrary();
201199
this.renderSessionHistory();
@@ -316,35 +314,50 @@ class WorkspaceV2SessionProducer {
316314

317315
writeLastMergedHostContextId(hostContextId) {
318316
if (typeof hostContextId !== "string" || !hostContextId.trim()) {
319-
this.lastMergedHostContextId = "";
320317
sessionStorage.removeItem(this.lastMergedSessionStorageKey);
321318
return;
322319
}
323-
this.lastMergedHostContextId = hostContextId.trim();
324-
sessionStorage.setItem(this.lastMergedSessionStorageKey, this.lastMergedHostContextId);
320+
sessionStorage.setItem(this.lastMergedSessionStorageKey, hostContextId.trim());
325321
}
326322

327-
updateUndoLastMergeState() {
328-
const mergedHostContextId = typeof this.lastMergedHostContextId === "string"
329-
? this.lastMergedHostContextId.trim()
330-
: "";
323+
resolveAuthoritativeLastMergedHostContextId() {
324+
const mergedHostContextId = this.readLastMergedHostContextId();
331325
if (!mergedHostContextId) {
332-
this.undoLastMergeButton.disabled = true;
333-
return;
326+
return "";
334327
}
335328
const history = this.readSessionHistory();
336-
const existsInRecent = history.some((entry) => entry.hostContextId === mergedHostContextId);
329+
const mergedRecentEntry = history.find((entry) => entry.hostContextId === mergedHostContextId);
330+
const existsInRecent = Boolean(mergedRecentEntry);
337331
const existsInSessionStorage = typeof sessionStorage.getItem(mergedHostContextId) === "string";
338-
const hasRecentMerged = Boolean(mergedHostContextId && existsInRecent && existsInSessionStorage);
339-
if (!hasRecentMerged) {
340-
console.debug("[WorkspaceV2UndoLastMerge] stale_last_merged_context", {
332+
const existsAsMergedRecent = Boolean(
333+
mergedRecentEntry &&
334+
mergedRecentEntry.payload &&
335+
typeof mergedRecentEntry.payload === "object" &&
336+
!Array.isArray(mergedRecentEntry.payload) &&
337+
mergedRecentEntry.payload.mergeResultMeta &&
338+
typeof mergedRecentEntry.payload.mergeResultMeta === "object" &&
339+
mergedRecentEntry.payload.mergeResultMeta.isMergedResult === true
340+
);
341+
if (!existsInRecent || !existsInSessionStorage || !existsAsMergedRecent) {
342+
console.debug("[WorkspaceV2UndoLastMerge] stale_authoritative_merge_record", {
341343
lastMergedHostContextId: mergedHostContextId,
342344
existsInRecent,
343-
existsInSessionStorage
345+
existsInSessionStorage,
346+
existsAsMergedRecent
344347
});
345348
this.writeLastMergedHostContextId("");
349+
return "";
350+
}
351+
return mergedHostContextId;
352+
}
353+
354+
updateUndoLastMergeState() {
355+
const mergedHostContextId = this.resolveAuthoritativeLastMergedHostContextId();
356+
if (!mergedHostContextId) {
357+
this.undoLastMergeButton.disabled = true;
358+
return;
346359
}
347-
this.undoLastMergeButton.disabled = !hasRecentMerged;
360+
this.undoLastMergeButton.disabled = false;
348361
}
349362

350363
readActiveSessionPayloadForLibraryActions() {
@@ -879,7 +892,7 @@ class WorkspaceV2SessionProducer {
879892
}
880893

881894
undoLastMerge() {
882-
const lastMergedId = typeof this.lastMergedHostContextId === "string" ? this.lastMergedHostContextId.trim() : "";
895+
const lastMergedId = this.resolveAuthoritativeLastMergedHostContextId();
883896
if (!lastMergedId) {
884897
this.updateUndoLastMergeState();
885898
this.clearMergePanelTransientState(
@@ -2037,7 +2050,6 @@ class WorkspaceV2SessionProducer {
20372050

20382051
clearSessionStorage(emitStatus = true) {
20392052
sessionStorage.clear();
2040-
this.lastMergedHostContextId = "";
20412053
this.clearPersistedSessionSelection();
20422054
this.diffLeftSelect.value = "";
20432055
this.diffRightSelect.value = "";
@@ -2095,7 +2107,6 @@ class WorkspaceV2SessionProducer {
20952107
this.shareUrlNode.value = "";
20962108
this.sessionNameNode.value = "";
20972109
this.lastMergedSessionResult = null;
2098-
this.lastMergedHostContextId = "";
20992110
this.mergeOutputSelectionKey = "";
21002111
this.mergedSessionIdNode.value = "";
21012112
this.mergedSessionStatusNode.textContent = "No merged session result to save.";

0 commit comments

Comments
 (0)