@abelxiaoxing/cadence 1.0.14 → 1.1.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.
Files changed (46) hide show
  1. package/README.md +29 -13
  2. package/agents/contract-reviewer.md +2 -2
  3. package/agents/design-explorer.md +1 -1
  4. package/agents/diagnosis-worker.md +6 -3
  5. package/agents/implementation-worker.md +14 -9
  6. package/config/.env.example +5 -17
  7. package/config/routes.example.json +42 -0
  8. package/package.json +3 -3
  9. package/prompts/abel-design.md +150 -228
  10. package/prompts/abel-diagnose.md +37 -18
  11. package/prompts/abel-implement.md +111 -178
  12. package/prompts/abel-init.md +36 -33
  13. package/skills/abel-workflow/SKILL.md +156 -168
  14. package/src/apply-transaction.ts +1427 -0
  15. package/src/artifact-store.ts +204 -0
  16. package/src/child-session.ts +136 -17
  17. package/src/contracts.ts +105 -696
  18. package/src/control-contracts.ts +259 -0
  19. package/src/delivery-compiler.ts +1088 -0
  20. package/src/file-snapshot.ts +5 -0
  21. package/src/implement-graph.ts +11 -4
  22. package/src/index.ts +1465 -133
  23. package/src/isolation-backend.ts +269 -0
  24. package/src/packet-runtime.ts +533 -0
  25. package/src/parent-provider.ts +23 -4
  26. package/src/route-policy.ts +483 -0
  27. package/src/run-state.ts +393 -0
  28. package/src/run-store.ts +918 -0
  29. package/src/safe-path.ts +11 -0
  30. package/src/scoped-tools.ts +36 -0
  31. package/src/state-root.ts +215 -0
  32. package/src/subagent-activity.ts +526 -27
  33. package/src/submit-tool.ts +274 -0
  34. package/src/task-ledger.ts +1380 -0
  35. package/src/verification-capability.ts +529 -42
  36. package/src/worker-broker.ts +703 -0
  37. package/src/workflow-engine.ts +7391 -0
  38. package/src/workspace-store.ts +544 -0
  39. package/src/candidate-preflight.ts +0 -1618
  40. package/src/drain.ts +0 -20
  41. package/src/patch.ts +0 -505
  42. package/src/result-store.ts +0 -312
  43. package/src/runtime.ts +0 -2436
  44. package/src/scheduler.ts +0 -384
  45. package/src/subagent-endpoint.ts +0 -158
  46. package/src/worker.ts +0 -204
