@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.
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +2 -2
- package/dist/commands/taskWorkspaceCommands.js +11 -4
- package/dist/controller/controller.js +11 -5
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +82 -16
- package/dist/controller/runtimeEventProcessor.js +268 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/repository/taskWorkspacePreparer.js +10 -7
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/migration/productionRegistry.js +33 -0
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +444 -0
- package/dist/storage/sqliteStore.js +1222 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +277 -0
- package/dist/storage/taskStore.js +9 -0
- package/dist/storage/upgrade/homeClassification.js +38 -11
- package/dist/storage/upgrade/sqliteMigrationTarget.js +299 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +457 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +41 -15
- package/package.json +2 -1
|
@@ -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
|
+
}
|