@yeaft/webchat-agent 1.0.295 → 1.0.298

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,674 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ export const WORK_CENTER_SCHEMA_VERSION = 35;
4
+
5
+ const MIGRATIONS = [
6
+ ['23-conversation-stream', migrateConversationStream],
7
+ ['24-action-stream', migrateActionStream],
8
+ ['25-engine-turns', migrateEngineTurns],
9
+ ['26-operation-identity', migrateOperations],
10
+ ['27-coordinator-mailbox', migrateCoordinatorMailbox],
11
+ ['28-run-identity', migrateRunIdentity],
12
+ ['29-runtime-indexes', migrateRuntimeIndexes],
13
+ ['30-backfill-projections', backfillLegacyProjections],
14
+ ['31-reliability-guards', migrateReliabilityGuards],
15
+ ['32-engine-turn-status-contract', migrateEngineTurnStatusContract],
16
+ ['33-coordinator-provider-turns', migrateCoordinatorProviderTurns],
17
+ ['34-engine-turn-status-repair', repairEngineTurnStatusContract],
18
+ ['35-coordinator-provider-claims', migrateCoordinatorProviderClaims],
19
+ ];
20
+
21
+ const MIGRATION_ALIASES = new Map([
22
+ ['23-conversation-stream-v1', '23-conversation-stream'],
23
+ ['24-action-stream-v1', '24-action-stream'],
24
+ ['25-engine-turns-v1', '25-engine-turns'],
25
+ ['26-operation-identity-v1', '26-operation-identity'],
26
+ ['27-coordinator-mailbox-v1', '27-coordinator-mailbox'],
27
+ ['28-run-identity-v1', '28-run-identity'],
28
+ ]);
29
+
30
+ function hasColumn(db, table, column) {
31
+ return db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column);
32
+ }
33
+
34
+ function parseJson(value, fallback) {
35
+ if (typeof value !== 'string' || !value) return fallback;
36
+ try { return JSON.parse(value); } catch { return fallback; }
37
+ }
38
+
39
+ function stableJson(value) {
40
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
41
+ if (value && typeof value === 'object') {
42
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
43
+ }
44
+ return JSON.stringify(value);
45
+ }
46
+
47
+ function hash(value) {
48
+ return createHash('sha256').update(String(value), 'utf8').digest('hex');
49
+ }
50
+
51
+ function migrationChecksum(name) {
52
+ return hash(`work-center-migration:${name}:v1`);
53
+ }
54
+
55
+ function runMigration(db, now, name, migration) {
56
+ const checksum = migrationChecksum(name);
57
+ const aliases = [...MIGRATION_ALIASES.entries()]
58
+ .filter(([, canonical]) => canonical === name)
59
+ .map(([alias]) => alias);
60
+ const prior = db.prepare(`SELECT name, checksum, applied_at FROM schema_migrations
61
+ WHERE name = ? OR name IN (${aliases.map(() => '?').join(',') || "''"})
62
+ ORDER BY CASE WHEN name = ? THEN 0 ELSE 1 END LIMIT 1`).get(name, ...aliases, name);
63
+ if (prior?.name === name) {
64
+ if (prior.checksum !== checksum) throw new Error(`Work Center migration checksum changed: ${name}`);
65
+ return;
66
+ }
67
+ if (prior) {
68
+ if (prior.checksum !== migrationChecksum(prior.name)) {
69
+ throw new Error(`Work Center migration alias checksum changed: ${prior.name}`);
70
+ }
71
+ db.prepare(`INSERT INTO schema_migrations(name, checksum, applied_at)
72
+ VALUES (?, ?, ?) ON CONFLICT(name) DO NOTHING`).run(name, checksum, prior.applied_at || now);
73
+ return;
74
+ }
75
+ const apply = () => {
76
+ migration(db, now);
77
+ db.prepare(`INSERT INTO schema_migrations(name, checksum, applied_at)
78
+ VALUES (?, ?, ?)`).run(name, checksum, now);
79
+ };
80
+ if (db.isTransaction) return apply();
81
+ db.exec('BEGIN IMMEDIATE');
82
+ try {
83
+ apply();
84
+ db.exec('COMMIT');
85
+ } catch (error) {
86
+ try { db.exec('ROLLBACK'); } catch {}
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ export function migrateDurableWorkCenterModel(db, now = Date.now(), sourceSchemaVersion = 22) {
92
+ db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
93
+ name TEXT PRIMARY KEY,
94
+ checksum TEXT NOT NULL,
95
+ applied_at INTEGER NOT NULL
96
+ )`);
97
+ db.exec('DROP TABLE IF EXISTS migration_context');
98
+ db.exec('CREATE TEMP TABLE migration_context(source_schema_version INTEGER NOT NULL)');
99
+ db.prepare('INSERT INTO migration_context(source_schema_version) VALUES (?)').run(sourceSchemaVersion);
100
+ for (const [name, migration] of MIGRATIONS) runMigration(db, now, name, migration);
101
+ }
102
+
103
+ function migrateConversationStream(db) {
104
+ db.exec(`
105
+ CREATE TABLE IF NOT EXISTS conversations (
106
+ id TEXT PRIMARY KEY,
107
+ work_item_id TEXT NOT NULL UNIQUE REFERENCES work_items(id) ON DELETE CASCADE,
108
+ status TEXT NOT NULL DEFAULT 'active',
109
+ created_at INTEGER NOT NULL,
110
+ updated_at INTEGER NOT NULL
111
+ );
112
+ CREATE TABLE IF NOT EXISTS conversation_entries (
113
+ id TEXT PRIMARY KEY,
114
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
115
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
116
+ sequence INTEGER NOT NULL,
117
+ kind TEXT NOT NULL CHECK(kind IN ('message', 'control')),
118
+ role TEXT,
119
+ status TEXT NOT NULL,
120
+ text TEXT NOT NULL DEFAULT '',
121
+ attachments TEXT NOT NULL DEFAULT '[]',
122
+ turn_id TEXT,
123
+ source_key TEXT NOT NULL UNIQUE,
124
+ payload TEXT NOT NULL DEFAULT '{}',
125
+ created_at INTEGER NOT NULL,
126
+ updated_at INTEGER NOT NULL,
127
+ UNIQUE(conversation_id, sequence)
128
+ );
129
+ CREATE INDEX IF NOT EXISTS idx_conversation_entries_work_item
130
+ ON conversation_entries(work_item_id, sequence);
131
+ `);
132
+ }
133
+
134
+ function migrateActionStream(db) {
135
+ db.exec(`
136
+ CREATE TABLE IF NOT EXISTS action_entries (
137
+ id TEXT PRIMARY KEY,
138
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
139
+ action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
140
+ run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
141
+ sequence INTEGER NOT NULL,
142
+ kind TEXT NOT NULL CHECK(kind IN ('message', 'control')),
143
+ role TEXT,
144
+ status TEXT NOT NULL CHECK(status IN
145
+ ('pending', 'scheduled', 'bound', 'consumed', 'blocked', 'rejected', 'cancelled')),
146
+ text TEXT NOT NULL DEFAULT '',
147
+ attachments TEXT NOT NULL DEFAULT '[]',
148
+ source_key TEXT NOT NULL UNIQUE,
149
+ payload TEXT NOT NULL DEFAULT '{}',
150
+ engine_turn_id TEXT,
151
+ created_at INTEGER NOT NULL,
152
+ updated_at INTEGER NOT NULL,
153
+ consumed_at INTEGER,
154
+ UNIQUE(action_id, sequence)
155
+ );
156
+ CREATE INDEX IF NOT EXISTS idx_action_entries_delivery
157
+ ON action_entries(action_id, run_id, status, sequence);
158
+ `);
159
+ }
160
+
161
+ function migrateEngineTurns(db) {
162
+ db.exec(`
163
+ CREATE TABLE IF NOT EXISTS engine_turns (
164
+ id TEXT PRIMARY KEY,
165
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
166
+ action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
167
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
168
+ ordinal INTEGER NOT NULL,
169
+ status TEXT NOT NULL CHECK(status IN
170
+ ('prepared', 'dispatching', 'responded', 'unknown', 'cancelled', 'legacy_imported')),
171
+ owner_boot_id TEXT NOT NULL,
172
+ lease_epoch INTEGER NOT NULL,
173
+ input_entry_ids TEXT NOT NULL DEFAULT '[]',
174
+ message_entry_ids TEXT NOT NULL DEFAULT '[]',
175
+ control_entry_ids TEXT NOT NULL DEFAULT '[]',
176
+ claimed_through_sequence INTEGER NOT NULL DEFAULT 0,
177
+ consumed_through_sequence INTEGER NOT NULL DEFAULT 0,
178
+ request_body TEXT NOT NULL DEFAULT '{}',
179
+ request_hash TEXT NOT NULL DEFAULT '',
180
+ request_key TEXT NOT NULL UNIQUE,
181
+ dispatch_attempt INTEGER NOT NULL DEFAULT 0,
182
+ dispatch_capability TEXT NOT NULL DEFAULT 'unknown',
183
+ response TEXT,
184
+ response_hash TEXT,
185
+ provider_request_id TEXT,
186
+ claimed_at INTEGER,
187
+ dispatched_at INTEGER,
188
+ responded_at INTEGER,
189
+ consumed_at INTEGER,
190
+ error TEXT,
191
+ created_at INTEGER NOT NULL,
192
+ updated_at INTEGER NOT NULL,
193
+ UNIQUE(run_id, ordinal)
194
+ );
195
+ CREATE INDEX IF NOT EXISTS idx_engine_turns_recovery
196
+ ON engine_turns(status, updated_at);
197
+ `);
198
+ }
199
+
200
+ function migrateOperations(db) {
201
+ db.exec(`
202
+ CREATE TABLE IF NOT EXISTS operations (
203
+ id TEXT PRIMARY KEY,
204
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
205
+ action_id TEXT REFERENCES actions(id) ON DELETE CASCADE,
206
+ run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
207
+ engine_turn_id TEXT REFERENCES engine_turns(id) ON DELETE CASCADE,
208
+ operation_type TEXT NOT NULL,
209
+ target TEXT NOT NULL DEFAULT '{}',
210
+ idempotency_key TEXT NOT NULL UNIQUE,
211
+ replay_policy TEXT NOT NULL CHECK(replay_policy IN ('safe', 'probe_first', 'never_automatic')),
212
+ concurrency_policy TEXT NOT NULL DEFAULT 'blocking'
213
+ CHECK(concurrency_policy IN ('blocking', 'detached_read_only')),
214
+ effect_status TEXT NOT NULL CHECK(effect_status IN
215
+ ('pending', 'applied', 'not_applied', 'failed_no_effect', 'unknown')),
216
+ effect_observation TEXT,
217
+ effect_reconciliation TEXT NOT NULL DEFAULT '{"status":"pending"}',
218
+ execution_status TEXT NOT NULL CHECK(execution_status IN
219
+ ('not_started', 'running', 'cancel_requested', 'quiescent', 'fenced', 'hazardous_orphan')),
220
+ execution_epoch INTEGER NOT NULL DEFAULT 0,
221
+ effect_cutoff TEXT,
222
+ grant_manifest TEXT NOT NULL DEFAULT '{"status":"closed","safetyStatus":"current","inventoryComplete":true,"pendingGrantAttemptIds":[],"requiredAuthorityIds":[],"authorityClosures":[]}',
223
+ resource_release TEXT NOT NULL DEFAULT '{"status":"released","requiredLeaseIds":[],"leases":[]}',
224
+ supplemental_inventory TEXT NOT NULL DEFAULT '{"status":"clear","generation":0,"discoveries":[]}',
225
+ authority_fence TEXT,
226
+ owner_boot_id TEXT,
227
+ owner_lease_epoch INTEGER,
228
+ revision INTEGER NOT NULL DEFAULT 1,
229
+ payload TEXT NOT NULL DEFAULT '{}',
230
+ result TEXT,
231
+ claimed_at INTEGER,
232
+ completed_at INTEGER,
233
+ created_at INTEGER NOT NULL,
234
+ updated_at INTEGER NOT NULL
235
+ );
236
+ CREATE INDEX IF NOT EXISTS idx_operations_recovery
237
+ ON operations(execution_status, replay_policy, effect_status, updated_at);
238
+ `);
239
+ }
240
+
241
+ function migrateCoordinatorMailbox(db) {
242
+ db.exec(`
243
+ CREATE TABLE IF NOT EXISTS coordinator_mailbox_entries (
244
+ id TEXT PRIMARY KEY,
245
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
246
+ sequence INTEGER NOT NULL,
247
+ kind TEXT NOT NULL,
248
+ status TEXT NOT NULL CHECK(status IN ('pending', 'claimed', 'acked', 'cancelled')),
249
+ source_key TEXT NOT NULL UNIQUE,
250
+ payload TEXT NOT NULL DEFAULT '{}',
251
+ claim_owner TEXT,
252
+ claim_epoch INTEGER NOT NULL DEFAULT 0,
253
+ claimed_at INTEGER,
254
+ lease_expires_at INTEGER,
255
+ acked_at INTEGER,
256
+ created_at INTEGER NOT NULL,
257
+ updated_at INTEGER NOT NULL,
258
+ UNIQUE(work_item_id, sequence)
259
+ );
260
+ CREATE INDEX IF NOT EXISTS idx_coordinator_mailbox_claim
261
+ ON coordinator_mailbox_entries(status, lease_expires_at, sequence);
262
+ `);
263
+ }
264
+
265
+ function migrateRunIdentity(db) {
266
+ if (!hasColumn(db, 'runs', 'ordinal')) db.exec('ALTER TABLE runs ADD COLUMN ordinal INTEGER');
267
+ if (!hasColumn(db, 'runs', 'terminal_status')) db.exec('ALTER TABLE runs ADD COLUMN terminal_status TEXT');
268
+ if (!hasColumn(db, 'runs', 'terminal_at')) db.exec('ALTER TABLE runs ADD COLUMN terminal_at INTEGER');
269
+ const update = db.prepare(`UPDATE runs SET ordinal = ?,
270
+ terminal_status = CASE WHEN status != 'running' THEN status ELSE terminal_status END,
271
+ terminal_at = CASE WHEN status != 'running' THEN COALESCE(ended_at, started_at) ELSE terminal_at END
272
+ WHERE id = ?`);
273
+ const ordinals = new Map();
274
+ for (const row of db.prepare('SELECT id, action_id FROM runs ORDER BY action_id, started_at, id').all()) {
275
+ const ordinal = (ordinals.get(row.action_id) || 0) + 1;
276
+ ordinals.set(row.action_id, ordinal);
277
+ update.run(ordinal, row.id);
278
+ }
279
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_action_ordinal ON runs(action_id, ordinal)');
280
+ }
281
+
282
+ function migrateRuntimeIndexes(db) {
283
+ for (const [column, definition] of [
284
+ ['message_entry_ids', "TEXT NOT NULL DEFAULT '[]'"],
285
+ ['control_entry_ids', "TEXT NOT NULL DEFAULT '[]'"],
286
+ ['claimed_through_sequence', 'INTEGER NOT NULL DEFAULT 0'],
287
+ ['consumed_through_sequence', 'INTEGER NOT NULL DEFAULT 0'],
288
+ ]) {
289
+ if (!hasColumn(db, 'engine_turns', column)) {
290
+ db.exec(`ALTER TABLE engine_turns ADD COLUMN ${column} ${definition}`);
291
+ }
292
+ }
293
+ db.exec(`
294
+ CREATE INDEX IF NOT EXISTS idx_action_entries_engine_turn ON action_entries(engine_turn_id, sequence);
295
+ CREATE INDEX IF NOT EXISTS idx_mailbox_work_item_status
296
+ ON coordinator_mailbox_entries(work_item_id, status, sequence);
297
+ `);
298
+ }
299
+
300
+ const ENGINE_TURN_STATUS_CHECK = /status\s+TEXT\s+NOT\s+NULL\s+CHECK\s*\(\s*status\s+IN\s*\(\s*'prepared'\s*,\s*'dispatching'\s*,\s*'responded'\s*,\s*'unknown'\s*,\s*'cancelled'\s*,\s*'legacy_imported'\s*\)\s*\)/i;
301
+
302
+ function hasEngineTurnStatusContract(db) {
303
+ const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'engine_turns'`).get()?.sql || '';
304
+ return ENGINE_TURN_STATUS_CHECK.test(sql);
305
+ }
306
+
307
+ function rebuildEngineTurnStatusContract(db, now) {
308
+ db.exec('PRAGMA defer_foreign_keys = ON');
309
+ db.exec(`
310
+ CREATE TABLE engine_turns_new (
311
+ id TEXT PRIMARY KEY,
312
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
313
+ action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
314
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
315
+ ordinal INTEGER NOT NULL,
316
+ status TEXT NOT NULL CHECK(status IN
317
+ ('prepared', 'dispatching', 'responded', 'unknown', 'cancelled', 'legacy_imported')),
318
+ owner_boot_id TEXT NOT NULL,
319
+ lease_epoch INTEGER NOT NULL,
320
+ input_entry_ids TEXT NOT NULL DEFAULT '[]',
321
+ message_entry_ids TEXT NOT NULL DEFAULT '[]',
322
+ control_entry_ids TEXT NOT NULL DEFAULT '[]',
323
+ claimed_through_sequence INTEGER NOT NULL DEFAULT 0,
324
+ consumed_through_sequence INTEGER NOT NULL DEFAULT 0,
325
+ request_body TEXT NOT NULL DEFAULT '{}',
326
+ request_hash TEXT NOT NULL DEFAULT '',
327
+ request_key TEXT NOT NULL UNIQUE,
328
+ dispatch_attempt INTEGER NOT NULL DEFAULT 0,
329
+ dispatch_capability TEXT NOT NULL DEFAULT 'unknown',
330
+ response TEXT,
331
+ response_hash TEXT,
332
+ provider_request_id TEXT,
333
+ claimed_at INTEGER,
334
+ dispatched_at INTEGER,
335
+ responded_at INTEGER,
336
+ consumed_at INTEGER,
337
+ error TEXT,
338
+ created_at INTEGER NOT NULL,
339
+ updated_at INTEGER NOT NULL,
340
+ UNIQUE(run_id, ordinal)
341
+ );
342
+ INSERT INTO engine_turns_new
343
+ (id, work_item_id, action_id, run_id, ordinal, status, owner_boot_id, lease_epoch,
344
+ input_entry_ids, message_entry_ids, control_entry_ids, claimed_through_sequence,
345
+ consumed_through_sequence, request_body, request_hash, request_key, dispatch_attempt,
346
+ dispatch_capability, response, response_hash, provider_request_id, claimed_at,
347
+ dispatched_at, responded_at, consumed_at, error, created_at, updated_at)
348
+ SELECT id, work_item_id, action_id, run_id, ordinal,
349
+ CASE status
350
+ WHEN 'prepared' THEN 'prepared'
351
+ WHEN 'consumed' THEN 'responded'
352
+ WHEN 'responded' THEN 'responded'
353
+ WHEN 'legacy_imported' THEN 'legacy_imported'
354
+ WHEN 'cancelled' THEN 'cancelled'
355
+ ELSE 'unknown'
356
+ END,
357
+ owner_boot_id, lease_epoch, input_entry_ids,
358
+ COALESCE(message_entry_ids, input_entry_ids, '[]'), COALESCE(control_entry_ids, '[]'),
359
+ COALESCE(claimed_through_sequence, 0), COALESCE(consumed_through_sequence, 0),
360
+ COALESCE(request_body, '{}'), COALESCE(request_hash, ''), request_key,
361
+ COALESCE(dispatch_attempt, 0), COALESCE(dispatch_capability, 'unknown'), response,
362
+ response_hash, provider_request_id, claimed_at, dispatched_at, responded_at, consumed_at,
363
+ CASE WHEN status IN ('claimed', 'dispatching', 'blocked')
364
+ THEN COALESCE(error, 'Legacy provider dispatch outcome is unknown after schema upgrade')
365
+ ELSE error END,
366
+ created_at, COALESCE(updated_at, ${Number(now) || 0})
367
+ FROM engine_turns;
368
+ CREATE TEMP TABLE engine_turn_action_entry_refs AS
369
+ SELECT id, engine_turn_id FROM action_entries WHERE engine_turn_id IS NOT NULL;
370
+ CREATE TEMP TABLE engine_turn_operation_refs AS
371
+ SELECT id, engine_turn_id FROM operations WHERE engine_turn_id IS NOT NULL;
372
+ UPDATE action_entries SET engine_turn_id = NULL WHERE engine_turn_id IS NOT NULL;
373
+ UPDATE operations SET engine_turn_id = NULL WHERE engine_turn_id IS NOT NULL;
374
+ DROP TABLE engine_turns;
375
+ ALTER TABLE engine_turns_new RENAME TO engine_turns;
376
+ UPDATE action_entries SET engine_turn_id = (
377
+ SELECT ref.engine_turn_id FROM engine_turn_action_entry_refs ref WHERE ref.id = action_entries.id
378
+ ) WHERE id IN (SELECT id FROM engine_turn_action_entry_refs);
379
+ UPDATE operations SET engine_turn_id = (
380
+ SELECT ref.engine_turn_id FROM engine_turn_operation_refs ref WHERE ref.id = operations.id
381
+ ) WHERE id IN (SELECT id FROM engine_turn_operation_refs);
382
+ DROP TABLE engine_turn_action_entry_refs;
383
+ DROP TABLE engine_turn_operation_refs;
384
+ CREATE INDEX idx_engine_turns_recovery ON engine_turns(status, updated_at);
385
+ CREATE TRIGGER trg_engine_turn_request_immutable
386
+ BEFORE UPDATE ON engine_turns
387
+ WHEN NEW.run_id IS NOT OLD.run_id OR NEW.ordinal IS NOT OLD.ordinal OR
388
+ NEW.request_body IS NOT OLD.request_body OR NEW.request_hash IS NOT OLD.request_hash OR
389
+ NEW.input_entry_ids IS NOT OLD.input_entry_ids OR
390
+ NEW.message_entry_ids IS NOT OLD.message_entry_ids OR NEW.control_entry_ids IS NOT OLD.control_entry_ids
391
+ BEGIN
392
+ SELECT RAISE(ABORT, 'prepared EngineTurn request is immutable');
393
+ END;
394
+ `);
395
+ const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
396
+ if (foreignKeyViolations.length > 0) throw new Error('EngineTurn status migration violated foreign keys');
397
+ }
398
+
399
+ function migrateEngineTurnStatusContract(db, now) {
400
+ if (hasEngineTurnStatusContract(db)) return;
401
+ rebuildEngineTurnStatusContract(db, now);
402
+ }
403
+
404
+ function repairEngineTurnStatusContract(db, now) {
405
+ if (!hasEngineTurnStatusContract(db)) rebuildEngineTurnStatusContract(db, now);
406
+ if (!hasEngineTurnStatusContract(db)) {
407
+ throw new Error('EngineTurn status repair did not install the required status contract');
408
+ }
409
+ const foreignKeys = db.prepare('PRAGMA foreign_key_list(engine_turns)').all();
410
+ const referencedTables = foreignKeys.map(row => row.table).sort();
411
+ if (foreignKeys.length !== 3
412
+ || JSON.stringify(referencedTables) !== JSON.stringify(['actions', 'runs', 'work_items'])) {
413
+ throw new Error('EngineTurn status repair did not preserve required foreign keys');
414
+ }
415
+ const indexes = db.prepare('PRAGMA index_list(engine_turns)').all();
416
+ const hasIndex = columns => indexes.some(index => {
417
+ const actual = db.prepare(`PRAGMA index_info(${JSON.stringify(index.name)})`).all()
418
+ .map(row => row.name);
419
+ return actual.length === columns.length && actual.every((value, offset) => value === columns[offset]);
420
+ });
421
+ if (!hasIndex(['status', 'updated_at'])
422
+ || !hasIndex(['request_key'])
423
+ || !hasIndex(['run_id', 'ordinal'])) {
424
+ throw new Error('EngineTurn status repair did not preserve required indexes');
425
+ }
426
+ const immutableTrigger = db.prepare(`SELECT 1 AS present FROM sqlite_master
427
+ WHERE type = 'trigger' AND name = 'trg_engine_turn_request_immutable'`).get();
428
+ if (!immutableTrigger) throw new Error('EngineTurn status repair did not preserve request immutability');
429
+ const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
430
+ if (foreignKeyViolations.length > 0) throw new Error('EngineTurn status repair violated foreign keys');
431
+ }
432
+
433
+ function migrateCoordinatorProviderTurns(db) {
434
+ db.exec(`
435
+ CREATE TABLE IF NOT EXISTS coordinator_provider_turns (
436
+ id TEXT PRIMARY KEY,
437
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
438
+ coordinator_turn_id TEXT NOT NULL,
439
+ attempt_number INTEGER NOT NULL,
440
+ status TEXT NOT NULL CHECK(status IN ('prepared', 'dispatching', 'responded', 'unknown', 'cancelled')),
441
+ request_body TEXT NOT NULL,
442
+ request_hash TEXT NOT NULL,
443
+ response TEXT,
444
+ response_hash TEXT,
445
+ error TEXT,
446
+ prepared_at INTEGER NOT NULL,
447
+ dispatched_at INTEGER,
448
+ responded_at INTEGER,
449
+ updated_at INTEGER NOT NULL,
450
+ UNIQUE(coordinator_turn_id, attempt_number)
451
+ );
452
+ CREATE INDEX IF NOT EXISTS idx_coordinator_provider_turns_recovery
453
+ ON coordinator_provider_turns(status, updated_at);
454
+ CREATE TRIGGER IF NOT EXISTS trg_coordinator_provider_request_immutable
455
+ BEFORE UPDATE ON coordinator_provider_turns
456
+ WHEN NEW.work_item_id IS NOT OLD.work_item_id OR
457
+ NEW.coordinator_turn_id IS NOT OLD.coordinator_turn_id OR
458
+ NEW.attempt_number IS NOT OLD.attempt_number OR
459
+ NEW.request_body IS NOT OLD.request_body OR NEW.request_hash IS NOT OLD.request_hash
460
+ BEGIN
461
+ SELECT RAISE(ABORT, 'prepared Coordinator provider request is immutable');
462
+ END;
463
+ `);
464
+ }
465
+
466
+ function migrateCoordinatorProviderClaims(db) {
467
+ for (const [column, definition] of [
468
+ ['claim_owner', 'TEXT'],
469
+ ['claim_epoch', 'INTEGER NOT NULL DEFAULT 0'],
470
+ ]) {
471
+ if (!hasColumn(db, 'coordinator_provider_turns', column)) {
472
+ db.exec(`ALTER TABLE coordinator_provider_turns ADD COLUMN ${column} ${definition}`);
473
+ }
474
+ }
475
+ db.exec(`
476
+ CREATE INDEX IF NOT EXISTS idx_coordinator_provider_turns_claim
477
+ ON coordinator_provider_turns(coordinator_turn_id, claim_owner, claim_epoch, status);
478
+ `);
479
+ }
480
+
481
+ function migrateReliabilityGuards(db) {
482
+ for (const [column, definition] of [
483
+ ['dispatch_capability', "TEXT NOT NULL DEFAULT 'unknown'"],
484
+ ['dispatched_at', 'INTEGER'],
485
+ ['response_hash', 'TEXT'],
486
+ ['error', 'TEXT'],
487
+ ]) {
488
+ if (!hasColumn(db, 'engine_turns', column)) {
489
+ db.exec(`ALTER TABLE engine_turns ADD COLUMN ${column} ${definition}`);
490
+ }
491
+ }
492
+ db.exec(`
493
+ DROP TRIGGER IF EXISTS trg_runs_capture_terminal_identity;
494
+ DROP TRIGGER IF EXISTS trg_runs_terminal_identity_immutable;
495
+ CREATE TRIGGER IF NOT EXISTS trg_engine_turn_request_immutable
496
+ BEFORE UPDATE ON engine_turns
497
+ WHEN NEW.run_id IS NOT OLD.run_id OR NEW.ordinal IS NOT OLD.ordinal OR
498
+ NEW.request_body IS NOT OLD.request_body OR NEW.request_hash IS NOT OLD.request_hash OR
499
+ NEW.input_entry_ids IS NOT OLD.input_entry_ids OR
500
+ NEW.message_entry_ids IS NOT OLD.message_entry_ids OR NEW.control_entry_ids IS NOT OLD.control_entry_ids
501
+ BEGIN
502
+ SELECT RAISE(ABORT, 'prepared EngineTurn request is immutable');
503
+ END;
504
+ CREATE TRIGGER IF NOT EXISTS trg_runs_identity_immutable
505
+ BEFORE UPDATE ON runs
506
+ WHEN NEW.action_id IS NOT OLD.action_id OR NEW.work_item_id IS NOT OLD.work_item_id OR
507
+ NEW.owner_boot_id IS NOT OLD.owner_boot_id OR NEW.lease_epoch IS NOT OLD.lease_epoch OR
508
+ NEW.ordinal IS NOT OLD.ordinal OR NEW.started_at IS NOT OLD.started_at
509
+ BEGIN
510
+ SELECT RAISE(ABORT, 'Run identity is immutable');
511
+ END;
512
+ CREATE TRIGGER IF NOT EXISTS trg_runs_capture_terminal_identity
513
+ AFTER UPDATE OF status ON runs
514
+ WHEN OLD.terminal_status IS NULL AND OLD.status = 'running' AND NEW.status != 'running'
515
+ BEGIN
516
+ UPDATE runs SET terminal_status = NEW.status,
517
+ terminal_at = COALESCE(NEW.ended_at, NEW.started_at)
518
+ WHERE id = NEW.id AND terminal_status IS NULL;
519
+ END;
520
+ CREATE TRIGGER IF NOT EXISTS trg_runs_terminal_identity_immutable
521
+ BEFORE UPDATE ON runs
522
+ WHEN OLD.terminal_status IS NOT NULL AND (
523
+ NEW.action_id IS NOT OLD.action_id OR NEW.work_item_id IS NOT OLD.work_item_id OR
524
+ NEW.owner_boot_id IS NOT OLD.owner_boot_id OR NEW.lease_epoch IS NOT OLD.lease_epoch OR
525
+ NEW.ordinal IS NOT OLD.ordinal OR NEW.started_at IS NOT OLD.started_at OR
526
+ NEW.status IS NOT OLD.status OR NEW.ended_at IS NOT OLD.ended_at OR
527
+ NEW.terminal_status IS NOT OLD.terminal_status OR NEW.terminal_at IS NOT OLD.terminal_at OR
528
+ NEW.response IS NOT OLD.response OR NEW.summary IS NOT OLD.summary OR
529
+ NEW.evidence IS NOT OLD.evidence OR NEW.waiting_reason IS NOT OLD.waiting_reason OR
530
+ NEW.error IS NOT OLD.error OR NEW.failure_kind IS NOT OLD.failure_kind OR
531
+ NEW.failure_code IS NOT OLD.failure_code OR NEW.review_decision IS NOT OLD.review_decision OR
532
+ NEW.contract_patch IS NOT OLD.contract_patch OR NEW.checkpoint IS NOT OLD.checkpoint)
533
+ BEGIN
534
+ SELECT RAISE(ABORT, 'terminal Run result is immutable');
535
+ END;
536
+ `);
537
+ }
538
+
539
+ function backfillLegacyProjections(db, now) {
540
+ const sourceSchemaVersion = Number(
541
+ db.prepare('SELECT source_schema_version FROM migration_context').get()?.source_schema_version,
542
+ ) || 22;
543
+ const sourcePrefix = String(sourceSchemaVersion);
544
+ const ensureConversation = db.prepare(`INSERT INTO conversations
545
+ (id, work_item_id, status, created_at, updated_at) VALUES (?, ?, 'active', ?, ?)
546
+ ON CONFLICT(work_item_id) DO NOTHING`);
547
+ const insertConversationEntry = db.prepare(`INSERT INTO conversation_entries
548
+ (id, conversation_id, work_item_id, sequence, kind, role, status, text, attachments,
549
+ turn_id, source_key, payload, created_at, updated_at)
550
+ VALUES (?, ?, ?, ?, 'message', ?, ?, ?, ?, ?, ?, ?, ?, ?)
551
+ ON CONFLICT(source_key) DO NOTHING`);
552
+ for (const row of db.prepare('SELECT id, messages, created_at, updated_at FROM work_items ORDER BY id').all()) {
553
+ const conversationId = `work-item:${row.id}`;
554
+ ensureConversation.run(conversationId, row.id, row.created_at || now, row.updated_at || now);
555
+ const messages = parseJson(row.messages, []);
556
+ if (!Array.isArray(messages)) continue;
557
+ const identityOccurrences = new Map();
558
+ messages.forEach((message, index) => {
559
+ if (!message || typeof message !== 'object') return;
560
+ const identity = String(message.id || message.turnId
561
+ || `${message.role || 'legacy'}:${hash(stableJson(message))}`);
562
+ const occurrence = (identityOccurrences.get(identity) || 0) + 1;
563
+ identityOccurrences.set(identity, occurrence);
564
+ const sourceKey = `${sourcePrefix}:work_items.messages:${row.id}:${identity}:${occurrence}`;
565
+ insertConversationEntry.run(
566
+ `legacy-conversation-${hash(sourceKey).slice(0, 32)}`,
567
+ conversationId,
568
+ row.id,
569
+ index + 1,
570
+ message.role || 'legacy_instruction',
571
+ message.status || 'completed',
572
+ typeof message.text === 'string' ? message.text : '',
573
+ JSON.stringify(Array.isArray(message.attachments) ? message.attachments : []),
574
+ message.turnId || null,
575
+ sourceKey,
576
+ stableJson(message),
577
+ Number(message.createdAt) || row.created_at || now,
578
+ Number(message.updatedAt) || Number(message.createdAt) || row.updated_at || now,
579
+ );
580
+ });
581
+ }
582
+
583
+ const ensureLegacyTurn = db.prepare(`INSERT INTO engine_turns
584
+ (id, work_item_id, action_id, run_id, ordinal, status, owner_boot_id, lease_epoch,
585
+ input_entry_ids, message_entry_ids, control_entry_ids, request_body, request_hash,
586
+ request_key, responded_at, consumed_at, created_at, updated_at)
587
+ VALUES (?, ?, ?, ?, ?, 'legacy_imported', ?, ?, '[]', '[]', '[]', '{}', '', ?, ?, ?, ?, ?)
588
+ ON CONFLICT(request_key) DO NOTHING`);
589
+ const insertActionEntry = db.prepare(`INSERT INTO action_entries
590
+ (id, work_item_id, action_id, run_id, sequence, kind, role, status, text, attachments,
591
+ source_key, payload, engine_turn_id, created_at, updated_at, consumed_at)
592
+ VALUES (?, ?, ?, ?, ?, 'message', 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?)
593
+ ON CONFLICT(source_key) DO NOTHING`);
594
+ const nextSequence = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS value FROM action_entries WHERE action_id = ?');
595
+ const nextTurnOrdinal = db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS value FROM engine_turns WHERE run_id = ?');
596
+ for (const row of db.prepare(`SELECT p.*, e.created_at, r.owner_boot_id, r.lease_epoch FROM pending_action_inputs p
597
+ JOIN events e ON e.id = p.event_id LEFT JOIN runs r ON r.id = p.run_id
598
+ ORDER BY p.action_id, p.event_id`).all()) {
599
+ const sourceKey = `${sourcePrefix}:pending_action_inputs:${row.event_id}`;
600
+ const sequence = Number(nextSequence.get(row.action_id)?.value) || 1;
601
+ const status = row.superseded_at != null ? 'cancelled' : row.consumed_at != null ? 'consumed' : 'pending';
602
+ let legacyTurnId = null;
603
+ if (status === 'consumed' && row.run_id && row.owner_boot_id) {
604
+ const requestKey = `${sourcePrefix}:pending_action_inputs:${row.event_id}:legacy-turn`;
605
+ legacyTurnId = `legacy-engine-turn-${hash(requestKey).slice(0, 32)}`;
606
+ const existingTurn = db.prepare('SELECT id FROM engine_turns WHERE request_key = ?').get(requestKey);
607
+ if (existingTurn) {
608
+ legacyTurnId = existingTurn.id;
609
+ } else {
610
+ const ordinal = Number(nextTurnOrdinal.get(row.run_id)?.value) || 1;
611
+ ensureLegacyTurn.run(
612
+ legacyTurnId, row.work_item_id, row.action_id, row.run_id, ordinal,
613
+ row.owner_boot_id, Number(row.lease_epoch) || 0, requestKey,
614
+ row.consumed_at, row.consumed_at, row.created_at || now, row.consumed_at,
615
+ );
616
+ }
617
+ }
618
+ insertActionEntry.run(
619
+ `legacy-action-entry-${hash(sourceKey).slice(0, 32)}`,
620
+ row.work_item_id,
621
+ row.action_id,
622
+ row.run_id || null,
623
+ sequence,
624
+ status,
625
+ row.text || '',
626
+ row.attachments || '[]',
627
+ sourceKey,
628
+ stableJson({ eventId: row.event_id }),
629
+ legacyTurnId,
630
+ row.created_at || now,
631
+ row.consumed_at || row.superseded_at || row.created_at || now,
632
+ row.consumed_at || null,
633
+ );
634
+ }
635
+
636
+ const eventRows = db.prepare(`SELECT e.*, a.status AS action_status, a.generation, a.spec_hash
637
+ FROM events e JOIN actions a ON a.id = e.action_id
638
+ LEFT JOIN pending_action_inputs p ON p.event_id = e.id
639
+ WHERE e.type = 'action.input_added' AND p.event_id IS NULL
640
+ ORDER BY e.action_id, e.id`).all();
641
+ for (const row of eventRows) {
642
+ const data = parseJson(row.data, {});
643
+ const sourceKey = `${sourcePrefix}:events:${row.id}`;
644
+ const status = row.action_status === 'ready' ? 'consumed' : 'rejected';
645
+ const sequence = Number(nextSequence.get(row.action_id)?.value) || 1;
646
+ insertActionEntry.run(
647
+ `legacy-action-entry-${hash(sourceKey).slice(0, 32)}`,
648
+ row.work_item_id,
649
+ row.action_id,
650
+ null,
651
+ sequence,
652
+ status,
653
+ typeof data.text === 'string' ? data.text : '',
654
+ JSON.stringify(Array.isArray(data.attachments) ? data.attachments : []),
655
+ sourceKey,
656
+ stableJson({
657
+ eventId: row.id,
658
+ inputId: data.inputId || null,
659
+ sourceGeneration: row.action_generation || null,
660
+ currentGeneration: row.generation,
661
+ currentSpecHash: row.spec_hash || '',
662
+ migrationDisposition: status,
663
+ }),
664
+ null,
665
+ row.created_at || now,
666
+ row.created_at || now,
667
+ status === 'consumed' ? row.created_at || now : null,
668
+ );
669
+ }
670
+ }
671
+
672
+ export function durableId(prefix = 'durable') {
673
+ return `${prefix}-${randomUUID()}`;
674
+ }