@@ -0,0 +1,918 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, lstatSync, openSync } from "node:fs";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import type { ControlStage } from "./control-contracts.ts";
5
+ import {
6
+ createRunEvent,
7
+ type DeliveryGate,
8
+ type RunEvent,
9
+ type RunProjection,
10
+ type RunState,
11
+ reduceRunEvents,
12
+ ZERO_EVENT_HASH,
13
+ } from "./run-state.ts";
14
+ import {
15
+ prepareStateRoot,
16
+ type ResolvedStateRoot,
17
+ StateRootError,
18
+ } from "./state-root.ts";
19
+
20
+ export const RUN_STORE_SCHEMA_VERSION = 2 as const;
21
+
22
+ const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
23
+ const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
24
+ const SHA256 = /^[a-f0-9]{64}$/u;
25
+
26
+ interface RunRow {
27
+ run_id: string;
28
+ projection_json: string;
29
+ }
30
+
31
+ interface EventRow {
32
+ sequence: number;
33
+ type: string;
34
+ payload_json: string;
35
+ prior_hash: string;
36
+ event_hash: string;
37
+ }
38
+
39
+ interface OperationRow {
40
+ state: string;
41
+ outcome_json: string | null;
42
+ lease_token: string | null;
43
+ lease_expires_at: number | null;
44
+ }
45
+
46
+ export interface RunStoreOptions {
47
+ now?: () => number;
48
+ busyTimeoutMs?: number;
49
+ }
50
+
51
+ export interface StartRunInput {
52
+ stage: ControlStage;
53
+ change?: string;
54
+ provisionalKey?: string;
55
+ operationId: string;
56
+ }
57
+
58
+ export interface BindDeliveryInput {
59
+ runId: string;
60
+ gate: DeliveryGate;
61
+ revision: number;
62
+ receiptHash: string;
63
+ operationId: string;
64
+ lease?: OperationLease;
65
+ }
66
+
67
+ export interface OperationLease {
68
+ runId: string;
69
+ operationId: string;
70
+ token: string;
71
+ expiresAt: number;
72
+ }
73
+
74
+ export interface OperationLeaseStatus {
75
+ state: "running" | "committed" | "interrupted";
76
+ outcome?: Record<string, unknown>;
77
+ expiresAt?: number;
78
+ }
79
+
80
+ const SCHEMA = `
81
+ CREATE TABLE IF NOT EXISTS schema_meta (
82
+ version INTEGER PRIMARY KEY CHECK (version = 2)
83
+ ) STRICT;
84
+
85
+ CREATE TABLE IF NOT EXISTS runs (
86
+ run_id TEXT PRIMARY KEY,
87
+ root_hash TEXT NOT NULL,
88
+ stage TEXT NOT NULL,
89
+ lookup_key TEXT NOT NULL,
90
+ change_name TEXT,
91
+ provisional_key TEXT,
92
+ state TEXT NOT NULL,
93
+ sequence INTEGER NOT NULL,
94
+ event_hash TEXT NOT NULL,
95
+ delivery_revision INTEGER,
96
+ projection_json TEXT NOT NULL,
97
+ terminal_tombstone TEXT,
98
+ UNIQUE (root_hash, stage, lookup_key)
99
+ ) STRICT;
100
+
101
+ CREATE TABLE IF NOT EXISTS delivery_bindings (
102
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
103
+ gate TEXT NOT NULL,
104
+ revision INTEGER NOT NULL,
105
+ receipt_hash TEXT NOT NULL,
106
+ operation_id TEXT NOT NULL,
107
+ PRIMARY KEY (run_id, gate, revision),
108
+ UNIQUE (run_id, operation_id)
109
+ ) STRICT;
110
+
111
+ CREATE TABLE IF NOT EXISTS events (
112
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
113
+ sequence INTEGER NOT NULL,
114
+ type TEXT NOT NULL,
115
+ payload_json TEXT NOT NULL,
116
+ prior_hash TEXT NOT NULL,
117
+ event_hash TEXT NOT NULL,
118
+ PRIMARY KEY (run_id, sequence),
119
+ UNIQUE (run_id, event_hash)
120
+ ) STRICT;
121
+
122
+ CREATE TABLE IF NOT EXISTS tasks (
123
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
124
+ task_id TEXT NOT NULL,
125
+ state TEXT NOT NULL,
126
+ phase TEXT,
127
+ projection_json TEXT NOT NULL,
128
+ PRIMARY KEY (run_id, task_id)
129
+ ) STRICT;
130
+
131
+ CREATE TABLE IF NOT EXISTS operations (
132
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
133
+ operation_id TEXT NOT NULL,
134
+ kind TEXT NOT NULL,
135
+ state TEXT NOT NULL,
136
+ outcome_json TEXT,
137
+ lease_token TEXT,
138
+ lease_expires_at INTEGER,
139
+ PRIMARY KEY (run_id, operation_id)
140
+ ) STRICT;
141
+
142
+ CREATE TABLE IF NOT EXISTS route_health (
143
+ route_fingerprint TEXT PRIMARY KEY,
144
+ state TEXT NOT NULL,
145
+ next_half_open_at INTEGER,
146
+ projection_json TEXT NOT NULL
147
+ ) STRICT;
148
+
149
+ CREATE TABLE IF NOT EXISTS artifacts (
150
+ content_hash TEXT PRIMARY KEY,
151
+ size_bytes INTEGER NOT NULL,
152
+ seal_state TEXT NOT NULL,
153
+ owner_run_id TEXT REFERENCES runs(run_id) ON DELETE CASCADE,
154
+ reference_count INTEGER NOT NULL
155
+ ) STRICT;
156
+
157
+ CREATE TABLE IF NOT EXISTS workspace_revisions (
158
+ revision_id TEXT PRIMARY KEY,
159
+ owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
160
+ parent_revision_id TEXT,
161
+ manifest_hash TEXT NOT NULL
162
+ ) STRICT;
163
+
164
+ CREATE TABLE IF NOT EXISTS apply_transactions (
165
+ transaction_id TEXT PRIMARY KEY,
166
+ owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
167
+ state TEXT NOT NULL,
168
+ intent_json TEXT NOT NULL,
169
+ recovery_json TEXT
170
+ ) STRICT;
171
+
172
+ CREATE TABLE IF NOT EXISTS bootstrap_handoffs (
173
+ handoff_id TEXT PRIMARY KEY,
174
+ owner_run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
175
+ receipt_hash TEXT NOT NULL UNIQUE,
176
+ state TEXT NOT NULL,
177
+ facts_json TEXT NOT NULL
178
+ ) STRICT;
179
+ `;
180
+
181
+ function requireIdentifier(value: string, label: string): void {
182
+ if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
183
+ }
184
+
185
+ function parseProjection(value: string): RunProjection {
186
+ const parsed = JSON.parse(value) as RunProjection;
187
+ if (
188
+ !parsed ||
189
+ typeof parsed !== "object" ||
190
+ typeof parsed.runId !== "string"
191
+ ) {
192
+ throw new Error("run-projection-invalid");
193
+ }
194
+ return parsed;
195
+ }
196
+
197
+ function lookupKey(input: StartRunInput): string {
198
+ const hasChange = typeof input.change === "string";
199
+ const hasProvisional = typeof input.provisionalKey === "string";
200
+ if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
201
+ if (hasChange) {
202
+ if (!CHANGE_NAME.test(input.change as string))
203
+ throw new Error("invalid-change");
204
+ return `change:${input.change}`;
205
+ }
206
+ if (!SHA256.test(input.provisionalKey as string)) {
207
+ throw new Error("invalid-provisional-key");
208
+ }
209
+ if (input.stage !== "abel-design") {
210
+ throw new Error("provisional-run-requires-design");
211
+ }
212
+ return `provisional:${input.provisionalKey}`;
213
+ }
214
+
215
+ export class RunStore {
216
+ readonly stateRoot: ResolvedStateRoot;
217
+ readonly #database: DatabaseSync;
218
+ readonly #now: () => number;
219
+ #closed = false;
220
+
221
+ private constructor(
222
+ stateRoot: ResolvedStateRoot,
223
+ database: DatabaseSync,
224
+ options: RunStoreOptions,
225
+ ) {
226
+ this.stateRoot = stateRoot;
227
+ this.#database = database;
228
+ this.#now = options.now ?? Date.now;
229
+ }
230
+
231
+ static open(
232
+ unresolved: ResolvedStateRoot,
233
+ options: RunStoreOptions = {},
234
+ ): RunStore {
235
+ const stateRoot = prepareStateRoot(unresolved);
236
+ try {
237
+ const existing = lstatSync(stateRoot.databasePath);
238
+ if (existing.isSymbolicLink()) {
239
+ throw new StateRootError("state-component-symlink");
240
+ }
241
+ if (!existing.isFile()) {
242
+ throw new StateRootError("state-component-type");
243
+ }
244
+ } catch (error) {
245
+ if (
246
+ error instanceof StateRootError ||
247
+ (error as NodeJS.ErrnoException).code !== "ENOENT"
248
+ ) {
249
+ throw error;
250
+ }
251
+ const descriptor = openSync(stateRoot.databasePath, "ax", 0o600);
252
+ closeSync(descriptor);
253
+ }
254
+ chmodSync(stateRoot.databasePath, 0o600);
255
+
256
+ const database = new DatabaseSync(stateRoot.databasePath);
257
+ database.exec("PRAGMA foreign_keys = ON");
258
+ database.exec("PRAGMA journal_mode = WAL");
259
+ database.exec("PRAGMA synchronous = FULL");
260
+ database.exec(
261
+ `PRAGMA busy_timeout = ${Math.max(1, Math.min(30_000, options.busyTimeoutMs ?? 5_000))}`,
262
+ );
263
+ database.exec(SCHEMA);
264
+ const schemaRow = database
265
+ .prepare("SELECT version FROM schema_meta LIMIT 1")
266
+ .get() as { version: number } | undefined;
267
+ if (!schemaRow) {
268
+ database
269
+ .prepare("INSERT INTO schema_meta(version) VALUES (?)")
270
+ .run(RUN_STORE_SCHEMA_VERSION);
271
+ } else if (schemaRow.version !== RUN_STORE_SCHEMA_VERSION) {
272
+ database.close();
273
+ throw new Error("run-store-schema-version-rejected");
274
+ }
275
+ const store = new RunStore(stateRoot, database, options);
276
+ store.#secureDatabaseFiles();
277
+ store.recoverExpiredLeases();
278
+ return store;
279
+ }
280
+
281
+ #assertOpen(): void {
282
+ if (this.#closed) throw new Error("run-store-closed");
283
+ }
284
+
285
+ #transaction<T>(work: () => T): T {
286
+ this.#assertOpen();
287
+ this.#database.exec("BEGIN IMMEDIATE");
288
+ let value: T;
289
+ try {
290
+ value = work();
291
+ this.#database.exec("COMMIT");
292
+ } catch (error) {
293
+ this.#database.exec("ROLLBACK");
294
+ throw error;
295
+ }
296
+ this.#secureDatabaseFiles();
297
+ return value;
298
+ }
299
+
300
+ #secureDatabaseFiles(): void {
301
+ for (const file of [
302
+ this.stateRoot.databasePath,
303
+ `${this.stateRoot.databasePath}-wal`,
304
+ `${this.stateRoot.databasePath}-shm`,
305
+ ]) {
306
+ try {
307
+ const stat = lstatSync(file);
308
+ if (stat.isSymbolicLink() || !stat.isFile()) {
309
+ throw new StateRootError(
310
+ stat.isSymbolicLink()
311
+ ? "state-component-symlink"
312
+ : "state-component-type",
313
+ );
314
+ }
315
+ chmodSync(file, 0o600);
316
+ } catch (error) {
317
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
318
+ }
319
+ }
320
+ }
321
+
322
+ #runRow(runId: string): RunRow {
323
+ const row = this.#database
324
+ .prepare("SELECT run_id, projection_json FROM runs WHERE run_id = ?")
325
+ .get(runId) as RunRow | undefined;
326
+ if (!row) throw new Error("run-not-found");
327
+ return row;
328
+ }
329
+
330
+ #operation(runId: string, operationId: string): OperationRow | undefined {
331
+ return this.#database
332
+ .prepare(
333
+ `SELECT state, outcome_json, lease_token, lease_expires_at
334
+ FROM operations WHERE run_id = ? AND operation_id = ?`,
335
+ )
336
+ .get(runId, operationId) as OperationRow | undefined;
337
+ }
338
+
339
+ #operationOutcome(
340
+ runId: string,
341
+ operationId: string,
342
+ ): RunProjection | undefined {
343
+ const row = this.#operation(runId, operationId);
344
+ if (row?.state !== "committed" || row.outcome_json === null)
345
+ return undefined;
346
+ return parseProjection(row.outcome_json);
347
+ }
348
+
349
+ #recordOperation(
350
+ runId: string,
351
+ operationId: string,
352
+ kind: string,
353
+ outcome: RunProjection,
354
+ ): void {
355
+ this.#database
356
+ .prepare(
357
+ `INSERT INTO operations(
358
+ run_id, operation_id, kind, state, outcome_json
359
+ ) VALUES (?, ?, ?, 'committed', ?)
360
+ ON CONFLICT(run_id, operation_id) DO UPDATE SET
361
+ state = 'committed', outcome_json = excluded.outcome_json,
362
+ lease_token = NULL, lease_expires_at = NULL`,
363
+ )
364
+ .run(runId, operationId, kind, JSON.stringify(outcome));
365
+ }
366
+
367
+ #events(runId: string): RunEvent[] {
368
+ const rows = this.#database
369
+ .prepare(
370
+ `SELECT sequence, type, payload_json, prior_hash, event_hash
371
+ FROM events WHERE run_id = ? ORDER BY sequence`,
372
+ )
373
+ .all(runId) as unknown as EventRow[];
374
+ return rows.map((row) => ({
375
+ sequence: Number(row.sequence),
376
+ type: row.type as RunEvent["type"],
377
+ payload: JSON.parse(row.payload_json) as Record<string, unknown>,
378
+ priorHash: row.prior_hash,
379
+ hash: row.event_hash,
380
+ }));
381
+ }
382
+
383
+ #appendEvent(
384
+ runId: string,
385
+ type: RunEvent["type"],
386
+ payload: Record<string, unknown>,
387
+ ): RunProjection {
388
+ const row = this.#runRow(runId);
389
+ const current = parseProjection(row.projection_json);
390
+ const event = createRunEvent(
391
+ current.sequence + 1,
392
+ type,
393
+ payload,
394
+ current.eventHash,
395
+ );
396
+ const projection = reduceRunEvents([...this.#events(runId), event]);
397
+ this.#database
398
+ .prepare(
399
+ `INSERT INTO events(
400
+ run_id, sequence, type, payload_json, prior_hash, event_hash
401
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
402
+ )
403
+ .run(
404
+ runId,
405
+ event.sequence,
406
+ event.type,
407
+ JSON.stringify(event.payload),
408
+ event.priorHash,
409
+ event.hash,
410
+ );
411
+ this.#database
412
+ .prepare(
413
+ `UPDATE runs SET state = ?, sequence = ?, event_hash = ?,
414
+ delivery_revision = ?, projection_json = ?, terminal_tombstone = ?
415
+ WHERE run_id = ?`,
416
+ )
417
+ .run(
418
+ projection.state,
419
+ projection.sequence,
420
+ projection.eventHash,
421
+ projection.deliveryRevision ?? null,
422
+ JSON.stringify(projection),
423
+ projection.terminal ?? null,
424
+ runId,
425
+ );
426
+ return projection;
427
+ }
428
+
429
+ startRun(input: StartRunInput): RunProjection {
430
+ requireIdentifier(input.operationId, "operation-id");
431
+ if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
432
+ throw new Error("invalid-run-stage");
433
+ }
434
+ const key = lookupKey(input);
435
+ return this.#transaction(() => {
436
+ const existing = this.#database
437
+ .prepare(
438
+ `SELECT run_id, projection_json FROM runs
439
+ WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
440
+ )
441
+ .get(this.stateRoot.consumerRootHash, input.stage, key) as
442
+ | RunRow
443
+ | undefined;
444
+ if (existing) {
445
+ const replay = this.#operationOutcome(
446
+ existing.run_id,
447
+ input.operationId,
448
+ );
449
+ if (replay) return replay;
450
+ const projection = parseProjection(existing.projection_json);
451
+ this.#recordOperation(
452
+ existing.run_id,
453
+ input.operationId,
454
+ "start",
455
+ projection,
456
+ );
457
+ return projection;
458
+ }
459
+
460
+ const runId = randomUUID();
461
+ const event = createRunEvent(
462
+ 1,
463
+ "run-created",
464
+ {
465
+ runId,
466
+ rootHash: this.stateRoot.consumerRootHash,
467
+ stage: input.stage,
468
+ ...(input.change ? { change: input.change } : {}),
469
+ ...(input.provisionalKey
470
+ ? { provisionalKey: input.provisionalKey }
471
+ : {}),
472
+ },
473
+ ZERO_EVENT_HASH,
474
+ );
475
+ const projection = reduceRunEvents([event]);
476
+ this.#database
477
+ .prepare(
478
+ `INSERT INTO runs(
479
+ run_id, root_hash, stage, lookup_key, change_name,
480
+ provisional_key, state, sequence, event_hash, projection_json
481
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
482
+ )
483
+ .run(
484
+ runId,
485
+ this.stateRoot.consumerRootHash,
486
+ input.stage,
487
+ key,
488
+ input.change ?? null,
489
+ input.provisionalKey ?? null,
490
+ projection.state,
491
+ projection.sequence,
492
+ projection.eventHash,
493
+ JSON.stringify(projection),
494
+ );
495
+ this.#database
496
+ .prepare(
497
+ `INSERT INTO events(
498
+ run_id, sequence, type, payload_json, prior_hash, event_hash
499
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
500
+ )
501
+ .run(
502
+ runId,
503
+ event.sequence,
504
+ event.type,
505
+ JSON.stringify(event.payload),
506
+ event.priorHash,
507
+ event.hash,
508
+ );
509
+ this.#recordOperation(runId, input.operationId, "start", projection);
510
+ return projection;
511
+ });
512
+ }
513
+
514
+ bindChange(input: {
515
+ runId: string;
516
+ change: string;
517
+ operationId: string;
518
+ }): RunProjection {
519
+ requireIdentifier(input.operationId, "operation-id");
520
+ if (!CHANGE_NAME.test(input.change)) throw new Error("invalid-change");
521
+ return this.#transaction(() => {
522
+ const replay = this.#operationOutcome(input.runId, input.operationId);
523
+ if (replay) return replay;
524
+ const current = this.status(input.runId);
525
+ if (current.stage !== "abel-design" || !current.provisionalKey) {
526
+ throw new Error("change-binding-not-allowed");
527
+ }
528
+ const conflict = this.#database
529
+ .prepare(
530
+ `SELECT run_id FROM runs
531
+ WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
532
+ )
533
+ .get(current.rootHash, current.stage, `change:${input.change}`) as
534
+ | { run_id: string }
535
+ | undefined;
536
+ if (conflict && conflict.run_id !== input.runId) {
537
+ throw new Error("change-run-already-exists");
538
+ }
539
+ this.#database
540
+ .prepare(
541
+ `UPDATE runs SET lookup_key = ?, change_name = ?, provisional_key = NULL
542
+ WHERE run_id = ?`,
543
+ )
544
+ .run(`change:${input.change}`, input.change, input.runId);
545
+ const projection = this.#appendEvent(input.runId, "change-bound", {
546
+ change: input.change,
547
+ });
548
+ this.#recordOperation(
549
+ input.runId,
550
+ input.operationId,
551
+ "change-bound",
552
+ projection,
553
+ );
554
+ return projection;
555
+ });
556
+ }
557
+
558
+ #requireDeliveryBinding(input: BindDeliveryInput): void {
559
+ requireIdentifier(input.operationId, "operation-id");
560
+ if (
561
+ (input.gate !== "gate-a" && input.gate !== "gate-b") ||
562
+ !Number.isSafeInteger(input.revision) ||
563
+ input.revision < 1 ||
564
+ !SHA256.test(input.receiptHash)
565
+ ) {
566
+ throw new Error("invalid-delivery-binding");
567
+ }
568
+ }
569
+
570
+ #bindDelivery(input: BindDeliveryInput): RunProjection {
571
+ if (input.lease) {
572
+ if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
573
+ this.#assertLease(input.lease);
574
+ }
575
+ const replay = this.#operationOutcome(input.runId, input.operationId);
576
+ if (replay) return replay;
577
+ const existing = this.#database
578
+ .prepare(
579
+ `SELECT receipt_hash FROM delivery_bindings
580
+ WHERE run_id = ? AND gate = ? AND revision = ?`,
581
+ )
582
+ .get(input.runId, input.gate, input.revision) as
583
+ | { receipt_hash: string }
584
+ | undefined;
585
+ if (existing) {
586
+ if (existing.receipt_hash !== input.receiptHash) {
587
+ throw new Error("delivery-revision-conflict");
588
+ }
589
+ const projection = this.status(input.runId);
590
+ this.#recordOperation(
591
+ input.runId,
592
+ input.operationId,
593
+ "delivery-bound",
594
+ projection,
595
+ );
596
+ return projection;
597
+ }
598
+ this.#runRow(input.runId);
599
+ this.#database
600
+ .prepare(
601
+ `INSERT INTO delivery_bindings(
602
+ run_id, gate, revision, receipt_hash, operation_id
603
+ ) VALUES (?, ?, ?, ?, ?)`,
604
+ )
605
+ .run(
606
+ input.runId,
607
+ input.gate,
608
+ input.revision,
609
+ input.receiptHash,
610
+ input.operationId,
611
+ );
612
+ const projection = this.#appendEvent(input.runId, "delivery-bound", {
613
+ gate: input.gate,
614
+ revision: input.revision,
615
+ receiptHash: input.receiptHash,
616
+ });
617
+ this.#recordOperation(
618
+ input.runId,
619
+ input.operationId,
620
+ "delivery-bound",
621
+ projection,
622
+ );
623
+ return projection;
624
+ }
625
+
626
+ bindDelivery(input: BindDeliveryInput): RunProjection {
627
+ this.#requireDeliveryBinding(input);
628
+ return this.#transaction(() => this.#bindDelivery(input));
629
+ }
630
+
631
+ bindDeliveryAtomically(
632
+ input: BindDeliveryInput,
633
+ write: (database: DatabaseSync) => void,
634
+ ): RunProjection {
635
+ this.#requireDeliveryBinding(input);
636
+ if (typeof write !== "function") {
637
+ throw new Error("delivery-binding-write-invalid");
638
+ }
639
+ return this.#transaction(() => {
640
+ const projection = this.#bindDelivery(input);
641
+ write(this.#database);
642
+ return projection;
643
+ });
644
+ }
645
+
646
+ acquireLease(input: {
647
+ runId: string;
648
+ operationId: string;
649
+ ttlMs: number;
650
+ preempt?: boolean;
651
+ exclusive?: boolean;
652
+ }): OperationLease {
653
+ requireIdentifier(input.operationId, "operation-id");
654
+ if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1) {
655
+ throw new Error("invalid-lease-ttl");
656
+ }
657
+ return this.#transaction(() => {
658
+ this.#runRow(input.runId);
659
+ const now = this.#now();
660
+ const existing = this.#operation(input.runId, input.operationId);
661
+ if (
662
+ existing?.state === "running" &&
663
+ existing.lease_token &&
664
+ existing.lease_expires_at !== null &&
665
+ existing.lease_expires_at >= now
666
+ ) {
667
+ if (!input.preempt) {
668
+ if (input.exclusive) throw new Error("operation-already-running");
669
+ return {
670
+ runId: input.runId,
671
+ operationId: input.operationId,
672
+ token: existing.lease_token,
673
+ expiresAt: existing.lease_expires_at,
674
+ };
675
+ }
676
+ }
677
+ const token = randomUUID();
678
+ const expiresAt = now + input.ttlMs;
679
+ this.#database
680
+ .prepare(
681
+ `INSERT INTO operations(
682
+ run_id, operation_id, kind, state, lease_token, lease_expires_at
683
+ ) VALUES (?, ?, 'lease', 'running', ?, ?)
684
+ ON CONFLICT(run_id, operation_id) DO UPDATE SET
685
+ state = 'running', outcome_json = NULL,
686
+ lease_token = excluded.lease_token,
687
+ lease_expires_at = excluded.lease_expires_at`,
688
+ )
689
+ .run(input.runId, input.operationId, token, expiresAt);
690
+ return {
691
+ runId: input.runId,
692
+ operationId: input.operationId,
693
+ token,
694
+ expiresAt,
695
+ };
696
+ });
697
+ }
698
+
699
+ renewLease(lease: OperationLease, ttlMs: number): OperationLease {
700
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
701
+ throw new Error("invalid-lease-ttl");
702
+ return this.#transaction(() => {
703
+ const existing = this.#operation(lease.runId, lease.operationId);
704
+ if (
705
+ existing?.state !== "running" ||
706
+ existing.lease_token !== lease.token ||
707
+ existing.lease_expires_at === null ||
708
+ existing.lease_expires_at < this.#now()
709
+ ) {
710
+ throw new Error("lease-fenced");
711
+ }
712
+ const expiresAt = this.#now() + ttlMs;
713
+ this.#database
714
+ .prepare(
715
+ `UPDATE operations SET lease_expires_at = ?
716
+ WHERE run_id = ? AND operation_id = ? AND lease_token = ?`,
717
+ )
718
+ .run(expiresAt, lease.runId, lease.operationId, lease.token);
719
+ return { ...lease, expiresAt };
720
+ });
721
+ }
722
+
723
+ #assertLease(lease: OperationLease): void {
724
+ const existing = this.#operation(lease.runId, lease.operationId);
725
+ if (
726
+ existing?.state !== "running" ||
727
+ existing.lease_token !== lease.token ||
728
+ existing.lease_expires_at === null ||
729
+ existing.lease_expires_at < this.#now()
730
+ ) {
731
+ throw new Error("lease-fenced");
732
+ }
733
+ }
734
+
735
+ assertLease(lease: OperationLease): void {
736
+ this.#transaction(() => this.#assertLease(lease));
737
+ }
738
+
739
+ inspectOperation(
740
+ runId: string,
741
+ operationId: string,
742
+ ): OperationLeaseStatus | undefined {
743
+ this.#assertOpen();
744
+ requireIdentifier(operationId, "operation-id");
745
+ this.#runRow(runId);
746
+ let operation = this.#operation(runId, operationId);
747
+ if (!operation) return undefined;
748
+ if (
749
+ operation.state === "running" &&
750
+ operation.lease_expires_at !== null &&
751
+ operation.lease_expires_at < this.#now()
752
+ ) {
753
+ this.#transaction(() => {
754
+ this.#database
755
+ .prepare(
756
+ `UPDATE operations
757
+ SET state = 'interrupted', lease_token = NULL,
758
+ lease_expires_at = NULL
759
+ WHERE run_id = ? AND operation_id = ? AND state = 'running'
760
+ AND lease_expires_at < ?`,
761
+ )
762
+ .run(runId, operationId, this.#now());
763
+ });
764
+ operation = this.#operation(runId, operationId);
765
+ if (!operation) throw new Error("operation-recovery-invalid");
766
+ }
767
+ if (
768
+ operation.state !== "running" &&
769
+ operation.state !== "committed" &&
770
+ operation.state !== "interrupted"
771
+ ) {
772
+ throw new Error("operation-state-invalid");
773
+ }
774
+ let outcome: Record<string, unknown> | undefined;
775
+ if (operation.outcome_json !== null) {
776
+ const parsed = JSON.parse(operation.outcome_json) as unknown;
777
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
778
+ throw new Error("operation-outcome-invalid");
779
+ }
780
+ outcome = parsed as Record<string, unknown>;
781
+ }
782
+ return {
783
+ state: operation.state,
784
+ ...(outcome ? { outcome: structuredClone(outcome) } : {}),
785
+ ...(operation.lease_expires_at === null
786
+ ? {}
787
+ : { expiresAt: operation.lease_expires_at }),
788
+ };
789
+ }
790
+
791
+ commitLease(
792
+ lease: OperationLease,
793
+ outcome: Record<string, unknown>,
794
+ ): Record<string, unknown> {
795
+ const serialized = JSON.stringify(outcome);
796
+ if (!serialized) throw new Error("operation-outcome-invalid");
797
+ return this.#transaction(() => {
798
+ this.#assertLease(lease);
799
+ const result = this.#database
800
+ .prepare(
801
+ `UPDATE operations
802
+ SET state = 'committed', outcome_json = ?,
803
+ lease_token = NULL, lease_expires_at = NULL
804
+ WHERE run_id = ? AND operation_id = ? AND state = 'running'
805
+ AND lease_token = ?`,
806
+ )
807
+ .run(serialized, lease.runId, lease.operationId, lease.token);
808
+ if (Number(result.changes) !== 1) throw new Error("lease-fenced");
809
+ return structuredClone(outcome);
810
+ });
811
+ }
812
+
813
+ interruptLease(lease: OperationLease): void {
814
+ this.#transaction(() => {
815
+ this.#assertLease(lease);
816
+ const result = this.#database
817
+ .prepare(
818
+ `UPDATE operations
819
+ SET state = 'interrupted', outcome_json = NULL,
820
+ lease_token = NULL, lease_expires_at = NULL
821
+ WHERE run_id = ? AND operation_id = ? AND state = 'running'
822
+ AND lease_token = ?`,
823
+ )
824
+ .run(lease.runId, lease.operationId, lease.token);
825
+ if (Number(result.changes) !== 1) throw new Error("lease-fenced");
826
+ });
827
+ }
828
+
829
+ transition(input: {
830
+ runId: string;
831
+ to: RunState;
832
+ operationId: string;
833
+ code?: string;
834
+ lease?: OperationLease;
835
+ }): RunProjection {
836
+ requireIdentifier(input.operationId, "operation-id");
837
+ return this.#transaction(() => {
838
+ if (input.lease) {
839
+ if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
840
+ this.#assertLease(input.lease);
841
+ }
842
+ const replay = this.#operationOutcome(input.runId, input.operationId);
843
+ if (replay) return replay;
844
+ const current = this.status(input.runId);
845
+ const projection = this.#appendEvent(input.runId, "state-transitioned", {
846
+ from: current.state,
847
+ to: input.to,
848
+ ...(input.code ? { code: input.code } : {}),
849
+ });
850
+ this.#recordOperation(
851
+ input.runId,
852
+ input.operationId,
853
+ "state-transitioned",
854
+ projection,
855
+ );
856
+ return projection;
857
+ });
858
+ }
859
+
860
+ status(runId: string): RunProjection {
861
+ this.#assertOpen();
862
+ return parseProjection(this.#runRow(runId).projection_json);
863
+ }
864
+
865
+ listEvents(runId: string): RunEvent[] {
866
+ this.#assertOpen();
867
+ this.#runRow(runId);
868
+ return structuredClone(this.#events(runId));
869
+ }
870
+
871
+ rebuildProjection(runId: string): RunProjection {
872
+ return reduceRunEvents(this.listEvents(runId));
873
+ }
874
+
875
+ verifyIntegrity(runId: string): RunProjection {
876
+ const rebuilt = this.rebuildProjection(runId);
877
+ const stored = this.status(runId);
878
+ if (JSON.stringify(rebuilt) !== JSON.stringify(stored)) {
879
+ throw new Error("run-projection-integrity-mismatch");
880
+ }
881
+ return rebuilt;
882
+ }
883
+
884
+ recoverExpiredLeases(): number {
885
+ this.#assertOpen();
886
+ const expired = this.#database
887
+ .prepare(
888
+ `SELECT run_id, operation_id FROM operations
889
+ WHERE state = 'running' AND lease_expires_at < ?`,
890
+ )
891
+ .all(this.#now()) as unknown as Array<{
892
+ run_id: string;
893
+ operation_id: string;
894
+ }>;
895
+ if (expired.length === 0) return 0;
896
+ return this.#transaction(() => {
897
+ for (const row of expired) {
898
+ this.#database
899
+ .prepare(
900
+ `UPDATE operations
901
+ SET state = 'interrupted', lease_token = NULL,
902
+ lease_expires_at = NULL
903
+ WHERE run_id = ? AND operation_id = ? AND state = 'running'`,
904
+ )
905
+ .run(row.run_id, row.operation_id);
906
+ }
907
+ return expired.length;
908
+ });
909
+ }
910
+
911
+ close(): void {
912
+ if (this.#closed) return;
913
+ this.#database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
914
+ this.#secureDatabaseFiles();
915
+ this.#database.close();
916
+ this.#closed = true;
917
+ }
918
+ }