@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,194 @@
1
+ /**
2
+ * Persistence Worker Thread (task-21, work-item-5).
3
+ *
4
+ * The worker owns the {@link SqliteTaskStore} connection: one writer connection
5
+ * (single-writer, `BEGIN IMMEDIATE`) plus a small read pool (separate WAL
6
+ * connections that never take the write lock, §3.2). The main thread never
7
+ * touches the db; it sends commands over a `MessageChannel` port and receives
8
+ * results.
9
+ *
10
+ * The port handshake, dispatch, cancellation observation, and error
11
+ * serialization are provided by the shared `core/boundedRpc` worker host
12
+ * (`runRpcWorker`); this module supplies the storage dialect:
13
+ *
14
+ * - `call` ......... a `TaskStore` method. Reads use the read pool; writes
15
+ * run on the writer inside a transaction and are made
16
+ * idempotent via the durable outbox (§5.4): a retried
17
+ * write after a crash returns `already-applied`.
18
+ * - `transaction` .. an ordered command batch inside one
19
+ * `BEGIN IMMEDIATE … COMMIT` on the writer (§3.2),
20
+ * yielding between commands so a `cancel` can roll back an
21
+ * open transaction (§3.1). Committed batches are not undone.
22
+ * - `observer` ..... a controller observer method hosted by the worker (the
23
+ * `FileSchedulerStoreAdapter` folds run here, off the main
24
+ * event loop).
25
+ * - `expectedRevision` enforces the global revision CAS in the same txn
26
+ * (§5.3); a mismatch fails with `StorageConflictError`.
27
+ *
28
+ * `synchronous=FULL` is never weakened; the worker opens the store with the same
29
+ * pragmas as the in-process path (WAL, FULL, busy_timeout).
30
+ */
31
+ import { runRpcWorker } from "../core/boundedRpc.js";
32
+ import { SqliteTaskStore } from "./sqliteStore.js";
33
+ import { StorageRecordError } from "./taskStore.js";
34
+ // -- method invocation -------------------------------------------------------
35
+ function invokeMethod(target, method, args) {
36
+ const fn = target[method];
37
+ if (typeof fn !== "function") {
38
+ throw new StorageRecordError(`Unknown store method: ${method}`);
39
+ }
40
+ return fn.apply(target, args);
41
+ }
42
+ /**
43
+ * Sentinel returned by a handler when the durable outbox already records the
44
+ * request's effect (§5.4). The host's `result` builder maps it to an
45
+ * `already-applied` response. Store results are plain JSON data, so a Symbol
46
+ * sentinel can never collide with a real result.
47
+ */
48
+ const ALREADY_APPLIED = Symbol("already-applied");
49
+ const state = {
50
+ writer: undefined,
51
+ readers: [],
52
+ readIndex: 0,
53
+ observer: undefined,
54
+ cancelled: new Set()
55
+ };
56
+ function nextReader() {
57
+ const reader = state.readers[state.readIndex % state.readers.length];
58
+ state.readIndex += 1;
59
+ return reader;
60
+ }
61
+ // -- request handlers --------------------------------------------------------
62
+ async function handleInit(request) {
63
+ const writer = new SqliteTaskStore(request.home);
64
+ const poolSize = request.readPoolSize ?? 4;
65
+ const readers = [];
66
+ for (let index = 0; index < poolSize; index += 1) {
67
+ readers.push(new SqliteTaskStore(request.home));
68
+ }
69
+ state.writer = writer;
70
+ state.readers = readers;
71
+ if (request.observerModule !== undefined) {
72
+ const module = (await import(request.observerModule));
73
+ const Adapter = module.FileSchedulerStoreAdapter;
74
+ if (Adapter === undefined) {
75
+ throw new Error(`Observer module ${request.observerModule} does not export FileSchedulerStoreAdapter.`);
76
+ }
77
+ const host = new Adapter(writer);
78
+ state.observer = {
79
+ invoke(method, args) {
80
+ return invokeMethod(host, method, args);
81
+ }
82
+ };
83
+ }
84
+ }
85
+ async function handleCall(request) {
86
+ const { requestId, method, args, readOnly } = request;
87
+ if (readOnly) {
88
+ return invokeMethod(nextReader(), method, args);
89
+ }
90
+ // Write. With a requestId, make it idempotent via the durable outbox
91
+ // (§5.4): skip if already committed, otherwise record the effect in the
92
+ // same transaction. The worker is single-threaded, so the check and the
93
+ // write are race-free; the UNIQUE constraint is the backstop.
94
+ const writer = state.writer;
95
+ if (writer === undefined)
96
+ throw new Error("Worker not initialized.");
97
+ if (writer.hasOutboxEntry(requestId)) {
98
+ return ALREADY_APPLIED;
99
+ }
100
+ return writer.transaction((store) => invokeMethod(store, method, args), { requestId });
101
+ }
102
+ async function handleTransaction(request) {
103
+ const { requestId, commands, expectedRevision } = request;
104
+ const writer = state.writer;
105
+ if (writer === undefined) {
106
+ throw new Error("Worker not initialized.");
107
+ }
108
+ // Idempotent replay: a batch that committed before a crash is not re-run.
109
+ if (writer.hasOutboxEntry(requestId)) {
110
+ return ALREADY_APPLIED;
111
+ }
112
+ const shouldCancel = () => state.cancelled.has(requestId);
113
+ try {
114
+ return await writer.transactionAsyncBatch(commands, {
115
+ requestId,
116
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
117
+ shouldCancel
118
+ });
119
+ }
120
+ finally {
121
+ state.cancelled.delete(requestId);
122
+ }
123
+ }
124
+ async function handleObserver(request) {
125
+ if (state.observer === undefined) {
126
+ throw new Error("Worker has no observer host (observerModule not provided at init).");
127
+ }
128
+ return state.observer.invoke(request.method, request.args);
129
+ }
130
+ // -- worker host --------------------------------------------------------------
131
+ runRpcWorker({
132
+ serial: true,
133
+ kindOf: (request) => {
134
+ switch (request.kind) {
135
+ case "init":
136
+ return "init";
137
+ case "cancel":
138
+ return "cancel";
139
+ case "shutdown":
140
+ return "shutdown";
141
+ case "call":
142
+ case "transaction":
143
+ case "observer":
144
+ return "request";
145
+ }
146
+ },
147
+ requestIdOf: (request) => {
148
+ switch (request.kind) {
149
+ case "call":
150
+ case "transaction":
151
+ case "observer":
152
+ case "cancel":
153
+ return request.requestId;
154
+ case "init":
155
+ case "shutdown":
156
+ return undefined;
157
+ }
158
+ },
159
+ init: (request) => handleInit(request),
160
+ handle: (request) => {
161
+ switch (request.kind) {
162
+ case "call":
163
+ return handleCall(request);
164
+ case "transaction":
165
+ return handleTransaction(request);
166
+ case "observer":
167
+ return handleObserver(request);
168
+ case "init":
169
+ case "cancel":
170
+ case "shutdown":
171
+ throw new Error(`Unexpected request kind for handle: ${request.kind}`);
172
+ }
173
+ },
174
+ cancel: (requestId) => {
175
+ state.cancelled.add(requestId);
176
+ },
177
+ shutdown: async () => {
178
+ for (const reader of state.readers) {
179
+ try {
180
+ reader.close();
181
+ }
182
+ catch { /* already closed */ }
183
+ }
184
+ try {
185
+ state.writer?.close();
186
+ }
187
+ catch { /* already closed */ }
188
+ },
189
+ ready: () => ({ kind: "ready" }),
190
+ result: (requestId, value) => (value === ALREADY_APPLIED
191
+ ? { kind: "already-applied", requestId }
192
+ : { kind: "result", requestId, result: value }),
193
+ error: (requestId, error) => ({ kind: "error", requestId, error })
194
+ });
@@ -0,0 +1,444 @@
1
+ /**
2
+ * SQLite WAL control-plane schema and migration runner (task-21, work-item-3).
3
+ *
4
+ * This is the normalized, `task_id`-partitioned schema that replaces the single
5
+ * aggregate `state.json` document. It implements the logical schema from
6
+ * `docs/sqlite-control-plane-design.md` §4 (31 tables) plus the cross-task
7
+ * coordination tables the design references in §5:
8
+ *
9
+ * - `global_sequences` (§5.3): global record ID high-water marks.
10
+ * - `outbox` (§5.4): durable outbox with `UNIQUE(request_id)` for exactly-once.
11
+ * - `config`: the `YuiConfig` singleton (the design keeps `home_meta` for
12
+ * identity/revision/versions; config is a separate singleton).
13
+ *
14
+ * Record payloads are stored two ways, per §4: typed columns for fields that are
15
+ * queried/filtered/used-for-CAS, and a `payload` JSON column holding the full
16
+ * versioned record (including its family `schemaVersion`). The record axis of
17
+ * the three-axis versioning is therefore unchanged.
18
+ *
19
+ * The migration runner is idempotent: it records applied versions in
20
+ * `schema_migrations` and uses `CREATE TABLE IF NOT EXISTS`, so re-running on an
21
+ * already-current database is a no-op and a crash mid-migration is rolled back
22
+ * by SQLite (DDL is transactional) and re-applied on the next open.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ /** The SQLite layout version (state.json layout 6 -> SQLite layout 7, per §8.1). */
26
+ export const SQLITE_LAYOUT_VERSION = 7;
27
+ /** The aggregate version of the normalized SQLite schema. */
28
+ export const SQLITE_AGGREGATE_VERSION = 1;
29
+ /** The current schema migration version. */
30
+ export const SQLITE_SCHEMA_VERSION = 1;
31
+ /** Telemetry retention bounds (§4.4). Open question 3 in §11; defaults from the design. */
32
+ export const TELEMETRY_KEEP_PER_GENERATION = 200;
33
+ export const TELEMETRY_RUN_CAP = 50_000;
34
+ /**
35
+ * Version 1 migration: creates every table and index.
36
+ *
37
+ * `synchronous`/`foreign_keys`/`busy_timeout` are per-connection PRAGMAs set by
38
+ * the store; `journal_mode=WAL` is a persistent database property set on open.
39
+ * The migration itself only contains schema objects.
40
+ */
41
+ const MIGRATION_1_SQL = `
42
+ -- Global catalog and coordination (§4.1) -------------------------------------
43
+
44
+ CREATE TABLE IF NOT EXISTS home_meta (
45
+ id INTEGER PRIMARY KEY CHECK (id = 1),
46
+ home_identity TEXT NOT NULL,
47
+ revision INTEGER NOT NULL,
48
+ layout_version INTEGER NOT NULL,
49
+ aggregate_version INTEGER NOT NULL,
50
+ created_at TEXT NOT NULL,
51
+ updated_at TEXT NOT NULL
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS config (
55
+ id INTEGER PRIMARY KEY CHECK (id = 1),
56
+ payload TEXT NOT NULL,
57
+ updated_at TEXT NOT NULL
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS configured_agents (
61
+ id TEXT PRIMARY KEY,
62
+ payload TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS agent_profiles (
67
+ id TEXT PRIMARY KEY,
68
+ payload TEXT NOT NULL,
69
+ updated_at TEXT NOT NULL
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS projects (
73
+ id TEXT PRIMARY KEY,
74
+ name TEXT NOT NULL,
75
+ path TEXT NOT NULL,
76
+ payload TEXT NOT NULL,
77
+ created_at TEXT NOT NULL,
78
+ updated_at TEXT NOT NULL
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS global_roles (
82
+ name TEXT PRIMARY KEY,
83
+ payload TEXT NOT NULL,
84
+ updated_at TEXT NOT NULL
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS global_role_session_sets (
88
+ name TEXT PRIMARY KEY,
89
+ payload TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL,
91
+ FOREIGN KEY (name) REFERENCES global_roles(name)
92
+ );
93
+
94
+ -- Global record ID high-water marks (§5.3).
95
+ CREATE TABLE IF NOT EXISTS global_sequences (
96
+ name TEXT PRIMARY KEY,
97
+ high_water INTEGER NOT NULL
98
+ );
99
+
100
+ -- Task catalog: the global active index and lifecycle lookup.
101
+ CREATE TABLE IF NOT EXISTS tasks_catalog (
102
+ task_id TEXT PRIMARY KEY,
103
+ status TEXT NOT NULL,
104
+ lifecycle TEXT NOT NULL,
105
+ is_active INTEGER NOT NULL CHECK (is_active IN (0,1)),
106
+ created_at TEXT NOT NULL,
107
+ updated_at TEXT NOT NULL
108
+ );
109
+ CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks_catalog(is_active) WHERE is_active = 1;
110
+
111
+ -- Session/Workspace ownership is global (workspaces outlive task activity).
112
+ CREATE TABLE IF NOT EXISTS managed_workspaces (
113
+ owner_kind TEXT NOT NULL CHECK (owner_kind IN
114
+ ('task','work-item','review-round','integration-attempt','execution-lane')),
115
+ owner_id TEXT NOT NULL,
116
+ task_id TEXT,
117
+ path TEXT NOT NULL,
118
+ payload TEXT NOT NULL,
119
+ status TEXT NOT NULL,
120
+ created_at TEXT NOT NULL,
121
+ updated_at TEXT NOT NULL,
122
+ PRIMARY KEY (owner_kind, owner_id)
123
+ );
124
+ CREATE INDEX IF NOT EXISTS idx_workspaces_task ON managed_workspaces(task_id);
125
+
126
+ -- Per-task record ID high-water marks (replaces StoredTask.idHighWaterMarks).
127
+ CREATE TABLE IF NOT EXISTS id_sequences (
128
+ task_id TEXT NOT NULL,
129
+ kind TEXT NOT NULL,
130
+ high_water INTEGER NOT NULL,
131
+ PRIMARY KEY (task_id, kind)
132
+ );
133
+
134
+ -- Cross-task coordination: Project locks and the Integration queue.
135
+ CREATE TABLE IF NOT EXISTS coordination_locks (
136
+ lock_key TEXT PRIMARY KEY,
137
+ holder_task TEXT NOT NULL,
138
+ holder_ref TEXT NOT NULL,
139
+ acquired_at TEXT NOT NULL,
140
+ expires_at TEXT
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS integration_queue (
144
+ queue_id TEXT PRIMARY KEY,
145
+ task_id TEXT NOT NULL,
146
+ project_id TEXT NOT NULL,
147
+ change_set TEXT NOT NULL,
148
+ status TEXT NOT NULL,
149
+ payload TEXT NOT NULL,
150
+ created_at TEXT NOT NULL,
151
+ updated_at TEXT NOT NULL
152
+ );
153
+ CREATE INDEX IF NOT EXISTS idx_integration_queue_status ON integration_queue(status, created_at);
154
+
155
+ -- Durable outbox (§5.4): UNIQUE(request_id) makes cross-task effects exactly-once.
156
+ CREATE TABLE IF NOT EXISTS outbox (
157
+ outbox_id INTEGER PRIMARY KEY,
158
+ request_id TEXT NOT NULL UNIQUE,
159
+ command TEXT NOT NULL,
160
+ state TEXT NOT NULL DEFAULT 'pending',
161
+ created_at TEXT NOT NULL,
162
+ applied_at TEXT
163
+ );
164
+ CREATE INDEX IF NOT EXISTS idx_outbox_state ON outbox(state, created_at);
165
+
166
+ -- Mailboxes (§4.2): per-target ordering and exactly-once signals. -------------
167
+
168
+ CREATE TABLE IF NOT EXISTS mailboxes (
169
+ mailbox_id INTEGER PRIMARY KEY,
170
+ target_kind TEXT NOT NULL CHECK (target_kind IN
171
+ ('task','role','role-runtime','global-role-runtime','operator')),
172
+ task_id TEXT,
173
+ role_name TEXT,
174
+ -- The stable mailboxTargetKey string carries the real uniqueness: the column
175
+ -- UNIQUE below cannot, because NULL task_id/role_name (the 'operator' and
176
+ -- 'global-role-runtime' targets) are distinct under SQL NULL semantics.
177
+ target_key TEXT NOT NULL,
178
+ next_sequence INTEGER NOT NULL,
179
+ processing TEXT,
180
+ pending TEXT,
181
+ UNIQUE (target_kind, task_id, role_name)
182
+ );
183
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_mailboxes_target_key
184
+ ON mailboxes(target_key);
185
+
186
+ -- Signals are the durable append log; (mailbox, sequence) is the exactly-once key.
187
+ CREATE TABLE IF NOT EXISTS mailbox_signals (
188
+ mailbox_id INTEGER NOT NULL,
189
+ sequence INTEGER NOT NULL,
190
+ reason TEXT NOT NULL,
191
+ ref_type TEXT,
192
+ ref_task_id TEXT,
193
+ ref_id TEXT,
194
+ occurred_at TEXT NOT NULL,
195
+ request_id TEXT NOT NULL,
196
+ PRIMARY KEY (mailbox_id, sequence),
197
+ FOREIGN KEY (mailbox_id) REFERENCES mailboxes(mailbox_id)
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_mailbox_signals_request ON mailbox_signals(request_id);
200
+
201
+ -- Task-partitioned tables (§4.3). Every task-local read constrains task_id. ----
202
+
203
+ CREATE TABLE IF NOT EXISTS task_records (
204
+ task_id TEXT PRIMARY KEY,
205
+ payload TEXT NOT NULL,
206
+ brief TEXT,
207
+ updated_at TEXT NOT NULL,
208
+ FOREIGN KEY (task_id) REFERENCES tasks_catalog(task_id)
209
+ );
210
+
211
+ CREATE TABLE IF NOT EXISTS task_roles (
212
+ task_id TEXT NOT NULL,
213
+ role_name TEXT NOT NULL,
214
+ payload TEXT NOT NULL,
215
+ updated_at TEXT NOT NULL,
216
+ PRIMARY KEY (task_id, role_name)
217
+ );
218
+
219
+ CREATE TABLE IF NOT EXISTS role_session_sets (
220
+ task_id TEXT NOT NULL,
221
+ role_name TEXT NOT NULL,
222
+ payload TEXT NOT NULL,
223
+ updated_at TEXT NOT NULL,
224
+ PRIMARY KEY (task_id, role_name)
225
+ );
226
+
227
+ CREATE TABLE IF NOT EXISTS work_items (
228
+ task_id TEXT NOT NULL,
229
+ work_item_id TEXT NOT NULL,
230
+ status TEXT NOT NULL,
231
+ payload TEXT NOT NULL,
232
+ updated_at TEXT NOT NULL,
233
+ PRIMARY KEY (task_id, work_item_id)
234
+ );
235
+ CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(task_id, status);
236
+
237
+ CREATE TABLE IF NOT EXISTS work_item_candidates (
238
+ task_id TEXT NOT NULL,
239
+ candidate_id TEXT NOT NULL,
240
+ work_item_id TEXT NOT NULL,
241
+ payload TEXT NOT NULL,
242
+ created_at TEXT NOT NULL,
243
+ PRIMARY KEY (task_id, candidate_id)
244
+ );
245
+
246
+ CREATE TABLE IF NOT EXISTS agent_runs (
247
+ task_id TEXT NOT NULL,
248
+ run_id TEXT NOT NULL,
249
+ role_name TEXT NOT NULL,
250
+ status TEXT NOT NULL,
251
+ payload TEXT NOT NULL,
252
+ updated_at TEXT NOT NULL,
253
+ PRIMARY KEY (task_id, run_id)
254
+ );
255
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_role_status ON agent_runs(task_id, role_name, status);
256
+
257
+ -- Active-run pointers (getActiveAgentRun / execution-lane runs).
258
+ CREATE TABLE IF NOT EXISTS active_runs (
259
+ task_id TEXT NOT NULL,
260
+ pointer TEXT NOT NULL,
261
+ run_id TEXT NOT NULL,
262
+ payload TEXT NOT NULL,
263
+ updated_at TEXT NOT NULL,
264
+ PRIMARY KEY (task_id, pointer)
265
+ );
266
+
267
+ CREATE TABLE IF NOT EXISTS review_rounds (
268
+ task_id TEXT NOT NULL,
269
+ review_round_id TEXT NOT NULL,
270
+ status TEXT NOT NULL,
271
+ payload TEXT NOT NULL,
272
+ updated_at TEXT NOT NULL,
273
+ PRIMARY KEY (task_id, review_round_id)
274
+ );
275
+
276
+ CREATE TABLE IF NOT EXISTS change_sets (
277
+ task_id TEXT NOT NULL,
278
+ change_set_id TEXT NOT NULL,
279
+ project_id TEXT NOT NULL,
280
+ head_sha TEXT NOT NULL,
281
+ payload TEXT NOT NULL,
282
+ created_at TEXT NOT NULL,
283
+ PRIMARY KEY (task_id, change_set_id)
284
+ );
285
+ CREATE INDEX IF NOT EXISTS idx_change_sets_project ON change_sets(task_id, project_id);
286
+
287
+ CREATE TABLE IF NOT EXISTS integration_attempts (
288
+ task_id TEXT NOT NULL,
289
+ integration_id TEXT NOT NULL,
290
+ status TEXT NOT NULL,
291
+ payload TEXT NOT NULL,
292
+ updated_at TEXT NOT NULL,
293
+ PRIMARY KEY (task_id, integration_id)
294
+ );
295
+
296
+ CREATE TABLE IF NOT EXISTS messages (
297
+ task_id TEXT NOT NULL,
298
+ message_id TEXT NOT NULL,
299
+ seq INTEGER NOT NULL,
300
+ payload TEXT NOT NULL,
301
+ created_at TEXT NOT NULL,
302
+ PRIMARY KEY (task_id, message_id)
303
+ );
304
+ CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(task_id, seq);
305
+
306
+ CREATE TABLE IF NOT EXISTS input_requests (
307
+ task_id TEXT NOT NULL,
308
+ input_id TEXT NOT NULL,
309
+ status TEXT NOT NULL,
310
+ blocks TEXT,
311
+ payload TEXT NOT NULL,
312
+ created_at TEXT NOT NULL,
313
+ updated_at TEXT NOT NULL,
314
+ PRIMARY KEY (task_id, input_id)
315
+ );
316
+ CREATE INDEX IF NOT EXISTS idx_input_open ON input_requests(task_id, status) WHERE status <> 'resolved';
317
+
318
+ CREATE TABLE IF NOT EXISTS decisions (
319
+ task_id TEXT NOT NULL,
320
+ decision_id TEXT NOT NULL,
321
+ payload TEXT NOT NULL,
322
+ created_at TEXT NOT NULL,
323
+ PRIMARY KEY (task_id, decision_id)
324
+ );
325
+
326
+ CREATE TABLE IF NOT EXISTS milestones (
327
+ task_id TEXT NOT NULL,
328
+ milestone_id TEXT NOT NULL,
329
+ payload TEXT NOT NULL,
330
+ created_at TEXT NOT NULL,
331
+ PRIMARY KEY (task_id, milestone_id)
332
+ );
333
+
334
+ -- Terminal/semantic events: retained individually, never pruned.
335
+ CREATE TABLE IF NOT EXISTS events (
336
+ task_id TEXT NOT NULL,
337
+ event_id TEXT NOT NULL,
338
+ type TEXT NOT NULL,
339
+ occurred_at TEXT NOT NULL,
340
+ payload TEXT NOT NULL,
341
+ PRIMARY KEY (task_id, event_id)
342
+ );
343
+ CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(task_id, type, occurred_at);
344
+
345
+ -- Per-task scheduler projections (leaderFailure, operatorNotification).
346
+ CREATE TABLE IF NOT EXISTS task_projections (
347
+ task_id TEXT NOT NULL,
348
+ kind TEXT NOT NULL CHECK (kind IN ('leader-failure','operator-notification')),
349
+ payload TEXT,
350
+ updated_at TEXT NOT NULL,
351
+ PRIMARY KEY (task_id, kind)
352
+ );
353
+
354
+ -- Telemetry (§4.4): bounded, latest-per-key. WITHOUT ROWID, PK is the key.
355
+ CREATE TABLE IF NOT EXISTS telemetry (
356
+ task_id TEXT NOT NULL,
357
+ role_name TEXT NOT NULL,
358
+ run_id TEXT NOT NULL,
359
+ generation TEXT NOT NULL,
360
+ progress_id TEXT NOT NULL,
361
+ sequence INTEGER,
362
+ payload TEXT NOT NULL,
363
+ received_at TEXT NOT NULL,
364
+ PRIMARY KEY (task_id, role_name, run_id, generation, progress_id)
365
+ ) WITHOUT ROWID;
366
+ CREATE INDEX IF NOT EXISTS idx_telemetry_run ON telemetry(task_id, run_id);
367
+ `;
368
+ const MIGRATIONS = [
369
+ { version: 1, axis: "layout", sql: MIGRATION_1_SQL }
370
+ ];
371
+ function checksum(sql) {
372
+ return createHash("sha256").update(sql).digest("hex");
373
+ }
374
+ /**
375
+ * Apply pending migrations idempotently inside transactions.
376
+ *
377
+ * Each migration runs in its own transaction: the DDL and the
378
+ * `schema_migrations` bookkeeping commit atomically, so a crash mid-migration
379
+ * rolls back and the next open re-applies cleanly. Re-running on a current
380
+ * database performs no work (every version is already recorded).
381
+ */
382
+ export function migrateSqliteSchema(db) {
383
+ db.exec(`
384
+ CREATE TABLE IF NOT EXISTS schema_migrations (
385
+ version INTEGER PRIMARY KEY,
386
+ axis TEXT NOT NULL CHECK (axis IN ('layout','aggregate','record')),
387
+ record_kind TEXT,
388
+ applied_at TEXT NOT NULL,
389
+ checksum TEXT NOT NULL
390
+ )
391
+ `);
392
+ const applied = new Set(db.prepare("SELECT version FROM schema_migrations").all().map((row) => row.version));
393
+ const newlyApplied = [];
394
+ for (const migration of MIGRATIONS) {
395
+ if (applied.has(migration.version))
396
+ continue;
397
+ const apply = db.transaction(() => {
398
+ db.exec(migration.sql);
399
+ db.prepare(`INSERT INTO schema_migrations (version, axis, record_kind, applied_at, checksum)
400
+ VALUES (?, ?, ?, ?, ?)`).run(migration.version, migration.axis, migration.recordKind ?? null, new Date().toISOString(), checksum(migration.sql));
401
+ });
402
+ apply();
403
+ newlyApplied.push(migration.version);
404
+ }
405
+ const version = MIGRATIONS.reduce((max, m) => Math.max(max, m.version), 0);
406
+ return { applied: newlyApplied, version };
407
+ }
408
+ /** The names of every table the schema creates (for tests/introspection). */
409
+ export const SQLITE_SCHEMA_TABLES = [
410
+ "schema_migrations",
411
+ "home_meta",
412
+ "config",
413
+ "configured_agents",
414
+ "agent_profiles",
415
+ "projects",
416
+ "global_roles",
417
+ "global_role_session_sets",
418
+ "global_sequences",
419
+ "tasks_catalog",
420
+ "managed_workspaces",
421
+ "id_sequences",
422
+ "coordination_locks",
423
+ "integration_queue",
424
+ "outbox",
425
+ "mailboxes",
426
+ "mailbox_signals",
427
+ "task_records",
428
+ "task_roles",
429
+ "role_session_sets",
430
+ "work_items",
431
+ "work_item_candidates",
432
+ "agent_runs",
433
+ "active_runs",
434
+ "review_rounds",
435
+ "change_sets",
436
+ "integration_attempts",
437
+ "messages",
438
+ "input_requests",
439
+ "decisions",
440
+ "milestones",
441
+ "events",
442
+ "task_projections",
443
+ "telemetry"
444
+ ];