@zq-silk/yui 0.5.3 → 0.6.1

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.
Files changed (157) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/agentConfigurationPicker.js +1 -1
  4. package/dist/cli/commandCatalog.js +251 -13
  5. package/dist/cli/updateOrchestrator.js +8 -0
  6. package/dist/cli/updatePorts.js +76 -22
  7. package/dist/cli.js +264 -20
  8. package/dist/commands/configCommands.js +83 -9
  9. package/dist/commands/controllerCommands.js +103 -0
  10. package/dist/commands/deliveryGuardPreflight.js +35 -0
  11. package/dist/commands/durableJobCommands.js +231 -0
  12. package/dist/commands/executionAuditCommands.js +193 -0
  13. package/dist/commands/grantCommands.js +374 -0
  14. package/dist/commands/projectCommands.js +119 -81
  15. package/dist/commands/releaseCommands.js +444 -0
  16. package/dist/commands/resourcesCommands.js +274 -0
  17. package/dist/commands/sessionCommands.js +104 -0
  18. package/dist/commands/taskActor.js +117 -0
  19. package/dist/commands/taskChangeSetCommands.js +60 -0
  20. package/dist/commands/taskCommands.js +610 -201
  21. package/dist/commands/taskCompletionGate.js +78 -1
  22. package/dist/commands/taskContextCommand.js +24 -6
  23. package/dist/commands/taskInputCommands.js +1 -1
  24. package/dist/commands/taskIntegrationCommands.js +136 -33
  25. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  26. package/dist/commands/taskNextActionCommand.js +85 -0
  27. package/dist/commands/taskOverlapCommands.js +120 -0
  28. package/dist/commands/taskOverviewCommand.js +36 -8
  29. package/dist/commands/telemetryCommands.js +330 -0
  30. package/dist/commands/workflowCommands.js +415 -0
  31. package/dist/config/yuiConfig.js +60 -0
  32. package/dist/controller/clientRuntime.js +42 -1
  33. package/dist/controller/controller.js +413 -61
  34. package/dist/controller/controllerMain.js +25 -2
  35. package/dist/controller/domainIdentity.js +16 -8
  36. package/dist/controller/ephemeralResourceReaper.js +2 -1
  37. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  38. package/dist/controller/handoverCandidate.js +168 -0
  39. package/dist/controller/jobClient.js +102 -0
  40. package/dist/controller/jobControl.js +613 -0
  41. package/dist/controller/jobSupervisor.js +498 -0
  42. package/dist/controller/providerHookRunFence.js +34 -5
  43. package/dist/controller/resourceCleanupLinux.js +18 -9
  44. package/dist/controller/resourceInventoryLinux.js +90 -39
  45. package/dist/controller/resourceInventoryRpc.js +85 -0
  46. package/dist/controller/resourceInventoryWorker.js +50 -0
  47. package/dist/controller/runtime.js +238 -22
  48. package/dist/controller/runtimeEventInbox.js +234 -57
  49. package/dist/controller/runtimeEventProcessor.js +549 -42
  50. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  51. package/dist/core/boundedRpc.js +475 -0
  52. package/dist/core/controllerServer.js +416 -27
  53. package/dist/core/controllerTelemetry.js +167 -0
  54. package/dist/doctor/doctor.js +113 -16
  55. package/dist/domain/validation.js +9 -0
  56. package/dist/execution/executionGroup.js +40 -3
  57. package/dist/executor/agentExecutor.js +6 -3
  58. package/dist/executor/effectiveLaunch.js +52 -0
  59. package/dist/executor/executorRegistry.js +50 -0
  60. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  61. package/dist/grant/capabilityGrant.js +282 -0
  62. package/dist/integration/changeSet.js +16 -3
  63. package/dist/integration/changeSetManifest.js +46 -0
  64. package/dist/integration/gitIntegrationService.js +528 -147
  65. package/dist/integration/integrationAttempt.js +54 -5
  66. package/dist/integration/integrationQueueEntry.js +221 -0
  67. package/dist/integration/integrationQueueService.js +955 -0
  68. package/dist/integration/manifestTags.js +99 -0
  69. package/dist/integration/overlapDiagnostics.js +211 -0
  70. package/dist/job/durableJob.js +449 -0
  71. package/dist/job/jobRunner.js +350 -0
  72. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  73. package/dist/lifecycle/providerErrorClass.js +126 -0
  74. package/dist/message/message.js +16 -3
  75. package/dist/observability/executionAudit.js +545 -0
  76. package/dist/observability/faultClassification.js +160 -0
  77. package/dist/observability/runtimeIdentity.js +367 -0
  78. package/dist/release/fakeReleasePorts.js +55 -0
  79. package/dist/release/releaseHandover.js +475 -0
  80. package/dist/release/releaseIdempotencyStore.js +165 -0
  81. package/dist/release/releaseWorkflow.js +459 -0
  82. package/dist/release/releaseWorkflowEngine.js +688 -0
  83. package/dist/release/releaseWorkflowPorts.js +1720 -0
  84. package/dist/release/runtimeRelease.js +495 -0
  85. package/dist/release/workflowFileLock.js +218 -0
  86. package/dist/repository/gitWorkspace.js +177 -1
  87. package/dist/repository/projectMaintenanceLock.js +315 -0
  88. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  89. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  90. package/dist/resources/autoResourceGc.js +116 -0
  91. package/dist/resources/liveReferences.js +574 -0
  92. package/dist/resources/resourceDiscovery.js +477 -0
  93. package/dist/resources/resourceGc.js +645 -0
  94. package/dist/resources/resourceRegistrar.js +256 -0
  95. package/dist/resources/resourceRegistry.js +150 -0
  96. package/dist/resources/resourceRegistryStore.js +41 -0
  97. package/dist/resources/resourceTypes.js +42 -0
  98. package/dist/resources/sqliteResourceRegistry.js +111 -0
  99. package/dist/review/reviewConfig.js +10 -0
  100. package/dist/review/reviewFinding.js +240 -0
  101. package/dist/review/reviewFindingLedger.js +545 -0
  102. package/dist/review/reviewOutcomeClassifier.js +61 -0
  103. package/dist/review/reviewRound.js +56 -4
  104. package/dist/run/agentRun.js +80 -4
  105. package/dist/run/providerRetry.js +84 -0
  106. package/dist/run/providerRetryConfig.js +63 -0
  107. package/dist/run/yieldReceipt.js +65 -0
  108. package/dist/runtime/exactControlPlane.js +79 -2
  109. package/dist/runtime/index.js +4 -0
  110. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  111. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  112. package/dist/runtime/sessionReconciliation.js +93 -0
  113. package/dist/runtime/sessionTerminationGuard.js +211 -0
  114. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  115. package/dist/runtime/tmuxAdapters.js +34 -1
  116. package/dist/scheduler/actionability.js +155 -0
  117. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  118. package/dist/scheduler/activeTaskProgress.js +60 -0
  119. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  120. package/dist/scheduler/roleRunStall.js +135 -29
  121. package/dist/scheduler/taskExecutionProjection.js +11 -0
  122. package/dist/setup/setupCommand.js +27 -4
  123. package/dist/storage/compatibleTaskStore.js +112 -5
  124. package/dist/storage/migration/productionRegistry.js +769 -1
  125. package/dist/storage/persistenceWorker.js +194 -0
  126. package/dist/storage/sqliteSchema.js +705 -0
  127. package/dist/storage/sqliteStore.js +1695 -0
  128. package/dist/storage/storageVersions.js +9 -2
  129. package/dist/storage/storeRpc.js +298 -0
  130. package/dist/storage/taskStore.js +982 -21
  131. package/dist/storage/upgrade/homeClassification.js +157 -12
  132. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  133. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  134. package/dist/storage/upgrade/recordVersions.js +10 -1
  135. package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
  136. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  137. package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
  138. package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
  139. package/dist/task/deliveryGuard.js +226 -0
  140. package/dist/task/nextAction.js +343 -0
  141. package/dist/task/repairWave.js +137 -0
  142. package/dist/task/taskRecordReference.js +6 -1
  143. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  144. package/dist/telemetry/telemetryCompaction.js +251 -0
  145. package/dist/telemetry/telemetryConfig.js +64 -0
  146. package/dist/telemetry/telemetryRouter.js +32 -0
  147. package/dist/telemetry/telemetryStore.js +19 -0
  148. package/dist/telemetry/telemetryWiring.js +33 -0
  149. package/dist/tmux/tmuxManager.js +20 -1
  150. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  151. package/dist/verification/gateArtifact.js +216 -0
  152. package/dist/verification/gateArtifactStore.js +87 -0
  153. package/dist/verification/verificationGateService.js +414 -0
  154. package/dist/verification/verificationPlan.js +308 -0
  155. package/dist/workspace/gitChangeSetCapture.js +12 -2
  156. package/dist/workspace/workItemChangeSetManager.js +60 -3
  157. package/package.json +2 -1
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The SQLite-backed {@link MigrationTarget} for the layout 6 -> 7 staged
3
+ * migration (task-21 §8, work-item-4).
4
+ *
5
+ * This target is the seam between the generic, domain-free migration engine
6
+ * and the SQLite writer. It stages the migrated state into a sidecar
7
+ * `yui.db.staged` INSIDE the Home (not a sibling copy), verifies the staged
8
+ * database against an independent re-read of `state.json`, and commits by
9
+ * swapping `yui.db.staged` -> `yui.db` and advancing `schema.json` to layout 7
10
+ * in the same coordination critical section.
11
+ *
12
+ * Staged orchestration (§8.2):
13
+ * - Snapshot: `readSource` reads `schema.json` + `state.json` read-only.
14
+ * - Stage: `writeFreshOutput` populates `yui.db.staged` from the (possibly
15
+ * transformed) snapshot; refuses to overwrite an existing stage.
16
+ * - Verify: `validateCurrentState` independently re-reads `state.json`,
17
+ * re-derives the expected state through the registered transforms,
18
+ * and compares per-family checksums against the staged database.
19
+ * - Commit: `atomicSwitchWithBackup` swaps the sidecar into `yui.db` (with
20
+ * a timestamped backup of any prior database) and advances
21
+ * `schema.json` to layout 7.
22
+ * - Rollback: `rollbackSqliteMigration` quarantines `yui.db` and flips
23
+ * `schema.json` back to layout 6. A layout-6→7 migration retains
24
+ * `state.json` in place (never touched); a pseudo-layout-7 repair
25
+ * archived it to `state.json.backup-*`, so rollback restores the
26
+ * newest backup when `state.json` is absent.
27
+ *
28
+ * The source `state.json` is retained read-only throughout: it is never
29
+ * overwritten, truncated, or deleted by the migration. This preserves the
30
+ * rollback path and the §8.4 invariants (no healthy Session reset, no evidence
31
+ * deleted).
32
+ */
33
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { createHash } from "node:crypto";
36
+ import { writeTextFileAtomically } from "../durableFile.js";
37
+ import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
38
+ import { STORAGE_STATE_FILE } from "../taskStore.js";
39
+ import { planMigration } from "../migration/planner.js";
40
+ import { AmbiguousSwitchError } from "../migration/index.js";
41
+ import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
42
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState } from "./sqliteStateMigration.js";
43
+ import { latestStateBackupPath } from "./pseudoLayoutRepair.js";
44
+ import { migrationReceiptPath, writeMigrationReceipt } from "./migrationReceipt.js";
45
+ /** Build the SQLite-backed migration target. */
46
+ export function createSqliteMigrationTarget(options) {
47
+ const home = options.home;
48
+ const latest = options.latest;
49
+ const registry = options.registry;
50
+ const now = options.now ?? (() => new Date());
51
+ const callerPid = options.callerPid ?? process.pid;
52
+ const stagedDbPath = join(home, STAGED_DATABASE_FILENAME);
53
+ const committedDbPath = join(home, COMMITTED_DATABASE_FILENAME);
54
+ // The transformed schema manifest is cached during writeFreshOutput so the
55
+ // switch can advance schema.json without re-reading or re-deriving it.
56
+ let stagedSchemaManifest = null;
57
+ // The source document's revision and sha256, cached during writeFreshOutput
58
+ // so the switch can certify the dual-copy state with a persistent receipt
59
+ // (Issue 01: a layout-7 Home that retains state.json must carry the receipt
60
+ // that proves yui.db was promoted from that exact document).
61
+ let stagedReceiptSource = null;
62
+ return {
63
+ stagedDbPath,
64
+ inspectVersions() {
65
+ const inspected = inspectSourceVersionState(home, latest);
66
+ if ("corruption" in inspected) {
67
+ throw new Error(inspected.corruption.detail);
68
+ }
69
+ return inspected.source;
70
+ },
71
+ detectLiveRuntime() {
72
+ const signals = inspectHomeRuntime(home, callerPid);
73
+ if (!homeRuntimeIsActive(signals))
74
+ return { active: false };
75
+ return { active: true, detail: describeActiveRuntime(signals) };
76
+ },
77
+ readSource() {
78
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
79
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
80
+ const statePath = join(home, STORAGE_STATE_FILE);
81
+ const state = existsSync(statePath)
82
+ ? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
83
+ : null;
84
+ return Object.freeze({ schemaManifest, state });
85
+ },
86
+ writeFreshOutput(snapshot) {
87
+ if (existsSync(stagedDbPath)) {
88
+ throw new Error(`Refusing to overwrite an existing staged SQLite database: ${stagedDbPath}. ` +
89
+ "Discard it and retry.");
90
+ }
91
+ // Cache the transformed manifest for the switch's schema.json advancement.
92
+ // Ensure the layout version is the latest: the orchestrator applies the
93
+ // 6->7 transform before calling us, but direct callers (tests, drills)
94
+ // may pass an untransformed snapshot. Setting it here is idempotent.
95
+ stagedSchemaManifest = {
96
+ ...snapshot.schemaManifest,
97
+ storageVersion: latest.layout,
98
+ updatedAt: now().toISOString()
99
+ };
100
+ if (snapshot.state !== null) {
101
+ // Hash the actual on-disk bytes (the file is retained read-only
102
+ // throughout the migration), not a re-serialization of the parsed
103
+ // snapshot, so the receipt can be compared against the file itself.
104
+ const stateRaw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
105
+ stagedReceiptSource = {
106
+ revision: typeof snapshot.state.revision === "number" ? snapshot.state.revision : 0,
107
+ sha256: createHash("sha256").update(stateRaw, "utf8").digest("hex")
108
+ };
109
+ populateSqliteFromState(home, snapshot.state, STAGED_DATABASE_FILENAME);
110
+ }
111
+ else {
112
+ // An empty Home (no state.json) still gets a schema-ready database.
113
+ stagedReceiptSource = { revision: 0, sha256: "" };
114
+ populateSqliteFromState(home, {}, STAGED_DATABASE_FILENAME);
115
+ }
116
+ },
117
+ rebuildDerivedState(effects) {
118
+ // The SQLite database is fully normalised by populateSqliteFromState;
119
+ // there is no separate derived index to rebuild. Echo the declared
120
+ // effects for the report, mirroring the file target.
121
+ return { rebuiltEffects: [...effects] };
122
+ },
123
+ validateCurrentState() {
124
+ // Independently re-read state.json from disk (not the in-memory snapshot
125
+ // used for staging) and re-derive the expected state by applying the
126
+ // registered transforms. This catches staging corruption, a torn read,
127
+ // or a concurrent writer that slipped past the quiesce gate.
128
+ const freshSnapshot = readSourceFresh();
129
+ const expectedState = deriveExpectedState(freshSnapshot);
130
+ if (expectedState !== null) {
131
+ verifyChecksums(expectedState);
132
+ }
133
+ const dbChecksums = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
134
+ const familyCount = Object.keys(dbChecksums).length;
135
+ return {
136
+ checks: [
137
+ {
138
+ name: "SQLite staged-database checksum verification",
139
+ outcome: "passed",
140
+ detail: `verified ${familyCount} record families against an independent state.json re-read`
141
+ }
142
+ ]
143
+ };
144
+ },
145
+ atomicSwitchWithBackup() {
146
+ if (!existsSync(stagedDbPath)) {
147
+ throw new Error(`No staged SQLite database to promote: ${stagedDbPath}.`);
148
+ }
149
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
150
+ let backupPath;
151
+ // Phase 1: back up any existing yui.db, then promote the sidecar.
152
+ try {
153
+ if (existsSync(committedDbPath)) {
154
+ backupPath = join(home, `${COMMITTED_DATABASE_FILENAME}.backup-${stamp}`);
155
+ if (existsSync(backupPath)) {
156
+ throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
157
+ }
158
+ renameSync(committedDbPath, backupPath);
159
+ }
160
+ renameSync(stagedDbPath, committedDbPath);
161
+ // The staged connection may leave empty WAL/SHM sidecars behind
162
+ // even after a clean close; they are dead once promoted.
163
+ rmSync(`${stagedDbPath}-wal`, { force: true });
164
+ rmSync(`${stagedDbPath}-shm`, { force: true });
165
+ }
166
+ catch (error) {
167
+ // Pre-promotion failure: restore the original database if we moved it.
168
+ if (backupPath !== undefined && existsSync(backupPath)) {
169
+ try {
170
+ renameSync(backupPath, committedDbPath);
171
+ }
172
+ catch {
173
+ throw new AmbiguousSwitchError({
174
+ homePath: home,
175
+ backupPath,
176
+ stagingPath: stagedDbPath,
177
+ detail: `SQLite switch failed (${messageOf(error)}) and the automatic rollback also failed. ` +
178
+ `The original database is at ${backupPath}; recover manually by renaming it to ${committedDbPath}.`
179
+ });
180
+ }
181
+ }
182
+ throw error;
183
+ }
184
+ // Phase 2: advance schema.json to layout 7 in the same critical section.
185
+ try {
186
+ if (stagedSchemaManifest !== null) {
187
+ writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
188
+ }
189
+ // Certify the dual-copy state (Issue 01): the 6→7 migration retains
190
+ // state.json read-only, so the Home legitimately holds both copies.
191
+ // The persistent receipt is the evidence that lets the classifier and
192
+ // doctor distinguish this certified switch from a drifted conflict.
193
+ if (stagedReceiptSource !== null) {
194
+ const familyCount = Object.keys(computeDbFamilyChecksums(home, COMMITTED_DATABASE_FILENAME)).length;
195
+ writeMigrationReceipt(home, {
196
+ kind: "layout6-to-7",
197
+ completedAt: now().toISOString(),
198
+ sourceRevision: stagedReceiptSource.revision,
199
+ targetLayoutVersion: latest.layout,
200
+ sourceStateSha256: stagedReceiptSource.sha256,
201
+ verifiedFamilies: familyCount
202
+ });
203
+ }
204
+ }
205
+ catch (error) {
206
+ // The database is promoted but schema.json could not be advanced.
207
+ // Attempt to restore the original database; if that fails, the Home
208
+ // is ambiguous and must be recovered manually.
209
+ try {
210
+ if (backupPath !== undefined && existsSync(backupPath)) {
211
+ renameSync(backupPath, committedDbPath);
212
+ }
213
+ else {
214
+ rmSync(committedDbPath, { force: true });
215
+ }
216
+ }
217
+ catch {
218
+ throw new AmbiguousSwitchError({
219
+ homePath: home,
220
+ backupPath: backupPath ?? committedDbPath,
221
+ stagingPath: stagedDbPath,
222
+ detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
223
+ `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
224
+ `recover by advancing schema.json storageVersion to ${latest.layout} or restoring the backup.`
225
+ });
226
+ }
227
+ throw error;
228
+ }
229
+ return {
230
+ status: "switched",
231
+ ...(backupPath === undefined ? {} : { backupPath }),
232
+ detail: `SQLite database promoted to ${committedDbPath} and schema.json advanced to layout ${latest.layout}.`
233
+ };
234
+ },
235
+ discardFreshOutput() {
236
+ rmSync(stagedDbPath, { force: true });
237
+ // Clean up WAL/SHM sidecars if the connection left them.
238
+ rmSync(`${stagedDbPath}-wal`, { force: true });
239
+ rmSync(`${stagedDbPath}-shm`, { force: true });
240
+ stagedSchemaManifest = null;
241
+ }
242
+ };
243
+ // -- helpers ---------------------------------------------------------------
244
+ function readSourceFresh() {
245
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
246
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
247
+ const statePath = join(home, STORAGE_STATE_FILE);
248
+ const state = existsSync(statePath)
249
+ ? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
250
+ : null;
251
+ return Object.freeze({ schemaManifest, state });
252
+ }
253
+ /**
254
+ * Re-derive the expected post-migration state by reading the fresh snapshot,
255
+ * planning from its versions to `latest`, and applying every registered
256
+ * step transform. This mirrors the engine's apply phase but starts from an
257
+ * independent disk read.
258
+ */
259
+ function deriveExpectedState(snapshot) {
260
+ const inspected = inspectSnapshotVersionState(snapshot, latest);
261
+ if ("corruption" in inspected) {
262
+ throw new Error(inspected.corruption.detail);
263
+ }
264
+ const plan = planMigration(registry, inspected.source, latest);
265
+ if (plan.kind === "blocked") {
266
+ throw new Error(`SQLite migration verification cannot derive expected state: ${plan.blocker.message}`);
267
+ }
268
+ if (plan.kind === "no-op")
269
+ return snapshot.state;
270
+ let current = snapshot;
271
+ for (const planned of plan.steps) {
272
+ planned.step.preconditions(current);
273
+ current = planned.step.transform(current);
274
+ }
275
+ return current.state;
276
+ }
277
+ function verifyChecksums(expectedState) {
278
+ const expected = computeStateFamilyChecksums(expectedState);
279
+ const actual = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
280
+ const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
281
+ const mismatches = [];
282
+ for (const family of families) {
283
+ const e = expected[family];
284
+ const a = actual[family];
285
+ if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
286
+ mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
287
+ `found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
288
+ }
289
+ }
290
+ if (mismatches.length > 0) {
291
+ throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
292
+ }
293
+ }
294
+ }
295
+ /**
296
+ * Roll back a committed layout-7 SQLite migration: quarantine `yui.db` and
297
+ * flip `schema.json` back to layout 6. A layout-6→7 migration retained
298
+ * `state.json` read-only in place, so it is untouched there. A pseudo-layout-7
299
+ * repair archived it to `state.json.backup-*`; when `state.json` is absent the
300
+ * newest backup is restored so the layout-6 File store recovers every
301
+ * pre-switch committed revision. The persistent migration receipt is removed.
302
+ *
303
+ * Returns the quarantine path. Throws if the Home is not at layout 7, has no
304
+ * `yui.db` to quarantine, or has neither `state.json` nor a backup to restore.
305
+ */
306
+ export function rollbackSqliteMigration(home, options = {}) {
307
+ const now = options.now ?? (() => new Date());
308
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
309
+ const manifest = parseJsonObject(readFileSync(schemaPath, "utf8"), STORAGE_SCHEMA_FILE);
310
+ if (manifest.storageVersion !== 7) {
311
+ throw new Error(`Rollback requires a layout-7 Home; found layout ${manifest.storageVersion}.`);
312
+ }
313
+ const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
314
+ if (!existsSync(dbPath)) {
315
+ throw new Error(`No SQLite database to quarantine: ${dbPath}.`);
316
+ }
317
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
318
+ const quarantinePath = join(home, `${COMMITTED_DATABASE_FILENAME}.quarantine-${stamp}`);
319
+ if (existsSync(quarantinePath)) {
320
+ throw new Error(`Refusing to overwrite an existing quarantine: ${quarantinePath}.`);
321
+ }
322
+ renameSync(dbPath, quarantinePath);
323
+ // Clean up WAL/SHM sidecars.
324
+ rmSync(`${dbPath}-wal`, { force: true });
325
+ rmSync(`${dbPath}-shm`, { force: true });
326
+ // Restore the file-store authoritative document when the repair archived it.
327
+ const statePath = join(home, STORAGE_STATE_FILE);
328
+ if (!existsSync(statePath)) {
329
+ const backupPath = latestStateBackupPath(home);
330
+ if (backupPath === null) {
331
+ throw new Error(`Rollback requires ${STORAGE_STATE_FILE} or a state.json.backup-* archive; neither exists.`);
332
+ }
333
+ renameSync(backupPath, statePath);
334
+ }
335
+ // The persistent receipt certified the switch being rolled back.
336
+ rmSync(migrationReceiptPath(home), { force: true });
337
+ // Flip schema.json back to layout 6.
338
+ const rolledBack = { ...manifest, storageVersion: 6 };
339
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(rolledBack, null, 2)}\n`);
340
+ return quarantinePath;
341
+ }
342
+ function parseJsonObject(raw, label) {
343
+ const value = JSON.parse(raw);
344
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
345
+ throw new Error(`${label} must be a JSON object.`);
346
+ }
347
+ return value;
348
+ }
349
+ function messageOf(error) {
350
+ return error instanceof Error ? error.message : String(error);
351
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The SQLite-backed {@link MigrationTarget} for record-family migrations on a
3
+ * layout-7 Home (Issue 01 Phase 2).
4
+ *
5
+ * The layout 6 -> 7 target ({@link createSqliteMigrationTarget}) stages a
6
+ * fresh database from `state.json`. Once a Home is at layout 7 its
7
+ * authoritative store is `yui.db`; `state.json` may have been archived by the
8
+ * pseudo-layout-7 repair, so a record-only migration on such a Home cannot
9
+ * read its source from the document. This target closes that gap:
10
+ *
11
+ * - Snapshot: `readSource` reads `schema.json` and reconstructs the
12
+ * state.json-shaped snapshot from `yui.db` via
13
+ * {@link readStateFromSqlite} (raw payloads, so older record
14
+ * versions survive with their original `schemaVersion`).
15
+ * - Stage: `writeFreshOutput` populates a sidecar `yui.db.staged` from
16
+ * the (possibly transformed) snapshot; refuses to overwrite an
17
+ * existing stage.
18
+ * - Verify: `validateCurrentState` independently re-reads `yui.db`,
19
+ * re-derives the expected state through the registered
20
+ * transforms, and compares per-family checksums against the
21
+ * staged database.
22
+ * - Commit: `atomicSwitchWithBackup` swaps the sidecar into `yui.db` (with
23
+ * a timestamped backup of the prior database) and advances
24
+ * `schema.json`'s record-family versions in the same
25
+ * coordination critical section. The layout version is
26
+ * unchanged (this target never crosses a layout boundary).
27
+ *
28
+ * The source `yui.db` is retained read-only throughout: it is never
29
+ * overwritten, truncated, or deleted by the migration. This preserves the
30
+ * rollback path (restore the timestamped backup).
31
+ */
32
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { writeTextFileAtomically } from "../durableFile.js";
35
+ import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
36
+ import { planMigration } from "../migration/planner.js";
37
+ import { AmbiguousSwitchError } from "../migration/index.js";
38
+ import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
39
+ import { writeSwitchProgress } from "./switchProgress.js";
40
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
+ /** Build the SQLite-backed record-migration target. */
42
+ export function createSqliteRecordMigrationTarget(options) {
43
+ const home = options.home;
44
+ const latest = options.latest;
45
+ const registry = options.registry;
46
+ const now = options.now ?? (() => new Date());
47
+ const callerPid = options.callerPid ?? process.pid;
48
+ const promoteRename = options.renameImpl ?? renameSync;
49
+ const stagedDbPath = join(home, STAGED_DATABASE_FILENAME);
50
+ const committedDbPath = join(home, COMMITTED_DATABASE_FILENAME);
51
+ // The transformed schema manifest is cached during writeFreshOutput so the
52
+ // switch can advance schema.json without re-reading or re-deriving it.
53
+ let stagedSchemaManifest = null;
54
+ return {
55
+ stagedDbPath,
56
+ inspectVersions() {
57
+ const inspected = inspectSourceVersionState(home, latest);
58
+ if ("corruption" in inspected) {
59
+ throw new Error(inspected.corruption.detail);
60
+ }
61
+ return inspected.source;
62
+ },
63
+ detectLiveRuntime() {
64
+ const signals = inspectHomeRuntime(home, callerPid);
65
+ if (!homeRuntimeIsActive(signals))
66
+ return { active: false };
67
+ return { active: true, detail: describeActiveRuntime(signals) };
68
+ },
69
+ readSource() {
70
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
71
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
72
+ const state = readStateFromSqlite(home);
73
+ return Object.freeze({ schemaManifest, state });
74
+ },
75
+ writeFreshOutput(snapshot) {
76
+ if (existsSync(stagedDbPath)) {
77
+ throw new Error(`Refusing to overwrite an existing staged SQLite database: ${stagedDbPath}. ` +
78
+ "Discard it and retry.");
79
+ }
80
+ // Cache the transformed manifest for the switch's schema.json advancement.
81
+ // The orchestrator applies the record transforms before calling us, but
82
+ // direct callers (tests, drills) may pass an untransformed snapshot. The
83
+ // record-family versions are re-derived from the latest map so the
84
+ // staged manifest always declares the post-migration versions.
85
+ const recordVersions = {};
86
+ for (const [kind, entry] of Object.entries(latest.record)) {
87
+ recordVersions[kind] = entry.version;
88
+ }
89
+ stagedSchemaManifest = {
90
+ ...snapshot.schemaManifest,
91
+ recordVersions,
92
+ updatedAt: now().toISOString()
93
+ };
94
+ populateSqliteFromState(home, snapshot.state ?? {}, STAGED_DATABASE_FILENAME);
95
+ },
96
+ rebuildDerivedState(effects) {
97
+ // The SQLite database is fully normalised by populateSqliteFromState;
98
+ // there is no separate derived index to rebuild. Echo the declared
99
+ // effects for the report, mirroring the layout 6 -> 7 target.
100
+ return { rebuiltEffects: [...effects] };
101
+ },
102
+ validateCurrentState() {
103
+ // Independently re-read yui.db from disk (not the in-memory snapshot
104
+ // used for staging) and re-derive the expected state by applying the
105
+ // registered transforms. This catches staging corruption, a torn read,
106
+ // or a concurrent writer that slipped past the quiesce gate.
107
+ const freshSnapshot = readSourceFresh();
108
+ const expectedState = deriveExpectedState(freshSnapshot);
109
+ if (expectedState !== null) {
110
+ verifyChecksums(expectedState);
111
+ }
112
+ const dbChecksums = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
113
+ const familyCount = Object.keys(dbChecksums).length;
114
+ return {
115
+ checks: [
116
+ {
117
+ name: "SQLite staged-database checksum verification",
118
+ outcome: "passed",
119
+ detail: `verified ${familyCount} record families against an independent yui.db re-read`
120
+ }
121
+ ]
122
+ };
123
+ },
124
+ atomicSwitchWithBackup() {
125
+ if (!existsSync(stagedDbPath)) {
126
+ throw new Error(`No staged SQLite database to promote: ${stagedDbPath}.`);
127
+ }
128
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
129
+ let backupPath;
130
+ // Phase 1: back up the existing yui.db, then promote the sidecar.
131
+ try {
132
+ if (existsSync(committedDbPath)) {
133
+ backupPath = join(home, `${COMMITTED_DATABASE_FILENAME}.backup-${stamp}`);
134
+ if (existsSync(backupPath)) {
135
+ throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
136
+ }
137
+ renameSync(committedDbPath, backupPath);
138
+ }
139
+ promoteRename(stagedDbPath, committedDbPath);
140
+ // The staged connection may leave empty WAL/SHM sidecars behind
141
+ // even after a clean close; they are dead once promoted.
142
+ rmSync(`${stagedDbPath}-wal`, { force: true });
143
+ rmSync(`${stagedDbPath}-shm`, { force: true });
144
+ }
145
+ catch (error) {
146
+ // Pre-promotion failure: restore the original database if we moved it.
147
+ if (backupPath !== undefined && existsSync(backupPath)) {
148
+ try {
149
+ promoteRename(backupPath, committedDbPath);
150
+ }
151
+ catch {
152
+ writeInterruptedMarker(home, backupPath, stagedDbPath, now);
153
+ throw new AmbiguousSwitchError({
154
+ homePath: home,
155
+ backupPath,
156
+ stagingPath: stagedDbPath,
157
+ detail: `SQLite switch failed (${messageOf(error)}) and the automatic rollback also failed. ` +
158
+ `The original database is at ${backupPath}; recover manually by renaming it to ${committedDbPath}.`
159
+ });
160
+ }
161
+ }
162
+ throw error;
163
+ }
164
+ // Phase 2: advance schema.json record-family versions in the same
165
+ // critical section. The layout version is unchanged.
166
+ try {
167
+ if (stagedSchemaManifest !== null) {
168
+ writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
169
+ }
170
+ }
171
+ catch (error) {
172
+ // The database is promoted but schema.json could not be advanced.
173
+ // Attempt to restore the original database; if that fails, the Home
174
+ // is ambiguous and must be recovered manually.
175
+ try {
176
+ if (backupPath !== undefined && existsSync(backupPath)) {
177
+ promoteRename(backupPath, committedDbPath);
178
+ }
179
+ else {
180
+ rmSync(committedDbPath, { force: true });
181
+ }
182
+ }
183
+ catch {
184
+ writeInterruptedMarker(home, backupPath ?? committedDbPath, stagedDbPath, now);
185
+ throw new AmbiguousSwitchError({
186
+ homePath: home,
187
+ backupPath: backupPath ?? committedDbPath,
188
+ stagingPath: stagedDbPath,
189
+ detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
190
+ `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
191
+ `recover by advancing schema.json recordVersions or restoring the backup.`
192
+ });
193
+ }
194
+ throw error;
195
+ }
196
+ return {
197
+ status: "switched",
198
+ ...(backupPath === undefined ? {} : { backupPath }),
199
+ detail: `SQLite database promoted to ${committedDbPath} and schema.json record versions advanced.`
200
+ };
201
+ },
202
+ discardFreshOutput() {
203
+ rmSync(stagedDbPath, { force: true });
204
+ // Clean up WAL/SHM sidecars if the connection left them.
205
+ rmSync(`${stagedDbPath}-wal`, { force: true });
206
+ rmSync(`${stagedDbPath}-shm`, { force: true });
207
+ stagedSchemaManifest = null;
208
+ }
209
+ };
210
+ // -- helpers ---------------------------------------------------------------
211
+ function readSourceFresh() {
212
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
213
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
214
+ const state = readStateFromSqlite(home);
215
+ return Object.freeze({ schemaManifest, state });
216
+ }
217
+ /**
218
+ * Re-derive the expected post-migration state by reading the fresh snapshot,
219
+ * planning from its versions to `latest`, and applying every registered
220
+ * step transform. This mirrors the engine's apply phase but starts from an
221
+ * independent disk read.
222
+ */
223
+ function deriveExpectedState(snapshot) {
224
+ const inspected = inspectSnapshotVersionState(snapshot, latest);
225
+ if ("corruption" in inspected) {
226
+ throw new Error(inspected.corruption.detail);
227
+ }
228
+ const plan = planMigration(registry, inspected.source, latest);
229
+ if (plan.kind === "blocked") {
230
+ throw new Error(`SQLite record migration verification cannot derive expected state: ${plan.blocker.message}`);
231
+ }
232
+ if (plan.kind === "no-op")
233
+ return snapshot.state;
234
+ let current = snapshot;
235
+ for (const planned of plan.steps) {
236
+ planned.step.preconditions(current);
237
+ current = planned.step.transform(current);
238
+ }
239
+ return current.state;
240
+ }
241
+ function verifyChecksums(expectedState) {
242
+ const expected = computeStateFamilyChecksums(expectedState);
243
+ const actual = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
244
+ const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
245
+ const mismatches = [];
246
+ for (const family of families) {
247
+ const e = expected[family];
248
+ const a = actual[family];
249
+ if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
250
+ mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
251
+ `found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
252
+ }
253
+ }
254
+ if (mismatches.length > 0) {
255
+ throw new Error(`SQLite record migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
256
+ }
257
+ }
258
+ }
259
+ function parseJsonObject(raw, label) {
260
+ const value = JSON.parse(raw);
261
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
262
+ throw new Error(`${label} must be a JSON object.`);
263
+ }
264
+ return value;
265
+ }
266
+ function messageOf(error) {
267
+ return error instanceof Error ? error.message : String(error);
268
+ }
269
+ /**
270
+ * Persist the durable `interrupted` switch-progress marker for an ambiguous
271
+ * SQLite switch (P1-4): the original database was moved to its timestamped
272
+ * backup and neither the promotion nor its rollback completed. The marker is
273
+ * the honest durable signal — a completion receipt is never written for a
274
+ * switch that did not commit. Best-effort: the backup and on-disk state still
275
+ * recover the Home even if the marker write fails.
276
+ */
277
+ function writeInterruptedMarker(home, backupPath, stagingPath, now) {
278
+ try {
279
+ writeSwitchProgress(home, {
280
+ phase: "interrupted",
281
+ homePath: home,
282
+ backupPath,
283
+ stagingPath,
284
+ updatedAt: now().toISOString()
285
+ });
286
+ }
287
+ catch {
288
+ // Marker best-effort; the backup + on-disk state still recover the Home.
289
+ }
290
+ }