Skip to content

Commit e884c78

Browse files
committed
Add recovery workflow contract for tool states - PR_26152_068-tool-state-recovery-contract
1 parent 1e626a9 commit e884c78

4 files changed

Lines changed: 653 additions & 3 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Tool State Recovery Contract Validation
2+
3+
PR: PR_26152_068-tool-state-recovery-contract
4+
5+
Date: 2026-06-02
6+
7+
## Scope
8+
9+
Extended the reusable Tool State contract with recovery behavior as contract data and pure contract transition helpers.
10+
11+
No database implementation, authentication implementation, runtime/UI changes, page changes, CSS changes, HTML changes, or dependency changes were made.
12+
13+
## Files Validated
14+
15+
- `src/shared/contracts/toolStateContract.js`
16+
- `tests/fixtures/tool-states/tool-state-scenarios.json`
17+
- `tests/shared/ToolStateContract.test.mjs`
18+
- `src/shared/contracts/projectContract.js`
19+
- `tests/shared/ProjectContract.test.mjs`
20+
- `src/shared/contracts/identityPermissionsContract.js`
21+
- `tests/shared/IdentityPermissionsContract.test.mjs`
22+
23+
## Validation Lanes
24+
25+
- Lanes executed: contract - Tool State recovery rules, startup choices, Resume/Open behavior, discard, promotion, timestamp/tool/project validation, and saved-state separation.
26+
- Compatibility executed: project contract and identity/permissions contract - confirms recovery remains compatible with approved project and identity contract layers.
27+
- Lanes skipped: runtime, integration, engine, samples, recovery/UAT - this PR does not change runtime behavior, handoff contracts, engine code, samples, or UAT surfaces.
28+
- Samples decision: SKIP because this PR does not touch samples or sample JSON.
29+
- Playwright impacted: No. This PR adds shared contract data, fixtures, and targeted node tests only.
30+
31+
## Commands
32+
33+
```text
34+
node ./scripts/run-node-test-files.mjs tests/shared/ToolStateContract.test.mjs tests/shared/ProjectContract.test.mjs tests/shared/IdentityPermissionsContract.test.mjs
35+
```
36+
37+
Result:
38+
39+
```text
40+
PASS tests/shared/ToolStateContract.test.mjs
41+
PASS tests/shared/ProjectContract.test.mjs
42+
PASS tests/shared/IdentityPermissionsContract.test.mjs
43+
44+
3/3 targeted node test file(s) passed.
45+
```
46+
47+
```text
48+
git diff --check -- src/shared/contracts/toolStateContract.js tests/shared/ToolStateContract.test.mjs tests/fixtures/tool-states/tool-state-scenarios.json
49+
```
50+
51+
Result:
52+
53+
```text
54+
PASS - no whitespace errors reported.
55+
WARN - Git reported LF-to-CRLF working-copy warnings for edited fixture/test files.
56+
```
57+
58+
## Contract Coverage
59+
60+
- Recovery is separate from Saved State: PASS.
61+
- Recovery is temporary and user-selected: PASS.
62+
- Recovery is never auto-loaded or auto-applied: PASS.
63+
- Startup choices show Resume and Open when recovery exists: PASS.
64+
- Resume loads Recovery State: PASS.
65+
- Open loads Saved State: PASS.
66+
- Recovery does not overwrite Saved State automatically: PASS.
67+
- Recovery survives Open action: PASS.
68+
- Recovery can be discarded: PASS.
69+
- Recovery can be promoted to Saved State: PASS.
70+
- Recovery must show timestamp: PASS.
71+
- Recovery must identify tool and project: PASS.
72+
- Valid recovery exists/newer/older/without saved state fixtures pass: PASS.
73+
- Invalid recovery without owner/tool type or linked to wrong project fixtures fail with expected codes: PASS.
74+
75+
## Scope Guard
76+
77+
- No database files changed.
78+
- No authentication files changed.
79+
- No runtime/UI files changed.
80+
- No CSS files changed.
81+
- No HTML files changed.
82+
- No dependencies were added.
83+
- No samples tests were run.
84+
- No repo-wide tests were run.

src/shared/contracts/toolStateContract.js

