@zq-silk/yui 0.5.2 → 0.6.0

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.
@@ -0,0 +1,1222 @@
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 { 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 { generateHomeIdentity } from "../repository/homeIdentity.js";
44
+ import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
45
+ import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
46
+ import { assertHomeWritable } from "./upgradeFence.js";
47
+ import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, executionLaneActiveRunKey, StorageConflictError, StorageCancelledError, StorageRecordError, FileTaskStore } from "./taskStore.js";
48
+ import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
49
+ const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
50
+ function numericCompare(left, right) {
51
+ return left.localeCompare(right, undefined, { numeric: true });
52
+ }
53
+ /** True when the better-sqlite3 error is a UNIQUE/PRIMARY KEY constraint failure. */
54
+ function isUniqueConstraint(error) {
55
+ return error instanceof Error
56
+ && "code" in error
57
+ && error.code === "SQLITE_CONSTRAINT_PRIMARYKEY"
58
+ || error?.code === "SQLITE_CONSTRAINT_UNIQUE";
59
+ }
60
+ /** Project a leader-role work mailbox's pending batch to a PendingWakeup (mirrors taskStore.ts). */
61
+ function pendingWakeupProjection(mailbox) {
62
+ if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
63
+ || mailbox.pending === null) {
64
+ return null;
65
+ }
66
+ return {
67
+ schemaVersion: CURRENT_PENDING_WAKEUP_SCHEMA_VERSION,
68
+ taskId: mailbox.target.taskId,
69
+ reasons: [...mailbox.pending.reasons],
70
+ requestCount: mailbox.pending.requestCount,
71
+ firstRequestedAt: mailbox.pending.firstQueuedAt,
72
+ lastRequestedAt: mailbox.pending.lastQueuedAt
73
+ };
74
+ }
75
+ export class SqliteTaskStore {
76
+ #db;
77
+ #rootDir;
78
+ #migration;
79
+ #inTransaction = false;
80
+ #dirty = false;
81
+ constructor(rootDir, _options = {}) {
82
+ this.#rootDir = rootDir;
83
+ this.#migration = _options.migration ?? false;
84
+ mkdirSync(rootDir, { recursive: true, mode: 0o700 });
85
+ const filename = _options.databaseFilename ?? "yui.db";
86
+ this.#db = new Database(join(rootDir, filename));
87
+ // §4.1 / §9: WAL, no fsync weakening, FKs on, busy timeout for CLI contention.
88
+ this.#db.pragma("journal_mode = WAL");
89
+ this.#db.pragma("synchronous = FULL");
90
+ this.#db.pragma("foreign_keys = ON");
91
+ this.#db.pragma("busy_timeout = 5000");
92
+ this.#db.pragma("wal_autocheckpoint = 1000");
93
+ migrateSqliteSchema(this.#db);
94
+ this.#seedHomeMeta();
95
+ this.#seedConfig();
96
+ }
97
+ rootDirectory() { return this.#rootDir; }
98
+ /** Close the underlying database connection. */
99
+ close() { this.#db.close(); }
100
+ // -- transaction primitives -------------------------------------------------
101
+ #seedHomeMeta() {
102
+ const now = new Date().toISOString();
103
+ const identity = generateHomeIdentity(new Date());
104
+ this.#db.prepare(`INSERT OR IGNORE INTO home_meta (id, home_identity, revision, layout_version, aggregate_version, created_at, updated_at)
105
+ VALUES (1, ?, 0, ?, ?, ?, ?)`).run(JSON.stringify(identity), SQLITE_LAYOUT_VERSION, SQLITE_AGGREGATE_VERSION, now, now);
106
+ }
107
+ #seedConfig() {
108
+ this.#db.prepare(`INSERT OR IGNORE INTO config (id, payload, updated_at) VALUES (1, ?, ?)`).run(JSON.stringify(DEFAULT_CONFIG), new Date().toISOString());
109
+ }
110
+ #now() { return new Date().toISOString(); }
111
+ #json(value) { return JSON.stringify(value); }
112
+ #parse(text) { return JSON.parse(text); }
113
+ #begin() {
114
+ this.#db.exec("BEGIN IMMEDIATE");
115
+ this.#inTransaction = true;
116
+ this.#dirty = false;
117
+ }
118
+ #commit() {
119
+ this.#db.exec("COMMIT");
120
+ this.#inTransaction = false;
121
+ this.#dirty = false;
122
+ }
123
+ #rollback() {
124
+ try {
125
+ this.#db.exec("ROLLBACK");
126
+ }
127
+ catch { /* already closed */ }
128
+ this.#inTransaction = false;
129
+ this.#dirty = false;
130
+ }
131
+ /** The upgrade-admission fence, honored at the single write moment (§9). */
132
+ #prepareWrite() {
133
+ // The staged migration populates the sidecar database while the upgrade
134
+ // fence is active (the migration IS the upgrade), so it bypasses the
135
+ // per-write admission check. Production stores never set migration mode.
136
+ if (this.#migration)
137
+ return;
138
+ assertHomeWritable(this.#rootDir);
139
+ }
140
+ #bumpRevision() {
141
+ this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
142
+ }
143
+ /**
144
+ * Run a mutating closure. Inside an outer {@link transaction} it joins that
145
+ * transaction (single revision bump at the outer commit); standalone it takes
146
+ * the write lock, checks the fence, bumps the revision, and commits.
147
+ */
148
+ #mutate(fn) {
149
+ if (this.#inTransaction) {
150
+ const result = fn();
151
+ this.#dirty = true;
152
+ return result;
153
+ }
154
+ this.#prepareWrite();
155
+ this.#begin();
156
+ try {
157
+ const result = fn();
158
+ if (!this.#migration) {
159
+ this.#bumpRevision();
160
+ }
161
+ this.#commit();
162
+ return result;
163
+ }
164
+ catch (error) {
165
+ this.#rollback();
166
+ throw error;
167
+ }
168
+ }
169
+ /**
170
+ * The file-store's `transaction(closure)`: a single BEGIN IMMEDIATE … COMMIT.
171
+ * Nested calls join the outer transaction. The revision is bumped once, at
172
+ * commit, only when the closure wrote.
173
+ */
174
+ transaction(execute, options) {
175
+ if (this.#inTransaction)
176
+ return execute(this);
177
+ this.#begin();
178
+ try {
179
+ const result = execute(this);
180
+ if (this.#dirty) {
181
+ this.#prepareWrite();
182
+ if (options?.requestId !== undefined) {
183
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
184
+ }
185
+ // The staged migration sets the revision explicitly via
186
+ // migrationSetHomeMeta; the commit must not bump it.
187
+ if (!this.#migration) {
188
+ this.#bumpRevision();
189
+ }
190
+ }
191
+ this.#commit();
192
+ return result;
193
+ }
194
+ catch (error) {
195
+ this.#rollback();
196
+ throw error;
197
+ }
198
+ }
199
+ /**
200
+ * Revision CAS (§5.3): run `execute` only when the global revision is still
201
+ * `expectedRevision`; otherwise throw {@link StorageConflictError}. The check
202
+ * and the increment happen in the same write transaction.
203
+ */
204
+ transactionWithRevisionCas(expectedRevision, execute, options) {
205
+ if (this.#inTransaction)
206
+ return execute(this);
207
+ this.#begin();
208
+ try {
209
+ const current = this.getRevision();
210
+ if (current !== expectedRevision) {
211
+ throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`);
212
+ }
213
+ const result = execute(this);
214
+ if (this.#dirty) {
215
+ this.#prepareWrite();
216
+ if (options?.requestId !== undefined) {
217
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
218
+ }
219
+ // The staged migration sets the revision explicitly via
220
+ // migrationSetHomeMeta; the commit must not bump it.
221
+ if (!this.#migration) {
222
+ this.#bumpRevision();
223
+ }
224
+ }
225
+ this.#commit();
226
+ return result;
227
+ }
228
+ catch (error) {
229
+ this.#rollback();
230
+ if (error instanceof StorageConflictError)
231
+ throw error;
232
+ throw error;
233
+ }
234
+ }
235
+ /** The current global revision (the cross-writer CAS token). */
236
+ getRevision() {
237
+ const row = this.#db.prepare("SELECT revision FROM home_meta WHERE id = 1").get();
238
+ return row.revision;
239
+ }
240
+ /**
241
+ * Run an ordered command batch inside one `BEGIN IMMEDIATE … COMMIT`, yielding
242
+ * to the event loop between commands so a cancellation signal can interrupt
243
+ * the batch (§3.1). The persistence worker uses this for `transactionAsync`:
244
+ * a cancelled batch rolls back (the db is unchanged); already-committed
245
+ * batches are not undone (their effects are idempotent and caller-owned).
246
+ *
247
+ * Each command is `{op, args}` where `op` is a `TaskStore` method name. The
248
+ * batch runs on the single writer connection, so writes are serialized exactly
249
+ * as the synchronous {@link transaction} (§3.2). The revision is bumped once
250
+ * at commit when the batch wrote; an optional `requestId` records the effect
251
+ * in the durable outbox for exactly-once replay (§5.4).
252
+ */
253
+ async transactionAsyncBatch(commands, options = {}) {
254
+ if (this.#inTransaction) {
255
+ // Nested inside a synchronous transaction: run without yielding (the
256
+ // caller already holds the write lock).
257
+ return commands.map((command) => this.#executeCommand(command.op, command.args));
258
+ }
259
+ this.#begin();
260
+ try {
261
+ // Revision CAS (§5.3): the check and the increment happen in the same
262
+ // write transaction, exactly as transactionWithRevisionCas.
263
+ if (options.expectedRevision !== undefined) {
264
+ const current = this.getRevision();
265
+ if (current !== options.expectedRevision) {
266
+ throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current}).`);
267
+ }
268
+ }
269
+ const results = [];
270
+ for (const command of commands) {
271
+ if (options.shouldCancel?.() === true) {
272
+ throw new StorageCancelledError(`Storage command batch cancelled before op '${command.op}'.`);
273
+ }
274
+ results.push(this.#executeCommand(command.op, command.args));
275
+ // Yield so the worker's message loop can observe a cancel signal
276
+ // between statements (§3.1). A single-command batch still yields once
277
+ // so a cancel that raced the batch start is honoured before commit.
278
+ await new Promise((resolve) => setImmediate(resolve));
279
+ }
280
+ if (options.shouldCancel?.() === true) {
281
+ throw new StorageCancelledError("Storage command batch cancelled before commit.");
282
+ }
283
+ if (this.#dirty) {
284
+ this.#prepareWrite();
285
+ if (options.requestId !== undefined) {
286
+ this.#insertOutbox(options.requestId, options.outboxCommand ?? commands);
287
+ }
288
+ if (!this.#migration) {
289
+ this.#bumpRevision();
290
+ }
291
+ }
292
+ this.#commit();
293
+ return results;
294
+ }
295
+ catch (error) {
296
+ this.#rollback();
297
+ throw error;
298
+ }
299
+ }
300
+ /** Invoke a TaskStore method by name (used by the worker's command batches). */
301
+ #executeCommand(op, args) {
302
+ const method = this[op];
303
+ if (typeof method !== "function") {
304
+ throw new StorageRecordError(`Unknown store command: ${op}`);
305
+ }
306
+ return method.apply(this, args);
307
+ }
308
+ // -- outbox (§5.4) ----------------------------------------------------------
309
+ #insertOutbox(requestId, command) {
310
+ try {
311
+ this.#db.prepare(`INSERT INTO outbox (request_id, command, state, created_at) VALUES (?, ?, 'pending', ?)`).run(requestId, this.#json(command), this.#now());
312
+ }
313
+ catch (error) {
314
+ if (isUniqueConstraint(error)) {
315
+ throw new StorageConflictError(`Outbox request already applied: ${requestId}`);
316
+ }
317
+ throw error;
318
+ }
319
+ }
320
+ /**
321
+ * Enqueue an outbox row idempotently. Returns true when a new row was
322
+ * inserted, false when `requestId` was already present (exactly-once).
323
+ */
324
+ enqueueOutbox(requestId, command) {
325
+ return this.#mutate(() => {
326
+ 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());
327
+ return result.changes > 0;
328
+ });
329
+ }
330
+ /** Outbox rows still awaiting acknowledgement (the replay source after a crash). */
331
+ listPendingOutbox() {
332
+ const rows = this.#db.prepare("SELECT request_id, command, created_at FROM outbox WHERE state = 'pending' ORDER BY outbox_id").all();
333
+ return rows.map((row) => ({
334
+ requestId: row.request_id,
335
+ command: this.#parse(row.command),
336
+ createdAt: row.created_at
337
+ }));
338
+ }
339
+ /**
340
+ * True when an outbox row already exists for `requestId` (the effect committed).
341
+ * The persistence worker consults this before re-executing a retried write so a
342
+ * main-thread retry after a worker restart never double-applies (§3.1, §5.4).
343
+ */
344
+ hasOutboxEntry(requestId) {
345
+ const row = this.#db.prepare("SELECT 1 FROM outbox WHERE request_id = ?").get(requestId);
346
+ return row !== undefined;
347
+ }
348
+ /** Mark an outbox row as applied (idempotent). */
349
+ markOutboxApplied(requestId) {
350
+ this.#mutate(() => {
351
+ this.#db.prepare("UPDATE outbox SET state = 'applied', applied_at = ? WHERE request_id = ?").run(this.#now(), requestId);
352
+ });
353
+ }
354
+ // -- ID allocation ----------------------------------------------------------
355
+ /**
356
+ * Global IDs (`task-<n>`, `project-<n>`) from `global_sequences` (§5.3). The
357
+ * file store computes these by scanning existing IDs (a read); the counter is
358
+ * the design's replacement and is allocated without bumping the revision.
359
+ */
360
+ #nextGlobalId(name) {
361
+ const allocate = this.#db.transaction(() => {
362
+ const row = this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, 1)
363
+ ON CONFLICT(name) DO UPDATE SET high_water = high_water + 1
364
+ RETURNING high_water`).get(name);
365
+ return `${name}-${row.high_water}`;
366
+ });
367
+ return allocate();
368
+ }
369
+ /**
370
+ * Task-record IDs from `id_sequences` (replaces StoredTask.idHighWaterMarks).
371
+ * Allocating a high-water mark is a durable write, so it bumps the revision
372
+ * exactly as the file store does.
373
+ */
374
+ #nextTaskRecordId(taskId, kind) {
375
+ this.#requireTask(taskId);
376
+ return this.#mutate(() => {
377
+ const row = this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, 1)
378
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = high_water + 1
379
+ RETURNING high_water`).get(taskId, kind);
380
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${row.high_water}`;
381
+ });
382
+ }
383
+ #peekTaskRecordId(taskId, kind) {
384
+ this.#requireTask(taskId);
385
+ const row = this.#db.prepare("SELECT high_water FROM id_sequences WHERE task_id = ? AND kind = ?").get(taskId, kind);
386
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${(row?.high_water ?? 0) + 1}`;
387
+ }
388
+ #requireTask(taskId) {
389
+ const row = this.#db.prepare("SELECT 1 FROM task_records WHERE task_id = ?").get(taskId);
390
+ if (row === undefined)
391
+ throw new StorageRecordError(`Task not found: ${taskId}`);
392
+ }
393
+ // -- migration bulk-load helpers (state.json -> SQLite, task-21 §8) ----------
394
+ // These are used only by the staged offline migration, which runs with the
395
+ // `migration` option (fence bypass). They seed infrastructure tables that the
396
+ // document owns (home identity/revision, global and per-task ID high-water
397
+ // marks) so the opened store continues from the same counters.
398
+ /** Preserve the document's Home identity and revision in `home_meta`. */
399
+ migrationSetHomeMeta(identity, revision) {
400
+ this.#mutate(() => {
401
+ this.#db.prepare(`UPDATE home_meta SET home_identity = ?, revision = ?, updated_at = ? WHERE id = 1`).run(this.#json(identity), revision, this.#now());
402
+ });
403
+ }
404
+ /** Seed a global ID high-water mark (task/project) at least `highWater`. */
405
+ migrationSeedGlobalSequence(name, highWater) {
406
+ this.#mutate(() => {
407
+ this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
408
+ ON CONFLICT(name) DO UPDATE SET high_water = MAX(high_water, ?)`).run(name, highWater, highWater);
409
+ });
410
+ }
411
+ /** Seed a per-task ID high-water mark at least `highWater`. */
412
+ migrationSeedIdSequence(taskId, kind, highWater) {
413
+ this.#mutate(() => {
414
+ this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
415
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, highWater, highWater);
416
+ });
417
+ }
418
+ // -- generic payload helpers ------------------------------------------------
419
+ #getPayload(table, where, params) {
420
+ const row = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).get(...params);
421
+ return row === undefined ? null : this.#parse(row.payload);
422
+ }
423
+ #listPayload(table, where, params) {
424
+ const rows = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).all(...params);
425
+ return rows.map((row) => this.#parse(row.payload));
426
+ }
427
+ #sortById(rows, idOf) {
428
+ return [...rows].sort((left, right) => numericCompare(idOf(left), idOf(right)));
429
+ }
430
+ // -- config / identity ------------------------------------------------------
431
+ getConfig() {
432
+ const row = this.#db.prepare("SELECT payload FROM config WHERE id = 1").get();
433
+ return this.#parse(row.payload);
434
+ }
435
+ saveConfig(config) {
436
+ this.#mutate(() => {
437
+ this.#db.prepare("UPDATE config SET payload = ?, updated_at = ? WHERE id = 1").run(this.#json(config), this.#now());
438
+ });
439
+ }
440
+ getHomeIdentity() {
441
+ const row = this.#db.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
442
+ return this.#parse(row.home_identity);
443
+ }
444
+ getReviewConfig() {
445
+ return this.getConfig().review ?? null;
446
+ }
447
+ // -- configured agents ------------------------------------------------------
448
+ saveConfiguredAgent(agent) {
449
+ this.#mutate(() => {
450
+ this.#db.prepare(`INSERT INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)
451
+ ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(agent.id, this.#json(agent), this.#now());
452
+ });
453
+ }
454
+ createConfiguredAgentIfAbsent(agent) {
455
+ return this.#mutate(() => {
456
+ const result = this.#db.prepare("INSERT OR IGNORE INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)").run(agent.id, this.#json(agent), this.#now());
457
+ return result.changes > 0 ? agent : null;
458
+ });
459
+ }
460
+ updateConfiguredAgent(id, patch, now) {
461
+ return this.transaction((store) => {
462
+ const existing = store.getConfiguredAgent(id);
463
+ if (existing === null)
464
+ return null;
465
+ const candidate = { ...existing, ...patch, updatedAt: now.toISOString() };
466
+ const unchanged = isDeepStrictEqual({ ...existing, updatedAt: candidate.updatedAt }, candidate);
467
+ if (unchanged)
468
+ return { status: "unchanged", agent: existing };
469
+ store.saveConfiguredAgent(candidate);
470
+ return { status: "updated", agent: candidate };
471
+ });
472
+ }
473
+ listConfiguredAgents() {
474
+ return this.#sortById(this.#listPayload("configured_agents", "1=1", []), (agent) => agent.id);
475
+ }
476
+ getConfiguredAgent(id) {
477
+ return this.#getPayload("configured_agents", "id = ?", [id]);
478
+ }
479
+ removeConfiguredAgent(id) {
480
+ return this.#mutate(() => {
481
+ const result = this.#db.prepare("DELETE FROM configured_agents WHERE id = ?").run(id);
482
+ return result.changes > 0;
483
+ });
484
+ }
485
+ // -- projects ---------------------------------------------------------------
486
+ nextProjectId() { return this.#nextGlobalId("project"); }
487
+ saveProject(project) {
488
+ this.#mutate(() => {
489
+ this.#db.prepare(`INSERT INTO projects (id, name, path, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
490
+ 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);
491
+ });
492
+ }
493
+ createProjectIfAbsent(project) {
494
+ return this.#mutate(() => {
495
+ 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);
496
+ return result.changes > 0 ? project : null;
497
+ });
498
+ }
499
+ listProjects() {
500
+ return this.#sortById(this.#listPayload("projects", "1=1", []), (project) => project.id);
501
+ }
502
+ getProject(id) {
503
+ return this.#getPayload("projects", "id = ?", [id]);
504
+ }
505
+ removeProject(id) {
506
+ return this.#mutate(() => this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0);
507
+ }
508
+ // -- agent profiles ---------------------------------------------------------
509
+ saveAgentProfile(profile) {
510
+ this.#mutate(() => {
511
+ this.#db.prepare(`INSERT INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)
512
+ ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(profile.id, this.#json(profile), this.#now());
513
+ });
514
+ }
515
+ createAgentProfileIfAbsent(profile) {
516
+ return this.#mutate(() => {
517
+ const result = this.#db.prepare("INSERT OR IGNORE INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)").run(profile.id, this.#json(profile), this.#now());
518
+ return result.changes > 0 ? profile : null;
519
+ });
520
+ }
521
+ listAgentProfiles() {
522
+ return this.#sortById(this.#listPayload("agent_profiles", "1=1", []), (profile) => profile.id);
523
+ }
524
+ getAgentProfile(id) {
525
+ return this.#getPayload("agent_profiles", "id = ?", [id]);
526
+ }
527
+ removeAgentProfile(id) {
528
+ return this.#mutate(() => this.#db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id).changes > 0);
529
+ }
530
+ // -- global roles -----------------------------------------------------------
531
+ saveGlobalRole(role) {
532
+ this.#mutate(() => {
533
+ this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
534
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
535
+ });
536
+ }
537
+ saveGlobalRoleWithSessionSet(role, sessions) {
538
+ this.#mutate(() => {
539
+ this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
540
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
541
+ if (sessions !== null) {
542
+ this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
543
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(sessions), this.#now());
544
+ }
545
+ });
546
+ }
547
+ createGlobalRoleIfAbsent(role) {
548
+ return this.#mutate(() => {
549
+ const result = this.#db.prepare("INSERT OR IGNORE INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)").run(role.name, this.#json(role), this.#now());
550
+ return result.changes > 0 ? role : null;
551
+ });
552
+ }
553
+ listGlobalRoles() {
554
+ return this.#sortById(this.#listPayload("global_roles", "1=1", []), (role) => role.name);
555
+ }
556
+ getGlobalRole(name) {
557
+ return this.#getPayload("global_roles", "name = ?", [name]);
558
+ }
559
+ removeGlobalRole(name) {
560
+ return this.#mutate(() => this.#db.prepare("DELETE FROM global_roles WHERE name = ?").run(name).changes > 0);
561
+ }
562
+ getGlobalRoleSessionSet(name) {
563
+ return this.#getPayload("global_role_session_sets", "name = ?", [name]);
564
+ }
565
+ listGlobalRoleSessionSets() {
566
+ const rows = this.#listPayload("global_role_session_sets", "1=1", []);
567
+ return [...rows].sort((left, right) => numericCompare(left.owner.roleName, right.owner.roleName));
568
+ }
569
+ saveGlobalRoleSessionSet(sessions) {
570
+ this.#mutate(() => {
571
+ this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
572
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.roleName, this.#json(sessions), this.#now());
573
+ });
574
+ }
575
+ // -- tasks ------------------------------------------------------------------
576
+ nextTaskId() { return this.#nextGlobalId("task"); }
577
+ saveTask(task) {
578
+ if (typeof task.id !== "string" || task.id.length === 0) {
579
+ throw new StorageRecordError("Task id is required.");
580
+ }
581
+ this.#mutate(() => {
582
+ for (const binding of task.projectBindings) {
583
+ const found = this.#db.prepare("SELECT 1 FROM projects WHERE id = ?").get(binding.projectId);
584
+ if (found === undefined)
585
+ throw new StorageRecordError(`Task Project not found: ${binding.projectId}`);
586
+ }
587
+ const isActive = task.status === "active" ? 1 : 0;
588
+ // The catalog projection is inserted first because task_records FKs it.
589
+ this.#db.prepare(`INSERT INTO tasks_catalog (task_id, status, lifecycle, is_active, created_at, updated_at)
590
+ VALUES (?, ?, ?, ?, ?, ?)
591
+ ON CONFLICT(task_id) DO UPDATE SET status = excluded.status, lifecycle = excluded.lifecycle,
592
+ is_active = excluded.is_active, updated_at = excluded.updated_at`).run(task.id, task.status, task.status, isActive, task.createdAt, task.updatedAt);
593
+ this.#db.prepare(`INSERT INTO task_records (task_id, payload, updated_at) VALUES (?, ?, ?)
594
+ ON CONFLICT(task_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(task.id, this.#json(task), this.#now());
595
+ });
596
+ }
597
+ listTasks() {
598
+ const tasks = this.#listPayload("task_records", "1=1", []);
599
+ return this.#sortById(tasks, (task) => task.id);
600
+ }
601
+ getTask(id) {
602
+ return this.#getPayload("task_records", "task_id = ?", [id]);
603
+ }
604
+ /** Task ids flagged active in the catalog projection (the global active index). */
605
+ listActiveTaskIds() {
606
+ const rows = this.#db.prepare("SELECT task_id FROM tasks_catalog WHERE is_active = 1 ORDER BY task_id").all();
607
+ return rows.map((row) => row.task_id);
608
+ }
609
+ getTaskBrief(taskId) {
610
+ const row = this.#db.prepare("SELECT brief FROM task_records WHERE task_id = ?").get(taskId);
611
+ if (row === undefined || row.brief === null)
612
+ return null;
613
+ return this.#parse(row.brief);
614
+ }
615
+ saveTaskBrief(taskId, brief) {
616
+ this.#requireTask(taskId);
617
+ this.#mutate(() => {
618
+ this.#db.prepare("UPDATE task_records SET brief = ?, updated_at = ? WHERE task_id = ?")
619
+ .run(this.#json(brief), this.#now(), taskId);
620
+ });
621
+ }
622
+ clearTaskBrief(taskId) {
623
+ this.#requireTask(taskId);
624
+ this.#mutate(() => {
625
+ this.#db.prepare("UPDATE task_records SET brief = NULL, updated_at = ? WHERE task_id = ?").run(this.#now(), taskId);
626
+ });
627
+ }
628
+ // -- change sets ------------------------------------------------------------
629
+ nextChangeSetId(taskId) { return this.#nextTaskRecordId(taskId, "changeSet"); }
630
+ saveChangeSet(taskId, changeSet) {
631
+ if (changeSet.taskId !== taskId)
632
+ throw new StorageRecordError(`Change set belongs to another Task: ${changeSet.taskId}`);
633
+ this.#requireTask(taskId);
634
+ this.#mutate(() => {
635
+ this.#db.prepare(`INSERT INTO change_sets (task_id, change_set_id, project_id, head_sha, payload, created_at)
636
+ VALUES (?, ?, ?, ?, ?, ?)
637
+ ON CONFLICT(task_id, change_set_id) DO UPDATE SET project_id = excluded.project_id,
638
+ head_sha = excluded.head_sha, payload = excluded.payload`).run(taskId, changeSet.id, changeSet.projectId, changeSet.headCommit, this.#json(changeSet), changeSet.createdAt);
639
+ });
640
+ }
641
+ listChangeSets(taskId) {
642
+ return this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id);
643
+ }
644
+ getChangeSet(taskId, changeSetId) {
645
+ return this.#getPayload("change_sets", "task_id = ? AND change_set_id = ?", [taskId, changeSetId]);
646
+ }
647
+ // -- integration attempts ---------------------------------------------------
648
+ nextIntegrationAttemptId(taskId) { return this.#nextTaskRecordId(taskId, "integrationAttempt"); }
649
+ saveIntegrationAttempt(taskId, attempt) {
650
+ if (attempt.taskId !== taskId)
651
+ throw new StorageRecordError(`Integration attempt belongs to another Task: ${attempt.taskId}`);
652
+ this.#requireTask(taskId);
653
+ this.#mutate(() => {
654
+ this.#db.prepare(`INSERT INTO integration_attempts (task_id, integration_id, status, payload, updated_at)
655
+ VALUES (?, ?, ?, ?, ?)
656
+ ON CONFLICT(task_id, integration_id) DO UPDATE SET status = excluded.status,
657
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, attempt.id, attempt.status, this.#json(attempt), this.#now());
658
+ });
659
+ }
660
+ listIntegrationAttempts(taskId) {
661
+ return this.#sortById(this.#listPayload("integration_attempts", "task_id = ?", [taskId]), (attempt) => attempt.id);
662
+ }
663
+ getIntegrationAttempt(taskId, integrationId) {
664
+ return this.#getPayload("integration_attempts", "task_id = ? AND integration_id = ?", [taskId, integrationId]);
665
+ }
666
+ // -- task roles -------------------------------------------------------------
667
+ saveRole(taskId, role) {
668
+ this.#requireTask(taskId);
669
+ this.#mutate(() => {
670
+ this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
671
+ 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());
672
+ });
673
+ }
674
+ listRoles(taskId) {
675
+ return this.#sortById(this.#listPayload("task_roles", "task_id = ?", [taskId]), (role) => role.name);
676
+ }
677
+ getRole(taskId, name) {
678
+ return this.#getPayload("task_roles", "task_id = ? AND role_name = ?", [taskId, name]);
679
+ }
680
+ saveTaskRoleWithSessionSet(role, sessions) {
681
+ this.#requireTask(role.name ? role.taskId : sessions.owner.taskId);
682
+ this.#mutate(() => {
683
+ this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
684
+ 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());
685
+ this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
686
+ 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());
687
+ });
688
+ }
689
+ removeTaskRole(taskId, name) {
690
+ return this.#mutate(() => {
691
+ const result = this.#db.prepare("DELETE FROM task_roles WHERE task_id = ? AND role_name = ?").run(taskId, name);
692
+ return result.changes > 0;
693
+ });
694
+ }
695
+ // -- managed workspaces -----------------------------------------------------
696
+ saveManagedWorkspace(workspace) {
697
+ const taskId = workspace.owner.taskId;
698
+ this.#requireTask(taskId);
699
+ this.#mutate(() => {
700
+ const ownerId = managedWorkspaceKey(workspace.owner);
701
+ this.#db.prepare(`INSERT INTO managed_workspaces (owner_kind, owner_id, task_id, path, payload, status, created_at, updated_at)
702
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
703
+ ON CONFLICT(owner_kind, owner_id) DO UPDATE SET task_id = excluded.task_id, path = excluded.path,
704
+ 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);
705
+ });
706
+ }
707
+ listManagedWorkspaces(taskId) {
708
+ return this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner));
709
+ }
710
+ listManagedWorkspace(taskId) {
711
+ return this.listManagedWorkspaces(taskId);
712
+ }
713
+ getManagedWorkspace(owner) {
714
+ return this.#getPayload("managed_workspaces", "owner_kind = ? AND owner_id = ?", [owner.type, managedWorkspaceKey(owner)]);
715
+ }
716
+ getTaskWorkspace(taskId) {
717
+ return this.getManagedWorkspace({ type: "task", taskId });
718
+ }
719
+ getWorkItemWorkspace(taskId, workItemId) {
720
+ return this.getManagedWorkspace({ type: "work-item", taskId, workItemId });
721
+ }
722
+ getReviewRoundWorkspace(taskId, reviewRoundId) {
723
+ return this.getManagedWorkspace({ type: "review-round", taskId, reviewRoundId });
724
+ }
725
+ getIntegrationWorkspace(taskId, integrationAttemptId) {
726
+ return this.getManagedWorkspace({ type: "integration-attempt", taskId, integrationAttemptId });
727
+ }
728
+ removeManagedWorkspace(owner) {
729
+ this.#requireTask(owner.taskId);
730
+ return this.#mutate(() => {
731
+ const result = this.#db.prepare("DELETE FROM managed_workspaces WHERE owner_kind = ? AND owner_id = ?").run(owner.type, managedWorkspaceKey(owner));
732
+ return result.changes > 0;
733
+ });
734
+ }
735
+ // -- role session sets ------------------------------------------------------
736
+ getRoleSessionSet(taskId, roleName) {
737
+ return this.#getPayload("role_session_sets", "task_id = ? AND role_name = ?", [taskId, roleName]);
738
+ }
739
+ getTaskRoleSessionSet(taskId, roleName) {
740
+ return this.getRoleSessionSet(taskId, roleName);
741
+ }
742
+ listRoleSessionSets(taskId) {
743
+ return this.#sortById(this.#listPayload("role_session_sets", "task_id = ?", [taskId]), (set) => set.owner.roleName);
744
+ }
745
+ saveRoleSessionSet(sessions) {
746
+ const taskId = sessions.owner.taskId;
747
+ this.#requireTask(taskId);
748
+ this.#mutate(() => {
749
+ this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
750
+ 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());
751
+ });
752
+ }
753
+ saveTaskRoleSessionSet(sessions) {
754
+ this.saveRoleSessionSet(sessions);
755
+ }
756
+ getRoleSession(taskId, roleName) {
757
+ const set = this.getRoleSessionSet(taskId, roleName);
758
+ if (set === null)
759
+ return null;
760
+ const session = set.sessions[set.activeAgentId];
761
+ return session === undefined ? null : session;
762
+ }
763
+ // -- work items -------------------------------------------------------------
764
+ nextWorkItemId(taskId) { return this.#nextTaskRecordId(taskId, "workItem"); }
765
+ getWorkItem(taskId, workItemId) {
766
+ return this.#getPayload("work_items", "task_id = ? AND work_item_id = ?", [taskId, workItemId]);
767
+ }
768
+ listWorkItems(taskId) {
769
+ return this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id);
770
+ }
771
+ saveWorkItem(taskId, item) {
772
+ if (item.taskId !== taskId)
773
+ throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`);
774
+ this.#requireTask(taskId);
775
+ this.#mutate(() => {
776
+ this.#db.prepare(`INSERT INTO work_items (task_id, work_item_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
777
+ ON CONFLICT(task_id, work_item_id) DO UPDATE SET status = excluded.status,
778
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, item.id, item.status, this.#json(item), this.#now());
779
+ });
780
+ }
781
+ // -- agent runs -------------------------------------------------------------
782
+ nextAgentRunId(taskId) { return this.#nextTaskRecordId(taskId, "agentRun"); }
783
+ peekNextAgentRunId(taskId) { return this.#peekTaskRecordId(taskId, "agentRun"); }
784
+ getAgentRun(taskId, runId) {
785
+ return this.#getPayload("agent_runs", "task_id = ? AND run_id = ?", [taskId, runId]);
786
+ }
787
+ listAgentRuns(taskId) {
788
+ return this.#sortById(this.#listPayload("agent_runs", "task_id = ?", [taskId]), (run) => run.id);
789
+ }
790
+ saveAgentRun(run) {
791
+ if (run.taskId !== undefined)
792
+ this.#requireTask(run.taskId);
793
+ this.#mutate(() => {
794
+ this.#db.prepare(`INSERT INTO agent_runs (task_id, run_id, role_name, status, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)
795
+ ON CONFLICT(task_id, run_id) DO UPDATE SET role_name = excluded.role_name, status = excluded.status,
796
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(run.taskId, run.id, run.roleName, run.status, this.#json(run), this.#now());
797
+ });
798
+ }
799
+ // -- review rounds ----------------------------------------------------------
800
+ nextReviewRoundId(taskId) { return this.#nextTaskRecordId(taskId, "reviewRound"); }
801
+ getReviewRound(taskId, reviewRoundId) {
802
+ return this.#getPayload("review_rounds", "task_id = ? AND review_round_id = ?", [taskId, reviewRoundId]);
803
+ }
804
+ listReviewRounds(taskId) {
805
+ return this.#sortById(this.#listPayload("review_rounds", "task_id = ?", [taskId]), (round) => round.id);
806
+ }
807
+ saveReviewRound(taskId, round) {
808
+ if (round.taskId !== taskId)
809
+ throw new StorageRecordError(`Review round belongs to another Task: ${round.taskId}`);
810
+ this.#requireTask(taskId);
811
+ this.#mutate(() => {
812
+ this.#db.prepare(`INSERT INTO review_rounds (task_id, review_round_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
813
+ ON CONFLICT(task_id, review_round_id) DO UPDATE SET status = excluded.status,
814
+ payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, round.id, round.status, this.#json(round), this.#now());
815
+ });
816
+ }
817
+ // -- active runs ------------------------------------------------------------
818
+ #saveActiveRun(taskId, pointer, runId) {
819
+ this.#mutate(() => {
820
+ const payload = this.#json({ schemaVersion: 3, runId });
821
+ this.#db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at) VALUES (?, ?, ?, ?, ?)
822
+ 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());
823
+ });
824
+ }
825
+ #getActiveRun(taskId, pointer) {
826
+ const row = this.#db.prepare("SELECT run_id FROM active_runs WHERE task_id = ? AND pointer = ?").get(taskId, pointer);
827
+ if (row === undefined)
828
+ return null;
829
+ return this.getAgentRun(taskId, row.run_id);
830
+ }
831
+ #clearActiveRun(taskId, pointer) {
832
+ this.#mutate(() => {
833
+ this.#db.prepare("DELETE FROM active_runs WHERE task_id = ? AND pointer = ?").run(taskId, pointer);
834
+ });
835
+ }
836
+ getActiveAgentRun(taskId, roleName) {
837
+ return this.#getActiveRun(taskId, roleName);
838
+ }
839
+ saveActiveAgentRun(run) {
840
+ this.#saveActiveRun(run.taskId, run.roleName, run.id);
841
+ }
842
+ clearActiveAgentRun(taskId, roleName) {
843
+ this.#clearActiveRun(taskId, roleName);
844
+ }
845
+ getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
846
+ return this.#getActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
847
+ }
848
+ saveActiveExecutionLaneRun(run) {
849
+ if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
850
+ throw new StorageRecordError(`Active execution-lane run requires group and lane ids: ${run.id}`);
851
+ }
852
+ this.#saveActiveRun(run.taskId, executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId), run.id);
853
+ }
854
+ clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
855
+ this.#clearActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
856
+ }
857
+ // -- messages ----------------------------------------------------------------
858
+ nextMessageId(taskId) { return this.#nextTaskRecordId(taskId, "message"); }
859
+ saveMessage(taskId, message) {
860
+ if (message.taskId !== taskId)
861
+ throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
862
+ this.#requireTask(taskId);
863
+ this.#mutate(() => {
864
+ const seq = this.#idSequence(message.id, "message");
865
+ 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);
866
+ this.#observeHighWater(taskId, "message", seq);
867
+ });
868
+ }
869
+ listMessages(taskId) {
870
+ return this.#sortById(this.#listPayload("messages", "task_id = ?", [taskId]), (message) => message.id);
871
+ }
872
+ // -- input requests ----------------------------------------------------------
873
+ nextInputRequestId(taskId) { return this.#nextTaskRecordId(taskId, "inputRequest"); }
874
+ saveInputRequest(taskId, request) {
875
+ if (request.taskId !== taskId)
876
+ throw new StorageRecordError(`Input request belongs to another Task: ${request.taskId}`);
877
+ this.#requireTask(taskId);
878
+ this.#mutate(() => {
879
+ this.#db.prepare(`INSERT INTO input_requests (task_id, input_id, status, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
880
+ 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());
881
+ });
882
+ }
883
+ getInputRequest(taskId, requestId) {
884
+ return this.#getPayload("input_requests", "task_id = ? AND input_id = ?", [taskId, requestId]);
885
+ }
886
+ listInputRequests(taskId) {
887
+ return this.#sortById(this.#listPayload("input_requests", "task_id = ?", [taskId]), (request) => request.id);
888
+ }
889
+ listAllInputRequests() {
890
+ return this.#sortById(this.#listPayload("input_requests", "1=1", []), (request) => `${request.taskId}/${request.id}`);
891
+ }
892
+ // -- decisions ----------------------------------------------------------------
893
+ nextDecisionId(taskId) { return this.#nextTaskRecordId(taskId, "decision"); }
894
+ saveDecision(taskId, decision) {
895
+ if (decision.taskId !== taskId)
896
+ throw new StorageRecordError(`Decision belongs to another Task: ${decision.taskId}`);
897
+ this.#requireTask(taskId);
898
+ this.#mutate(() => {
899
+ this.#db.prepare(`INSERT INTO decisions (task_id, decision_id, payload, created_at) VALUES (?, ?, ?, ?)
900
+ ON CONFLICT(task_id, decision_id) DO UPDATE SET payload = excluded.payload`).run(taskId, decision.id, this.#json(decision), decision.createdAt);
901
+ });
902
+ }
903
+ listDecisions(taskId) {
904
+ return this.#sortById(this.#listPayload("decisions", "task_id = ?", [taskId]), (decision) => decision.id);
905
+ }
906
+ getDecision(taskId, decisionId) {
907
+ return this.#getPayload("decisions", "task_id = ? AND decision_id = ?", [taskId, decisionId]);
908
+ }
909
+ // -- milestones ----------------------------------------------------------------
910
+ nextMilestoneId(taskId) { return this.#nextTaskRecordId(taskId, "milestone"); }
911
+ saveMilestone(taskId, milestone) {
912
+ if (milestone.taskId !== taskId)
913
+ throw new StorageRecordError(`Milestone belongs to another Task: ${milestone.taskId}`);
914
+ this.#requireTask(taskId);
915
+ this.#mutate(() => {
916
+ this.#db.prepare(`INSERT INTO milestones (task_id, milestone_id, payload, created_at) VALUES (?, ?, ?, ?)
917
+ ON CONFLICT(task_id, milestone_id) DO UPDATE SET payload = excluded.payload`).run(taskId, milestone.id, this.#json(milestone), milestone.createdAt);
918
+ });
919
+ }
920
+ listMilestones(taskId) {
921
+ return this.#sortById(this.#listPayload("milestones", "task_id = ?", [taskId]), (milestone) => milestone.id);
922
+ }
923
+ getMilestone(taskId, milestoneId) {
924
+ return this.#getPayload("milestones", "task_id = ? AND milestone_id = ?", [taskId, milestoneId]);
925
+ }
926
+ // -- events -------------------------------------------------------------------
927
+ nextEventId(taskId) { return this.#nextTaskRecordId(taskId, "event"); }
928
+ saveEvent(taskId, event) {
929
+ if (event.taskId !== taskId)
930
+ throw new StorageRecordError(`Task event belongs to another Task: ${event.taskId}`);
931
+ this.#requireTask(taskId);
932
+ this.#mutate(() => {
933
+ const seq = this.#idSequence(event.id, "event");
934
+ // Events are terminal/semantic: retained individually, never pruned (§9).
935
+ 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));
936
+ this.#observeHighWater(taskId, "event", seq);
937
+ });
938
+ }
939
+ listEvents(taskId) {
940
+ return this.#sortById(this.#listPayload("events", "task_id = ?", [taskId]), (event) => event.id);
941
+ }
942
+ // -- high-water maintenance ---------------------------------------------------
943
+ /** Extract the numeric suffix of a `<prefix>-<n>` record id. */
944
+ #idSequence(id, kind) {
945
+ const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`).exec(id);
946
+ if (match === null)
947
+ throw new StorageRecordError(`Task-local ${kind} id is invalid: ${id}.`);
948
+ return Number.parseInt(match[1], 10);
949
+ }
950
+ /** Advance the per-task high-water mark to at least `seq` (mirrors observeTaskRecordId). */
951
+ #observeHighWater(taskId, kind, seq) {
952
+ this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
953
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, seq, seq);
954
+ }
955
+ // -- work mailboxes ------------------------------------------------------------
956
+ #mailboxCols(target) {
957
+ return {
958
+ targetKind: target.kind,
959
+ taskId: "taskId" in target ? target.taskId : null,
960
+ roleName: "roleName" in target ? target.roleName : null,
961
+ targetKey: mailboxTargetKey(target)
962
+ };
963
+ }
964
+ #rowToMailbox(row) {
965
+ const target = this.#targetFromCols(row.target_kind, row.task_id, row.role_name);
966
+ return {
967
+ schemaVersion: 1,
968
+ target,
969
+ nextSequence: row.next_sequence,
970
+ processing: row.processing === null ? null : this.#parse(row.processing),
971
+ pending: row.pending === null ? null : this.#parse(row.pending)
972
+ };
973
+ }
974
+ #targetFromCols(kind, taskId, roleName) {
975
+ switch (kind) {
976
+ case "operator": return { kind: "operator" };
977
+ case "task": return { kind: "task", taskId: taskId };
978
+ case "role": return { kind: "role", taskId: taskId, roleName: roleName };
979
+ case "role-runtime": return { kind: "role-runtime", taskId: taskId, roleName: roleName };
980
+ case "global-role-runtime": return { kind: "global-role-runtime", roleName: roleName };
981
+ default: throw new StorageRecordError(`Unknown mailbox target kind: ${kind}`);
982
+ }
983
+ }
984
+ getWorkMailbox(target) {
985
+ const cols = this.#mailboxCols(target);
986
+ const row = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
987
+ return row === undefined ? null : this.#rowToMailbox(row);
988
+ }
989
+ listWorkMailboxes() {
990
+ const rows = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
991
+ return rows.map((row) => this.#rowToMailbox(row));
992
+ }
993
+ saveWorkMailbox(mailbox) {
994
+ const cols = this.#mailboxCols(mailbox.target);
995
+ this.#mutate(() => {
996
+ this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
997
+ VALUES (?, ?, ?, ?, ?, ?, ?)
998
+ ON CONFLICT(target_key) DO UPDATE SET next_sequence = excluded.next_sequence,
999
+ 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));
1000
+ });
1001
+ }
1002
+ removeWorkMailbox(target) {
1003
+ const cols = this.#mailboxCols(target);
1004
+ return this.#mutate(() => {
1005
+ const result = this.#db.prepare("DELETE FROM mailboxes WHERE target_key = ?").run(cols.targetKey);
1006
+ return result.changes > 0;
1007
+ });
1008
+ }
1009
+ /**
1010
+ * Append a mailbox signal (§4.2). One transaction: insert the signal at the
1011
+ * mailbox's next sequence and advance `next_sequence` on the same row. The
1012
+ * single writer connection serializes enqueues, so sequences stay gapless per
1013
+ * mailbox. `(mailbox_id, sequence)` is the exactly-once key.
1014
+ */
1015
+ enqueueMailboxSignal(target, input) {
1016
+ return this.#mutate(() => {
1017
+ const cols = this.#mailboxCols(target);
1018
+ let mailboxId;
1019
+ let sequence;
1020
+ const existing = this.#db.prepare("SELECT mailbox_id, next_sequence FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
1021
+ if (existing === undefined) {
1022
+ const result = this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
1023
+ VALUES (?, ?, ?, ?, 1, NULL, NULL)`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey);
1024
+ mailboxId = Number(result.lastInsertRowid);
1025
+ sequence = 1;
1026
+ }
1027
+ else {
1028
+ mailboxId = existing.mailbox_id;
1029
+ sequence = existing.next_sequence;
1030
+ }
1031
+ this.#db.prepare(`INSERT INTO mailbox_signals (mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
1032
+ 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);
1033
+ this.#db.prepare("UPDATE mailboxes SET next_sequence = ? WHERE mailbox_id = ?").run(sequence + 1, mailboxId);
1034
+ return sequence;
1035
+ });
1036
+ }
1037
+ // -- scheduler projections -----------------------------------------------------
1038
+ #getProjection(taskId, kind) {
1039
+ const row = this.#db.prepare("SELECT payload FROM task_projections WHERE task_id = ? AND kind = ?").get(taskId, kind);
1040
+ if (row === undefined || row.payload === null)
1041
+ return null;
1042
+ return this.#parse(row.payload);
1043
+ }
1044
+ #saveProjection(taskId, kind, value) {
1045
+ this.#requireTask(taskId);
1046
+ this.#mutate(() => {
1047
+ this.#db.prepare(`INSERT INTO task_projections (task_id, kind, payload, updated_at) VALUES (?, ?, ?, ?)
1048
+ ON CONFLICT(task_id, kind) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, kind, this.#json(value), this.#now());
1049
+ });
1050
+ }
1051
+ #clearProjection(taskId, kind) {
1052
+ this.#mutate(() => {
1053
+ this.#db.prepare("UPDATE task_projections SET payload = NULL, updated_at = ? WHERE task_id = ? AND kind = ?")
1054
+ .run(this.#now(), taskId, kind);
1055
+ });
1056
+ }
1057
+ getLeaderFailure(taskId) {
1058
+ return this.#getProjection(taskId, "leader-failure");
1059
+ }
1060
+ saveLeaderFailure(failure) {
1061
+ this.#saveProjection(failure.taskId, "leader-failure", failure);
1062
+ }
1063
+ clearLeaderFailure(taskId) {
1064
+ this.#clearProjection(taskId, "leader-failure");
1065
+ }
1066
+ getOperatorNotification(taskId) {
1067
+ return this.#getProjection(taskId, "operator-notification");
1068
+ }
1069
+ saveOperatorNotification(notification) {
1070
+ this.#saveProjection(notification.taskId, "operator-notification", notification);
1071
+ }
1072
+ clearOperatorNotification(taskId) {
1073
+ this.#clearProjection(taskId, "operator-notification");
1074
+ }
1075
+ // -- pending wakeups (leader-role work-mailbox projection, mirrors taskStore.ts) --
1076
+ getPendingWakeup(taskId) {
1077
+ return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
1078
+ }
1079
+ listPendingWakeups() {
1080
+ return this.listWorkMailboxes()
1081
+ .flatMap((mailbox) => {
1082
+ const wakeup = pendingWakeupProjection(mailbox);
1083
+ return wakeup === null ? [] : [wakeup];
1084
+ })
1085
+ .sort((a, b) => numericCompare(a.taskId, b.taskId));
1086
+ }
1087
+ savePendingWakeup(value) {
1088
+ const target = { kind: "role", taskId: value.taskId, roleName: "leader" };
1089
+ this.transaction((store) => {
1090
+ const existing = store.getWorkMailbox(target);
1091
+ if (existing !== null && existing.pending !== null
1092
+ && value.requestCount <= existing.pending.requestCount) {
1093
+ throw new StorageRecordError(`Pending wakeup is stale: ${value.taskId}`);
1094
+ }
1095
+ const fromSequence = existing?.pending?.fromSequence ?? existing?.nextSequence ?? 1;
1096
+ const toSequence = fromSequence + value.requestCount - 1;
1097
+ store.saveWorkMailbox({
1098
+ schemaVersion: CURRENT_WORK_MAILBOX_SCHEMA_VERSION,
1099
+ target,
1100
+ nextSequence: Math.max(existing?.nextSequence ?? 1, toSequence + 1),
1101
+ processing: existing?.processing ?? null,
1102
+ pending: {
1103
+ ...existing?.pending,
1104
+ fromSequence,
1105
+ toSequence,
1106
+ reasons: [...value.reasons],
1107
+ refs: existing?.pending?.refs ?? [],
1108
+ requestCount: value.requestCount,
1109
+ firstQueuedAt: value.firstRequestedAt,
1110
+ lastQueuedAt: value.lastRequestedAt
1111
+ }
1112
+ });
1113
+ });
1114
+ }
1115
+ clearPendingWakeup(taskId) {
1116
+ this.removeWorkMailbox({ kind: "role", taskId, roleName: "leader" });
1117
+ }
1118
+ // -- telemetry (§4.4) -----------------------------------------------------------
1119
+ /**
1120
+ * Upsert one progress row. The PK is (task_id, role_name, run_id, generation,
1121
+ * progress_id): a repeated progress id updates in place, so a high-frequency
1122
+ * `runtime.provider-turn-progress` event is a single-row write that never
1123
+ * rewrites global state or another Task's rows.
1124
+ */
1125
+ upsertTelemetryProgress(entry) {
1126
+ this.#mutate(() => {
1127
+ this.#db.prepare(`INSERT INTO telemetry (task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at)
1128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1129
+ ON CONFLICT(task_id, role_name, run_id, generation, progress_id)
1130
+ 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);
1131
+ });
1132
+ }
1133
+ listTelemetry(taskId, runId) {
1134
+ const rows = runId === undefined
1135
+ ? 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)
1136
+ : 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);
1137
+ return rows
1138
+ .map((row) => ({
1139
+ taskId: row.task_id,
1140
+ roleName: row.role_name,
1141
+ runId: row.run_id,
1142
+ generation: row.generation,
1143
+ progressId: row.progress_id,
1144
+ sequence: row.sequence ?? undefined,
1145
+ payload: this.#parse(row.payload),
1146
+ receivedAt: row.received_at
1147
+ }));
1148
+ }
1149
+ countTelemetry(taskId, runId) {
1150
+ const row = runId === undefined
1151
+ ? this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ?").get(taskId)
1152
+ : this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ? AND run_id = ?").get(taskId, runId);
1153
+ return row.n;
1154
+ }
1155
+ /**
1156
+ * Bounded retention (§4.4): keep the newest `keep` rows per
1157
+ * (task, role, run, generation) and delete older ones. The DELETE is scoped
1158
+ * by task_id; it never rewrites global rows or other Tasks. Returns the number
1159
+ * of rows deleted. Terminal/semantic events go to `events` and are never pruned.
1160
+ */
1161
+ pruneTelemetry(taskId, roleName, runId, generation, keep = TELEMETRY_KEEP_PER_GENERATION) {
1162
+ return this.#mutate(() => {
1163
+ const result = this.#db.prepare(`DELETE FROM telemetry
1164
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
1165
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
1166
+ SELECT task_id, role_name, run_id, generation, progress_id
1167
+ FROM telemetry
1168
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
1169
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
1170
+ LIMIT ?
1171
+ )`).run(taskId, roleName, runId, generation, taskId, roleName, runId, generation, keep);
1172
+ return result.changes;
1173
+ });
1174
+ }
1175
+ /**
1176
+ * Hard cap for an active run (§4.4): trim oldest rows across the run beyond
1177
+ * `cap` (default 50k). Returns the number of rows deleted.
1178
+ */
1179
+ capTelemetryRun(taskId, runId, cap = TELEMETRY_RUN_CAP) {
1180
+ return this.#mutate(() => {
1181
+ const result = this.#db.prepare(`DELETE FROM telemetry
1182
+ WHERE task_id = ? AND run_id = ?
1183
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
1184
+ SELECT task_id, role_name, run_id, generation, progress_id
1185
+ FROM telemetry
1186
+ WHERE task_id = ? AND run_id = ?
1187
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
1188
+ LIMIT ?
1189
+ )`).run(taskId, runId, taskId, runId, cap);
1190
+ return result.changes;
1191
+ });
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Open a {@link TaskStore} for the given backend. `file` returns the existing
1196
+ * {@link FileTaskStore}; `sqlite` returns the in-process {@link SqliteTaskStore}.
1197
+ * Backend selection is explicit (design §6); the environment switch lives in
1198
+ * {@link resolveTaskStoreBackend}. The file store is not removed — rollback is
1199
+ * a config flip.
1200
+ */
1201
+ export function openTaskStore(home, backend, options) {
1202
+ if (backend === "sqlite") {
1203
+ return new SqliteTaskStore(home, options);
1204
+ }
1205
+ return new FileTaskStore(home);
1206
+ }
1207
+ /**
1208
+ * Resolve the storage backend from `YUI_STORE_BACKEND` (default `file`,
1209
+ * design §6). Only the exact value `sqlite` selects the SQLite store; any
1210
+ * other value (including unset) keeps the file store.
1211
+ */
1212
+ export function resolveTaskStoreBackend(env = process.env) {
1213
+ return env.YUI_STORE_BACKEND?.toLowerCase() === "sqlite" ? "sqlite" : "file";
1214
+ }
1215
+ /**
1216
+ * Convenience: open the store for the backend resolved from the environment
1217
+ * (`YUI_STORE_BACKEND`, default `file`). CLI/controller entry points that want
1218
+ * the env-driven switch call this instead of {@link openTaskStore} directly.
1219
+ */
1220
+ export function openConfiguredTaskStore(home, options, env = process.env) {
1221
+ return openTaskStore(home, resolveTaskStoreBackend(env), options);
1222
+ }