@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,713 @@
1
+ /**
2
+ * state.json -> SQLite staged migration (task-21 §8, work-item-4).
3
+ *
4
+ * This module is the document-to-database population and verification seam for
5
+ * the layout 6 -> 7 offline migration. It is deliberately side-effect-light:
6
+ *
7
+ * - `populateSqliteFromState` opens a {@link SqliteTaskStore} on the sidecar
8
+ * `yui.db.staged` and bulk-loads every record family from the parsed
9
+ * `state.json` document, preserving the Home identity, revision, and ID
10
+ * high-water marks. The source document is never written.
11
+ * - `computeStateFamilyChecksums` / `computeDbFamilyChecksums` compute a
12
+ * per-family `{ count, hash }` over the canonical JSON of every record, so
13
+ * the staged database can be verified against an independent re-read of
14
+ * `state.json` (the Verify phase of §8.2).
15
+ * - `verifySqliteMigration` compares the two checksum maps and throws on any
16
+ * count or content mismatch, leaving the source untouched.
17
+ *
18
+ * The checksum is content-based: each record is canonicalised (sorted keys,
19
+ * stable array ordering) and hashed individually; the per-family hash is the
20
+ * sha256 of the sorted record hashes. This is order-independent (a map
21
+ * serialised in any key order produces the same checksum) and catches any
22
+ * dropped, duplicated, or mutated record.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { join } from "node:path";
26
+ import Database from "better-sqlite3";
27
+ import { mailboxTargetKey } from "../../coordination/workMailbox.js";
28
+ import { managedWorkspaceKey } from "../../worktree/managedWorkspace.js";
29
+ import { SqliteTaskStore } from "../sqliteStore.js";
30
+ import { readStorageSchemaManifest } from "../storageSchema.js";
31
+ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
32
+ /** The sidecar database filename used during staging. */
33
+ export const STAGED_DATABASE_FILENAME = "yui.db.staged";
34
+ /** The committed database filename. */
35
+ export const COMMITTED_DATABASE_FILENAME = "yui.db";
36
+ // ---------------------------------------------------------------------------
37
+ // Canonical JSON and hashing
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * Deterministic JSON serialisation: object keys are sorted recursively so that
41
+ * two semantically-equal objects serialise identically regardless of key
42
+ * insertion order. Arrays preserve their order.
43
+ */
44
+ function canonicalJson(value) {
45
+ if (value === null || typeof value !== "object")
46
+ return JSON.stringify(value);
47
+ if (Array.isArray(value)) {
48
+ return `[${value.map(canonicalJson).join(",")}]`;
49
+ }
50
+ const record = value;
51
+ const keys = Object.keys(record).sort();
52
+ return `{${keys
53
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
54
+ .join(",")}}`;
55
+ }
56
+ /**
57
+ * Compute a per-family checksum over a set of records. Each record is hashed
58
+ * individually (over its canonical JSON); the family hash is the sha256 of
59
+ * the sorted per-record hashes, making the result independent of record
60
+ * ordering.
61
+ */
62
+ function hashRecords(records) {
63
+ const hashes = records
64
+ .map((record) => createHash("sha256").update(canonicalJson(record)).digest("hex"))
65
+ .sort();
66
+ const hash = createHash("sha256").update(hashes.join("\n")).digest("hex");
67
+ return { count: records.length, hash };
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Document shape helpers
71
+ // ---------------------------------------------------------------------------
72
+ function asObject(value) {
73
+ if (value === null || typeof value !== "object" || Array.isArray(value))
74
+ return {};
75
+ return value;
76
+ }
77
+ function asObjectMap(value) {
78
+ const record = asObject(value);
79
+ const result = {};
80
+ for (const [key, entry] of Object.entries(record)) {
81
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
82
+ result[key] = entry;
83
+ }
84
+ }
85
+ return result;
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
+ }
100
+ function asNullableObject(value) {
101
+ if (value === null || value === undefined)
102
+ return null;
103
+ if (typeof value !== "object" || Array.isArray(value))
104
+ return null;
105
+ return value;
106
+ }
107
+ function asStoredTask(value) {
108
+ const record = asObject(value);
109
+ return {
110
+ task: asObject(record.task),
111
+ brief: asNullableObject(record.brief),
112
+ roles: asObjectMap(record.roles),
113
+ managedWorkspaces: asObjectMap(record.managedWorkspaces),
114
+ roleSessionSets: asObjectMap(record.roleSessionSets),
115
+ workItems: asObjectMap(record.workItems),
116
+ agentRuns: asObjectMap(record.agentRuns),
117
+ reviewRounds: asObjectMap(record.reviewRounds),
118
+ changeSets: asObjectMap(record.changeSets),
119
+ integrationAttempts: asObjectMap(record.integrationAttempts),
120
+ integrationQueue: asObjectMap(record.integrationQueue),
121
+ durableJobs: asObjectMap(record.durableJobs),
122
+ jobCallerKeyHashes: asStringMap(record.jobCallerKeyHashes, "jobCallerKeyHashes"),
123
+ activeRuns: asObjectMap(record.activeRuns),
124
+ messages: asObjectMap(record.messages),
125
+ inputRequests: asObjectMap(record.inputRequests),
126
+ decisions: asObjectMap(record.decisions),
127
+ milestones: asObjectMap(record.milestones),
128
+ events: asObjectMap(record.events),
129
+ leaderFailure: asNullableObject(record.leaderFailure),
130
+ operatorNotification: asNullableObject(record.operatorNotification),
131
+ idHighWaterMarks: asObject(record.idHighWaterMarks),
132
+ capabilityGrants: asObjectMap(record.capabilityGrants),
133
+ releaseWorkflows: asObjectMap(record.releaseWorkflows)
134
+ };
135
+ }
136
+ function tasksOf(state) {
137
+ const result = {};
138
+ for (const [taskId, raw] of Object.entries(asObject(state.tasks))) {
139
+ result[taskId] = asStoredTask(raw);
140
+ }
141
+ return result;
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Population (Snapshot -> Stage)
145
+ // ---------------------------------------------------------------------------
146
+ /**
147
+ * Populate a fresh SQLite database from the parsed `state.json` document.
148
+ *
149
+ * The database is opened with the `migration` option (fence bypass) because
150
+ * the staged load runs while the upgrade fence is active. Every record family
151
+ * is saved through the canonical {@link SqliteTaskStore} methods so the
152
+ * typed-column projections and payload contents match what a live store would
153
+ * produce. The Home identity, revision, and ID high-water marks are preserved
154
+ * so the opened store continues from the same counters.
155
+ *
156
+ * This function NEVER writes `state.json`; it only creates/populates the
157
+ * sidecar database file.
158
+ */
159
+ export function populateSqliteFromState(home, state, databaseFilename) {
160
+ const store = new SqliteTaskStore(home, { databaseFilename, migration: true });
161
+ try {
162
+ store.transaction(() => {
163
+ // Home identity + revision continuity.
164
+ const identity = asNullableObject(state.homeIdentity);
165
+ const revision = typeof state.revision === "number" ? state.revision : 0;
166
+ if (identity !== null) {
167
+ store.migrationSetHomeMeta(identity, revision);
168
+ }
169
+ // Config singleton.
170
+ const config = asNullableObject(state.config);
171
+ if (config !== null)
172
+ store.saveConfig(config);
173
+ // Global record families.
174
+ for (const agent of Object.values(asObjectMap(state.configuredAgents))) {
175
+ store.saveConfiguredAgent(agent);
176
+ }
177
+ for (const project of Object.values(asObjectMap(state.projects))) {
178
+ store.saveProject(project);
179
+ }
180
+ for (const profile of Object.values(asObjectMap(state.agentProfiles))) {
181
+ store.saveAgentProfile(profile);
182
+ }
183
+ for (const role of Object.values(asObjectMap(state.globalRoles))) {
184
+ store.saveGlobalRole(role);
185
+ }
186
+ for (const sessions of Object.values(asObjectMap(state.globalRoleSessionSets))) {
187
+ store.saveGlobalRoleSessionSet(sessions);
188
+ }
189
+ // Tasks and their per-task families.
190
+ const tasks = tasksOf(state);
191
+ for (const [taskId, stored] of Object.entries(tasks)) {
192
+ store.saveTask(stored.task);
193
+ if (stored.brief !== null) {
194
+ store.saveTaskBrief(taskId, stored.brief);
195
+ }
196
+ for (const role of Object.values(stored.roles)) {
197
+ store.saveRole(taskId, role);
198
+ }
199
+ for (const workspace of Object.values(stored.managedWorkspaces)) {
200
+ store.saveManagedWorkspace(workspace);
201
+ }
202
+ for (const sessions of Object.values(stored.roleSessionSets)) {
203
+ store.saveRoleSessionSet(sessions);
204
+ }
205
+ for (const item of Object.values(stored.workItems)) {
206
+ store.saveWorkItem(taskId, item);
207
+ }
208
+ for (const run of Object.values(stored.agentRuns)) {
209
+ store.saveAgentRun(run);
210
+ }
211
+ for (const round of Object.values(stored.reviewRounds)) {
212
+ store.saveReviewRound(taskId, round);
213
+ }
214
+ for (const changeSet of Object.values(stored.changeSets)) {
215
+ store.saveChangeSet(taskId, changeSet);
216
+ }
217
+ for (const attempt of Object.values(stored.integrationAttempts)) {
218
+ store.saveIntegrationAttempt(taskId, attempt);
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
+ }
234
+ // Active-run pointers: the document stores { schemaVersion, runId }
235
+ // keyed by pointer; the store derives the pointer from the Run.
236
+ for (const [pointer, value] of Object.entries(stored.activeRuns)) {
237
+ const run = stored.agentRuns[value.runId];
238
+ if (run === undefined) {
239
+ throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${value.runId}.`);
240
+ }
241
+ if (pointer.startsWith("/execution-lane/")) {
242
+ store.saveActiveExecutionLaneRun(run);
243
+ }
244
+ else {
245
+ store.saveActiveAgentRun(run);
246
+ }
247
+ }
248
+ for (const message of Object.values(stored.messages)) {
249
+ store.saveMessage(taskId, message);
250
+ }
251
+ for (const request of Object.values(stored.inputRequests)) {
252
+ store.saveInputRequest(taskId, request);
253
+ }
254
+ for (const decision of Object.values(stored.decisions)) {
255
+ store.saveDecision(taskId, decision);
256
+ }
257
+ for (const milestone of Object.values(stored.milestones)) {
258
+ store.saveMilestone(taskId, milestone);
259
+ }
260
+ for (const event of Object.values(stored.events)) {
261
+ store.saveEvent(taskId, event);
262
+ }
263
+ if (stored.leaderFailure !== null) {
264
+ store.saveLeaderFailure(stored.leaderFailure);
265
+ }
266
+ if (stored.operatorNotification !== null) {
267
+ store.saveOperatorNotification(stored.operatorNotification);
268
+ }
269
+ // Per-task ID high-water marks.
270
+ for (const [kind, highWater] of Object.entries(stored.idHighWaterMarks)) {
271
+ if (typeof highWater === "number" && Number.isFinite(highWater)) {
272
+ store.migrationSeedIdSequence(taskId, kind, highWater);
273
+ }
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
+ }
282
+ }
283
+ // Work mailboxes.
284
+ for (const mailbox of Object.values(asObjectMap(state.mailboxes))) {
285
+ store.saveWorkMailbox(mailbox);
286
+ }
287
+ // Global ID high-water marks, derived from existing task/project ids.
288
+ seedGlobalSequences(store, state);
289
+ });
290
+ }
291
+ finally {
292
+ store.close();
293
+ }
294
+ }
295
+ /**
296
+ * Seed `global_sequences` from the numeric suffixes of existing task and
297
+ * project ids so the next allocated id never collides with a historical one.
298
+ * Ids that do not match `<kind>-<n>` are ignored (legacy formats).
299
+ */
300
+ function seedGlobalSequences(store, state) {
301
+ const taskMax = maxIdSuffix(Object.keys(asObject(state.tasks)), "task");
302
+ const projectMax = maxIdSuffix(Object.keys(asObject(state.projects)), "project");
303
+ if (taskMax > 0)
304
+ store.migrationSeedGlobalSequence("task", taskMax);
305
+ if (projectMax > 0)
306
+ store.migrationSeedGlobalSequence("project", projectMax);
307
+ }
308
+ function maxIdSuffix(ids, prefix) {
309
+ let max = 0;
310
+ const pattern = new RegExp(`^${prefix}-(\\d+)$`, "u");
311
+ for (const id of ids) {
312
+ const match = pattern.exec(id);
313
+ if (match !== null) {
314
+ const value = Number.parseInt(match[1], 10);
315
+ if (Number.isSafeInteger(value))
316
+ max = Math.max(max, value);
317
+ }
318
+ }
319
+ return max;
320
+ }
321
+ // ---------------------------------------------------------------------------
322
+ // State-side checksums (the source of truth)
323
+ // ---------------------------------------------------------------------------
324
+ /**
325
+ * Compute per-family checksums from the parsed `state.json` document. This is
326
+ * the expected checksum set; the staged database is verified against it.
327
+ */
328
+ export function computeStateFamilyChecksums(state) {
329
+ const checksums = {};
330
+ checksums.config = hashRecords(asNullableObject(state.config) === null ? [] : [asObject(state.config)]);
331
+ checksums.configuredAgent = hashRecords(Object.values(asObjectMap(state.configuredAgents)));
332
+ checksums.project = hashRecords(Object.values(asObjectMap(state.projects)));
333
+ checksums.agentProfile = hashRecords(Object.values(asObjectMap(state.agentProfiles)));
334
+ checksums.globalRole = hashRecords(Object.values(asObjectMap(state.globalRoles)));
335
+ checksums.globalRoleSessionSet = hashRecords(Object.values(asObjectMap(state.globalRoleSessionSets)));
336
+ // Flatten the per-task families across all tasks.
337
+ const tasks = tasksOf(state);
338
+ const taskRecords = [];
339
+ const briefs = [];
340
+ const roles = [];
341
+ const workspaces = [];
342
+ const roleSessionSets = [];
343
+ const workItems = [];
344
+ const agentRuns = [];
345
+ const reviewRounds = [];
346
+ const changeSets = [];
347
+ const integrationAttempts = [];
348
+ const integrationQueue = [];
349
+ const durableJobs = [];
350
+ const jobCallerKeyHashes = [];
351
+ const activeRunPointers = [];
352
+ const messages = [];
353
+ const inputRequests = [];
354
+ const decisions = [];
355
+ const milestones = [];
356
+ const events = [];
357
+ const leaderFailures = [];
358
+ const operatorNotifications = [];
359
+ const capabilityGrants = [];
360
+ const releaseWorkflows = [];
361
+ for (const stored of Object.values(tasks)) {
362
+ taskRecords.push(stored.task);
363
+ if (stored.brief !== null)
364
+ briefs.push(stored.brief);
365
+ roles.push(...Object.values(stored.roles));
366
+ workspaces.push(...Object.values(stored.managedWorkspaces));
367
+ roleSessionSets.push(...Object.values(stored.roleSessionSets));
368
+ workItems.push(...Object.values(stored.workItems));
369
+ agentRuns.push(...Object.values(stored.agentRuns));
370
+ reviewRounds.push(...Object.values(stored.reviewRounds));
371
+ changeSets.push(...Object.values(stored.changeSets));
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
+ }
387
+ activeRunPointers.push(...Object.values(stored.activeRuns));
388
+ messages.push(...Object.values(stored.messages));
389
+ inputRequests.push(...Object.values(stored.inputRequests));
390
+ decisions.push(...Object.values(stored.decisions));
391
+ milestones.push(...Object.values(stored.milestones));
392
+ events.push(...Object.values(stored.events));
393
+ if (stored.leaderFailure !== null)
394
+ leaderFailures.push(stored.leaderFailure);
395
+ if (stored.operatorNotification !== null)
396
+ operatorNotifications.push(stored.operatorNotification);
397
+ capabilityGrants.push(...Object.values(stored.capabilityGrants));
398
+ releaseWorkflows.push(...Object.values(stored.releaseWorkflows));
399
+ }
400
+ checksums.task = hashRecords(taskRecords);
401
+ checksums.taskBrief = hashRecords(briefs);
402
+ checksums.taskRole = hashRecords(roles);
403
+ checksums.managedWorkspace = hashRecords(workspaces);
404
+ checksums.taskRoleSessionSet = hashRecords(roleSessionSets);
405
+ checksums.workItem = hashRecords(workItems);
406
+ checksums.agentRun = hashRecords(agentRuns);
407
+ checksums.reviewRound = hashRecords(reviewRounds);
408
+ checksums.changeSet = hashRecords(changeSets);
409
+ checksums.integrationAttempt = hashRecords(integrationAttempts);
410
+ checksums.integrationQueue = hashRecords(integrationQueue);
411
+ checksums.durableJob = hashRecords(durableJobs);
412
+ checksums.jobCallerKeyHash = hashRecords(jobCallerKeyHashes);
413
+ checksums.activeRunPointer = hashRecords(activeRunPointers);
414
+ checksums.message = hashRecords(messages);
415
+ checksums.inputRequest = hashRecords(inputRequests);
416
+ checksums.decision = hashRecords(decisions);
417
+ checksums.milestone = hashRecords(milestones);
418
+ checksums.event = hashRecords(events);
419
+ checksums.leaderFailure = hashRecords(leaderFailures);
420
+ checksums.operatorNotification = hashRecords(operatorNotifications);
421
+ checksums.capabilityGrant = hashRecords(capabilityGrants);
422
+ checksums.releaseWorkflow = hashRecords(releaseWorkflows);
423
+ checksums.workMailbox = hashRecords(Object.values(asObjectMap(state.mailboxes)));
424
+ return checksums;
425
+ }
426
+ function hashPayloadTable(db, sql) {
427
+ const rows = db.prepare(sql).all();
428
+ return hashRecords(rows.map((row) => JSON.parse(row.payload)));
429
+ }
430
+ /** Reconstruct a WorkMailbox from the normalised mailboxes table columns. */
431
+ export function rowToMailbox(row) {
432
+ let target;
433
+ switch (row.target_kind) {
434
+ case "operator":
435
+ target = { kind: "operator" };
436
+ break;
437
+ case "task":
438
+ target = { kind: "task", taskId: row.task_id };
439
+ break;
440
+ case "role":
441
+ target = { kind: "role", taskId: row.task_id, roleName: row.role_name };
442
+ break;
443
+ case "role-runtime":
444
+ target = { kind: "role-runtime", taskId: row.task_id, roleName: row.role_name };
445
+ break;
446
+ case "global-role-runtime":
447
+ target = { kind: "global-role-runtime", roleName: row.role_name };
448
+ break;
449
+ default:
450
+ target = { kind: row.target_kind };
451
+ }
452
+ return {
453
+ schemaVersion: 1,
454
+ target,
455
+ nextSequence: row.next_sequence,
456
+ processing: row.processing === null ? null : JSON.parse(row.processing),
457
+ pending: row.pending === null ? null : JSON.parse(row.pending)
458
+ };
459
+ }
460
+ /**
461
+ * Compute per-family checksums from the SQLite database. The database is
462
+ * opened read-only; this is the independent verification read.
463
+ */
464
+ export function computeDbFamilyChecksums(home, databaseFilename) {
465
+ const dbPath = join(home, databaseFilename);
466
+ const db = new Database(dbPath, { readonly: true });
467
+ try {
468
+ const checksums = {};
469
+ checksums.config = hashPayloadTable(db, "SELECT payload FROM config");
470
+ checksums.configuredAgent = hashPayloadTable(db, "SELECT payload FROM configured_agents");
471
+ checksums.project = hashPayloadTable(db, "SELECT payload FROM projects");
472
+ checksums.agentProfile = hashPayloadTable(db, "SELECT payload FROM agent_profiles");
473
+ checksums.globalRole = hashPayloadTable(db, "SELECT payload FROM global_roles");
474
+ checksums.globalRoleSessionSet = hashPayloadTable(db, "SELECT payload FROM global_role_session_sets");
475
+ checksums.task = hashPayloadTable(db, "SELECT payload FROM task_records");
476
+ checksums.taskBrief = hashPayloadTable(db, "SELECT brief AS payload FROM task_records WHERE brief IS NOT NULL");
477
+ checksums.taskRole = hashPayloadTable(db, "SELECT payload FROM task_roles");
478
+ checksums.managedWorkspace = hashPayloadTable(db, "SELECT payload FROM managed_workspaces");
479
+ checksums.taskRoleSessionSet = hashPayloadTable(db, "SELECT payload FROM role_session_sets");
480
+ checksums.workItem = hashPayloadTable(db, "SELECT payload FROM work_items");
481
+ checksums.agentRun = hashPayloadTable(db, "SELECT payload FROM agent_runs");
482
+ checksums.reviewRound = hashPayloadTable(db, "SELECT payload FROM review_rounds");
483
+ checksums.changeSet = hashPayloadTable(db, "SELECT payload FROM change_sets");
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
+ })));
493
+ checksums.activeRunPointer = hashPayloadTable(db, "SELECT payload FROM active_runs");
494
+ checksums.message = hashPayloadTable(db, "SELECT payload FROM messages");
495
+ checksums.inputRequest = hashPayloadTable(db, "SELECT payload FROM input_requests");
496
+ checksums.decision = hashPayloadTable(db, "SELECT payload FROM decisions");
497
+ checksums.milestone = hashPayloadTable(db, "SELECT payload FROM milestones");
498
+ checksums.event = hashPayloadTable(db, "SELECT payload FROM events");
499
+ checksums.leaderFailure = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'leader-failure' AND payload IS NOT NULL");
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");
503
+ // Mailboxes are reconstructed from typed columns (no payload column).
504
+ const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
505
+ checksums.workMailbox = hashRecords(mailboxRows.map(rowToMailbox));
506
+ return checksums;
507
+ }
508
+ finally {
509
+ db.close();
510
+ }
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Verification (Verify phase of §8.2)
514
+ // ---------------------------------------------------------------------------
515
+ /**
516
+ * Compare the per-family checksums of the source document against those of the
517
+ * staged database. Throws on any count or content mismatch, naming every
518
+ * divergent family. The source document is never modified.
519
+ */
520
+ export function verifySqliteChecksums(state, home, databaseFilename) {
521
+ const expected = computeStateFamilyChecksums(state);
522
+ const actual = computeDbFamilyChecksums(home, databaseFilename);
523
+ const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
524
+ const mismatches = [];
525
+ for (const family of families) {
526
+ const e = expected[family];
527
+ const a = actual[family];
528
+ if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
529
+ mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
530
+ `found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
531
+ }
532
+ }
533
+ if (mismatches.length > 0) {
534
+ throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
535
+ }
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
+ }