@zq-silk/yui 0.6.0 → 0.6.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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -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
+ }
@@ -24,7 +24,11 @@
24
24
  import { createHash } from "node:crypto";
25
25
  import { join } from "node:path";
26
26
  import Database from "better-sqlite3";
27
+ import { mailboxTargetKey } from "../../coordination/workMailbox.js";
28
+ import { managedWorkspaceKey } from "../../worktree/managedWorkspace.js";
27
29
  import { SqliteTaskStore } from "../sqliteStore.js";
30
+ import { readStorageSchemaManifest } from "../storageSchema.js";
31
+ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
28
32
  /** The sidecar database filename used during staging. */
29
33
  export const STAGED_DATABASE_FILENAME = "yui.db.staged";
30
34
  /** The committed database filename. */
@@ -80,6 +84,19 @@ function asObjectMap(value) {
80
84
  }
81
85
  return result;
82
86
  }
87
+ /** Read a persisted map whose values are opaque strings without silently
88
+ * dropping malformed entries during an offline migration. */
89
+ function asStringMap(value, label) {
90
+ const record = asObject(value);
91
+ const result = {};
92
+ for (const [key, entry] of Object.entries(record)) {
93
+ if (typeof entry !== "string") {
94
+ throw new Error(`${label}.${key} must be a string.`);
95
+ }
96
+ result[key] = entry;
97
+ }
98
+ return result;
99
+ }
83
100
  function asNullableObject(value) {
84
101
  if (value === null || value === undefined)
85
102
  return null;
@@ -100,6 +117,9 @@ function asStoredTask(value) {
100
117
  reviewRounds: asObjectMap(record.reviewRounds),
101
118
  changeSets: asObjectMap(record.changeSets),
102
119
  integrationAttempts: asObjectMap(record.integrationAttempts),
120
+ integrationQueue: asObjectMap(record.integrationQueue),
121
+ durableJobs: asObjectMap(record.durableJobs),
122
+ jobCallerKeyHashes: asStringMap(record.jobCallerKeyHashes, "jobCallerKeyHashes"),
103
123
  activeRuns: asObjectMap(record.activeRuns),
104
124
  messages: asObjectMap(record.messages),
105
125
  inputRequests: asObjectMap(record.inputRequests),
@@ -108,7 +128,9 @@ function asStoredTask(value) {
108
128
  events: asObjectMap(record.events),
109
129
  leaderFailure: asNullableObject(record.leaderFailure),
110
130
  operatorNotification: asNullableObject(record.operatorNotification),
111
- idHighWaterMarks: asObject(record.idHighWaterMarks)
131
+ idHighWaterMarks: asObject(record.idHighWaterMarks),
132
+ capabilityGrants: asObjectMap(record.capabilityGrants),
133
+ releaseWorkflows: asObjectMap(record.releaseWorkflows)
112
134
  };
113
135
  }
114
136
  function tasksOf(state) {
@@ -195,6 +217,20 @@ export function populateSqliteFromState(home, state, databaseFilename) {
195
217
  for (const attempt of Object.values(stored.integrationAttempts)) {
196
218
  store.saveIntegrationAttempt(taskId, attempt);
197
219
  }
220
+ for (const entry of Object.values(stored.integrationQueue)) {
221
+ store.saveIntegrationQueueEntry(taskId, entry);
222
+ }
223
+ for (const job of Object.values(stored.durableJobs)) {
224
+ store.saveDurableJob(taskId, job);
225
+ }
226
+ for (const [key, hash] of Object.entries(stored.jobCallerKeyHashes)) {
227
+ const separator = key.indexOf("\0");
228
+ if (separator <= 0 || separator === key.length - 1
229
+ || key.indexOf("\0", separator + 1) !== -1) {
230
+ throw new Error(`Job caller key hash identity is invalid: ${taskId}/${key}.`);
231
+ }
232
+ store.setJobCallerKeyHash(taskId, key.slice(0, separator), key.slice(separator + 1), hash);
233
+ }
198
234
  // Active-run pointers: the document stores { schemaVersion, runId }
199
235
  // keyed by pointer; the store derives the pointer from the Run.
200
236
  for (const [pointer, value] of Object.entries(stored.activeRuns)) {
@@ -236,6 +272,13 @@ export function populateSqliteFromState(home, state, databaseFilename) {
236
272
  store.migrationSeedIdSequence(taskId, kind, highWater);
237
273
  }
238
274
  }
275
+ // Capability grants and release workflows (task-15 record families).
276
+ for (const grant of Object.values(stored.capabilityGrants)) {
277
+ store.saveCapabilityGrant(taskId, grant);
278
+ }
279
+ for (const workflow of Object.values(stored.releaseWorkflows)) {
280
+ store.saveReleaseWorkflow(taskId, workflow);
281
+ }
239
282
  }
240
283
  // Work mailboxes.
241
284
  for (const mailbox of Object.values(asObjectMap(state.mailboxes))) {
@@ -302,6 +345,9 @@ export function computeStateFamilyChecksums(state) {
302
345
  const reviewRounds = [];
303
346
  const changeSets = [];
304
347
  const integrationAttempts = [];
348
+ const integrationQueue = [];
349
+ const durableJobs = [];
350
+ const jobCallerKeyHashes = [];
305
351
  const activeRunPointers = [];
306
352
  const messages = [];
307
353
  const inputRequests = [];
@@ -310,6 +356,8 @@ export function computeStateFamilyChecksums(state) {
310
356
  const events = [];
311
357
  const leaderFailures = [];
312
358
  const operatorNotifications = [];
359
+ const capabilityGrants = [];
360
+ const releaseWorkflows = [];
313
361
  for (const stored of Object.values(tasks)) {
314
362
  taskRecords.push(stored.task);
315
363
  if (stored.brief !== null)
@@ -322,6 +370,20 @@ export function computeStateFamilyChecksums(state) {
322
370
  reviewRounds.push(...Object.values(stored.reviewRounds));
323
371
  changeSets.push(...Object.values(stored.changeSets));
324
372
  integrationAttempts.push(...Object.values(stored.integrationAttempts));
373
+ integrationQueue.push(...Object.values(stored.integrationQueue));
374
+ durableJobs.push(...Object.values(stored.durableJobs));
375
+ for (const [key, hash] of Object.entries(stored.jobCallerKeyHashes)) {
376
+ const separator = key.indexOf("\0");
377
+ if (separator <= 0 || separator === key.length - 1
378
+ || key.indexOf("\0", separator + 1) !== -1) {
379
+ throw new Error(`Job caller key hash identity is invalid: ${stored.task.id}/${key}.`);
380
+ }
381
+ jobCallerKeyHashes.push({
382
+ taskId: stored.task.id,
383
+ key,
384
+ hash
385
+ });
386
+ }
325
387
  activeRunPointers.push(...Object.values(stored.activeRuns));
326
388
  messages.push(...Object.values(stored.messages));
327
389
  inputRequests.push(...Object.values(stored.inputRequests));
@@ -332,6 +394,8 @@ export function computeStateFamilyChecksums(state) {
332
394
  leaderFailures.push(stored.leaderFailure);
333
395
  if (stored.operatorNotification !== null)
334
396
  operatorNotifications.push(stored.operatorNotification);
397
+ capabilityGrants.push(...Object.values(stored.capabilityGrants));
398
+ releaseWorkflows.push(...Object.values(stored.releaseWorkflows));
335
399
  }
336
400
  checksums.task = hashRecords(taskRecords);
337
401
  checksums.taskBrief = hashRecords(briefs);
@@ -343,6 +407,9 @@ export function computeStateFamilyChecksums(state) {
343
407
  checksums.reviewRound = hashRecords(reviewRounds);
344
408
  checksums.changeSet = hashRecords(changeSets);
345
409
  checksums.integrationAttempt = hashRecords(integrationAttempts);
410
+ checksums.integrationQueue = hashRecords(integrationQueue);
411
+ checksums.durableJob = hashRecords(durableJobs);
412
+ checksums.jobCallerKeyHash = hashRecords(jobCallerKeyHashes);
346
413
  checksums.activeRunPointer = hashRecords(activeRunPointers);
347
414
  checksums.message = hashRecords(messages);
348
415
  checksums.inputRequest = hashRecords(inputRequests);
@@ -351,6 +418,8 @@ export function computeStateFamilyChecksums(state) {
351
418
  checksums.event = hashRecords(events);
352
419
  checksums.leaderFailure = hashRecords(leaderFailures);
353
420
  checksums.operatorNotification = hashRecords(operatorNotifications);
421
+ checksums.capabilityGrant = hashRecords(capabilityGrants);
422
+ checksums.releaseWorkflow = hashRecords(releaseWorkflows);
354
423
  checksums.workMailbox = hashRecords(Object.values(asObjectMap(state.mailboxes)));
355
424
  return checksums;
356
425
  }
@@ -359,7 +428,7 @@ function hashPayloadTable(db, sql) {
359
428
  return hashRecords(rows.map((row) => JSON.parse(row.payload)));
360
429
  }
361
430
  /** Reconstruct a WorkMailbox from the normalised mailboxes table columns. */
362
- function rowToMailbox(row) {
431
+ export function rowToMailbox(row) {
363
432
  let target;
364
433
  switch (row.target_kind) {
365
434
  case "operator":
@@ -413,6 +482,14 @@ export function computeDbFamilyChecksums(home, databaseFilename) {
413
482
  checksums.reviewRound = hashPayloadTable(db, "SELECT payload FROM review_rounds");
414
483
  checksums.changeSet = hashPayloadTable(db, "SELECT payload FROM change_sets");
415
484
  checksums.integrationAttempt = hashPayloadTable(db, "SELECT payload FROM integration_attempts");
485
+ checksums.integrationQueue = hashPayloadTable(db, "SELECT payload FROM integration_queue");
486
+ checksums.durableJob = hashPayloadTable(db, "SELECT payload FROM durable_jobs");
487
+ const callerHashRows = db.prepare("SELECT task_id, role_name, agent_id, hash FROM job_caller_key_hashes").all();
488
+ checksums.jobCallerKeyHash = hashRecords(callerHashRows.map((row) => ({
489
+ taskId: row.task_id,
490
+ key: `${row.role_name}\0${row.agent_id}`,
491
+ hash: row.hash
492
+ })));
416
493
  checksums.activeRunPointer = hashPayloadTable(db, "SELECT payload FROM active_runs");
417
494
  checksums.message = hashPayloadTable(db, "SELECT payload FROM messages");
418
495
  checksums.inputRequest = hashPayloadTable(db, "SELECT payload FROM input_requests");
@@ -421,6 +498,8 @@ export function computeDbFamilyChecksums(home, databaseFilename) {
421
498
  checksums.event = hashPayloadTable(db, "SELECT payload FROM events");
422
499
  checksums.leaderFailure = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'leader-failure' AND payload IS NOT NULL");
423
500
  checksums.operatorNotification = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'operator-notification' AND payload IS NOT NULL");
501
+ checksums.capabilityGrant = hashPayloadTable(db, "SELECT payload FROM capability_grants");
502
+ checksums.releaseWorkflow = hashPayloadTable(db, "SELECT payload FROM release_workflows");
424
503
  // Mailboxes are reconstructed from typed columns (no payload column).
425
504
  const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
426
505
  checksums.workMailbox = hashRecords(mailboxRows.map(rowToMailbox));
@@ -455,3 +534,180 @@ export function verifySqliteChecksums(state, home, databaseFilename) {
455
534
  throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
456
535
  }
457
536
  }
537
+ // ---------------------------------------------------------------------------
538
+ // SQLite -> Snapshot reconstruction (record-migration source, Issue 01 Phase 2)
539
+ // ---------------------------------------------------------------------------
540
+ /**
541
+ * Reconstruct the state.json-shaped snapshot from a committed SQLite database.
542
+ *
543
+ * This is the reverse of {@link populateSqliteFromState}: it reads every
544
+ * record family from `yui.db` and rebuilds the nested document shape the
545
+ * generic migration engine's record transforms operate on. Payloads are read
546
+ * RAW (`JSON.parse` without the strict current-version parsers) so a record
547
+ * family at an older persisted version survives the round-trip with its
548
+ * original `schemaVersion` intact — the strict parsers only understand the
549
+ * current version and would reject the very records the migration is meant to
550
+ * transform.
551
+ *
552
+ * The database is opened read-only; this function never writes.
553
+ */
554
+ export function readStateFromSqlite(home) {
555
+ const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
556
+ const db = new Database(dbPath, { readonly: true });
557
+ try {
558
+ const manifest = readStorageSchemaManifest(home);
559
+ const storedTaskVersion = typeof manifest.recordVersions?.storedTask === "number"
560
+ ? manifest.recordVersions.storedTask
561
+ : CURRENT_STORED_TASK_SCHEMA_VERSION;
562
+ // Home identity + revision continuity.
563
+ const meta = db.prepare("SELECT home_identity, revision FROM home_meta WHERE id = 1").get();
564
+ const state = {
565
+ // The state document's `schemaVersion` is the Yui aggregate version,
566
+ // sourced from the durable manifest (the version contract). The
567
+ // `home_meta.aggregate_version` column is the SQLite database's own
568
+ // schema version (SQLITE_AGGREGATE_VERSION), a different fact.
569
+ schemaVersion: manifest.aggregateSchemaVersion,
570
+ revision: meta.revision,
571
+ homeIdentity: JSON.parse(meta.home_identity),
572
+ configuredAgents: {},
573
+ projects: {},
574
+ agentProfiles: {},
575
+ globalRoles: {},
576
+ globalRoleSessionSets: {},
577
+ tasks: {},
578
+ mailboxes: {}
579
+ };
580
+ // Config singleton (absent on a fresh, never-configured Home).
581
+ const configRow = db.prepare("SELECT payload FROM config WHERE id = 1").get();
582
+ if (configRow !== undefined) {
583
+ state.config = JSON.parse(configRow.payload);
584
+ }
585
+ // Global record families.
586
+ loadGlobalPayloadMap(db, "configured_agents", state, "configuredAgents", (record) => record.id);
587
+ loadGlobalPayloadMap(db, "projects", state, "projects", (record) => record.id);
588
+ loadGlobalPayloadMap(db, "agent_profiles", state, "agentProfiles", (record) => record.id);
589
+ loadGlobalPayloadMap(db, "global_roles", state, "globalRoles", (record) => record.name);
590
+ loadGlobalPayloadMap(db, "global_role_session_sets", state, "globalRoleSessionSets", (record) => record.owner.roleName);
591
+ // Tasks: the StoredTask aggregate wrapper. The wrapper `schemaVersion` is
592
+ // not persisted in the database (records live in flat tables), so it is
593
+ // taken from the manifest's declared `storedTask` version — the version
594
+ // the database was created with — so the version scanner and the migration
595
+ // engine see a consistent source.
596
+ const tasks = state.tasks;
597
+ const taskRows = db.prepare("SELECT task_id, payload, brief FROM task_records").all();
598
+ for (const row of taskRows) {
599
+ tasks[row.task_id] = {
600
+ schemaVersion: storedTaskVersion,
601
+ task: JSON.parse(row.payload),
602
+ brief: row.brief === null ? null : JSON.parse(row.brief),
603
+ roles: {},
604
+ managedWorkspaces: {},
605
+ roleSessionSets: {},
606
+ workItems: {},
607
+ agentRuns: {},
608
+ reviewRounds: {},
609
+ changeSets: {},
610
+ integrationAttempts: {},
611
+ integrationQueue: {},
612
+ durableJobs: {},
613
+ jobCallerKeyHashes: {},
614
+ activeRuns: {},
615
+ messages: {},
616
+ inputRequests: {},
617
+ decisions: {},
618
+ milestones: {},
619
+ events: {},
620
+ leaderFailure: null,
621
+ operatorNotification: null,
622
+ idHighWaterMarks: {},
623
+ capabilityGrants: {},
624
+ releaseWorkflows: {}
625
+ };
626
+ }
627
+ // Per-task record families, keyed by their state.json map keys.
628
+ loadTaskPayloadMap(db, "task_roles", tasks, "roles", (record) => record.name);
629
+ loadTaskPayloadMap(db, "managed_workspaces", tasks, "managedWorkspaces", (record) => managedWorkspaceKey(record.owner));
630
+ loadTaskPayloadMap(db, "role_session_sets", tasks, "roleSessionSets", (record) => record.owner.roleName);
631
+ loadTaskPayloadMap(db, "work_items", tasks, "workItems", (record) => record.id);
632
+ loadTaskPayloadMap(db, "agent_runs", tasks, "agentRuns", (record) => record.id);
633
+ loadTaskPayloadMap(db, "review_rounds", tasks, "reviewRounds", (record) => record.id);
634
+ loadTaskPayloadMap(db, "change_sets", tasks, "changeSets", (record) => record.id);
635
+ loadTaskPayloadMap(db, "integration_attempts", tasks, "integrationAttempts", (record) => record.id);
636
+ loadTaskPayloadMap(db, "integration_queue", tasks, "integrationQueue", (record) => record.id);
637
+ loadTaskPayloadMap(db, "durable_jobs", tasks, "durableJobs", (record) => record.id);
638
+ loadTaskPayloadMap(db, "messages", tasks, "messages", (record) => record.id);
639
+ loadTaskPayloadMap(db, "input_requests", tasks, "inputRequests", (record) => record.id);
640
+ loadTaskPayloadMap(db, "decisions", tasks, "decisions", (record) => record.id);
641
+ loadTaskPayloadMap(db, "milestones", tasks, "milestones", (record) => record.id);
642
+ loadTaskPayloadMap(db, "events", tasks, "events", (record) => record.id);
643
+ loadTaskPayloadMap(db, "capability_grants", tasks, "capabilityGrants", (record) => record.id);
644
+ loadTaskPayloadMap(db, "release_workflows", tasks, "releaseWorkflows", (record) => record.id);
645
+ // Job caller key hashes: keyed by `${roleName}\0${agentId}`.
646
+ const callerHashRows = db.prepare("SELECT task_id, role_name, agent_id, hash FROM job_caller_key_hashes").all();
647
+ for (const row of callerHashRows) {
648
+ const stored = requireTaskAggregate(tasks, row.task_id, "jobCallerKeyHashes");
649
+ stored.jobCallerKeyHashes[`${row.role_name}\0${row.agent_id}`] = row.hash;
650
+ }
651
+ // Active-run pointers: keyed by pointer, value is the persisted payload.
652
+ const activeRunRows = db.prepare("SELECT task_id, pointer, payload FROM active_runs").all();
653
+ for (const row of activeRunRows) {
654
+ const stored = requireTaskAggregate(tasks, row.task_id, "activeRuns");
655
+ stored.activeRuns[row.pointer] = JSON.parse(row.payload);
656
+ }
657
+ // Leader failure / operator notification projections.
658
+ const projectionRows = db.prepare("SELECT task_id, kind, payload FROM task_projections WHERE payload IS NOT NULL").all();
659
+ for (const row of projectionRows) {
660
+ const stored = requireTaskAggregate(tasks, row.task_id, row.kind);
661
+ if (row.kind === "leader-failure") {
662
+ stored.leaderFailure = JSON.parse(row.payload);
663
+ }
664
+ else {
665
+ stored.operatorNotification = JSON.parse(row.payload);
666
+ }
667
+ }
668
+ // Per-task ID high-water marks.
669
+ const idSeqRows = db.prepare("SELECT task_id, kind, high_water FROM id_sequences").all();
670
+ for (const row of idSeqRows) {
671
+ const stored = requireTaskAggregate(tasks, row.task_id, "idHighWaterMarks");
672
+ stored.idHighWaterMarks[row.kind] = row.high_water;
673
+ }
674
+ // Work mailboxes: reconstructed from typed columns (no payload column).
675
+ const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
676
+ const mailboxes = state.mailboxes;
677
+ for (const row of mailboxRows) {
678
+ const mailbox = rowToMailbox(row);
679
+ mailboxes[mailboxTargetKey(mailbox.target)] = mailbox;
680
+ }
681
+ return state;
682
+ }
683
+ finally {
684
+ db.close();
685
+ }
686
+ }
687
+ /** Load a global (non-task-scoped) payload table into a keyed map on `state`. */
688
+ function loadGlobalPayloadMap(db, table, state, stateKey, keyOf) {
689
+ const rows = db.prepare(`SELECT payload FROM ${table}`).all();
690
+ const target = state[stateKey];
691
+ for (const row of rows) {
692
+ const record = JSON.parse(row.payload);
693
+ target[keyOf(record)] = record;
694
+ }
695
+ }
696
+ /** Load a task-scoped payload table into the matching family map of each task. */
697
+ function loadTaskPayloadMap(db, table, tasks, familyKey, keyOf) {
698
+ const rows = db.prepare(`SELECT task_id, payload FROM ${table}`).all();
699
+ for (const row of rows) {
700
+ const stored = requireTaskAggregate(tasks, row.task_id, table);
701
+ const family = stored[familyKey];
702
+ const record = JSON.parse(row.payload);
703
+ family[keyOf(record)] = record;
704
+ }
705
+ }
706
+ /** Resolve a task's StoredTask aggregate, or throw on an orphaned child row. */
707
+ function requireTaskAggregate(tasks, taskId, context) {
708
+ const stored = tasks[taskId];
709
+ if (stored === undefined) {
710
+ throw new Error(`SQLite reconstruction found ${context} row for unknown task ${taskId}.`);
711
+ }
712
+ return stored;
713
+ }