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