@zq-silk/yui 0.2.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/** The one production transition registry and its baseline-to-current delivery gate. */
|
|
2
|
+
import { latestStorageVersionState } from "../upgrade/recordVersions.js";
|
|
3
|
+
import { assertBaselineConsistency, baselineStorageVersionState } from "./baseline.js";
|
|
4
|
+
import { planMigration } from "./planner.js";
|
|
5
|
+
import { MigrationRegistry } from "./registry.js";
|
|
6
|
+
const FINAL_REVIEW_AGGREGATE_FROM_VERSION = 16;
|
|
7
|
+
const FINAL_REVIEW_AGGREGATE_TO_VERSION = 17;
|
|
8
|
+
const WORK_ITEM_FROM_VERSION = 6;
|
|
9
|
+
const WORK_ITEM_TO_VERSION = 7;
|
|
10
|
+
const WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION = 7;
|
|
11
|
+
const WORK_ITEM_GIT_SNAPSHOT_TO_VERSION = 8;
|
|
12
|
+
const WORK_ITEM_GROUP_HISTORY_FROM_VERSION = 8;
|
|
13
|
+
const WORK_ITEM_GROUP_HISTORY_TO_VERSION = 9;
|
|
14
|
+
const AGENT_RUN_FROM_VERSION = 5;
|
|
15
|
+
const AGENT_RUN_TO_VERSION = 6;
|
|
16
|
+
const REVIEW_ROUND_FROM_VERSION = 2;
|
|
17
|
+
const REVIEW_ROUND_TO_VERSION = 3;
|
|
18
|
+
const REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION = 3;
|
|
19
|
+
const REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION = 4;
|
|
20
|
+
const ACTIVE_RUN_POINTER_FROM_VERSION = 1;
|
|
21
|
+
const ACTIVE_RUN_POINTER_TO_VERSION = 2;
|
|
22
|
+
const ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION = 2;
|
|
23
|
+
const ACTIVE_RUN_POINTER_NAMESPACE_TO_VERSION = 3;
|
|
24
|
+
const MANAGED_WORKSPACE_FROM_VERSION = 1;
|
|
25
|
+
const MANAGED_WORKSPACE_TO_VERSION = 2;
|
|
26
|
+
/**
|
|
27
|
+
* Build the authoritative production graph. Transition intent and executable
|
|
28
|
+
* transforms are registered together here; compatible loading and offline
|
|
29
|
+
* migration consume this same graph.
|
|
30
|
+
*/
|
|
31
|
+
export function createProductionStorageRegistry() {
|
|
32
|
+
assertBaselineConsistency();
|
|
33
|
+
const registry = new MigrationRegistry().registerOfflineMigration({
|
|
34
|
+
axis: "aggregate",
|
|
35
|
+
fromVersion: FINAL_REVIEW_AGGREGATE_FROM_VERSION,
|
|
36
|
+
toVersion: FINAL_REVIEW_AGGREGATE_TO_VERSION,
|
|
37
|
+
preconditions: requireAggregateV16Snapshot,
|
|
38
|
+
transform: migrateAggregateV16ToV17,
|
|
39
|
+
declaredEffects: []
|
|
40
|
+
})
|
|
41
|
+
.registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
|
|
42
|
+
.registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION, WORK_ITEM_GIT_SNAPSHOT_TO_VERSION, "workItems"))
|
|
43
|
+
.registerOfflineMigration(workItemExecutionGroupHistoryStep())
|
|
44
|
+
.registerOfflineMigration(recordFamilyStep("agentRun", AGENT_RUN_FROM_VERSION, AGENT_RUN_TO_VERSION, "agentRuns"))
|
|
45
|
+
.registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_FROM_VERSION, REVIEW_ROUND_TO_VERSION, "reviewRounds"))
|
|
46
|
+
.registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION, REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION, "reviewRounds"))
|
|
47
|
+
.registerOfflineMigration(recordFamilyStep("activeRunPointer", ACTIVE_RUN_POINTER_FROM_VERSION, ACTIVE_RUN_POINTER_TO_VERSION, "activeRuns"))
|
|
48
|
+
.registerOfflineMigration(managedWorkspaceFamilyStep())
|
|
49
|
+
.registerOfflineMigration(activeRunPointerNamespaceStep());
|
|
50
|
+
assertRegistryCoversBaselineToCurrent(registry);
|
|
51
|
+
return registry;
|
|
52
|
+
}
|
|
53
|
+
function workItemExecutionGroupHistoryStep() {
|
|
54
|
+
return {
|
|
55
|
+
axis: "record",
|
|
56
|
+
recordKind: "workItem",
|
|
57
|
+
fromVersion: WORK_ITEM_GROUP_HISTORY_FROM_VERSION,
|
|
58
|
+
toVersion: WORK_ITEM_GROUP_HISTORY_TO_VERSION,
|
|
59
|
+
preconditions: (snapshot) => requireRecordFamilyVersion(snapshot, "workItem", WORK_ITEM_GROUP_HISTORY_FROM_VERSION, "workItems"),
|
|
60
|
+
transform: migrateWorkItemExecutionGroupHistory,
|
|
61
|
+
declaredEffects: []
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* ManagedWorkspace is stored once in the owner-keyed workspace map and copied
|
|
66
|
+
* into Candidates, AgentRuns, and ReviewRounds as immutable lifecycle
|
|
67
|
+
* evidence. Those copies are the same record family, not independent parent
|
|
68
|
+
* records: advancing only the map leaves an otherwise legal historical Home
|
|
69
|
+
* unreadable by the strict current parser.
|
|
70
|
+
*/
|
|
71
|
+
function managedWorkspaceFamilyStep() {
|
|
72
|
+
return {
|
|
73
|
+
axis: "record",
|
|
74
|
+
recordKind: "managedWorkspace",
|
|
75
|
+
fromVersion: MANAGED_WORKSPACE_FROM_VERSION,
|
|
76
|
+
toVersion: MANAGED_WORKSPACE_TO_VERSION,
|
|
77
|
+
preconditions: requireLegacyManagedWorkspaceFamily,
|
|
78
|
+
transform: migrateManagedWorkspaceFamily,
|
|
79
|
+
declaredEffects: []
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function requireLegacyManagedWorkspaceFamily(snapshot) {
|
|
83
|
+
requireRecordFamilyVersion(snapshot, "managedWorkspace", MANAGED_WORKSPACE_FROM_VERSION, "managedWorkspaces");
|
|
84
|
+
visitEmbeddedManagedWorkspaces(snapshot, (workspace, label) => {
|
|
85
|
+
requireManagedWorkspaceVersion(workspace, MANAGED_WORKSPACE_FROM_VERSION, label);
|
|
86
|
+
return workspace;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function migrateManagedWorkspaceFamily(snapshot) {
|
|
90
|
+
requireLegacyManagedWorkspaceFamily(snapshot);
|
|
91
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
92
|
+
const schemaManifest = {
|
|
93
|
+
...snapshot.schemaManifest,
|
|
94
|
+
recordVersions: {
|
|
95
|
+
...manifestVersions,
|
|
96
|
+
managedWorkspace: MANAGED_WORKSPACE_TO_VERSION
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
if (snapshot.state === null)
|
|
100
|
+
return { schemaManifest, state: null };
|
|
101
|
+
const migrated = visitEmbeddedManagedWorkspaces(snapshot, (workspace) => ({ ...workspace, schemaVersion: MANAGED_WORKSPACE_TO_VERSION }));
|
|
102
|
+
return { schemaManifest, state: migrated.state };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Rewrite every persisted ManagedWorkspace occurrence while preserving the
|
|
106
|
+
* surrounding record bytes. The direct map is handled by the ordinary
|
|
107
|
+
* record-family migration; this traversal covers the immutable embedded
|
|
108
|
+
* snapshots which the strict validators also treat as ManagedWorkspace.
|
|
109
|
+
*/
|
|
110
|
+
function visitEmbeddedManagedWorkspaces(snapshot, visit) {
|
|
111
|
+
if (snapshot.state === null)
|
|
112
|
+
return snapshot;
|
|
113
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
114
|
+
const nextTasks = {};
|
|
115
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
116
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
117
|
+
const nextTask = { ...task };
|
|
118
|
+
if (task.managedWorkspaces !== undefined) {
|
|
119
|
+
const workspaces = asObject(task.managedWorkspaces, `managedWorkspace map ${taskId}`);
|
|
120
|
+
nextTask.managedWorkspaces = Object.fromEntries(Object.entries(workspaces).map(([recordId, rawWorkspace]) => [
|
|
121
|
+
recordId,
|
|
122
|
+
visit(asObject(rawWorkspace, `managedWorkspace ${taskId}/${recordId}`), `managedWorkspace ${taskId}/${recordId}`)
|
|
123
|
+
]));
|
|
124
|
+
}
|
|
125
|
+
if (task.workItems !== undefined) {
|
|
126
|
+
const workItems = asObject(task.workItems, `workItem map ${taskId}`);
|
|
127
|
+
nextTask.workItems = Object.fromEntries(Object.entries(workItems).map(([workItemId, rawWorkItem]) => {
|
|
128
|
+
const workItem = asObject(rawWorkItem, `workItem ${taskId}/${workItemId}`);
|
|
129
|
+
if (workItem.candidates === undefined)
|
|
130
|
+
return [workItemId, { ...workItem }];
|
|
131
|
+
if (!Array.isArray(workItem.candidates)) {
|
|
132
|
+
throw new Error(`workItem ${taskId}/${workItemId} candidates must be an array.`);
|
|
133
|
+
}
|
|
134
|
+
return [workItemId, {
|
|
135
|
+
...workItem,
|
|
136
|
+
candidates: workItem.candidates.map((rawCandidate, index) => {
|
|
137
|
+
const candidate = asObject(rawCandidate, `Candidate ${taskId}/${workItemId}/${index}`);
|
|
138
|
+
return migrateOptionalManagedWorkspace(candidate, `Candidate ${taskId}/${workItemId}/${index}`, visit);
|
|
139
|
+
})
|
|
140
|
+
}];
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
for (const mapKey of ["agentRuns", "reviewRounds"]) {
|
|
144
|
+
if (task[mapKey] === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
const records = asObject(task[mapKey], `${mapKey} map ${taskId}`);
|
|
147
|
+
nextTask[mapKey] = Object.fromEntries(Object.entries(records).map(([recordId, rawRecord]) => [
|
|
148
|
+
recordId,
|
|
149
|
+
migrateOptionalManagedWorkspace(asObject(rawRecord, `${mapKey} ${taskId}/${recordId}`), `${mapKey} ${taskId}/${recordId}`, visit)
|
|
150
|
+
]));
|
|
151
|
+
}
|
|
152
|
+
nextTasks[taskId] = nextTask;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
schemaManifest: snapshot.schemaManifest,
|
|
156
|
+
state: { ...snapshot.state, tasks: nextTasks }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function migrateOptionalManagedWorkspace(record, label, visit) {
|
|
160
|
+
if (record.workspace === undefined)
|
|
161
|
+
return { ...record };
|
|
162
|
+
return {
|
|
163
|
+
...record,
|
|
164
|
+
workspace: visit(asObject(record.workspace, `${label} workspace`), `${label} workspace`)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function requireManagedWorkspaceVersion(workspace, version, label) {
|
|
168
|
+
if (workspace.schemaVersion !== version) {
|
|
169
|
+
throw new Error(`${label} must use schemaVersion ${version}.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function migrateWorkItemExecutionGroupHistory(snapshot) {
|
|
173
|
+
requireRecordFamilyVersion(snapshot, "workItem", WORK_ITEM_GROUP_HISTORY_FROM_VERSION, "workItems");
|
|
174
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
175
|
+
const schemaManifest = {
|
|
176
|
+
...snapshot.schemaManifest,
|
|
177
|
+
recordVersions: { ...manifestVersions, workItem: WORK_ITEM_GROUP_HISTORY_TO_VERSION }
|
|
178
|
+
};
|
|
179
|
+
if (snapshot.state === null)
|
|
180
|
+
return { schemaManifest, state: null };
|
|
181
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
182
|
+
const nextTasks = {};
|
|
183
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
184
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
185
|
+
if (task.workItems === undefined) {
|
|
186
|
+
nextTasks[taskId] = { ...task };
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const workItems = asObject(task.workItems, `workItem map ${taskId}`);
|
|
190
|
+
const nextWorkItems = {};
|
|
191
|
+
for (const [recordId, rawRecord] of Object.entries(workItems)) {
|
|
192
|
+
const record = asObject(rawRecord, `workItem ${taskId}/${recordId}`);
|
|
193
|
+
const { executionGroup, ...rest } = record;
|
|
194
|
+
if (executionGroup !== undefined
|
|
195
|
+
&& (executionGroup === null || typeof executionGroup !== "object" || Array.isArray(executionGroup))) {
|
|
196
|
+
throw new Error(`workItem ${taskId}/${recordId} executionGroup must be an object.`);
|
|
197
|
+
}
|
|
198
|
+
const groupId = executionGroup === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: asObject(executionGroup, `ExecutionGroup ${taskId}/${recordId}`).id;
|
|
201
|
+
if (groupId !== undefined && (typeof groupId !== "string" || groupId.trim().length === 0)) {
|
|
202
|
+
throw new Error(`workItem ${taskId}/${recordId} executionGroup id is invalid.`);
|
|
203
|
+
}
|
|
204
|
+
nextWorkItems[recordId] = {
|
|
205
|
+
...rest,
|
|
206
|
+
schemaVersion: WORK_ITEM_GROUP_HISTORY_TO_VERSION,
|
|
207
|
+
executionGroups: executionGroup === undefined ? [] : [executionGroup],
|
|
208
|
+
...(groupId === undefined ? {} : { currentExecutionGroupId: groupId })
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
nextTasks[taskId] = { ...task, workItems: nextWorkItems };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
schemaManifest,
|
|
215
|
+
state: { ...snapshot.state, tasks: nextTasks }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Move the legacy `lane:<group>:<lane>` keys into a namespace that cannot be
|
|
220
|
+
* mistaken for a legal Role identity. A legacy key is only rewritten when
|
|
221
|
+
* its pointed Run proves the lane lineage; otherwise a legal Role with that
|
|
222
|
+
* exact name is retained. Ambiguous or malformed lane-looking records fail
|
|
223
|
+
* closed instead of guessing which active Run the old bytes meant.
|
|
224
|
+
*/
|
|
225
|
+
function activeRunPointerNamespaceStep() {
|
|
226
|
+
return {
|
|
227
|
+
axis: "record",
|
|
228
|
+
recordKind: "activeRunPointer",
|
|
229
|
+
fromVersion: ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION,
|
|
230
|
+
toVersion: ACTIVE_RUN_POINTER_NAMESPACE_TO_VERSION,
|
|
231
|
+
preconditions: (snapshot) => requireActiveRunPointerNamespaceVersion(snapshot),
|
|
232
|
+
transform: (snapshot) => migrateActiveRunPointerNamespace(snapshot),
|
|
233
|
+
declaredEffects: []
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function requireActiveRunPointerNamespaceVersion(snapshot) {
|
|
237
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
238
|
+
if (manifestVersions.activeRunPointer !== ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION) {
|
|
239
|
+
throw new Error(`Record activeRunPointer migration requires manifest version ${ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION}.`);
|
|
240
|
+
}
|
|
241
|
+
if (snapshot.state === null)
|
|
242
|
+
return;
|
|
243
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
244
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
245
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
246
|
+
const activeRuns = task.activeRuns;
|
|
247
|
+
if (activeRuns === undefined)
|
|
248
|
+
continue;
|
|
249
|
+
const pointers = asObject(activeRuns, `activeRunPointer map ${taskId}`);
|
|
250
|
+
for (const [key, rawPointer] of Object.entries(pointers)) {
|
|
251
|
+
const pointer = asObject(rawPointer, `Active run ${taskId}/${key}`);
|
|
252
|
+
if (pointer.schemaVersion !== ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION) {
|
|
253
|
+
throw new Error(`Active run ${taskId}/${key} must use schemaVersion ${ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION}.`);
|
|
254
|
+
}
|
|
255
|
+
if (typeof pointer.runId !== "string" || pointer.runId.trim().length === 0) {
|
|
256
|
+
throw new Error(`Active run ${taskId}/${key} has an invalid runId.`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function migrateActiveRunPointerNamespace(snapshot) {
|
|
262
|
+
requireActiveRunPointerNamespaceVersion(snapshot);
|
|
263
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
264
|
+
const schemaManifest = {
|
|
265
|
+
...snapshot.schemaManifest,
|
|
266
|
+
recordVersions: {
|
|
267
|
+
...manifestVersions,
|
|
268
|
+
activeRunPointer: ACTIVE_RUN_POINTER_NAMESPACE_TO_VERSION
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
if (snapshot.state === null)
|
|
272
|
+
return { schemaManifest, state: null };
|
|
273
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
274
|
+
const nextTasks = {};
|
|
275
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
276
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
277
|
+
const rawActiveRuns = task.activeRuns;
|
|
278
|
+
if (rawActiveRuns === undefined) {
|
|
279
|
+
nextTasks[taskId] = { ...task };
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const activeRuns = asObject(rawActiveRuns, `activeRunPointer map ${taskId}`);
|
|
283
|
+
const rawAgentRuns = asObject(task.agentRuns, `agentRun map ${taskId}`);
|
|
284
|
+
const nextActiveRuns = {};
|
|
285
|
+
for (const [key, rawPointer] of Object.entries(activeRuns)) {
|
|
286
|
+
const pointer = asObject(rawPointer, `Active run ${taskId}/${key}`);
|
|
287
|
+
const runId = typeof pointer.runId === "string" ? pointer.runId : "";
|
|
288
|
+
const run = asObject(rawAgentRuns[runId], `Agent run ${taskId}/${runId}`);
|
|
289
|
+
const lane = legacyLaneKeyParts(key);
|
|
290
|
+
const migratedPointer = {
|
|
291
|
+
...pointer,
|
|
292
|
+
schemaVersion: ACTIVE_RUN_POINTER_NAMESPACE_TO_VERSION
|
|
293
|
+
};
|
|
294
|
+
const addPointer = (nextKey) => {
|
|
295
|
+
if (nextActiveRuns[nextKey] !== undefined) {
|
|
296
|
+
throw new Error(`Active run pointer key collides after migration: ${taskId}/${nextKey}.`);
|
|
297
|
+
}
|
|
298
|
+
nextActiveRuns[nextKey] = migratedPointer;
|
|
299
|
+
};
|
|
300
|
+
if (lane !== null) {
|
|
301
|
+
const laneBacked = run.executionGroupId === lane.executionGroupId
|
|
302
|
+
&& run.executionLaneId === lane.executionLaneId;
|
|
303
|
+
// The prior writer used the same map key for a legal Role and a Lane
|
|
304
|
+
// when the Role itself happened to contain the `lane:g:l` shape. The
|
|
305
|
+
// v3 namespace must retain both identities instead of rejecting the
|
|
306
|
+
// record or silently dropping the Role pointer.
|
|
307
|
+
const roleBacked = run.roleName === key;
|
|
308
|
+
if (!laneBacked && !roleBacked) {
|
|
309
|
+
throw new Error(`Active run pointer lineage is invalid: ${taskId}/${key}.`);
|
|
310
|
+
}
|
|
311
|
+
if (roleBacked)
|
|
312
|
+
addPointer(key);
|
|
313
|
+
if (laneBacked) {
|
|
314
|
+
addPointer(executionLaneNamespaceKey(lane.executionGroupId, lane.executionLaneId));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (key.startsWith("lane:")) {
|
|
318
|
+
// A malformed lane-looking key is allowed only when it is a legal
|
|
319
|
+
// legacy Role pointer. It must not be silently reinterpreted.
|
|
320
|
+
if (run.roleName !== key) {
|
|
321
|
+
throw new Error(`Active run pointer key is malformed: ${taskId}/${key}.`);
|
|
322
|
+
}
|
|
323
|
+
addPointer(key);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// Role pointers remain keyed by the Role even when the pointed Run
|
|
327
|
+
// also carries execution lineage (a shape emitted by the prior
|
|
328
|
+
// writer). The lane namespace above preserves that second identity.
|
|
329
|
+
if (run.roleName !== key) {
|
|
330
|
+
throw new Error(`Active run Role pointer is invalid: ${taskId}/${key}.`);
|
|
331
|
+
}
|
|
332
|
+
addPointer(key);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
nextTasks[taskId] = { ...task, activeRuns: nextActiveRuns };
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
schemaManifest,
|
|
339
|
+
state: { ...snapshot.state, tasks: nextTasks }
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function legacyLaneKeyParts(key) {
|
|
343
|
+
const match = /^lane:([^:]+):([^:]+)$/u.exec(key);
|
|
344
|
+
if (match === null)
|
|
345
|
+
return null;
|
|
346
|
+
return { executionGroupId: match[1], executionLaneId: match[2] };
|
|
347
|
+
}
|
|
348
|
+
function executionLaneNamespaceKey(executionGroupId, executionLaneId) {
|
|
349
|
+
return `/execution-lane/${encodeLaneKeyPart(executionGroupId)}:${encodeLaneKeyPart(executionLaneId)}`;
|
|
350
|
+
}
|
|
351
|
+
function encodeLaneKeyPart(value) {
|
|
352
|
+
return encodeURIComponent(value).replace(/:/gu, "%3A");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Upgrade one nested Task record family without guessing fields or repairing
|
|
356
|
+
* malformed state. The new execution lineage fields are optional, so legal
|
|
357
|
+
* old single-lane records retain their direct shape; the next dispatch creates
|
|
358
|
+
* the unified one-lane Group explicitly. The version transition is still
|
|
359
|
+
* durable and centralized, which keeps old Homes out of the strict current
|
|
360
|
+
* parser until this step has run.
|
|
361
|
+
*/
|
|
362
|
+
function recordFamilyStep(recordKind, fromVersion, toVersion, taskMapKey) {
|
|
363
|
+
return {
|
|
364
|
+
axis: "record",
|
|
365
|
+
recordKind,
|
|
366
|
+
fromVersion,
|
|
367
|
+
toVersion,
|
|
368
|
+
preconditions: (snapshot) => requireRecordFamilyVersion(snapshot, recordKind, fromVersion, taskMapKey),
|
|
369
|
+
transform: (snapshot) => migrateRecordFamily(snapshot, recordKind, fromVersion, toVersion, taskMapKey),
|
|
370
|
+
declaredEffects: []
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The snapshot boundary is nested inside WorkItem/ReviewRound ExecutionGroup
|
|
375
|
+
* results. The parent record versions make that persisted shape explicit;
|
|
376
|
+
* this adjacent step intentionally performs no field rewrite, preserving old
|
|
377
|
+
* records while preventing a pre-v8/pre-v4 Home from entering the strict
|
|
378
|
+
* current parser without the declared transition.
|
|
379
|
+
*/
|
|
380
|
+
function requireRecordFamilyVersion(snapshot, recordKind, fromVersion, taskMapKey) {
|
|
381
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
382
|
+
if (manifestVersions[recordKind] !== fromVersion) {
|
|
383
|
+
throw new Error(`Record ${recordKind} migration requires manifest version ${fromVersion}.`);
|
|
384
|
+
}
|
|
385
|
+
if (snapshot.state === null)
|
|
386
|
+
return;
|
|
387
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
388
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
389
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
390
|
+
const records = task[taskMapKey];
|
|
391
|
+
if (records === undefined)
|
|
392
|
+
continue;
|
|
393
|
+
const map = asObject(records, `${recordKind} map ${taskId}`);
|
|
394
|
+
for (const [recordId, rawRecord] of Object.entries(map)) {
|
|
395
|
+
const record = asObject(rawRecord, `${recordKind} ${taskId}/${recordId}`);
|
|
396
|
+
if (record.schemaVersion !== fromVersion) {
|
|
397
|
+
throw new Error(`Record ${recordKind} ${taskId}/${recordId} must use schemaVersion ${fromVersion}.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function migrateRecordFamily(snapshot, recordKind, fromVersion, toVersion, taskMapKey) {
|
|
403
|
+
// Keep the same source-shape checks in the transform so a direct caller
|
|
404
|
+
// cannot bypass the migration's precondition contract.
|
|
405
|
+
requireRecordFamilyVersion(snapshot, recordKind, fromVersion, taskMapKey);
|
|
406
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
407
|
+
const schemaManifest = {
|
|
408
|
+
...snapshot.schemaManifest,
|
|
409
|
+
recordVersions: {
|
|
410
|
+
...manifestVersions,
|
|
411
|
+
[recordKind]: toVersion
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
if (snapshot.state === null)
|
|
415
|
+
return { schemaManifest, state: null };
|
|
416
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
417
|
+
const nextTasks = {};
|
|
418
|
+
for (const [taskId, rawTask] of Object.entries(tasks)) {
|
|
419
|
+
const task = asObject(rawTask, `Task aggregate ${taskId}`);
|
|
420
|
+
const records = task[taskMapKey];
|
|
421
|
+
if (records === undefined) {
|
|
422
|
+
nextTasks[taskId] = { ...task };
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const map = asObject(records, `${recordKind} map ${taskId}`);
|
|
426
|
+
const nextMap = {};
|
|
427
|
+
for (const [recordId, rawRecord] of Object.entries(map)) {
|
|
428
|
+
nextMap[recordId] = {
|
|
429
|
+
...asObject(rawRecord, `${recordKind} ${taskId}/${recordId}`),
|
|
430
|
+
schemaVersion: toVersion
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
nextTasks[taskId] = { ...task, [taskMapKey]: nextMap };
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
schemaManifest,
|
|
437
|
+
state: { ...snapshot.state, tasks: nextTasks }
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function asObject(value, label) {
|
|
441
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
442
|
+
throw new Error(`${label} must be an object.`);
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
/** Historical public spelling retained as an alias to the single graph. */
|
|
447
|
+
export const createProductionRegistry = createProductionStorageRegistry;
|
|
448
|
+
/** Advance the aggregate identity after proving manifest/root agreement at v16. */
|
|
449
|
+
function migrateAggregateV16ToV17(snapshot) {
|
|
450
|
+
return {
|
|
451
|
+
schemaManifest: {
|
|
452
|
+
...snapshot.schemaManifest,
|
|
453
|
+
aggregateSchemaVersion: FINAL_REVIEW_AGGREGATE_TO_VERSION
|
|
454
|
+
},
|
|
455
|
+
state: snapshot.state === null
|
|
456
|
+
? null
|
|
457
|
+
: {
|
|
458
|
+
...snapshot.state,
|
|
459
|
+
schemaVersion: FINAL_REVIEW_AGGREGATE_TO_VERSION
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function requireAggregateV16Snapshot(snapshot) {
|
|
464
|
+
if (snapshot.schemaManifest.aggregateSchemaVersion
|
|
465
|
+
!== FINAL_REVIEW_AGGREGATE_FROM_VERSION) {
|
|
466
|
+
throw new Error("Aggregate 16->17 migration requires schema.json aggregateSchemaVersion 16.");
|
|
467
|
+
}
|
|
468
|
+
if (snapshot.state !== null
|
|
469
|
+
&& snapshot.state.schemaVersion !== FINAL_REVIEW_AGGREGATE_FROM_VERSION) {
|
|
470
|
+
throw new Error("Aggregate 16->17 migration requires state.json schemaVersion 16 to match schema.json.");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* A version bump is deliverable only when the shared planner resolves the full
|
|
475
|
+
* adjacent path. This also covers target-only record families as explicit 0->1
|
|
476
|
+
* introductions and rejects offline declarations without executable steps.
|
|
477
|
+
*/
|
|
478
|
+
export function assertRegistryCoversBaselineToCurrent(registry, baseline = baselineStorageVersionState(), current = latestStorageVersionState()) {
|
|
479
|
+
const plan = planMigration(registry, baseline, current);
|
|
480
|
+
if (plan.kind !== "blocked")
|
|
481
|
+
return;
|
|
482
|
+
const coordinate = plan.blocker.axis === "record"
|
|
483
|
+
? `record/${plan.blocker.recordKind ?? "?"}`
|
|
484
|
+
: plan.blocker.axis;
|
|
485
|
+
throw new Error(`Storage migration delivery gate failed for ${coordinate}: ${plan.blocker.message}`);
|
|
486
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The central migration registry.
|
|
3
|
+
*
|
|
4
|
+
* Declarations and offline transforms are discoverable strictly by
|
|
5
|
+
* `axis + (recordKind) + fromVersion`. There is no version-magnitude or semver
|
|
6
|
+
* guessing: only an explicitly declared chain of adjacent transitions is
|
|
7
|
+
* usable. A fresh registry is EMPTY, so every older version fails closed until
|
|
8
|
+
* its compatible normalization or offline migration is authored explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export class MigrationRegistryError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "MigrationRegistryError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Explicit key-field delimiter: the ASCII Unit Separator (U+001F), built with
|
|
18
|
+
* `String.fromCharCode` so this source file stays pure printable ASCII (no raw
|
|
19
|
+
* control byte embedded in the text). The Unit Separator never appears in an
|
|
20
|
+
* `axis` literal, a `recordKind`, or a decimal version, so the three key fields
|
|
21
|
+
* cannot run together and the composite key is unambiguous.
|
|
22
|
+
*/
|
|
23
|
+
const KEY_SEPARATOR = String.fromCharCode(0x1f);
|
|
24
|
+
/**
|
|
25
|
+
* Composite lookup key. The record axis includes its `recordKind`; the layout
|
|
26
|
+
* and aggregate axes use a fixed sentinel so a single map serves all axes.
|
|
27
|
+
*/
|
|
28
|
+
function stepKey(axis, recordKind, fromVersion) {
|
|
29
|
+
const family = axis === "record" ? requireRecordKind(recordKind) : "-";
|
|
30
|
+
return `${axis}${KEY_SEPARATOR}${family}${KEY_SEPARATOR}${fromVersion}`;
|
|
31
|
+
}
|
|
32
|
+
function requireRecordKind(recordKind) {
|
|
33
|
+
if (typeof recordKind !== "string" || recordKind.trim().length === 0) {
|
|
34
|
+
throw new MigrationRegistryError("A record-axis migration step must declare a non-empty recordKind.");
|
|
35
|
+
}
|
|
36
|
+
return recordKind;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A discoverable set of adjacent migration steps. Register at most one step per
|
|
40
|
+
* `(axis, recordKind, fromVersion)`; lookup returns only that exact adjacent
|
|
41
|
+
* step (or `undefined`) — it never infers a step from version ordering.
|
|
42
|
+
*/
|
|
43
|
+
export class MigrationRegistry {
|
|
44
|
+
#steps = new Map();
|
|
45
|
+
#declarations = new Map();
|
|
46
|
+
/** Backward-compatible spelling: explicitly registers one offline transition and its transform. */
|
|
47
|
+
register(step) {
|
|
48
|
+
this.#validateMigrationStep(step);
|
|
49
|
+
const key = stepKey(step.axis, step.recordKind, step.fromVersion);
|
|
50
|
+
this.#assertDeclarationAvailable(step, key);
|
|
51
|
+
this.#assertStepAvailable(step, key);
|
|
52
|
+
// Publish the declaration and transform together only after every check has
|
|
53
|
+
// passed. A rejected combined registration must not leave a newly-runnable
|
|
54
|
+
// half update behind in the registry.
|
|
55
|
+
this.#declarations.set(key, { ...step, kind: "offline-migration" });
|
|
56
|
+
this.#steps.set(key, step);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
/** Explicitly register the declaration and transform for an offline change. */
|
|
60
|
+
registerOfflineMigration(step) {
|
|
61
|
+
return this.register(step);
|
|
62
|
+
}
|
|
63
|
+
/** Declare an offline change. A missing transform remains fail-closed. */
|
|
64
|
+
declareOfflineMigration(declaration) {
|
|
65
|
+
this.#validateCoordinates(declaration);
|
|
66
|
+
this.#registerDeclaration({ ...declaration, kind: "offline-migration" });
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
/** Declare an online-compatible normalization with deterministic defaults. */
|
|
70
|
+
registerCompatible(step) {
|
|
71
|
+
const runtimeAxis = step.axis;
|
|
72
|
+
if (runtimeAxis !== "record") {
|
|
73
|
+
throw new MigrationRegistryError("A compatible transition must use the record axis; layout and aggregate changes require offline migration.");
|
|
74
|
+
}
|
|
75
|
+
this.#validateCoordinates(step);
|
|
76
|
+
if (!Array.isArray(step.defaults)
|
|
77
|
+
|| step.defaults.length === 0
|
|
78
|
+
|| step.defaults.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
79
|
+
throw new MigrationRegistryError("A compatible transition must declare at least one deterministic default or rebuild rule.");
|
|
80
|
+
}
|
|
81
|
+
if (typeof step.normalize !== "function" || typeof step.validateSource !== "function") {
|
|
82
|
+
throw new MigrationRegistryError("A compatible transition must provide a strict source validator and normalizer.");
|
|
83
|
+
}
|
|
84
|
+
this.#registerDeclaration({ ...step, kind: "compatible" });
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
/** Register a transform without inferring its operational declaration. */
|
|
88
|
+
registerMigrationStep(step) {
|
|
89
|
+
this.#validateMigrationStep(step);
|
|
90
|
+
const key = stepKey(step.axis, step.recordKind, step.fromVersion);
|
|
91
|
+
this.#assertStepAvailable(step, key);
|
|
92
|
+
this.#steps.set(key, step);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
#validateMigrationStep(step) {
|
|
96
|
+
this.#validateCoordinates(step);
|
|
97
|
+
if (!Array.isArray(step.declaredEffects)
|
|
98
|
+
|| typeof step.transform !== "function"
|
|
99
|
+
|| typeof step.preconditions !== "function") {
|
|
100
|
+
throw new MigrationRegistryError("A migration step must provide its transform contract.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
#assertStepAvailable(step, key) {
|
|
104
|
+
if (!this.#steps.has(key))
|
|
105
|
+
return;
|
|
106
|
+
throw new MigrationRegistryError(`A migration step is already registered for ${step.axis}` +
|
|
107
|
+
`${step.recordKind ? `/${step.recordKind}` : ""} ${step.fromVersion}->${step.toVersion}.`);
|
|
108
|
+
}
|
|
109
|
+
#assertDeclarationAvailable(declaration, key) {
|
|
110
|
+
if (!this.#declarations.has(key))
|
|
111
|
+
return;
|
|
112
|
+
throw new MigrationRegistryError(`A transition is already registered for ${declaration.axis}` +
|
|
113
|
+
`${declaration.recordKind ? `/${declaration.recordKind}` : ""} ` +
|
|
114
|
+
`${declaration.fromVersion}->${declaration.toVersion}.`);
|
|
115
|
+
}
|
|
116
|
+
#validateCoordinates(step) {
|
|
117
|
+
if (step.axis === "record") {
|
|
118
|
+
requireRecordKind(step.recordKind);
|
|
119
|
+
}
|
|
120
|
+
else if (step.recordKind !== undefined) {
|
|
121
|
+
throw new MigrationRegistryError(`A ${step.axis}-axis transition must not declare a recordKind.`);
|
|
122
|
+
}
|
|
123
|
+
if (!Number.isSafeInteger(step.fromVersion) || step.fromVersion < 0) {
|
|
124
|
+
throw new MigrationRegistryError(`Transition fromVersion must be a non-negative integer: ${String(step.fromVersion)}.`);
|
|
125
|
+
}
|
|
126
|
+
if (step.fromVersion === 0) {
|
|
127
|
+
if (step.axis !== "record" || step.introduction !== true || step.toVersion !== 1) {
|
|
128
|
+
throw new MigrationRegistryError("Only an explicit record-family introduction may use the 0->1 transition.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (step.introduction === true) {
|
|
132
|
+
throw new MigrationRegistryError("A record-family introduction must be the explicit 0->1 transition.");
|
|
133
|
+
}
|
|
134
|
+
if (step.toVersion !== step.fromVersion + 1) {
|
|
135
|
+
throw new MigrationRegistryError(`Transitions must be adjacent and one-directional: ${step.axis}` +
|
|
136
|
+
`${step.recordKind ? `/${step.recordKind}` : ""} ${step.fromVersion}->${step.toVersion} ` +
|
|
137
|
+
`(expected ${step.fromVersion}->${step.fromVersion + 1}).`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
#registerDeclaration(declaration) {
|
|
141
|
+
const key = stepKey(declaration.axis, declaration.recordKind, declaration.fromVersion);
|
|
142
|
+
this.#assertDeclarationAvailable(declaration, key);
|
|
143
|
+
this.#declarations.set(key, declaration);
|
|
144
|
+
}
|
|
145
|
+
/** Look up the exact adjacent step advancing `fromVersion -> fromVersion + 1`. */
|
|
146
|
+
lookup(axis, recordKind, fromVersion) {
|
|
147
|
+
return this.#steps.get(stepKey(axis, recordKind, fromVersion));
|
|
148
|
+
}
|
|
149
|
+
/** Look up the exact compatible/offline declaration for one adjacent change. */
|
|
150
|
+
lookupDeclaration(axis, recordKind, fromVersion) {
|
|
151
|
+
return this.#declarations.get(stepKey(axis, recordKind, fromVersion));
|
|
152
|
+
}
|
|
153
|
+
/** Total number of declared adjacent transitions (0 for an EMPTY registry). */
|
|
154
|
+
get size() {
|
|
155
|
+
return this.#declarations.size;
|
|
156
|
+
}
|
|
157
|
+
/** True when neither declarations nor offline transforms are registered. */
|
|
158
|
+
isEmpty() {
|
|
159
|
+
return this.#declarations.size === 0 && this.#steps.size === 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Create a fresh, EMPTY registry for isolated planning or callers that
|
|
164
|
+
* authorize no transitions. Production wiring builds its explicit graph in a
|
|
165
|
+
* separate factory.
|
|
166
|
+
*/
|
|
167
|
+
export function createEmptyRegistry() {
|
|
168
|
+
return new MigrationRegistry();
|
|
169
|
+
}
|