@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,1695 @@
1
+ /**
2
+ * SQLite WAL Store (task-21, work-item-3).
3
+ *
4
+ * A `TaskStore` implementation backed by a single SQLite WAL database
5
+ * (`yui.db`), replacing the aggregate `state.json` read/parse/validate/write
6
+ * cycle. It implements the replaceable-Store seam from the design (§6) and the
7
+ * semantic-preservation checklist (§9):
8
+ *
9
+ * - Process write lock .......... single writer connection + BEGIN IMMEDIATE;
10
+ * busy_timeout absorbs CLI contention.
11
+ * - Revision CAS ................ home_meta.revision checked/incremented in
12
+ * the write transaction; conflict ->
13
+ * StorageConflictError (transactionWithRevisionCas).
14
+ * - Atomic durable write ........ WAL + synchronous=FULL; COMMIT == fsync.
15
+ * - Mailbox per-target ordering . mailboxes.next_sequence + mailbox_signals
16
+ * (mailbox_id, sequence) primary key.
17
+ * - Exactly-once terminal state . conditional updates + UNIQUE(request_id)
18
+ * on outbox / mailbox_signals.
19
+ * - Crash recovery .............. WAL rollback of uncommitted transactions;
20
+ * outbox replay of committed-but-unacked effects.
21
+ * - Record family versioning .... full record (incl. schemaVersion) in payload.
22
+ * - Upgrade fence ............... assertHomeWritable at the write boundary.
23
+ * - Evidence retention .......... events/review_rounds/change_sets/
24
+ * integration_attempts are never pruned.
25
+ *
26
+ * Records are stored as full versioned JSON in `payload` columns, with typed
27
+ * columns for the fields that are queried/filtered/used-for-CAS (§4). A
28
+ * high-frequency `runtime.provider-turn-progress` event is a single-row
29
+ * upsert into `telemetry` scoped by its primary key — it never rewrites global
30
+ * state and never touches another Task's rows (§4.4).
31
+ *
32
+ * The in-process store is phase 1 of §6. It does not re-run the heavy record
33
+ * validators on write (the domain layer that constructs records already does,
34
+ * and the design places record validation in the persistence worker, phase 2);
35
+ * it performs the same cheap structural checks the file store relies on
36
+ * (identity presence, taskId matching, referential lookups).
37
+ */
38
+ import { existsSync, mkdirSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { isDeepStrictEqual } from "node:util";
41
+ import Database from "better-sqlite3";
42
+ import { mailboxTargetKey } from "../coordination/workMailbox.js";
43
+ import { validateReviewFinding } from "../review/reviewFinding.js";
44
+ import { generateHomeIdentity } from "../repository/homeIdentity.js";
45
+ import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
46
+ import { validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
47
+ import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
48
+ import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
49
+ import { assertHomeWritable } from "./upgradeFence.js";
50
+ import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, executionLaneActiveRunKey, executionLaneActiveRunKeyParts, StorageConflictError, StorageCancelledError, StorageRecordError, FileTaskStore, storedCapabilityGrant, storedReleaseWorkflow, isValidCapabilityGrantTransition, isValidReleaseWorkflowTransition, validateYuiConfig } from "./taskStore.js";
51
+ import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
52
+ import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
53
+ import { inspectStorageSchema } from "./storageSchema.js";
54
+ const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
55
+ function numericCompare(left, right) {
56
+ return left.localeCompare(right, undefined, { numeric: true });
57
+ }
58
+ /** True when the better-sqlite3 error is a UNIQUE/PRIMARY KEY constraint failure. */
59
+ function isUniqueConstraint(error) {
60
+ return error instanceof Error
61
+ && "code" in error
62
+ && error.code === "SQLITE_CONSTRAINT_PRIMARYKEY"
63
+ || error?.code === "SQLITE_CONSTRAINT_UNIQUE";
64
+ }
65
+ /** Project a leader-role work mailbox's pending batch to a PendingWakeup (mirrors taskStore.ts). */
66
+ function pendingWakeupProjection(mailbox) {
67
+ if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
68
+ || mailbox.pending === null) {
69
+ return null;
70
+ }
71
+ return {
72
+ schemaVersion: CURRENT_PENDING_WAKEUP_SCHEMA_VERSION,
73
+ taskId: mailbox.target.taskId,
74
+ reasons: [...mailbox.pending.reasons],
75
+ requestCount: mailbox.pending.requestCount,
76
+ firstRequestedAt: mailbox.pending.firstQueuedAt,
77
+ lastRequestedAt: mailbox.pending.lastQueuedAt
78
+ };
79
+ }
80
+ export class SqliteTaskStore {
81
+ #db;
82
+ #rootDir;
83
+ #migration;
84
+ #inTransaction = false;
85
+ #dirty = false;
86
+ constructor(rootDir, _options = {}) {
87
+ this.#rootDir = rootDir;
88
+ this.#migration = _options.migration ?? false;
89
+ mkdirSync(rootDir, { recursive: true, mode: 0o700 });
90
+ const filename = _options.databaseFilename ?? "yui.db";
91
+ this.#db = new Database(join(rootDir, filename));
92
+ // §4.1 / §9: WAL, no fsync weakening, FKs on, busy timeout for CLI contention.
93
+ this.#db.pragma("journal_mode = WAL");
94
+ this.#db.pragma("synchronous = FULL");
95
+ this.#db.pragma("foreign_keys = ON");
96
+ this.#db.pragma("busy_timeout = 5000");
97
+ this.#db.pragma("wal_autocheckpoint = 1000");
98
+ migrateSqliteSchema(this.#db);
99
+ this.#seedHomeMeta();
100
+ this.#seedConfig();
101
+ }
102
+ rootDirectory() { return this.#rootDir; }
103
+ /** Close the underlying database connection. */
104
+ close() { this.#db.close(); }
105
+ /**
106
+ * The underlying database connection, for read-only diagnostics
107
+ * (`PRAGMA journal_mode`, `PRAGMA quick_check`). Callers must not mutate
108
+ * through this handle.
109
+ */
110
+ databaseHandle() { return this.#db; }
111
+ // -- transaction primitives -------------------------------------------------
112
+ #seedHomeMeta() {
113
+ const now = new Date().toISOString();
114
+ const identity = generateHomeIdentity(new Date());
115
+ this.#db.prepare(`INSERT OR IGNORE INTO home_meta (id, home_identity, revision, layout_version, aggregate_version, created_at, updated_at)
116
+ VALUES (1, ?, 0, ?, ?, ?, ?)`).run(JSON.stringify(identity), SQLITE_LAYOUT_VERSION, SQLITE_AGGREGATE_VERSION, now, now);
117
+ }
118
+ #seedConfig() {
119
+ this.#db.prepare(`INSERT OR IGNORE INTO config (id, payload, updated_at) VALUES (1, ?, ?)`).run(JSON.stringify(DEFAULT_CONFIG), new Date().toISOString());
120
+ }
121
+ #now() { return new Date().toISOString(); }
122
+ #json(value) { return JSON.stringify(value); }
123
+ #parse(text) { return JSON.parse(text); }
124
+ #begin() {
125
+ this.#db.exec("BEGIN IMMEDIATE");
126
+ this.#inTransaction = true;
127
+ this.#dirty = false;
128
+ }
129
+ #commit() {
130
+ this.#db.exec("COMMIT");
131
+ this.#inTransaction = false;
132
+ this.#dirty = false;
133
+ }
134
+ #rollback() {
135
+ try {
136
+ this.#db.exec("ROLLBACK");
137
+ }
138
+ catch { /* already closed */ }
139
+ this.#inTransaction = false;
140
+ this.#dirty = false;
141
+ }
142
+ /** The upgrade-admission fence, honored at the single write moment (§9). */
143
+ #prepareWrite() {
144
+ // The staged migration populates the sidecar database while the upgrade
145
+ // fence is active (the migration IS the upgrade), so it bypasses the
146
+ // per-write admission check. Production stores never set migration mode.
147
+ if (this.#migration)
148
+ return;
149
+ assertHomeWritable(this.#rootDir);
150
+ }
151
+ #bumpRevision() {
152
+ this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
153
+ }
154
+ /**
155
+ * Run a mutating closure. Inside an outer {@link transaction} it joins that
156
+ * transaction (single revision bump at the outer commit); standalone it takes
157
+ * the write lock, checks the fence, bumps the revision, and commits.
158
+ */
159
+ #mutate(fn) {
160
+ if (this.#inTransaction) {
161
+ const result = fn();
162
+ this.#dirty = true;
163
+ return result;
164
+ }
165
+ this.#prepareWrite();
166
+ this.#begin();
167
+ try {
168
+ const result = fn();
169
+ if (!this.#migration) {
170
+ this.#bumpRevision();
171
+ }
172
+ this.#commit();
173
+ return result;
174
+ }
175
+ catch (error) {
176
+ this.#rollback();
177
+ throw error;
178
+ }
179
+ }
180
+ transaction(execute, options) {
181
+ const run = execute;
182
+ if (this.#inTransaction)
183
+ return run(this);
184
+ this.#begin();
185
+ try {
186
+ const result = run(this);
187
+ if (this.#dirty) {
188
+ this.#prepareWrite();
189
+ if (options?.requestId !== undefined) {
190
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
191
+ }
192
+ // The staged migration sets the revision explicitly via
193
+ // migrationSetHomeMeta; the commit must not bump it.
194
+ if (!this.#migration) {
195
+ this.#bumpRevision();
196
+ }
197
+ }
198
+ this.#commit();
199
+ return result;
200
+ }
201
+ catch (error) {
202
+ this.#rollback();
203
+ throw error;
204
+ }
205
+ }
206
+ /** Async transaction seam used by queue operations that must inspect Git
207
+ * before committing the durable queue state. The SQLite write transaction
208
+ * remains open across the awaited callback, matching FileTaskStore's
209
+ * transactionAsync semantics and preserving the single-writer boundary. */
210
+ async transactionAsync(execute) {
211
+ if (this.#inTransaction)
212
+ return execute(this);
213
+ this.#prepareWrite();
214
+ this.#begin();
215
+ try {
216
+ const result = await execute(this);
217
+ if (this.#dirty)
218
+ this.#bumpRevision();
219
+ this.#commit();
220
+ return result;
221
+ }
222
+ catch (error) {
223
+ this.#rollback();
224
+ throw error;
225
+ }
226
+ }
227
+ /** Bounded runtime-event folds share the same SQLite write transaction. */
228
+ withRuntimeEventTransaction(execute) {
229
+ return this.transaction(() => execute());
230
+ }
231
+ /**
232
+ * Revision CAS (§5.3): run `execute` only when the global revision is still
233
+ * `expectedRevision`; otherwise throw {@link StorageConflictError}. The check
234
+ * and the increment happen in the same write transaction.
235
+ */
236
+ transactionWithRevisionCas(expectedRevision, execute, options) {
237
+ if (this.#inTransaction)
238
+ return execute(this);
239
+ this.#begin();
240
+ try {
241
+ const current = this.getRevision();
242
+ if (current !== expectedRevision) {
243
+ throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`);
244
+ }
245
+ const result = execute(this);
246
+ if (this.#dirty) {
247
+ this.#prepareWrite();
248
+ if (options?.requestId !== undefined) {
249
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
250
+ }
251
+ // The staged migration sets the revision explicitly via
252
+ // migrationSetHomeMeta; the commit must not bump it.
253
+ if (!this.#migration) {
254
+ this.#bumpRevision();
255
+ }
256
+ }
257
+ this.#commit();
258
+ return result;
259
+ }
260
+ catch (error) {
261
+ this.#rollback();
262
+ if (error instanceof StorageConflictError)
263
+ throw error;
264
+ throw error;
265
+ }
266
+ }
267
+ /** The current global revision (the cross-writer CAS token). */
268
+ getRevision() {
269
+ const row = this.#db.prepare("SELECT revision FROM home_meta WHERE id = 1").get();
270
+ return row.revision;
271
+ }
272
+ getStateRevision() { return this.getRevision(); }
273
+ /**
274
+ * Run an ordered command batch inside one `BEGIN IMMEDIATE … COMMIT`, yielding
275
+ * to the event loop between commands so a cancellation signal can interrupt
276
+ * the batch (§3.1). The persistence worker uses this for `transactionAsync`:
277
+ * a cancelled batch rolls back (the db is unchanged); already-committed
278
+ * batches are not undone (their effects are idempotent and caller-owned).
279
+ *
280
+ * Each command is `{op, args}` where `op` is a `TaskStore` method name. The
281
+ * batch runs on the single writer connection, so writes are serialized exactly
282
+ * as the synchronous {@link transaction} (§3.2). The revision is bumped once
283
+ * at commit when the batch wrote; an optional `requestId` records the effect
284
+ * in the durable outbox for exactly-once replay (§5.4).
285
+ */
286
+ async transactionAsyncBatch(commands, options = {}) {
287
+ if (this.#inTransaction) {
288
+ // Nested inside a synchronous transaction: run without yielding (the
289
+ // caller already holds the write lock).
290
+ return commands.map((command) => this.#executeCommand(command.op, command.args));
291
+ }
292
+ this.#begin();
293
+ try {
294
+ // Revision CAS (§5.3): the check and the increment happen in the same
295
+ // write transaction, exactly as transactionWithRevisionCas.
296
+ if (options.expectedRevision !== undefined) {
297
+ const current = this.getRevision();
298
+ if (current !== options.expectedRevision) {
299
+ throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current}).`);
300
+ }
301
+ }
302
+ const results = [];
303
+ for (const command of commands) {
304
+ if (options.shouldCancel?.() === true) {
305
+ throw new StorageCancelledError(`Storage command batch cancelled before op '${command.op}'.`);
306
+ }
307
+ results.push(this.#executeCommand(command.op, command.args));
308
+ // Yield so the worker's message loop can observe a cancel signal
309
+ // between statements (§3.1). A single-command batch still yields once
310
+ // so a cancel that raced the batch start is honoured before commit.
311
+ await new Promise((resolve) => setImmediate(resolve));
312
+ }
313
+ if (options.shouldCancel?.() === true) {
314
+ throw new StorageCancelledError("Storage command batch cancelled before commit.");
315
+ }
316
+ if (this.#dirty) {
317
+ this.#prepareWrite();
318
+ if (options.requestId !== undefined) {
319
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? commands);
320
+ }
321
+ if (!this.#migration) {
322
+ this.#bumpRevision();
323
+ }
324
+ }
325
+ this.#commit();
326
+ return results;
327
+ }
328
+ catch (error) {
329
+ this.#rollback();
330
+ throw error;
331
+ }
332
+ }
333
+ /** Invoke a TaskStore method by name (used by the worker's command batches). */
334
+ #executeCommand(op, args) {
335
+ const method = this[op];
336
+ if (typeof method !== "function") {
337
+ throw new StorageRecordError(`Unknown store command: ${op}`);
338
+ }
339
+ return method.apply(this, args);
340
+ }
341
+ // -- outbox (§5.4) ----------------------------------------------------------
342
+ #insertOutbox(requestId, command) {
343
+ try {
344
+ this.#db.prepare(`INSERT INTO outbox (request_id, command, state, created_at) VALUES (?, ?, 'pending', ?)`).run(requestId, this.#json(command), this.#now());
345
+ }
346
+ catch (error) {
347
+ if (isUniqueConstraint(error)) {
348
+ throw new StorageConflictError(`Outbox request already applied: ${requestId}`);
349
+ }
350
+ throw error;
351
+ }
352
+ }
353
+ /**
354
+ * Enqueue an outbox row idempotently. Returns true when a new row was
355
+ * inserted, false when `requestId` was already present (exactly-once).
356
+ */
357
+ enqueueOutbox(requestId, command) {
358
+ return this.#mutate(() => {
359
+ const result = this.#db.prepare(`INSERT OR IGNORE INTO outbox (request_id, command, state, created_at) VALUES (?, ?, 'pending', ?)`).run(requestId, this.#json(command), this.#now());
360
+ return result.changes > 0;
361
+ });
362
+ }
363
+ /** Outbox rows still awaiting acknowledgement (the replay source after a crash). */
364
+ listPendingOutbox() {
365
+ const rows = this.#db.prepare("SELECT request_id, command, created_at FROM outbox WHERE state = 'pending' ORDER BY outbox_id").all();
366
+ return rows.map((row) => ({
367
+ requestId: row.request_id,
368
+ command: this.#parse(row.command),
369
+ createdAt: row.created_at
370
+ }));
371
+ }
372
+ /**
373
+ * True when an outbox row already exists for `requestId` (the effect committed).
374
+ * The persistence worker consults this before re-executing a retried write so a
375
+ * main-thread retry after a worker restart never double-applies (§3.1, §5.4).
376
+ */
377
+ hasOutboxEntry(requestId) {
378
+ const row = this.#db.prepare("SELECT 1 FROM outbox WHERE request_id = ?").get(requestId);
379
+ return row !== undefined;
380
+ }
381
+ /** Mark an outbox row as applied (idempotent). */
382
+ markOutboxApplied(requestId) {
383
+ this.#mutate(() => {
384
+ this.#db.prepare("UPDATE outbox SET state = 'applied', applied_at = ? WHERE request_id = ?").run(this.#now(), requestId);
385
+ });
386
+ }
387
+ // -- ID allocation ----------------------------------------------------------
388
+ /**
389
+ * Global IDs (`task-<n>`, `project-<n>`) from `global_sequences` (§5.3). The
390
+ * file store computes these by scanning existing IDs (a read); the counter is
391
+ * the design's replacement and is allocated without bumping the revision.
392
+ */
393
+ #nextGlobalId(name) {
394
+ const allocate = this.#db.transaction(() => {
395
+ const row = this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, 1)
396
+ ON CONFLICT(name) DO UPDATE SET high_water = high_water + 1
397
+ RETURNING high_water`).get(name);
398
+ return `${name}-${row.high_water}`;
399
+ });
400
+ return allocate();
401
+ }
402
+ /**
403
+ * Task-record IDs from `id_sequences` (replaces StoredTask.idHighWaterMarks).
404
+ * Allocating a high-water mark is a durable write, so it bumps the revision
405
+ * exactly as the file store does.
406
+ */
407
+ #nextTaskRecordId(taskId, kind) {
408
+ this.#requireTask(taskId);
409
+ return this.#mutate(() => {
410
+ const row = this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, 1)
411
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = high_water + 1
412
+ RETURNING high_water`).get(taskId, kind);
413
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${row.high_water}`;
414
+ });
415
+ }
416
+ #peekTaskRecordId(taskId, kind) {
417
+ this.#requireTask(taskId);
418
+ const row = this.#db.prepare("SELECT high_water FROM id_sequences WHERE task_id = ? AND kind = ?").get(taskId, kind);
419
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${(row?.high_water ?? 0) + 1}`;
420
+ }
421
+ #requireTask(taskId) {
422
+ const row = this.#db.prepare("SELECT 1 FROM task_records WHERE task_id = ?").get(taskId);
423
+ if (row === undefined)
424
+ throw new StorageRecordError(`Task not found: ${taskId}`);
425
+ }
426
+ // -- migration bulk-load helpers (state.json -> SQLite, task-21 §8) ----------
427
+ // These are used only by the staged offline migration, which runs with the
428
+ // `migration` option (fence bypass). They seed infrastructure tables that the
429
+ // document owns (home identity/revision, global and per-task ID high-water
430
+ // marks) so the opened store continues from the same counters.
431
+ /** Preserve the document's Home identity and revision in `home_meta`. */
432
+ migrationSetHomeMeta(identity, revision) {
433
+ this.#mutate(() => {
434
+ this.#db.prepare(`UPDATE home_meta SET home_identity = ?, revision = ?, updated_at = ? WHERE id = 1`).run(this.#json(identity), revision, this.#now());
435
+ });
436
+ }
437
+ /** Seed a global ID high-water mark (task/project) at least `highWater`. */
438
+ migrationSeedGlobalSequence(name, highWater) {
439
+ this.#mutate(() => {
440
+ this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
441
+ ON CONFLICT(name) DO UPDATE SET high_water = MAX(high_water, ?)`).run(name, highWater, highWater);
442
+ });
443
+ }
444
+ /** Seed a per-task ID high-water mark at least `highWater`. */
445
+ migrationSeedIdSequence(taskId, kind, highWater) {
446
+ this.#mutate(() => {
447
+ this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
448
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, highWater, highWater);
449
+ });
450
+ }
451
+ // -- generic payload helpers ------------------------------------------------
452
+ #getPayload(table, where, params) {
453
+ const row = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).get(...params);
454
+ return row === undefined ? null : this.#parse(row.payload);
455
+ }
456
+ #listPayload(table, where, params) {
457
+ const rows = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).all(...params);
458
+ return rows.map((row) => this.#parse(row.payload));
459
+ }
460
+ #sortById(rows, idOf) {
461
+ return [...rows].sort((left, right) => numericCompare(idOf(left), idOf(right)));
462
+ }
463
+ // -- config / identity ------------------------------------------------------
464
+ getConfig() {
465
+ const row = this.#db.prepare("SELECT payload FROM config WHERE id = 1").get();
466
+ const config = this.#parse(row.payload);
467
+ // Fail closed on malformed durable config, matching the File store:
468
+ // GC mode and other settings must never silently fall back to defaults.
469
+ validateYuiConfig(config);
470
+ return config;
471
+ }
472
+ saveConfig(config) {
473
+ validateYuiConfig(config);
474
+ this.#mutate(() => {
475
+ this.#db.prepare("UPDATE config SET payload = ?, updated_at = ? WHERE id = 1").run(this.#json(config), this.#now());
476
+ });
477
+ }
478
+ getHomeIdentity() {
479
+ const row = this.#db.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
480
+ return this.#parse(row.home_identity);
481
+ }
482
+ getReviewConfig() {
483
+ return this.getConfig().review ?? null;
484
+ }
485
+ // -- configured agents ------------------------------------------------------
486
+ saveConfiguredAgent(agent) {
487
+ this.#mutate(() => {
488
+ this.#db.prepare(`INSERT INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)
489
+ ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(agent.id, this.#json(agent), this.#now());
490
+ });
491
+ }
492
+ createConfiguredAgentIfAbsent(agent) {
493
+ return this.#mutate(() => {
494
+ const result = this.#db.prepare("INSERT OR IGNORE INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)").run(agent.id, this.#json(agent), this.#now());
495
+ return result.changes > 0 ? agent : null;
496
+ });
497
+ }
498
+ updateConfiguredAgent(id, patch, now) {
499
+ return this.transaction((store) => {
500
+ const existing = store.getConfiguredAgent(id);
501
+ if (existing === null)
502
+ return null;
503
+ const candidate = { ...existing, ...patch, updatedAt: now.toISOString() };
504
+ const unchanged = isDeepStrictEqual({ ...existing, updatedAt: candidate.updatedAt }, candidate);
505
+ if (unchanged)
506
+ return { status: "unchanged", agent: existing };
507
+ store.saveConfiguredAgent(candidate);
508
+ return { status: "updated", agent: candidate };
509
+ });
510
+ }
511
+ listConfiguredAgents() {
512
+ return this.#sortById(this.#listPayload("configured_agents", "1=1", []), (agent) => agent.id);
513
+ }
514
+ getConfiguredAgent(id) {
515
+ return this.#getPayload("configured_agents", "id = ?", [id]);
516
+ }
517
+ removeConfiguredAgent(id) {
518
+ return this.#mutate(() => {
519
+ const result = this.#db.prepare("DELETE FROM configured_agents WHERE id = ?").run(id);
520
+ return result.changes > 0;
521
+ });
522
+ }
523
+ // -- projects ---------------------------------------------------------------
524
+ nextProjectId() { return this.#nextGlobalId("project"); }
525
+ saveProject(project) {
526
+ this.#mutate(() => {
527
+ this.#db.prepare(`INSERT INTO projects (id, name, path, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
528
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, path = excluded.path, payload = excluded.payload, updated_at = excluded.updated_at`).run(project.id, project.name, project.path, this.#json(project), project.createdAt, project.updatedAt);
529
+ });
530
+ }
531
+ createProjectIfAbsent(project) {
532
+ return this.#mutate(() => {
533
+ const result = this.#db.prepare("INSERT OR IGNORE INTO projects (id, name, path, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(project.id, project.name, project.path, this.#json(project), project.createdAt, project.updatedAt);
534
+ return result.changes > 0 ? project : null;
535
+ });
536
+ }
537
+ listProjects() {
538
+ return this.#sortById(this.#listPayload("projects", "1=1", []), (project) => project.id);
539
+ }
540
+ getProject(id) {
541
+ return this.#getPayload("projects", "id = ?", [id]);
542
+ }
543
+ removeProject(id) {
544
+ return this.#mutate(() => this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0);
545
+ }
546
+ // -- agent profiles ---------------------------------------------------------
547
+ saveAgentProfile(profile) {
548
+ this.#mutate(() => {
549
+ this.#db.prepare(`INSERT INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)
550
+ ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(profile.id, this.#json(profile), this.#now());
551
+ });
552
+ }
553
+ createAgentProfileIfAbsent(profile) {
554
+ return this.#mutate(() => {
555
+ const result = this.#db.prepare("INSERT OR IGNORE INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)").run(profile.id, this.#json(profile), this.#now());
556
+ return result.changes > 0 ? profile : null;
557
+ });
558
+ }
559
+ listAgentProfiles() {
560
+ return this.#sortById(this.#listPayload("agent_profiles", "1=1", []), (profile) => profile.id);
561
+ }
562
+ getAgentProfile(id) {
563
+ return this.#getPayload("agent_profiles", "id = ?", [id]);
564
+ }
565
+ removeAgentProfile(id) {
566
+ return this.#mutate(() => this.#db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id).changes > 0);
567
+ }
568
+ // -- global roles -----------------------------------------------------------
569
+ saveGlobalRole(role) {
570
+ this.#mutate(() => {
571
+ this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
572
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
573
+ });
574
+ }
575
+ saveGlobalRoleWithSessionSet(role, sessions) {
576
+ this.#mutate(() => {
577
+ this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
578
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
579
+ if (sessions !== null) {
580
+ this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
581
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(sessions), this.#now());
582
+ }
583
+ });
584
+ }
585
+ createGlobalRoleIfAbsent(role) {
586
+ return this.#mutate(() => {
587
+ const result = this.#db.prepare("INSERT OR IGNORE INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)").run(role.name, this.#json(role), this.#now());
588
+ return result.changes > 0 ? role : null;
589
+ });
590
+ }
591
+ listGlobalRoles() {
592
+ return this.#sortById(this.#listPayload("global_roles", "1=1", []), (role) => role.name);
593
+ }
594
+ getGlobalRole(name) {
595
+ return this.#getPayload("global_roles", "name = ?", [name]);
596
+ }
597
+ removeGlobalRole(name) {
598
+ return this.#mutate(() => this.#db.prepare("DELETE FROM global_roles WHERE name = ?").run(name).changes > 0);
599
+ }
600
+ getGlobalRoleSessionSet(name) {
601
+ return this.#getPayload("global_role_session_sets", "name = ?", [name]);
602
+ }
603
+ listGlobalRoleSessionSets() {
604
+ const rows = this.#listPayload("global_role_session_sets", "1=1", []);
605
+ return [...rows].sort((left, right) => numericCompare(left.owner.roleName, right.owner.roleName));
606
+ }
607
+ saveGlobalRoleSessionSet(sessions) {
608
+ this.#mutate(() => {
609
+ this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
610
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.roleName, this.#json(sessions), this.#now());
611
+ });
612
+ }
613
+ // -- tasks ------------------------------------------------------------------
614
+ nextTaskId() { return this.#nextGlobalId("task"); }
615
+ saveTask(task) {
616
+ if (typeof task.id !== "string" || task.id.length === 0) {
617
+ throw new StorageRecordError("Task id is required.");
618
+ }
619
+ this.#mutate(() => {
620
+ for (const binding of task.projectBindings) {
621
+ const found = this.#db.prepare("SELECT 1 FROM projects WHERE id = ?").get(binding.projectId);
622
+ if (found === undefined)
623
+ throw new StorageRecordError(`Task Project not found: ${binding.projectId}`);
624
+ }
625
+ const isActive = task.status === "active" ? 1 : 0;
626
+ // The catalog projection is inserted first because task_records FKs it.
627
+ this.#db.prepare(`INSERT INTO tasks_catalog (task_id, status, lifecycle, is_active, created_at, updated_at)
628
+ VALUES (?, ?, ?, ?, ?, ?)
629
+ ON CONFLICT(task_id) DO UPDATE SET status = excluded.status, lifecycle = excluded.lifecycle,
630
+ is_active = excluded.is_active, updated_at = excluded.updated_at`).run(task.id, task.status, task.status, isActive, task.createdAt, task.updatedAt);
631
+ this.#db.prepare(`INSERT INTO task_records (task_id, payload, updated_at) VALUES (?, ?, ?)
632
+ ON CONFLICT(task_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(task.id, this.#json(task), this.#now());
633
+ });
634
+ }
635
+ listTasks() {
636
+ const tasks = this.#listPayload("task_records", "1=1", []);
637
+ return this.#sortById(tasks, (task) => task.id);
638
+ }
639
+ getTask(id) {
640
+ return this.#getPayload("task_records", "task_id = ?", [id]);
641
+ }
642
+ readNextActionFacts(taskId) {
643
+ const task = this.#getPayload("task_records", "task_id = ?", [taskId]);
644
+ if (task === null)
645
+ return null;
646
+ // One indexed query (idx_agent_runs_role_status) covers both the active
647
+ // Runs the projection waits on and the Leader Runs the budget consumes.
648
+ const runs = this.#sortById(this.#listPayload("agent_runs", "task_id = ? AND (status = 'active' OR role_name = 'leader')", [taskId]), (run) => run.id);
649
+ return {
650
+ task: {
651
+ id: task.id,
652
+ status: task.status,
653
+ projectBindings: task.projectBindings
654
+ },
655
+ workItems: this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id),
656
+ changeSets: this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id),
657
+ integrations: this.#sortById(this.#listPayload("integration_attempts", "task_id = ?", [taskId]), (attempt) => attempt.id),
658
+ reviewRounds: this.#sortById(this.#listPayload("review_rounds", "task_id = ?", [taskId]), (round) => round.id),
659
+ openInputRequests: this.#sortById(this.#listPayload("input_requests", "task_id = ? AND status = 'open'", [taskId]), (request) => request.id),
660
+ activeRuns: runs.filter((run) => run.status === "active"),
661
+ leaderRuns: runs.filter((run) => run.roleName === "leader")
662
+ };
663
+ }
664
+ /** Task ids flagged active in the catalog projection (the global active index). */
665
+ listActiveTaskIds() {
666
+ const rows = this.#db.prepare("SELECT task_id FROM tasks_catalog WHERE is_active = 1 ORDER BY task_id").all();
667
+ return rows.map((row) => row.task_id);
668
+ }
669
+ getTaskBrief(taskId) {
670
+ const row = this.#db.prepare("SELECT brief FROM task_records WHERE task_id = ?").get(taskId);
671
+ if (row === undefined || row.brief === null)
672
+ return null;
673
+ return this.#parse(row.brief);
674
+ }
675
+ saveTaskBrief(taskId, brief) {
676
+ this.#requireTask(taskId);
677
+ this.#mutate(() => {
678
+ this.#db.prepare("UPDATE task_records SET brief = ?, updated_at = ? WHERE task_id = ?")
679
+ .run(this.#json(brief), this.#now(), taskId);
680
+ });
681
+ }
682
+ clearTaskBrief(taskId) {
683
+ this.#requireTask(taskId);
684
+ this.#mutate(() => {
685
+ this.#db.prepare("UPDATE task_records SET brief = NULL, updated_at = ? WHERE task_id = ?").run(this.#now(), taskId);
686
+ });
687
+ }
688
+ // -- change sets ------------------------------------------------------------
689
+ nextChangeSetId(taskId) { return this.#nextTaskRecordId(taskId, "changeSet"); }
690
+ saveChangeSet(taskId, changeSet) {
691
+ if (changeSet.taskId !== taskId)
692
+ throw new StorageRecordError(`Change set belongs to another Task: ${changeSet.taskId}`);
693
+ this.#requireTask(taskId);
694
+ this.#mutate(() => {
695
+ this.#db.prepare(`INSERT INTO change_sets (task_id, change_set_id, project_id, head_sha, payload, created_at)
696
+ VALUES (?, ?, ?, ?, ?, ?)
697
+ ON CONFLICT(task_id, change_set_id) DO UPDATE SET project_id = excluded.project_id,
698
+ head_sha = excluded.head_sha, payload = excluded.payload`).run(taskId, changeSet.id, changeSet.projectId, changeSet.headCommit, this.#json(changeSet), changeSet.createdAt);
699
+ });
700
+ }
701
+ listChangeSets(taskId) {
702
+ return this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id);
703
+ }
704
+ getChangeSet(taskId, changeSetId) {
705
+ return this.#getPayload("change_sets", "task_id = ? AND change_set_id = ?", [taskId, changeSetId]);
706
+ }
707
+ // -- integration attempts ---------------------------------------------------
708
+ nextIntegrationAttemptId(taskId) { return this.#nextTaskRecordId(taskId, "integrationAttempt"); }
709
+ saveIntegrationAttempt(taskId, attempt) {
710
+ if (attempt.taskId !== taskId)
711
+ throw new StorageRecordError(`Integration attempt belongs to another Task: ${attempt.taskId}`);
712
+ this.#requireTask(taskId);
713
+ this.#mutate(() => {
714
+ this.#db.prepare(`INSERT INTO integration_attempts (task_id, integration_id, status, payload, updated_at)
715
+ VALUES (?, ?, ?, ?, ?)
716
+ ON CONFLICT(task_id, integration_id) DO UPDATE SET status = excluded.status,
717
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, attempt.id, attempt.status, this.#json(attempt), this.#now());
718
+ });
719
+ }
720
+ listIntegrationAttempts(taskId) {
721
+ return this.#sortById(this.#listPayload("integration_attempts", "task_id = ?", [taskId]), (attempt) => attempt.id);
722
+ }
723
+ getIntegrationAttempt(taskId, integrationId) {
724
+ return this.#getPayload("integration_attempts", "task_id = ? AND integration_id = ?", [taskId, integrationId]);
725
+ }
726
+ // -- integration queue -----------------------------------------------------
727
+ nextIntegrationQueueEntryId(taskId) {
728
+ return this.#nextTaskRecordId(taskId, "integrationQueue");
729
+ }
730
+ saveIntegrationQueueEntry(taskId, entry) {
731
+ if (entry.taskId !== taskId) {
732
+ throw new StorageRecordError(`Integration queue entry belongs to another Task: ${entry.taskId}`);
733
+ }
734
+ validateIntegrationQueueEntry(entry);
735
+ this.#requireTask(taskId);
736
+ const changeSet = this.getChangeSet(taskId, entry.changeSetId);
737
+ if (changeSet === null) {
738
+ throw new StorageRecordError(`Integration queue ChangeSet not found: ${entry.changeSetId}`);
739
+ }
740
+ if (changeSet.projectId !== entry.projectId) {
741
+ throw new StorageRecordError(`Integration queue ChangeSet belongs to another Project: ${entry.changeSetId}`);
742
+ }
743
+ const existing = this.getIntegrationQueueEntry(taskId, entry.id);
744
+ if (existing !== null) {
745
+ if (Date.parse(entry.updatedAt) < Date.parse(existing.updatedAt)) {
746
+ throw new StorageRecordError(`Integration queue entry updatedAt cannot move backwards: ${entry.id}`);
747
+ }
748
+ if (!validIntegrationQueueTransition(existing, entry)) {
749
+ throw new StorageRecordError(`Integration queue entry transition is invalid: ${entry.id}`);
750
+ }
751
+ }
752
+ this.#mutate(() => {
753
+ this.#db.prepare(`INSERT INTO integration_queue (queue_id, task_id, project_id, change_set, status, payload, created_at, updated_at)
754
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
755
+ ON CONFLICT(queue_id) DO UPDATE SET task_id = excluded.task_id,
756
+ project_id = excluded.project_id, change_set = excluded.change_set,
757
+ status = excluded.status, payload = excluded.payload, updated_at = excluded.updated_at`).run(entry.id, taskId, entry.projectId, entry.changeSetId, entry.status, this.#json(entry), entry.createdAt, entry.updatedAt);
758
+ });
759
+ }
760
+ listIntegrationQueueEntries(taskId) {
761
+ return this.#sortById(this.#listPayload("integration_queue", "task_id = ?", [taskId]), (entry) => entry.id);
762
+ }
763
+ getIntegrationQueueEntry(taskId, entryId) {
764
+ return this.#getPayload("integration_queue", "task_id = ? AND queue_id = ?", [taskId, entryId]);
765
+ }
766
+ // -- durable jobs -----------------------------------------------------------
767
+ nextDurableJobId(taskId) {
768
+ return this.#nextTaskRecordId(taskId, "durableJob");
769
+ }
770
+ saveDurableJob(taskId, job) {
771
+ if (job.taskId !== taskId) {
772
+ throw new StorageRecordError(`DurableJob belongs to another Task: ${job.taskId}`);
773
+ }
774
+ validateDurableJob(job);
775
+ this.#requireTask(taskId);
776
+ const existing = this.getDurableJob(taskId, job.id);
777
+ if (existing !== null) {
778
+ if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
779
+ throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
780
+ }
781
+ if (!validDurableJobTransition(existing, job)) {
782
+ throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
783
+ }
784
+ }
785
+ this.#mutate(() => {
786
+ this.#db.prepare(`INSERT INTO durable_jobs (job_id, task_id, idempotency_key, status, payload, created_at, updated_at)
787
+ VALUES (?, ?, ?, ?, ?, ?, ?)
788
+ ON CONFLICT(task_id, job_id) DO UPDATE SET idempotency_key = excluded.idempotency_key,
789
+ status = excluded.status,
790
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(job.id, taskId, job.idempotencyKey ?? null, job.status, this.#json(job), job.createdAt, job.updatedAt);
791
+ });
792
+ }
793
+ listDurableJobs(taskId) {
794
+ return this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id);
795
+ }
796
+ getDurableJob(taskId, jobId) {
797
+ return this.#getPayload("durable_jobs", "task_id = ? AND job_id = ?", [taskId, jobId]);
798
+ }
799
+ findDurableJobByIdempotencyKey(taskId, key) {
800
+ return this.#getPayload("durable_jobs", "task_id = ? AND idempotency_key = ?", [taskId, key]);
801
+ }
802
+ listAllDurableJobs() {
803
+ return this.#listPayload("durable_jobs", "1 = 1", []);
804
+ }
805
+ hasActiveDurableJobs() {
806
+ const row = this.#db.prepare("SELECT 1 FROM durable_jobs WHERE status IN ('queued', 'running') LIMIT 1").get();
807
+ return row !== undefined;
808
+ }
809
+ // -- job caller key hashes (rr13) -------------------------------------------
810
+ getJobCallerKeyHash(taskId, roleName, agentId) {
811
+ const row = this.#db.prepare("SELECT hash FROM job_caller_key_hashes WHERE task_id = ? AND role_name = ? AND agent_id = ?").get(taskId, roleName, agentId);
812
+ return row === undefined ? null : row.hash;
813
+ }
814
+ setJobCallerKeyHash(taskId, roleName, agentId, hash) {
815
+ this.#requireTask(taskId);
816
+ if (!/^[a-f0-9]{64}$/u.test(hash)) {
817
+ throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${roleName}.`);
818
+ }
819
+ this.#mutate(() => {
820
+ this.#db.prepare(`INSERT INTO job_caller_key_hashes (task_id, role_name, agent_id, hash, updated_at)
821
+ VALUES (?, ?, ?, ?, ?)
822
+ ON CONFLICT(task_id, role_name, agent_id) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at`).run(taskId, roleName, agentId, hash, this.#now());
823
+ });
824
+ }
825
+ // -- session owners (Issue 03) ----------------------------------------------
826
+ saveSessionOwner(identity) {
827
+ const owner = identity.owner;
828
+ this.#mutate(() => {
829
+ this.#db.prepare(`INSERT INTO session_owners
830
+ (launch_id, scope, task_id, role_name, agent_id, native_session_id,
831
+ provider_root_pid, payload, recorded_at)
832
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
833
+ ON CONFLICT(launch_id) DO UPDATE SET
834
+ scope = excluded.scope, task_id = excluded.task_id,
835
+ role_name = excluded.role_name, agent_id = excluded.agent_id,
836
+ native_session_id = excluded.native_session_id,
837
+ provider_root_pid = excluded.provider_root_pid,
838
+ payload = excluded.payload, recorded_at = excluded.recorded_at`).run(identity.launchId, owner.scope, owner.scope === "task" ? owner.taskId : null, owner.roleName, identity.agentId, identity.nativeSessionId ?? null, identity.providerRoot.pid, this.#json(identity), identity.recordedAt);
839
+ });
840
+ }
841
+ getSessionOwner(launchId) {
842
+ const row = this.#db.prepare("SELECT payload FROM session_owners WHERE launch_id = ?").get(launchId);
843
+ return row === undefined ? null : this.#parse(row.payload);
844
+ }
845
+ listSessionOwners() {
846
+ return this.#listPayload("session_owners", "1=1", []).sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
847
+ }
848
+ listSessionOwnersForOwner(owner) {
849
+ if (owner.scope === "global") {
850
+ return this.#listPayload("session_owners", "scope = 'global' AND role_name = ?", [owner.roleName]);
851
+ }
852
+ return this.#listPayload("session_owners", "scope = 'task' AND task_id = ? AND role_name = ?", [owner.taskId, owner.roleName]);
853
+ }
854
+ removeSessionOwner(launchId) {
855
+ this.#mutate(() => {
856
+ this.#db.prepare("DELETE FROM session_owners WHERE launch_id = ?").run(launchId);
857
+ });
858
+ }
859
+ // -- task roles -------------------------------------------------------------
860
+ saveRole(taskId, role) {
861
+ this.#requireTask(taskId);
862
+ this.#mutate(() => {
863
+ this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
864
+ ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, role.name, this.#json(role), this.#now());
865
+ });
866
+ }
867
+ listRoles(taskId) {
868
+ return this.#sortById(this.#listPayload("task_roles", "task_id = ?", [taskId]), (role) => role.name);
869
+ }
870
+ getRole(taskId, name) {
871
+ return this.#getPayload("task_roles", "task_id = ? AND role_name = ?", [taskId, name]);
872
+ }
873
+ saveTaskRoleWithSessionSet(role, sessions) {
874
+ this.#requireTask(role.name ? role.taskId : sessions.owner.taskId);
875
+ this.#mutate(() => {
876
+ this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
877
+ ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, role.name, this.#json(role), this.#now());
878
+ this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
879
+ ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
880
+ });
881
+ }
882
+ removeTaskRole(taskId, name) {
883
+ return this.#mutate(() => {
884
+ const result = this.#db.prepare("DELETE FROM task_roles WHERE task_id = ? AND role_name = ?").run(taskId, name);
885
+ return result.changes > 0;
886
+ });
887
+ }
888
+ // -- managed workspaces -----------------------------------------------------
889
+ saveManagedWorkspace(workspace) {
890
+ const taskId = workspace.owner.taskId;
891
+ this.#requireTask(taskId);
892
+ this.#mutate(() => {
893
+ const ownerId = managedWorkspaceKey(workspace.owner);
894
+ this.#db.prepare(`INSERT INTO managed_workspaces (owner_kind, owner_id, task_id, path, payload, status, created_at, updated_at)
895
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
896
+ ON CONFLICT(owner_kind, owner_id) DO UPDATE SET task_id = excluded.task_id, path = excluded.path,
897
+ payload = excluded.payload, status = excluded.status, updated_at = excluded.updated_at`).run(workspace.owner.type, ownerId, taskId, workspace.root, this.#json(workspace), "active", workspace.createdAt, workspace.updatedAt);
898
+ });
899
+ }
900
+ listManagedWorkspaces(taskId) {
901
+ return this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner));
902
+ }
903
+ listManagedWorkspace(taskId) {
904
+ return this.listManagedWorkspaces(taskId);
905
+ }
906
+ getManagedWorkspace(owner) {
907
+ return this.#getPayload("managed_workspaces", "owner_kind = ? AND owner_id = ?", [owner.type, managedWorkspaceKey(owner)]);
908
+ }
909
+ getTaskWorkspace(taskId) {
910
+ return this.getManagedWorkspace({ type: "task", taskId });
911
+ }
912
+ getWorkItemWorkspace(taskId, workItemId) {
913
+ return this.getManagedWorkspace({ type: "work-item", taskId, workItemId });
914
+ }
915
+ getReviewRoundWorkspace(taskId, reviewRoundId) {
916
+ return this.getManagedWorkspace({ type: "review-round", taskId, reviewRoundId });
917
+ }
918
+ getIntegrationWorkspace(taskId, integrationAttemptId) {
919
+ return this.getManagedWorkspace({ type: "integration-attempt", taskId, integrationAttemptId });
920
+ }
921
+ removeManagedWorkspace(owner) {
922
+ this.#requireTask(owner.taskId);
923
+ return this.#mutate(() => {
924
+ const result = this.#db.prepare("DELETE FROM managed_workspaces WHERE owner_kind = ? AND owner_id = ?").run(owner.type, managedWorkspaceKey(owner));
925
+ return result.changes > 0;
926
+ });
927
+ }
928
+ // -- role session sets ------------------------------------------------------
929
+ getRoleSessionSet(taskId, roleName) {
930
+ return this.#getPayload("role_session_sets", "task_id = ? AND role_name = ?", [taskId, roleName]);
931
+ }
932
+ getTaskRoleSessionSet(taskId, roleName) {
933
+ return this.getRoleSessionSet(taskId, roleName);
934
+ }
935
+ listRoleSessionSets(taskId) {
936
+ return this.#sortById(this.#listPayload("role_session_sets", "task_id = ?", [taskId]), (set) => set.owner.roleName);
937
+ }
938
+ saveRoleSessionSet(sessions) {
939
+ const taskId = sessions.owner.taskId;
940
+ this.#requireTask(taskId);
941
+ this.#mutate(() => {
942
+ this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
943
+ ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
944
+ });
945
+ }
946
+ saveTaskRoleSessionSet(sessions) {
947
+ this.saveRoleSessionSet(sessions);
948
+ }
949
+ getRoleSession(taskId, roleName) {
950
+ const set = this.getRoleSessionSet(taskId, roleName);
951
+ if (set === null)
952
+ return null;
953
+ const session = set.sessions[set.activeAgentId];
954
+ return session === undefined ? null : session;
955
+ }
956
+ // -- work items -------------------------------------------------------------
957
+ nextWorkItemId(taskId) { return this.#nextTaskRecordId(taskId, "workItem"); }
958
+ getWorkItem(taskId, workItemId) {
959
+ return this.#getPayload("work_items", "task_id = ? AND work_item_id = ?", [taskId, workItemId]);
960
+ }
961
+ listWorkItems(taskId) {
962
+ return this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id);
963
+ }
964
+ saveWorkItem(taskId, item) {
965
+ if (item.taskId !== taskId)
966
+ throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`);
967
+ this.#requireTask(taskId);
968
+ this.#mutate(() => {
969
+ this.#db.prepare(`INSERT INTO work_items (task_id, work_item_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
970
+ ON CONFLICT(task_id, work_item_id) DO UPDATE SET status = excluded.status,
971
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, item.id, item.status, this.#json(item), this.#now());
972
+ });
973
+ }
974
+ // -- capability grants ------------------------------------------------------
975
+ nextCapabilityGrantId(taskId) { return this.#nextTaskRecordId(taskId, "capabilityGrant"); }
976
+ saveCapabilityGrant(taskId, grant) {
977
+ const stored = storedCapabilityGrant(grant);
978
+ if (stored.taskId !== taskId) {
979
+ throw new StorageRecordError(`Capability grant belongs to another Task: ${stored.taskId}`);
980
+ }
981
+ this.#requireTask(taskId);
982
+ this.#mutate(() => {
983
+ const existing = this.#getPayload("capability_grants", "task_id = ? AND grant_id = ?", [taskId, stored.id]);
984
+ if (existing === null) {
985
+ if (stored.revokedAt !== undefined) {
986
+ throw new StorageRecordError(`Capability grant must start unrevoked: ${stored.id}`);
987
+ }
988
+ if (stored.usesUsed !== 0) {
989
+ throw new StorageRecordError(`Capability grant must start unused: ${stored.id}`);
990
+ }
991
+ }
992
+ else if (!isValidCapabilityGrantTransition(existing, stored)) {
993
+ throw new StorageRecordError(`Capability grant cannot be overwritten: ${taskId}/${stored.id}`);
994
+ }
995
+ this.#db.prepare(`INSERT INTO capability_grants (task_id, grant_id, payload, updated_at) VALUES (?, ?, ?, ?)
996
+ ON CONFLICT(task_id, grant_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, stored.id, this.#json(stored), this.#now());
997
+ });
998
+ }
999
+ listCapabilityGrants(taskId) {
1000
+ return this.#sortById(this.#listPayload("capability_grants", "task_id = ?", [taskId]), (grant) => grant.id);
1001
+ }
1002
+ getCapabilityGrant(taskId, grantId) {
1003
+ return this.#getPayload("capability_grants", "task_id = ? AND grant_id = ?", [taskId, grantId]);
1004
+ }
1005
+ // -- release workflows ------------------------------------------------------
1006
+ nextReleaseWorkflowId(taskId) { return this.#nextTaskRecordId(taskId, "releaseWorkflow"); }
1007
+ saveReleaseWorkflow(taskId, workflow) {
1008
+ const stored = storedReleaseWorkflow(workflow);
1009
+ if (stored.taskId !== taskId) {
1010
+ throw new StorageRecordError(`Release workflow belongs to another Task: ${stored.taskId}`);
1011
+ }
1012
+ this.#requireTask(taskId);
1013
+ this.#mutate(() => {
1014
+ const existing = this.#getPayload("release_workflows", "task_id = ? AND workflow_id = ?", [taskId, stored.id]);
1015
+ if (existing !== null && !isValidReleaseWorkflowTransition(existing, stored)) {
1016
+ throw new StorageRecordError(`Release workflow cannot be overwritten: ${taskId}/${stored.id}`);
1017
+ }
1018
+ this.#db.prepare(`INSERT INTO release_workflows (task_id, workflow_id, payload, updated_at) VALUES (?, ?, ?, ?)
1019
+ ON CONFLICT(task_id, workflow_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, stored.id, this.#json(stored), this.#now());
1020
+ });
1021
+ }
1022
+ listReleaseWorkflows(taskId) {
1023
+ return this.#sortById(this.#listPayload("release_workflows", "task_id = ?", [taskId]), (workflow) => workflow.id);
1024
+ }
1025
+ getReleaseWorkflow(taskId, workflowId) {
1026
+ return this.#getPayload("release_workflows", "task_id = ? AND workflow_id = ?", [taskId, workflowId]);
1027
+ }
1028
+ // -- gate artifacts (Issue 08) ----------------------------------------------
1029
+ saveGateArtifact(artifact, logs) {
1030
+ validateGateArtifact(artifact);
1031
+ this.#mutate(() => {
1032
+ const targetRef = artifact.boundary?.targetRef ?? null;
1033
+ const completedAt = artifact.completedAt ?? null;
1034
+ this.#db.prepare(`INSERT INTO gate_artifacts
1035
+ (key, project_id, level, commit_sha, plan_digest, toolchain_digest,
1036
+ target_ref, status, outcome, payload, created_at, completed_at, last_used_at)
1037
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1038
+ ON CONFLICT(key) DO UPDATE SET
1039
+ project_id = excluded.project_id,
1040
+ level = excluded.level,
1041
+ commit_sha = excluded.commit_sha,
1042
+ plan_digest = excluded.plan_digest,
1043
+ toolchain_digest = excluded.toolchain_digest,
1044
+ target_ref = excluded.target_ref,
1045
+ status = excluded.status,
1046
+ outcome = excluded.outcome,
1047
+ payload = excluded.payload,
1048
+ completed_at = excluded.completed_at,
1049
+ last_used_at = excluded.last_used_at`).run(artifact.key, artifact.projectId, artifact.level, artifact.commit, artifact.planDigest, artifact.toolchainDigest, targetRef, artifact.status, artifact.outcome, this.#json(artifact), artifact.createdAt, completedAt, artifact.lastUsedAt);
1050
+ // Replace all logs for this artifact in the same transaction.
1051
+ this.#db.prepare("DELETE FROM gate_artifact_logs WHERE artifact_key = ?").run(artifact.key);
1052
+ const insertLog = this.#db.prepare(`INSERT INTO gate_artifact_logs (artifact_key, step_name, log_content, log_digest, log_bytes)
1053
+ VALUES (?, ?, ?, ?, ?)`);
1054
+ for (const [stepName, content] of logs) {
1055
+ const step = artifact.steps.find((s) => s.name === stepName);
1056
+ if (step === undefined) {
1057
+ throw new StorageRecordError(`Gate artifact log has no matching step: ${artifact.key}/${stepName}`);
1058
+ }
1059
+ insertLog.run(artifact.key, stepName, content, step.logDigest, step.logBytes);
1060
+ }
1061
+ });
1062
+ }
1063
+ touchGateArtifact(artifact) {
1064
+ validateGateArtifact(artifact);
1065
+ this.#mutate(() => {
1066
+ const result = this.#db.prepare(`UPDATE gate_artifacts SET payload = ?, last_used_at = ? WHERE key = ?`).run(this.#json(artifact), artifact.lastUsedAt, artifact.key);
1067
+ if (result.changes === 0) {
1068
+ throw new StorageRecordError(`Gate artifact not found for touch: ${artifact.key}`);
1069
+ }
1070
+ });
1071
+ }
1072
+ getGateArtifact(projectId, key) {
1073
+ const artifact = this.#getPayload("gate_artifacts", "project_id = ? AND key = ?", [projectId, key]);
1074
+ return artifact === null ? null : validateGateArtifact(artifact);
1075
+ }
1076
+ findGateArtifactByIdentity(identity) {
1077
+ return this.getGateArtifact(identity.projectId, gateArtifactKey(identity));
1078
+ }
1079
+ findL2GateArtifactsForCommit(query) {
1080
+ const rows = this.#listPayload("gate_artifacts", `project_id = ? AND commit_sha = ? AND level = 'L2'
1081
+ AND plan_digest = ? AND toolchain_digest = ? AND target_ref = ?
1082
+ AND status = 'complete' AND outcome = 'succeeded'`, [query.projectId, query.commit, query.planDigest, query.toolchainDigest, query.targetRef]);
1083
+ const valid = [];
1084
+ for (const row of rows) {
1085
+ try {
1086
+ valid.push(validateGateArtifact(row));
1087
+ }
1088
+ catch {
1089
+ // Skip corrupt rows; a direct lookup fails closed.
1090
+ }
1091
+ }
1092
+ return valid;
1093
+ }
1094
+ getGateArtifactLogs(artifactKey) {
1095
+ const rows = this.#db.prepare("SELECT step_name, log_content FROM gate_artifact_logs WHERE artifact_key = ?").all(artifactKey);
1096
+ const logs = new Map();
1097
+ for (const row of rows) {
1098
+ logs.set(row.step_name, Buffer.from(row.log_content));
1099
+ }
1100
+ return logs;
1101
+ }
1102
+ pruneGateArtifacts(projectId, options) {
1103
+ // Snapshot candidates outside the write transaction so the isReferenced
1104
+ // callback never holds the SQLite write lock.
1105
+ const rows = this.#db.prepare("SELECT key, payload, last_used_at FROM gate_artifacts WHERE project_id = ?").all(projectId);
1106
+ const toDelete = [];
1107
+ let retained = 0;
1108
+ for (const row of rows) {
1109
+ let artifact;
1110
+ try {
1111
+ artifact = validateGateArtifact(JSON.parse(row.payload));
1112
+ }
1113
+ catch {
1114
+ retained += 1;
1115
+ continue;
1116
+ }
1117
+ const age = options.now.getTime() - Date.parse(artifact.lastUsedAt);
1118
+ if (options.isReferenced(artifact.key) || age < options.ttlMs) {
1119
+ retained += 1;
1120
+ continue;
1121
+ }
1122
+ toDelete.push(artifact.key);
1123
+ }
1124
+ if (toDelete.length === 0) {
1125
+ return Object.freeze({ retained, deleted: 0 });
1126
+ }
1127
+ return this.#mutate(() => {
1128
+ const deleteArtifact = this.#db.prepare("DELETE FROM gate_artifacts WHERE key = ?");
1129
+ for (const key of toDelete) {
1130
+ deleteArtifact.run(key);
1131
+ }
1132
+ return Object.freeze({ retained, deleted: toDelete.length });
1133
+ });
1134
+ }
1135
+ // -- agent runs -------------------------------------------------------------
1136
+ nextAgentRunId(taskId) { return this.#nextTaskRecordId(taskId, "agentRun"); }
1137
+ peekNextAgentRunId(taskId) { return this.#peekTaskRecordId(taskId, "agentRun"); }
1138
+ getAgentRun(taskId, runId) {
1139
+ return this.#getPayload("agent_runs", "task_id = ? AND run_id = ?", [taskId, runId]);
1140
+ }
1141
+ listAgentRuns(taskId) {
1142
+ return this.#sortById(this.#listPayload("agent_runs", "task_id = ?", [taskId]), (run) => run.id);
1143
+ }
1144
+ saveAgentRun(run) {
1145
+ if (run.taskId !== undefined)
1146
+ this.#requireTask(run.taskId);
1147
+ this.#mutate(() => {
1148
+ this.#db.prepare(`INSERT INTO agent_runs (task_id, run_id, role_name, status, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)
1149
+ ON CONFLICT(task_id, run_id) DO UPDATE SET role_name = excluded.role_name, status = excluded.status,
1150
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(run.taskId, run.id, run.roleName, run.status, this.#json(run), this.#now());
1151
+ });
1152
+ }
1153
+ /**
1154
+ * Issue 04: SQLite-native pending retry query. A single indexed scan
1155
+ * replaces the adapter's per-Task in-memory sweep, so Controller deadline
1156
+ * arming no longer materializes every Task and Run in JavaScript.
1157
+ */
1158
+ listPendingProviderRetries() {
1159
+ const rows = this.#db.prepare(`SELECT ar.task_id AS taskId, ar.run_id AS runId, ar.role_name AS roleName,
1160
+ json_extract(ar.payload, '$.providerRetry.nextAttemptAt') AS nextAttemptAt
1161
+ FROM agent_runs ar
1162
+ JOIN tasks_catalog tc ON tc.task_id = ar.task_id
1163
+ WHERE ar.status = 'active'
1164
+ AND tc.is_active = 1
1165
+ AND json_extract(ar.payload, '$.providerRetry.nextAttemptAt') IS NOT NULL`).all();
1166
+ return rows;
1167
+ }
1168
+ // -- review rounds ----------------------------------------------------------
1169
+ nextReviewRoundId(taskId) { return this.#nextTaskRecordId(taskId, "reviewRound"); }
1170
+ getReviewRound(taskId, reviewRoundId) {
1171
+ return this.#getPayload("review_rounds", "task_id = ? AND review_round_id = ?", [taskId, reviewRoundId]);
1172
+ }
1173
+ listReviewRounds(taskId) {
1174
+ return this.#sortById(this.#listPayload("review_rounds", "task_id = ?", [taskId]), (round) => round.id);
1175
+ }
1176
+ saveReviewRound(taskId, round) {
1177
+ if (round.taskId !== taskId)
1178
+ throw new StorageRecordError(`Review round belongs to another Task: ${round.taskId}`);
1179
+ this.#requireTask(taskId);
1180
+ this.#mutate(() => {
1181
+ this.#db.prepare(`INSERT INTO review_rounds (task_id, review_round_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
1182
+ ON CONFLICT(task_id, review_round_id) DO UPDATE SET status = excluded.status,
1183
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, round.id, round.status, this.#json(round), this.#now());
1184
+ });
1185
+ }
1186
+ // -- review findings --------------------------------------------------------
1187
+ nextReviewFindingId(taskId) { return this.#nextTaskRecordId(taskId, "reviewFinding"); }
1188
+ getReviewFinding(taskId, findingId) {
1189
+ return this.#getPayload("review_findings", "task_id = ? AND finding_id = ?", [taskId, findingId]);
1190
+ }
1191
+ listReviewFindings(taskId) {
1192
+ return this.#sortById(this.#listPayload("review_findings", "task_id = ?", [taskId]), (finding) => finding.id);
1193
+ }
1194
+ saveReviewFinding(taskId, finding) {
1195
+ validateReviewFinding(finding);
1196
+ if (finding.taskId !== taskId) {
1197
+ throw new StorageRecordError(`Review finding belongs to another Task: ${finding.taskId}`);
1198
+ }
1199
+ this.#requireTask(taskId);
1200
+ this.#mutate(() => {
1201
+ this.#db.prepare(`INSERT INTO review_findings (task_id, finding_id, stable_key, severity, payload, updated_at)
1202
+ VALUES (?, ?, ?, ?, ?, ?)
1203
+ ON CONFLICT(task_id, finding_id) DO UPDATE SET stable_key = excluded.stable_key,
1204
+ severity = excluded.severity, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, finding.id, finding.stableKey, finding.severity, this.#json(finding), this.#now());
1205
+ });
1206
+ }
1207
+ // -- active runs ------------------------------------------------------------
1208
+ #saveActiveRun(taskId, pointer, runId) {
1209
+ this.#mutate(() => {
1210
+ const payload = this.#json({ schemaVersion: 3, runId });
1211
+ this.#db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at) VALUES (?, ?, ?, ?, ?)
1212
+ ON CONFLICT(task_id, pointer) DO UPDATE SET run_id = excluded.run_id, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, pointer, runId, payload, this.#now());
1213
+ });
1214
+ }
1215
+ #getActiveRun(taskId, pointer) {
1216
+ const row = this.#db.prepare("SELECT run_id FROM active_runs WHERE task_id = ? AND pointer = ?").get(taskId, pointer);
1217
+ if (row === undefined)
1218
+ return null;
1219
+ return this.getAgentRun(taskId, row.run_id);
1220
+ }
1221
+ #clearActiveRun(taskId, pointer) {
1222
+ this.#mutate(() => {
1223
+ this.#db.prepare("DELETE FROM active_runs WHERE task_id = ? AND pointer = ?").run(taskId, pointer);
1224
+ });
1225
+ }
1226
+ getActiveAgentRun(taskId, roleName) {
1227
+ return this.#getActiveRun(taskId, roleName);
1228
+ }
1229
+ saveActiveAgentRun(run) {
1230
+ if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
1231
+ this.saveActiveExecutionLaneRun(run);
1232
+ return;
1233
+ }
1234
+ this.#saveActiveRun(run.taskId, run.roleName, run.id);
1235
+ }
1236
+ clearActiveAgentRun(taskId, roleName) {
1237
+ // Older Controller paths only know the Role key. When that key points
1238
+ // at a lane-backed Run, remove the matching lane pointer too; preserve
1239
+ // every other lane for the same Role in a multi-lane group.
1240
+ const rolePointer = this.#getActiveRun(taskId, roleName);
1241
+ this.#clearActiveRun(taskId, roleName);
1242
+ if (rolePointer === null)
1243
+ return;
1244
+ const laneRows = this.#db.prepare("SELECT task_id, pointer FROM active_runs WHERE task_id = ?").all(taskId);
1245
+ for (const row of laneRows) {
1246
+ if (executionLaneActiveRunKeyParts(row.pointer) !== null) {
1247
+ const laneRun = this.#getActiveRun(taskId, row.pointer);
1248
+ if (laneRun !== null && laneRun.id === rolePointer.id) {
1249
+ this.#clearActiveRun(taskId, row.pointer);
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+ getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
1255
+ return this.#getActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
1256
+ }
1257
+ saveActiveExecutionLaneRun(run) {
1258
+ if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
1259
+ throw new StorageRecordError(`Active execution-lane run requires group and lane ids: ${run.id}`);
1260
+ }
1261
+ this.#saveActiveRun(run.taskId, executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId), run.id);
1262
+ }
1263
+ clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
1264
+ this.#clearActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
1265
+ }
1266
+ // -- messages ----------------------------------------------------------------
1267
+ nextMessageId(taskId) { return this.#nextTaskRecordId(taskId, "message"); }
1268
+ saveMessage(taskId, message) {
1269
+ if (message.taskId !== taskId)
1270
+ throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
1271
+ this.#requireTask(taskId);
1272
+ this.#mutate(() => {
1273
+ const seq = this.#idSequence(message.id, "message");
1274
+ this.#db.prepare(`INSERT INTO messages (task_id, message_id, seq, payload, created_at) VALUES (?, ?, ?, ?, ?)`).run(taskId, message.id, seq, this.#json(message), message.createdAt);
1275
+ this.#observeHighWater(taskId, "message", seq);
1276
+ });
1277
+ }
1278
+ listMessages(taskId) {
1279
+ return this.#sortById(this.#listPayload("messages", "task_id = ?", [taskId]), (message) => message.id);
1280
+ }
1281
+ // -- input requests ----------------------------------------------------------
1282
+ nextInputRequestId(taskId) { return this.#nextTaskRecordId(taskId, "inputRequest"); }
1283
+ saveInputRequest(taskId, request) {
1284
+ if (request.taskId !== taskId)
1285
+ throw new StorageRecordError(`Input request belongs to another Task: ${request.taskId}`);
1286
+ this.#requireTask(taskId);
1287
+ this.#mutate(() => {
1288
+ this.#db.prepare(`INSERT INTO input_requests (task_id, input_id, status, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
1289
+ ON CONFLICT(task_id, input_id) DO UPDATE SET status = excluded.status, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, request.id, request.status, this.#json(request), request.createdAt, this.#now());
1290
+ });
1291
+ }
1292
+ getInputRequest(taskId, requestId) {
1293
+ return this.#getPayload("input_requests", "task_id = ? AND input_id = ?", [taskId, requestId]);
1294
+ }
1295
+ listInputRequests(taskId) {
1296
+ return this.#sortById(this.#listPayload("input_requests", "task_id = ?", [taskId]), (request) => request.id);
1297
+ }
1298
+ listAllInputRequests() {
1299
+ return this.#sortById(this.#listPayload("input_requests", "1=1", []), (request) => `${request.taskId}/${request.id}`);
1300
+ }
1301
+ // -- decisions ----------------------------------------------------------------
1302
+ nextDecisionId(taskId) { return this.#nextTaskRecordId(taskId, "decision"); }
1303
+ saveDecision(taskId, decision) {
1304
+ if (decision.taskId !== taskId)
1305
+ throw new StorageRecordError(`Decision belongs to another Task: ${decision.taskId}`);
1306
+ this.#requireTask(taskId);
1307
+ this.#mutate(() => {
1308
+ this.#db.prepare(`INSERT INTO decisions (task_id, decision_id, payload, created_at) VALUES (?, ?, ?, ?)
1309
+ ON CONFLICT(task_id, decision_id) DO UPDATE SET payload = excluded.payload`).run(taskId, decision.id, this.#json(decision), decision.createdAt);
1310
+ });
1311
+ }
1312
+ listDecisions(taskId) {
1313
+ return this.#sortById(this.#listPayload("decisions", "task_id = ?", [taskId]), (decision) => decision.id);
1314
+ }
1315
+ getDecision(taskId, decisionId) {
1316
+ return this.#getPayload("decisions", "task_id = ? AND decision_id = ?", [taskId, decisionId]);
1317
+ }
1318
+ // -- milestones ----------------------------------------------------------------
1319
+ nextMilestoneId(taskId) { return this.#nextTaskRecordId(taskId, "milestone"); }
1320
+ saveMilestone(taskId, milestone) {
1321
+ if (milestone.taskId !== taskId)
1322
+ throw new StorageRecordError(`Milestone belongs to another Task: ${milestone.taskId}`);
1323
+ this.#requireTask(taskId);
1324
+ this.#mutate(() => {
1325
+ this.#db.prepare(`INSERT INTO milestones (task_id, milestone_id, payload, created_at) VALUES (?, ?, ?, ?)
1326
+ ON CONFLICT(task_id, milestone_id) DO UPDATE SET payload = excluded.payload`).run(taskId, milestone.id, this.#json(milestone), milestone.createdAt);
1327
+ });
1328
+ }
1329
+ listMilestones(taskId) {
1330
+ return this.#sortById(this.#listPayload("milestones", "task_id = ?", [taskId]), (milestone) => milestone.id);
1331
+ }
1332
+ getMilestone(taskId, milestoneId) {
1333
+ return this.#getPayload("milestones", "task_id = ? AND milestone_id = ?", [taskId, milestoneId]);
1334
+ }
1335
+ // -- events -------------------------------------------------------------------
1336
+ nextEventId(taskId) { return this.#nextTaskRecordId(taskId, "event"); }
1337
+ saveEvent(taskId, event) {
1338
+ if (event.taskId !== taskId)
1339
+ throw new StorageRecordError(`Task event belongs to another Task: ${event.taskId}`);
1340
+ this.#requireTask(taskId);
1341
+ this.#mutate(() => {
1342
+ const seq = this.#idSequence(event.id, "event");
1343
+ // Events are terminal/semantic: retained individually, never pruned (§9).
1344
+ this.#db.prepare(`INSERT INTO events (task_id, event_id, type, occurred_at, payload) VALUES (?, ?, ?, ?, ?)`).run(taskId, event.id, event.type, event.createdAt, this.#json(event));
1345
+ this.#observeHighWater(taskId, "event", seq);
1346
+ });
1347
+ }
1348
+ listEvents(taskId) {
1349
+ return this.#sortById(this.#listPayload("events", "task_id = ?", [taskId]), (event) => event.id);
1350
+ }
1351
+ removeEvents(taskId, eventIds) {
1352
+ if (eventIds.length === 0)
1353
+ return 0;
1354
+ this.#requireTask(taskId);
1355
+ return this.#mutate(() => {
1356
+ const deleteEvent = this.#db.prepare("DELETE FROM events WHERE task_id = ? AND event_id = ?");
1357
+ let removed = 0;
1358
+ for (const eventId of eventIds) {
1359
+ removed += deleteEvent.run(taskId, eventId).changes;
1360
+ }
1361
+ return removed;
1362
+ });
1363
+ }
1364
+ // -- high-water maintenance ---------------------------------------------------
1365
+ /** Extract the numeric suffix of a `<prefix>-<n>` record id. */
1366
+ #idSequence(id, kind) {
1367
+ const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`).exec(id);
1368
+ if (match === null)
1369
+ throw new StorageRecordError(`Task-local ${kind} id is invalid: ${id}.`);
1370
+ return Number.parseInt(match[1], 10);
1371
+ }
1372
+ /** Advance the per-task high-water mark to at least `seq` (mirrors observeTaskRecordId). */
1373
+ #observeHighWater(taskId, kind, seq) {
1374
+ this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
1375
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, seq, seq);
1376
+ }
1377
+ // -- work mailboxes ------------------------------------------------------------
1378
+ #mailboxCols(target) {
1379
+ return {
1380
+ targetKind: target.kind,
1381
+ taskId: "taskId" in target ? target.taskId : null,
1382
+ roleName: "roleName" in target ? target.roleName : null,
1383
+ targetKey: mailboxTargetKey(target)
1384
+ };
1385
+ }
1386
+ #rowToMailbox(row) {
1387
+ const target = this.#targetFromCols(row.target_kind, row.task_id, row.role_name);
1388
+ return {
1389
+ schemaVersion: 1,
1390
+ target,
1391
+ nextSequence: row.next_sequence,
1392
+ processing: row.processing === null ? null : this.#parse(row.processing),
1393
+ pending: row.pending === null ? null : this.#parse(row.pending)
1394
+ };
1395
+ }
1396
+ #targetFromCols(kind, taskId, roleName) {
1397
+ switch (kind) {
1398
+ case "operator": return { kind: "operator" };
1399
+ case "task": return { kind: "task", taskId: taskId };
1400
+ case "role": return { kind: "role", taskId: taskId, roleName: roleName };
1401
+ case "role-runtime": return { kind: "role-runtime", taskId: taskId, roleName: roleName };
1402
+ case "global-role-runtime": return { kind: "global-role-runtime", roleName: roleName };
1403
+ default: throw new StorageRecordError(`Unknown mailbox target kind: ${kind}`);
1404
+ }
1405
+ }
1406
+ getWorkMailbox(target) {
1407
+ const cols = this.#mailboxCols(target);
1408
+ const row = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
1409
+ return row === undefined ? null : this.#rowToMailbox(row);
1410
+ }
1411
+ listWorkMailboxes() {
1412
+ const rows = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
1413
+ return rows.map((row) => this.#rowToMailbox(row));
1414
+ }
1415
+ saveWorkMailbox(mailbox) {
1416
+ const cols = this.#mailboxCols(mailbox.target);
1417
+ this.#mutate(() => {
1418
+ this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
1419
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1420
+ ON CONFLICT(target_key) DO UPDATE SET next_sequence = excluded.next_sequence,
1421
+ processing = excluded.processing, pending = excluded.pending`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey, mailbox.nextSequence, mailbox.processing === null ? null : this.#json(mailbox.processing), mailbox.pending === null ? null : this.#json(mailbox.pending));
1422
+ });
1423
+ }
1424
+ removeWorkMailbox(target) {
1425
+ const cols = this.#mailboxCols(target);
1426
+ return this.#mutate(() => {
1427
+ const result = this.#db.prepare("DELETE FROM mailboxes WHERE target_key = ?").run(cols.targetKey);
1428
+ return result.changes > 0;
1429
+ });
1430
+ }
1431
+ /**
1432
+ * Append a mailbox signal (§4.2). One transaction: insert the signal at the
1433
+ * mailbox's next sequence and advance `next_sequence` on the same row. The
1434
+ * single writer connection serializes enqueues, so sequences stay gapless per
1435
+ * mailbox. `(mailbox_id, sequence)` is the exactly-once key.
1436
+ */
1437
+ enqueueMailboxSignal(target, input) {
1438
+ return this.#mutate(() => {
1439
+ const cols = this.#mailboxCols(target);
1440
+ let mailboxId;
1441
+ let sequence;
1442
+ const existing = this.#db.prepare("SELECT mailbox_id, next_sequence FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
1443
+ if (existing === undefined) {
1444
+ const result = this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
1445
+ VALUES (?, ?, ?, ?, 1, NULL, NULL)`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey);
1446
+ mailboxId = Number(result.lastInsertRowid);
1447
+ sequence = 1;
1448
+ }
1449
+ else {
1450
+ mailboxId = existing.mailbox_id;
1451
+ sequence = existing.next_sequence;
1452
+ }
1453
+ this.#db.prepare(`INSERT INTO mailbox_signals (mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
1454
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(mailboxId, sequence, input.reason, input.ref?.type ?? null, input.ref && "taskId" in input.ref ? input.ref.taskId : null, input.ref?.id ?? null, this.#now(), input.requestId);
1455
+ this.#db.prepare("UPDATE mailboxes SET next_sequence = ? WHERE mailbox_id = ?").run(sequence + 1, mailboxId);
1456
+ return sequence;
1457
+ });
1458
+ }
1459
+ // -- scheduler projections -----------------------------------------------------
1460
+ #getProjection(taskId, kind) {
1461
+ const row = this.#db.prepare("SELECT payload FROM task_projections WHERE task_id = ? AND kind = ?").get(taskId, kind);
1462
+ if (row === undefined || row.payload === null)
1463
+ return null;
1464
+ return this.#parse(row.payload);
1465
+ }
1466
+ #saveProjection(taskId, kind, value) {
1467
+ this.#requireTask(taskId);
1468
+ this.#mutate(() => {
1469
+ this.#db.prepare(`INSERT INTO task_projections (task_id, kind, payload, updated_at) VALUES (?, ?, ?, ?)
1470
+ ON CONFLICT(task_id, kind) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, kind, this.#json(value), this.#now());
1471
+ });
1472
+ }
1473
+ #clearProjection(taskId, kind) {
1474
+ this.#mutate(() => {
1475
+ this.#db.prepare("UPDATE task_projections SET payload = NULL, updated_at = ? WHERE task_id = ? AND kind = ?")
1476
+ .run(this.#now(), taskId, kind);
1477
+ });
1478
+ }
1479
+ getLeaderFailure(taskId) {
1480
+ return this.#getProjection(taskId, "leader-failure");
1481
+ }
1482
+ saveLeaderFailure(failure) {
1483
+ this.#saveProjection(failure.taskId, "leader-failure", failure);
1484
+ }
1485
+ clearLeaderFailure(taskId) {
1486
+ this.#clearProjection(taskId, "leader-failure");
1487
+ }
1488
+ getOperatorNotification(taskId) {
1489
+ return this.#getProjection(taskId, "operator-notification");
1490
+ }
1491
+ saveOperatorNotification(notification) {
1492
+ this.#saveProjection(notification.taskId, "operator-notification", notification);
1493
+ }
1494
+ clearOperatorNotification(taskId) {
1495
+ this.#clearProjection(taskId, "operator-notification");
1496
+ }
1497
+ // -- pending wakeups (leader-role work-mailbox projection, mirrors taskStore.ts) --
1498
+ getPendingWakeup(taskId) {
1499
+ return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
1500
+ }
1501
+ listPendingWakeups() {
1502
+ return this.listWorkMailboxes()
1503
+ .flatMap((mailbox) => {
1504
+ const wakeup = pendingWakeupProjection(mailbox);
1505
+ return wakeup === null ? [] : [wakeup];
1506
+ })
1507
+ .sort((a, b) => numericCompare(a.taskId, b.taskId));
1508
+ }
1509
+ savePendingWakeup(value) {
1510
+ const target = { kind: "role", taskId: value.taskId, roleName: "leader" };
1511
+ this.transaction((store) => {
1512
+ const existing = store.getWorkMailbox(target);
1513
+ if (existing !== null && existing.pending !== null
1514
+ && value.requestCount <= existing.pending.requestCount) {
1515
+ throw new StorageRecordError(`Pending wakeup is stale: ${value.taskId}`);
1516
+ }
1517
+ const fromSequence = existing?.pending?.fromSequence ?? existing?.nextSequence ?? 1;
1518
+ const toSequence = fromSequence + value.requestCount - 1;
1519
+ store.saveWorkMailbox({
1520
+ schemaVersion: CURRENT_WORK_MAILBOX_SCHEMA_VERSION,
1521
+ target,
1522
+ nextSequence: Math.max(existing?.nextSequence ?? 1, toSequence + 1),
1523
+ processing: existing?.processing ?? null,
1524
+ pending: {
1525
+ ...existing?.pending,
1526
+ fromSequence,
1527
+ toSequence,
1528
+ reasons: [...value.reasons],
1529
+ refs: existing?.pending?.refs ?? [],
1530
+ requestCount: value.requestCount,
1531
+ firstQueuedAt: value.firstRequestedAt,
1532
+ lastQueuedAt: value.lastRequestedAt
1533
+ }
1534
+ });
1535
+ });
1536
+ }
1537
+ clearPendingWakeup(taskId) {
1538
+ this.removeWorkMailbox({ kind: "role", taskId, roleName: "leader" });
1539
+ }
1540
+ // -- telemetry (§4.4) -----------------------------------------------------------
1541
+ /**
1542
+ * Upsert one progress row. The PK is (task_id, role_name, run_id, generation,
1543
+ * progress_id): a repeated progress id updates in place, so a high-frequency
1544
+ * `runtime.provider-turn-progress` event is a single-row write that never
1545
+ * rewrites global state or another Task's rows.
1546
+ */
1547
+ upsertTelemetryProgress(entry) {
1548
+ this.#mutate(() => {
1549
+ this.#db.prepare(`INSERT INTO telemetry (task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at)
1550
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1551
+ ON CONFLICT(task_id, role_name, run_id, generation, progress_id)
1552
+ DO UPDATE SET sequence = excluded.sequence, payload = excluded.payload, received_at = excluded.received_at`).run(entry.taskId, entry.roleName, entry.runId, entry.generation, entry.progressId, entry.sequence ?? null, this.#json(entry.payload), entry.receivedAt);
1553
+ });
1554
+ }
1555
+ listTelemetry(taskId, runId) {
1556
+ const rows = runId === undefined
1557
+ ? this.#db.prepare("SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM telemetry WHERE task_id = ? ORDER BY received_at").all(taskId)
1558
+ : this.#db.prepare("SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM telemetry WHERE task_id = ? AND run_id = ? ORDER BY received_at").all(taskId, runId);
1559
+ return rows
1560
+ .map((row) => ({
1561
+ taskId: row.task_id,
1562
+ roleName: row.role_name,
1563
+ runId: row.run_id,
1564
+ generation: row.generation,
1565
+ progressId: row.progress_id,
1566
+ sequence: row.sequence ?? undefined,
1567
+ payload: this.#parse(row.payload),
1568
+ receivedAt: row.received_at
1569
+ }));
1570
+ }
1571
+ countTelemetry(taskId, runId) {
1572
+ const row = runId === undefined
1573
+ ? this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ?").get(taskId)
1574
+ : this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ? AND run_id = ?").get(taskId, runId);
1575
+ return row.n;
1576
+ }
1577
+ /**
1578
+ * Bounded retention (§4.4): keep the newest `keep` rows per
1579
+ * (task, role, run, generation) and delete older ones. The DELETE is scoped
1580
+ * by task_id; it never rewrites global rows or other Tasks. Returns the number
1581
+ * of rows deleted. Terminal/semantic events go to `events` and are never pruned.
1582
+ */
1583
+ pruneTelemetry(taskId, roleName, runId, generation, keep = TELEMETRY_KEEP_PER_GENERATION) {
1584
+ return this.#mutate(() => {
1585
+ const result = this.#db.prepare(`DELETE FROM telemetry
1586
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
1587
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
1588
+ SELECT task_id, role_name, run_id, generation, progress_id
1589
+ FROM telemetry
1590
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
1591
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
1592
+ LIMIT ?
1593
+ )`).run(taskId, roleName, runId, generation, taskId, roleName, runId, generation, keep);
1594
+ return result.changes;
1595
+ });
1596
+ }
1597
+ /**
1598
+ * Hard cap for an active run (§4.4): trim oldest rows across the run beyond
1599
+ * `cap` (default 50k). Returns the number of rows deleted.
1600
+ */
1601
+ capTelemetryRun(taskId, runId, cap = TELEMETRY_RUN_CAP) {
1602
+ return this.#mutate(() => {
1603
+ const result = this.#db.prepare(`DELETE FROM telemetry
1604
+ WHERE task_id = ? AND run_id = ?
1605
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
1606
+ SELECT task_id, role_name, run_id, generation, progress_id
1607
+ FROM telemetry
1608
+ WHERE task_id = ? AND run_id = ?
1609
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
1610
+ LIMIT ?
1611
+ )`).run(taskId, runId, taskId, runId, cap);
1612
+ return result.changes;
1613
+ });
1614
+ }
1615
+ }
1616
+ function validIntegrationQueueTransition(before, after) {
1617
+ if (before.id !== after.id
1618
+ || before.taskId !== after.taskId
1619
+ || before.projectId !== after.projectId
1620
+ || before.changeSetId !== after.changeSetId
1621
+ || before.targetRef !== after.targetRef
1622
+ || !isDeepStrictEqual(before.checkCommands, after.checkCommands)
1623
+ || !isDeepStrictEqual(before.evidenceRefs, after.evidenceRefs)
1624
+ || before.createdAt !== after.createdAt)
1625
+ return false;
1626
+ const allowed = {
1627
+ queued: ["queued", "running", "validated", "superseded"],
1628
+ running: ["running", "conflicted", "committed"],
1629
+ conflicted: ["conflicted", "running", "committed", "queued", "superseded"],
1630
+ validated: ["validated", "running", "queued", "superseded"],
1631
+ committed: ["committed"],
1632
+ superseded: ["superseded"]
1633
+ };
1634
+ return allowed[before.status].includes(after.status);
1635
+ }
1636
+ /**
1637
+ * Open a {@link TaskStore} for the given backend. `file` returns the existing
1638
+ * {@link FileTaskStore}; `sqlite` returns the in-process {@link SqliteTaskStore}.
1639
+ * Backend selection is explicit (design §6); the environment switch lives in
1640
+ * {@link resolveTaskStoreBackend}. The file store is not removed — rollback is
1641
+ * a config flip.
1642
+ */
1643
+ export function openTaskStore(home, backend, options) {
1644
+ if (backend === "sqlite") {
1645
+ return new SqliteTaskStore(home, options);
1646
+ }
1647
+ return new FileTaskStore(home);
1648
+ }
1649
+ /**
1650
+ * Resolve the storage backend from `YUI_STORE_BACKEND` (default `file`,
1651
+ * design §6). Only the exact value `sqlite` selects the SQLite store; any
1652
+ * other value (including unset) keeps the file store.
1653
+ */
1654
+ export function resolveTaskStoreBackend(env = process.env) {
1655
+ return env.YUI_STORE_BACKEND?.toLowerCase() === "sqlite" ? "sqlite" : "file";
1656
+ }
1657
+ /**
1658
+ * Resolve the storage backend from the Home's verified manifest (Issue 01).
1659
+ *
1660
+ * The Home decides: a layout-7 Home's authoritative backend is SQLite WAL, so
1661
+ * ordinary CLI/Controller startup opens SQLite without requiring
1662
+ * `YUI_STORE_BACKEND=sqlite`. An explicit `YUI_STORE_BACKEND` env value still
1663
+ * wins — it is reserved for tests and explicit recovery commands — and any
1664
+ * other value (including unset) defers to the Home. A Home whose manifest
1665
+ * cannot be read falls back to the file store; the classifier/doctor surfaces
1666
+ * the manifest problem separately.
1667
+ */
1668
+ export function resolveTaskStoreBackendForHome(home, env = process.env) {
1669
+ const explicit = env.YUI_STORE_BACKEND?.toLowerCase();
1670
+ if (explicit === "sqlite" || explicit === "file")
1671
+ return explicit;
1672
+ const schema = inspectStorageSchema(home);
1673
+ const layout = schema.status === "current" || schema.status === "unsupported"
1674
+ ? schema.currentLayoutVersion
1675
+ : 0;
1676
+ if (layout < 7)
1677
+ return "file";
1678
+ // Issue 01: a layout-7 Home's authoritative backend is SQLite WAL, but only
1679
+ // when yui.db actually exists. A pseudo-layout-7 Home (manifest 7, no
1680
+ // yui.db) is classified NEEDS_STORAGE_REPAIR; until repair runs, the file
1681
+ // store remains the readable fallback. This keeps the Controller's backend
1682
+ // resolution consistent with openCompatibleFileTaskStore's physical check.
1683
+ // Uses the literal "yui.db" (not COMMITTED_DATABASE_FILENAME) to avoid a
1684
+ // circular import: sqliteStateMigration.ts imports SqliteTaskStore from here.
1685
+ return existsSync(join(home, "yui.db")) ? "sqlite" : "file";
1686
+ }
1687
+ /**
1688
+ * Convenience: open the store for the backend resolved from the environment
1689
+ * and the Home's verified manifest (see {@link resolveTaskStoreBackendForHome}).
1690
+ * CLI/controller entry points call this instead of {@link openTaskStore}
1691
+ * directly so a layout-7 Home opens SQLite without an env opt-in.
1692
+ */
1693
+ export function openConfiguredTaskStore(home, options, env = process.env) {
1694
+ return openTaskStore(home, resolveTaskStoreBackendForHome(home, env), options);
1695
+ }