Lines changed: 255 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import {
1919
} from "./projectContract.js";
2020

2121
export const TOOL_STATE_CONTRACT_ID = "gamefoundrystudio.tool-state.lifecycle";
22-
export const TOOL_STATE_CONTRACT_VERSION = "1.0.0";
22+
export const TOOL_STATE_CONTRACT_VERSION = "1.1.0";
23+
24+
export const TOOL_STATE_SLOTS = Object.freeze({
25+
CURRENT_SAVED_STATE: "current-saved-state",
26+
RECOVERY_STATE: "recovery-state",
27+
VERSION_HISTORY: "version-history",
28+
});
2329

2430
export const TOOL_STATE_FIELDS = Object.freeze({
2531
TOOL_STATE_ID: "toolStateId",
@@ -71,6 +77,40 @@ export const TOOL_STATE_RELATIONSHIP_LIST = Object.freeze([
7177
TOOL_STATE_RELATIONSHIPS.MAY_BE_EXPORTED,
7278
]);
7379

80+
export const TOOL_STATE_RECOVERY_ACTIONS = Object.freeze({
81+
RESUME: "resume",
82+
OPEN: "open",
83+
DISCARD: "discard",
84+
PROMOTE: "promote",
85+
});
86+
87+
export const TOOL_STATE_STARTUP_ACTION_LIST = Object.freeze([
88+
TOOL_STATE_RECOVERY_ACTIONS.RESUME,
89+
TOOL_STATE_RECOVERY_ACTIONS.OPEN,
90+
]);
91+
92+
export const TOOL_STATE_RECOVERY_AGE = Object.freeze({
93+
NEWER_THAN_SAVED: "newer-than-saved",
94+
OLDER_THAN_SAVED: "older-than-saved",
95+
SAME_AS_SAVED: "same-as-saved",
96+
WITHOUT_SAVED_STATE: "without-saved-state",
97+
});
98+
99+
export const TOOL_STATE_RECOVERY_RULES = Object.freeze({
100+
SEPARATE_FROM_SAVED_STATE: true,
101+
TEMPORARY: true,
102+
NEVER_OVERWRITES_SAVED_STATE_AUTOMATICALLY: true,
103+
USER_SELECTED: true,
104+
MAY_BE_AUTOSAVED: true,
105+
MAY_BE_DISCARDED: true,
106+
MAY_BE_PROMOTED_TO_SAVED_STATE: true,
107+
NEVER_AUTO_LOADED: true,
108+
NEVER_AUTO_APPLIED: true,
109+
REMAINS_AVAILABLE_UNTIL_SAVED_OR_DISCARDED: true,
110+
REQUIRES_TIMESTAMP: true,
111+
IDENTIFIES_TOOL_AND_PROJECT: true,
112+
});
113+
74114
export const TOOL_STATE_PORTABLE_EXPORT_FIELDS = Object.freeze([
75115
TOOL_STATE_FIELDS.TOOL_STATE_ID,
76116
TOOL_STATE_FIELDS.TOOL_TYPE,
@@ -95,6 +135,12 @@ export const TOOL_STATE_CONTRACT_ERRORS = Object.freeze({
95135
RELATIONSHIP_INVALID: "TOOL_STATE_RELATIONSHIP_INVALID",
96136
ASSET_REF_INVALID: "TOOL_STATE_ASSET_REF_INVALID",
97137
PORTABLE_EXPORT_INVALID: "TOOL_STATE_PORTABLE_EXPORT_INVALID",
138+
RECOVERY_OWNER_REQUIRED: "TOOL_STATE_RECOVERY_OWNER_REQUIRED",
139+
RECOVERY_PROJECT_REQUIRED: "TOOL_STATE_RECOVERY_PROJECT_REQUIRED",
140+
RECOVERY_TOOL_TYPE_REQUIRED: "TOOL_STATE_RECOVERY_TOOL_TYPE_REQUIRED",
141+
RECOVERY_TIMESTAMP_REQUIRED: "TOOL_STATE_RECOVERY_TIMESTAMP_REQUIRED",
142+
RECOVERY_TIMESTAMP_INVALID: "TOOL_STATE_RECOVERY_TIMESTAMP_INVALID",
143+
RECOVERY_PROJECT_MISMATCH: "TOOL_STATE_RECOVERY_PROJECT_MISMATCH",
98144
});
99145

100146
export function isToolStateStatus(value) {
@@ -369,10 +415,194 @@ export function validatePortableToolStateExport(portableExport) {
369415
});
370416
}
371417

418+
export function validateToolStateRecoveryContract({
419+
savedState = null,
420+
recoveryState = null,
421+
} = {}) {
422+
const errors = [];
423+
424+
if (!recoveryState) {
425+
return Object.freeze({
426+
valid: true,
427+
errors: Object.freeze(errors),
428+
});
429+
}
430+
431+
if (!hasNonEmptyString(recoveryState.ownerId)) {
432+
errors.push(createContractError(
433+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_OWNER_REQUIRED,
434+
"Recovery state requires ownerId.",
435+
"recoveryState.ownerId"
436+
));
437+
}
438+
439+
if (!hasNonEmptyString(recoveryState.projectId)) {
440+
errors.push(createContractError(
441+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_PROJECT_REQUIRED,
442+
"Recovery state requires projectId.",
443+
"recoveryState.projectId"
444+
));
445+
}
446+
447+
if (!hasNonEmptyString(recoveryState.toolType)) {
448+
errors.push(createContractError(
449+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_TOOL_TYPE_REQUIRED,
450+
"Recovery state requires toolType.",
451+
"recoveryState.toolType"
452+
));
453+
}
454+
455+
if (!hasNonEmptyString(recoveryState.timestamp)) {
456+
errors.push(createContractError(
457+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_TIMESTAMP_REQUIRED,
458+
"Recovery state requires timestamp.",
459+
"recoveryState.timestamp"
460+
));
461+
} else if (!isTimestamp(recoveryState.timestamp)) {
462+
errors.push(createContractError(
463+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_TIMESTAMP_INVALID,
464+
"Recovery state timestamp must be a valid timestamp.",
465+
"recoveryState.timestamp"
466+
));
467+
}
468+
469+
if (savedState && hasNonEmptyString(savedState.projectId) && recoveryState.projectId !== savedState.projectId) {
470+
errors.push(createContractError(
471+
TOOL_STATE_CONTRACT_ERRORS.RECOVERY_PROJECT_MISMATCH,
472+
"Recovery state projectId must match saved state projectId.",
473+
"recoveryState.projectId"
474+
));
475+
}
476+
477+
return Object.freeze({
478+
valid: errors.length === 0,
479+
errors: Object.freeze(errors),
480+
});
481+
}
482+
483+
export function getToolStateRecoveryAge({
484+
savedState = null,
485+
recoveryState = null,
486+
} = {}) {
487+
if (!savedState) {
488+
return TOOL_STATE_RECOVERY_AGE.WITHOUT_SAVED_STATE;
489+
}
490+
491+
const recoveryTime = Date.parse(recoveryState?.timestamp || "");
492+
const savedTime = Date.parse(savedState.savedAt || savedState.timestamp || "");
493+
494+
if (!Number.isFinite(recoveryTime) || !Number.isFinite(savedTime)) {
495+
return TOOL_STATE_RECOVERY_AGE.WITHOUT_SAVED_STATE;
496+
}
497+
498+
if (recoveryTime > savedTime) {
499+
return TOOL_STATE_RECOVERY_AGE.NEWER_THAN_SAVED;
500+
}
501+
502+
if (recoveryTime < savedTime) {
503+
return TOOL_STATE_RECOVERY_AGE.OLDER_THAN_SAVED;
504+
}
505+
506+
return TOOL_STATE_RECOVERY_AGE.SAME_AS_SAVED;
507+
}
508+
509+
export function getToolStateStartupChoices({
510+
savedState = null,
511+
recoveryState = null,
512+
} = {}) {
513+
if (recoveryState) {
514+
return Object.freeze([
515+
Object.freeze({
516+
action: TOOL_STATE_RECOVERY_ACTIONS.RESUME,
517+
label: "Resume",
518+
stateSlot: TOOL_STATE_SLOTS.RECOVERY_STATE,
519+
enabled: true,
520+
}),
521+
Object.freeze({
522+
action: TOOL_STATE_RECOVERY_ACTIONS.OPEN,
523+
label: "Open",
524+
stateSlot: TOOL_STATE_SLOTS.CURRENT_SAVED_STATE,
525+
enabled: Boolean(savedState),
526+
}),
527+
]);
528+
}
529+
530+
return Object.freeze([
531+
Object.freeze({
532+
action: TOOL_STATE_RECOVERY_ACTIONS.OPEN,
533+
label: "Open",
534+
stateSlot: TOOL_STATE_SLOTS.CURRENT_SAVED_STATE,
535+
enabled: Boolean(savedState),
536+
}),
537+
]);
538+
}
539+
540+
export function resolveToolStateStartupState(context = {}) {
541+
return Object.freeze({
542+
action: null,
543+
stateSlot: null,
544+
loadedState: null,
545+
recoveryAvailable: Boolean(context.recoveryState),
546+
choices: getToolStateStartupChoices(context),
547+
});
548+
}
549+
550+
export function selectToolStateStartupAction(context = {}, action) {
551+
if (action === TOOL_STATE_RECOVERY_ACTIONS.RESUME) {
552+
return Object.freeze({
553+
action,
554+
stateSlot: TOOL_STATE_SLOTS.RECOVERY_STATE,
555+
loadedState: cloneJsonData(context.recoveryState),
556+
savedState: cloneJsonData(context.savedState),
557+
recoveryState: cloneJsonData(context.recoveryState),
558+
});
559+
}
560+
561+
if (action === TOOL_STATE_RECOVERY_ACTIONS.OPEN) {
562+
return Object.freeze({
563+
action,
564+
stateSlot: TOOL_STATE_SLOTS.CURRENT_SAVED_STATE,
565+
loadedState: cloneJsonData(context.savedState),
566+
savedState: cloneJsonData(context.savedState),
567+
recoveryState: cloneJsonData(context.recoveryState),
568+
});
569+
}
570+
571+
return Object.freeze({
572+
action: null,
573+
stateSlot: null,
574+
loadedState: null,
575+
savedState: cloneJsonData(context.savedState),
576+
recoveryState: cloneJsonData(context.recoveryState),
577+
});
578+
}
579+
580+
export function discardToolStateRecovery(context = {}) {
581+
return Object.freeze({
582+
savedState: cloneJsonData(context.savedState),
583+
recoveryState: null,
584+
versionHistory: cloneJsonData(context.versionHistory ?? []),
585+
});
586+
}
587+
588+
export function promoteToolStateRecovery(context = {}) {
589+
const promotedSavedState = recoveryStateAsSavedState(context.recoveryState);
590+
591+
return Object.freeze({
592+
savedState: promotedSavedState,
593+
recoveryState: null,
594+
versionHistory: cloneJsonData(context.versionHistory ?? []),
595+
});
596+
}
597+
372598
function hasNonEmptyString(value) {
373599
return typeof value === "string" && value.trim().length > 0;
374600
}
375601

602+
function isTimestamp(value) {
603+
return hasNonEmptyString(value) && Number.isFinite(Date.parse(value));
604+
}
605+
376606
function normalizeStringArray(values) {
377607
if (!Array.isArray(values)) {
378608
return [];
@@ -385,6 +615,30 @@ function clonePortablePayload(payload) {
385615
return Object.freeze(JSON.parse(JSON.stringify(payload)));
386616
}
387617

618+
function cloneJsonData(value) {
619+
if (value === undefined || value === null) {
620+
return null;
621+
}
622+
623+
return JSON.parse(JSON.stringify(value));
624+
}
625+
626+
function recoveryStateAsSavedState(recoveryState) {
627+
if (!recoveryState) {
628+
return null;
629+
}
630+
631+
const savedState = cloneJsonData(recoveryState);
632+
delete savedState.autosaved;
633+
delete savedState.temporary;
634+
delete savedState.timestamp;
635+
636+
return Object.freeze({
637+
...savedState,
638+
savedAt: recoveryState.timestamp,
639+
});
640+
}
641+
388642
function createContractError(code, message, path) {
389643
return Object.freeze({ code, message, path });
390644
}

0 commit comments

Comments
 (0)