@hunterzhu/pulse-runtime 0.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 (93) hide show
  1. package/dist/context/builder.d.ts +68 -0
  2. package/dist/context/builder.js +127 -0
  3. package/dist/context/index.d.ts +2 -0
  4. package/dist/context/index.js +2 -0
  5. package/dist/context/merger.d.ts +25 -0
  6. package/dist/context/merger.js +125 -0
  7. package/dist/core/actions.d.ts +1 -0
  8. package/dist/core/actions.js +1 -0
  9. package/dist/core/errors.d.ts +8 -0
  10. package/dist/core/errors.js +36 -0
  11. package/dist/core/events.d.ts +10 -0
  12. package/dist/core/events.js +24 -0
  13. package/dist/core/factory.d.ts +35 -0
  14. package/dist/core/factory.js +27 -0
  15. package/dist/core/inbox.d.ts +119 -0
  16. package/dist/core/inbox.js +217 -0
  17. package/dist/core/mutations.d.ts +80 -0
  18. package/dist/core/mutations.js +127 -0
  19. package/dist/core/records.d.ts +1 -0
  20. package/dist/core/records.js +1 -0
  21. package/dist/core/types.d.ts +615 -0
  22. package/dist/core/types.js +109 -0
  23. package/dist/dependencies/graph.d.ts +25 -0
  24. package/dist/dependencies/graph.js +92 -0
  25. package/dist/dependencies/index.d.ts +1 -0
  26. package/dist/dependencies/index.js +1 -0
  27. package/dist/dsl/context-proxy.d.ts +20 -0
  28. package/dist/dsl/context-proxy.js +64 -0
  29. package/dist/dsl/index.d.ts +4 -0
  30. package/dist/dsl/index.js +4 -0
  31. package/dist/dsl/program.d.ts +314 -0
  32. package/dist/dsl/program.js +756 -0
  33. package/dist/dsl/session.d.ts +45 -0
  34. package/dist/dsl/session.js +93 -0
  35. package/dist/dsl/templates-index.d.ts +1 -0
  36. package/dist/dsl/templates-index.js +1 -0
  37. package/dist/dsl/templates.d.ts +85 -0
  38. package/dist/dsl/templates.js +110 -0
  39. package/dist/index.d.ts +15 -0
  40. package/dist/index.js +15 -0
  41. package/dist/lifecycle/index.d.ts +2 -0
  42. package/dist/lifecycle/index.js +2 -0
  43. package/dist/lifecycle/scopes.d.ts +38 -0
  44. package/dist/lifecycle/scopes.js +50 -0
  45. package/dist/lifecycle/watchdog.d.ts +16 -0
  46. package/dist/lifecycle/watchdog.js +66 -0
  47. package/dist/models/actions.d.ts +10 -0
  48. package/dist/models/actions.js +68 -0
  49. package/dist/models/index.d.ts +2 -0
  50. package/dist/models/index.js +2 -0
  51. package/dist/models/router.d.ts +187 -0
  52. package/dist/models/router.js +353 -0
  53. package/dist/scheduler/clock.d.ts +45 -0
  54. package/dist/scheduler/clock.js +92 -0
  55. package/dist/scheduler/decision.d.ts +72 -0
  56. package/dist/scheduler/decision.js +63 -0
  57. package/dist/scheduler/index.d.ts +6 -0
  58. package/dist/scheduler/index.js +6 -0
  59. package/dist/scheduler/locks.d.ts +18 -0
  60. package/dist/scheduler/locks.js +106 -0
  61. package/dist/scheduler/ready-queue.d.ts +32 -0
  62. package/dist/scheduler/ready-queue.js +40 -0
  63. package/dist/scheduler/runtime.d.ts +486 -0
  64. package/dist/scheduler/runtime.js +3445 -0
  65. package/dist/scheduler/telemetry.d.ts +111 -0
  66. package/dist/scheduler/telemetry.js +177 -0
  67. package/dist/scheduler/worker.d.ts +158 -0
  68. package/dist/scheduler/worker.js +744 -0
  69. package/dist/storage/artifacts.d.ts +17 -0
  70. package/dist/storage/artifacts.js +90 -0
  71. package/dist/storage/findings.d.ts +12 -0
  72. package/dist/storage/findings.js +70 -0
  73. package/dist/storage/index.d.ts +8 -0
  74. package/dist/storage/index.js +8 -0
  75. package/dist/storage/memory.d.ts +11 -0
  76. package/dist/storage/memory.js +21 -0
  77. package/dist/storage/mutation-log.d.ts +41 -0
  78. package/dist/storage/mutation-log.js +140 -0
  79. package/dist/storage/outbox.d.ts +30 -0
  80. package/dist/storage/outbox.js +59 -0
  81. package/dist/storage/persistence.d.ts +183 -0
  82. package/dist/storage/persistence.js +999 -0
  83. package/dist/storage/policy.d.ts +80 -0
  84. package/dist/storage/policy.js +268 -0
  85. package/dist/storage/session.d.ts +140 -0
  86. package/dist/storage/session.js +447 -0
  87. package/dist/tools/registry.d.ts +125 -0
  88. package/dist/tools/registry.js +308 -0
  89. package/dist/transitions/index.d.ts +2 -0
  90. package/dist/transitions/index.js +1 -0
  91. package/dist/transitions/validate.d.ts +4 -0
  92. package/dist/transitions/validate.js +1118 -0
  93. package/package.json +21 -0
@@ -0,0 +1,999 @@
1
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
2
+ import { mkdirSync, readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { dirname, join } from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { parseContextSnapshotRef, provenanceRefId, provenanceRefKind } from '../core/types.js';
7
+ import { stableSerialize } from '../context/builder.js';
8
+ import { FactInbox, factInboxDedupeDigest } from '../core/inbox.js';
9
+ import { exportRuntimeState, FileRuntimeSessionStore, importRuntimeState, SqliteRuntimeSessionStore } from './session.js';
10
+ import { EffectOutbox } from './outbox.js';
11
+ import { MutationLog } from './mutation-log.js';
12
+ import { SessionStoragePolicy } from './policy.js';
13
+ function pidAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (cause) {
19
+ if (cause.code === 'ESRCH')
20
+ return false;
21
+ throw cause;
22
+ }
23
+ }
24
+ async function acquireExclusiveLock(lockPath, timeoutCode, timeoutMs = 30_000) {
25
+ const deadline = Date.now() + timeoutMs;
26
+ const payload = JSON.stringify({ pid: process.pid, token: `${process.pid}:${process.hrtime.bigint()}` });
27
+ for (;;) {
28
+ try {
29
+ const handle = await open(lockPath, 'wx', 0o600);
30
+ await handle.writeFile(payload);
31
+ return handle;
32
+ }
33
+ catch (cause) {
34
+ if (cause.code !== 'EEXIST')
35
+ throw cause;
36
+ const body = await readFile(lockPath, 'utf8').catch(() => undefined);
37
+ let owner;
38
+ try {
39
+ owner = body ? JSON.parse(body) : undefined;
40
+ }
41
+ catch {
42
+ owner = undefined;
43
+ }
44
+ if (typeof owner?.pid === 'number' && !pidAlive(owner.pid) && body !== undefined) {
45
+ const current = await readFile(lockPath, 'utf8').catch(() => undefined);
46
+ if (current === body) {
47
+ await rm(lockPath, { force: true });
48
+ continue;
49
+ }
50
+ }
51
+ if (Date.now() >= deadline)
52
+ throw new Error(timeoutCode);
53
+ await new Promise((resolve) => setTimeout(resolve, 5));
54
+ }
55
+ }
56
+ }
57
+ /** Atomic, idempotent file-backed body store usable as both ResultStore and SnapshotStore. */
58
+ export class FileRuntimeContentStore {
59
+ directory;
60
+ constructor(directory) {
61
+ this.directory = directory;
62
+ }
63
+ async save(ref, value) {
64
+ if (!ref)
65
+ throw new Error('INVALID_RUNTIME_CONTENT_REF');
66
+ await mkdir(this.directory, { recursive: true });
67
+ const target = this.pathFor(ref);
68
+ await this.withLock(target, async () => {
69
+ const existing = await this.readEnvelope(target);
70
+ if (existing !== undefined) {
71
+ if (existing.ref !== ref)
72
+ throw new Error('RUNTIME_CONTENT_REF_COLLISION');
73
+ if (stableSerialize(existing.value) !== stableSerialize(value))
74
+ throw new Error('RUNTIME_CONTENT_CONFLICT');
75
+ return;
76
+ }
77
+ const temporaryPath = `${target}.tmp-${process.pid}-${Date.now()}-${process.hrtime.bigint().toString()}`;
78
+ let handle;
79
+ try {
80
+ handle = await open(temporaryPath, 'wx', 0o600);
81
+ const envelope = { schemaVersion: 1, ref, value: structuredClone(value) };
82
+ await handle.writeFile(JSON.stringify(envelope), 'utf8');
83
+ await handle.sync();
84
+ await handle.close();
85
+ handle = undefined;
86
+ await rename(temporaryPath, target);
87
+ }
88
+ finally {
89
+ if (handle)
90
+ await handle.close().catch(() => undefined);
91
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
92
+ }
93
+ });
94
+ }
95
+ async load(ref) {
96
+ if (!ref)
97
+ throw new Error('INVALID_RUNTIME_CONTENT_REF');
98
+ const envelope = await this.readEnvelope(this.pathFor(ref));
99
+ if (envelope === undefined)
100
+ return undefined;
101
+ if (envelope.ref !== ref)
102
+ throw new Error('RUNTIME_CONTENT_REF_COLLISION');
103
+ return structuredClone(envelope.value);
104
+ }
105
+ pathFor(ref) { return join(this.directory, `${createHash('sha256').update(ref).digest('hex')}.json`); }
106
+ async readEnvelope(path) {
107
+ try {
108
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
109
+ if (!parsed || parsed.schemaVersion !== 1 || typeof parsed.ref !== 'string' || parsed.value === undefined)
110
+ throw new Error('INVALID_RUNTIME_CONTENT');
111
+ return parsed;
112
+ }
113
+ catch (cause) {
114
+ if (cause.code === 'ENOENT')
115
+ return undefined;
116
+ if (cause instanceof Error && cause.message === 'INVALID_RUNTIME_CONTENT')
117
+ throw cause;
118
+ throw new Error('INVALID_RUNTIME_CONTENT');
119
+ }
120
+ }
121
+ async withLock(target, work) {
122
+ const lock = await acquireExclusiveLock(`${target}.lock`, 'RUNTIME_CONTENT_LOCK_TIMEOUT');
123
+ try {
124
+ return await work();
125
+ }
126
+ finally {
127
+ await lock.close().catch(() => undefined);
128
+ await rm(`${target}.lock`, { force: true }).catch(() => undefined);
129
+ }
130
+ }
131
+ }
132
+ /** SQLite-backed result/snapshot body store with idempotent writes and conflict detection. */
133
+ export class SqliteRuntimeContentStore {
134
+ filePath;
135
+ namespace;
136
+ database;
137
+ tail = Promise.resolve();
138
+ constructor(filePath, namespace) {
139
+ this.filePath = filePath;
140
+ this.namespace = namespace;
141
+ }
142
+ async save(ref, value) {
143
+ if (!ref)
144
+ throw new Error('INVALID_RUNTIME_CONTENT_REF');
145
+ await this.enqueue(async () => {
146
+ await mkdir(dirname(this.filePath), { recursive: true });
147
+ const database = this.open();
148
+ database.exec('BEGIN IMMEDIATE');
149
+ try {
150
+ const current = database.prepare('SELECT payload FROM runtime_content WHERE namespace = ? AND ref = ?').get(this.namespace, ref);
151
+ if (current && typeof current.payload === 'string') {
152
+ if (stableSerialize(JSON.parse(current.payload)) !== stableSerialize(value))
153
+ throw new Error(`RUNTIME_CONTENT_CONFLICT:${this.namespace}:${ref}`);
154
+ }
155
+ else
156
+ database.prepare('INSERT INTO runtime_content (namespace, ref, payload) VALUES (?, ?, ?)').run(this.namespace, ref, JSON.stringify(value));
157
+ database.exec('COMMIT');
158
+ }
159
+ catch (cause) {
160
+ try {
161
+ database.exec('ROLLBACK');
162
+ }
163
+ catch { /* transaction already closed */ }
164
+ throw cause;
165
+ }
166
+ });
167
+ }
168
+ async load(ref) {
169
+ if (!ref)
170
+ throw new Error('INVALID_RUNTIME_CONTENT_REF');
171
+ return await this.enqueue(async () => {
172
+ await mkdir(dirname(this.filePath), { recursive: true });
173
+ const row = this.open().prepare('SELECT payload FROM runtime_content WHERE namespace = ? AND ref = ?').get(this.namespace, ref);
174
+ if (!row || typeof row.payload !== 'string')
175
+ return undefined;
176
+ return structuredClone(JSON.parse(row.payload));
177
+ });
178
+ }
179
+ async close() {
180
+ await this.enqueue(async () => {
181
+ this.database?.close();
182
+ this.database = undefined;
183
+ });
184
+ }
185
+ open() {
186
+ if (this.database)
187
+ return this.database;
188
+ const require = createRequire(import.meta.url);
189
+ const { DatabaseSync } = require('node:sqlite');
190
+ this.database = new DatabaseSync(this.filePath);
191
+ this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS runtime_content (namespace TEXT NOT NULL, ref TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (namespace, ref))');
192
+ return this.database;
193
+ }
194
+ enqueue(work) {
195
+ const operation = this.tail.then(work, work);
196
+ this.tail = operation.then(() => undefined, () => undefined);
197
+ return operation;
198
+ }
199
+ }
200
+ /** SQLite-backed fact event archive with idempotent sequence append and range reads. */
201
+ export class SqliteRuntimeEventArchive {
202
+ filePath;
203
+ database;
204
+ tail = Promise.resolve();
205
+ constructor(filePath) {
206
+ this.filePath = filePath;
207
+ }
208
+ async append(events) {
209
+ if (events.length === 0)
210
+ return;
211
+ const incoming = new Map();
212
+ for (const event of events) {
213
+ if (!Number.isInteger(event.seq) || event.seq < 1)
214
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE');
215
+ const previous = incoming.get(event.seq);
216
+ if (previous && stableSerialize(previous) !== stableSerialize(event))
217
+ throw new Error('RUNTIME_EVENT_ARCHIVE_CONFLICT');
218
+ incoming.set(event.seq, structuredClone(event));
219
+ }
220
+ await this.enqueue(async () => {
221
+ await mkdir(dirname(this.filePath), { recursive: true });
222
+ const database = this.open();
223
+ database.exec('BEGIN IMMEDIATE');
224
+ try {
225
+ for (const [seq, event] of incoming) {
226
+ const current = database.prepare('SELECT payload FROM runtime_event_archive WHERE seq = ?').get(seq);
227
+ if (current && typeof current.payload === 'string') {
228
+ if (stableSerialize(JSON.parse(current.payload)) !== stableSerialize(event))
229
+ throw new Error('RUNTIME_EVENT_ARCHIVE_CONFLICT');
230
+ }
231
+ else
232
+ database.prepare('INSERT INTO runtime_event_archive (seq, payload) VALUES (?, ?)').run(seq, JSON.stringify(event));
233
+ }
234
+ database.exec('COMMIT');
235
+ }
236
+ catch (cause) {
237
+ try {
238
+ database.exec('ROLLBACK');
239
+ }
240
+ catch { /* transaction already closed */ }
241
+ throw cause;
242
+ }
243
+ });
244
+ }
245
+ async read(fromSeq, toSeq = Number.POSITIVE_INFINITY) {
246
+ if (!Number.isInteger(fromSeq) || fromSeq < 0 || Number.isNaN(toSeq))
247
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE_RANGE');
248
+ return await this.enqueue(async () => {
249
+ await mkdir(dirname(this.filePath), { recursive: true });
250
+ const statement = toSeq === Number.POSITIVE_INFINITY
251
+ ? this.open().prepare('SELECT payload FROM runtime_event_archive WHERE seq >= ? ORDER BY seq')
252
+ : this.open().prepare('SELECT payload FROM runtime_event_archive WHERE seq >= ? AND seq <= ? ORDER BY seq');
253
+ const rows = toSeq === Number.POSITIVE_INFINITY ? statement.all(fromSeq) : statement.all(fromSeq, toSeq);
254
+ return rows.filter((row) => typeof row.payload === 'string').map((row) => structuredClone(JSON.parse(row.payload)));
255
+ });
256
+ }
257
+ async close() {
258
+ await this.enqueue(async () => {
259
+ this.database?.close();
260
+ this.database = undefined;
261
+ });
262
+ }
263
+ open() {
264
+ if (this.database)
265
+ return this.database;
266
+ const require = createRequire(import.meta.url);
267
+ const { DatabaseSync } = require('node:sqlite');
268
+ this.database = new DatabaseSync(this.filePath);
269
+ this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS runtime_event_archive (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL)');
270
+ return this.database;
271
+ }
272
+ enqueue(work) {
273
+ const operation = this.tail.then(work, work);
274
+ this.tail = operation.then(() => undefined, () => undefined);
275
+ return operation;
276
+ }
277
+ }
278
+ /** Atomic file-backed EventArchive with idempotent sequence append and range reads. */
279
+ export class FileRuntimeEventArchive {
280
+ directory;
281
+ filePath;
282
+ constructor(directory) {
283
+ this.directory = directory;
284
+ this.filePath = join(directory, 'events.json');
285
+ }
286
+ async append(events) {
287
+ if (events.length === 0)
288
+ return;
289
+ const incoming = new Map();
290
+ for (const event of events) {
291
+ if (!Number.isInteger(event.seq) || event.seq < 1)
292
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE');
293
+ const previous = incoming.get(event.seq);
294
+ if (previous && stableSerialize(previous) !== stableSerialize(event))
295
+ throw new Error('RUNTIME_EVENT_ARCHIVE_CONFLICT');
296
+ incoming.set(event.seq, structuredClone(event));
297
+ }
298
+ await mkdir(this.directory, { recursive: true });
299
+ await this.withLock(async () => {
300
+ const existing = await this.readEnvelope();
301
+ const bySeq = new Map(existing.map((event) => [event.seq, event]));
302
+ for (const [seq, event] of incoming) {
303
+ const previous = bySeq.get(seq);
304
+ if (previous && stableSerialize(previous) !== stableSerialize(event))
305
+ throw new Error('RUNTIME_EVENT_ARCHIVE_CONFLICT');
306
+ bySeq.set(seq, event);
307
+ }
308
+ await this.writeEnvelope([...bySeq.values()].sort((left, right) => left.seq - right.seq));
309
+ });
310
+ }
311
+ async read(fromSeq, toSeq = Number.POSITIVE_INFINITY) {
312
+ if (!Number.isInteger(fromSeq) || fromSeq < 0 || Number.isNaN(toSeq))
313
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE_RANGE');
314
+ const events = await this.readEnvelope();
315
+ return events.filter((event) => event.seq >= fromSeq && event.seq <= toSeq).map((event) => structuredClone(event));
316
+ }
317
+ async readEnvelope() {
318
+ try {
319
+ const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
320
+ if (!parsed || parsed.schemaVersion !== 1 || !Array.isArray(parsed.events))
321
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE');
322
+ return parsed.events.map((event) => {
323
+ if (!event || !Number.isInteger(event.seq) || event.seq < 1)
324
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE');
325
+ return structuredClone(event);
326
+ });
327
+ }
328
+ catch (cause) {
329
+ if (cause.code === 'ENOENT')
330
+ return [];
331
+ if (cause instanceof Error && cause.message === 'INVALID_RUNTIME_EVENT_ARCHIVE')
332
+ throw cause;
333
+ throw new Error('INVALID_RUNTIME_EVENT_ARCHIVE');
334
+ }
335
+ }
336
+ async writeEnvelope(events) {
337
+ const temporaryPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${process.hrtime.bigint().toString()}`;
338
+ let handle;
339
+ try {
340
+ handle = await open(temporaryPath, 'wx', 0o600);
341
+ await handle.writeFile(JSON.stringify({ schemaVersion: 1, events }), 'utf8');
342
+ await handle.sync();
343
+ await handle.close();
344
+ handle = undefined;
345
+ await rename(temporaryPath, this.filePath);
346
+ }
347
+ finally {
348
+ if (handle)
349
+ await handle.close().catch(() => undefined);
350
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
351
+ }
352
+ }
353
+ async withLock(work) {
354
+ const lockPath = `${this.filePath}.lock`;
355
+ const lock = await acquireExclusiveLock(lockPath, 'RUNTIME_EVENT_ARCHIVE_LOCK_TIMEOUT');
356
+ try {
357
+ return await work();
358
+ }
359
+ finally {
360
+ await lock.close().catch(() => undefined);
361
+ await rm(lockPath, { force: true }).catch(() => undefined);
362
+ }
363
+ }
364
+ }
365
+ const FILE_FACT_INBOX_DEDUPE_ARCHIVE_ID = 'pulse.fact-inbox-dedupe.file.v1';
366
+ const SQLITE_FACT_INBOX_DEDUPE_ARCHIVE_ID = 'pulse.fact-inbox-dedupe.sqlite.v1';
367
+ function sortedDedupeEntries(entries) {
368
+ return [...entries].sort(([left], [right]) => left - right).map(([receivedSeq, eventId]) => ({ eventId, receivedSeq }));
369
+ }
370
+ function validateDedupeEntries(entries, archiveId, watermark, digest) {
371
+ if (!Number.isInteger(watermark) || watermark < 0 || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
372
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE');
373
+ const bySeq = new Map();
374
+ const ids = new Set();
375
+ for (const entry of entries) {
376
+ if (!entry || typeof entry.eventId !== 'string' || entry.eventId.length === 0 || !Number.isInteger(entry.receivedSeq) || entry.receivedSeq < 1 || entry.receivedSeq > watermark || bySeq.has(entry.receivedSeq) || ids.has(entry.eventId))
377
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE');
378
+ bySeq.set(entry.receivedSeq, entry.eventId);
379
+ ids.add(entry.eventId);
380
+ }
381
+ if (bySeq.size !== watermark || [...bySeq.keys()].some((seq, index) => seq !== index + 1) || factInboxDedupeDigest(sortedDedupeEntries(bySeq.entries())) !== digest)
382
+ throw new Error(`INVALID_FACT_INBOX_DEDUPE_ARCHIVE:${archiveId}`);
383
+ return bySeq;
384
+ }
385
+ function mergeDedupeBatch(existing, archiveId, batch) {
386
+ if (!batch || batch.schemaVersion !== 1 || batch.archiveId !== archiveId || !Number.isInteger(batch.through) || batch.through < 1 || !Array.isArray(batch.entries) || batch.entries.length === 0)
387
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE_BATCH');
388
+ const sorted = [...batch.entries].sort((left, right) => left.receivedSeq - right.receivedSeq);
389
+ if (sorted.some((entry, index) => !entry || typeof entry.eventId !== 'string' || entry.eventId.length === 0 || !Number.isInteger(entry.receivedSeq) || entry.receivedSeq !== sorted[0].receivedSeq + index || (index > 0 && entry.eventId === sorted[index - 1].eventId)))
390
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE_BATCH');
391
+ if (sorted[0].receivedSeq > existing.size + 1 || sorted.at(-1).receivedSeq !== batch.through)
392
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE_BATCH');
393
+ const merged = new Map(existing);
394
+ for (const entry of sorted) {
395
+ const previous = merged.get(entry.receivedSeq);
396
+ if (previous !== undefined && previous !== entry.eventId)
397
+ throw new Error('RUNTIME_FACT_INBOX_DEDUPE_CONFLICT');
398
+ merged.set(entry.receivedSeq, entry.eventId);
399
+ }
400
+ if (merged.size < batch.through || [...Array(batch.through)].some((_, index) => !merged.has(index + 1)))
401
+ throw new Error('FACT_INBOX_DEDUPE_LEDGER_INCOMPLETE');
402
+ const prefix = new Map([...merged.entries()].filter(([receivedSeq]) => receivedSeq <= batch.through));
403
+ if (factInboxDedupeDigest(sortedDedupeEntries(prefix.entries())) !== batch.ledgerDigest)
404
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE_BATCH');
405
+ return merged;
406
+ }
407
+ /** Durable file-backed membership archive for compacted FactInbox ids. */
408
+ export class FileRuntimeFactInboxDedupeArchive {
409
+ directory;
410
+ archiveId = FILE_FACT_INBOX_DEDUPE_ARCHIVE_ID;
411
+ filePath;
412
+ entries = new Map();
413
+ constructor(directory) {
414
+ this.directory = directory;
415
+ this.filePath = join(directory, 'fact-inbox-dedupe.json');
416
+ try {
417
+ const value = JSON.parse(readFileSync(this.filePath, 'utf8'));
418
+ if (!value || value.schemaVersion !== 1 || value.archiveId !== this.archiveId)
419
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE');
420
+ this.entries = validateDedupeEntries(value.entries, this.archiveId, value.watermark, value.digest);
421
+ }
422
+ catch (cause) {
423
+ if (cause.code !== 'ENOENT') {
424
+ if (cause instanceof Error && cause.message.startsWith('INVALID_FACT_INBOX_DEDUPE_ARCHIVE'))
425
+ throw cause;
426
+ throw new Error('INVALID_FACT_INBOX_DEDUPE_ARCHIVE');
427
+ }
428
+ }
429
+ }
430
+ get watermark() { return this.entries.size; }
431
+ contains(eventId, receivedSeq) {
432
+ if (receivedSeq !== undefined)
433
+ return this.entries.get(receivedSeq) === eventId;
434
+ return [...this.entries.values()].includes(eventId);
435
+ }
436
+ digestThrough(through) {
437
+ if (!Number.isInteger(through) || through < 0 || through > this.watermark)
438
+ return '';
439
+ return factInboxDedupeDigest(sortedDedupeEntries([...this.entries.entries()].filter(([receivedSeq]) => receivedSeq <= through)));
440
+ }
441
+ async append(batch) {
442
+ await mkdir(this.directory, { recursive: true });
443
+ await this.withLock(async () => {
444
+ const current = new Map(this.entries);
445
+ const merged = mergeDedupeBatch(current, this.archiveId, batch);
446
+ if (merged.size === current.size && [...merged.entries()].every(([seq, eventId]) => current.get(seq) === eventId))
447
+ return;
448
+ const envelope = { schemaVersion: 1, archiveId: this.archiveId, watermark: merged.size, entries: sortedDedupeEntries(merged.entries()), digest: factInboxDedupeDigest(sortedDedupeEntries(merged.entries())) };
449
+ const temporaryPath = `${this.filePath}.tmp-${process.pid}-${process.hrtime.bigint().toString()}`;
450
+ let handle;
451
+ try {
452
+ handle = await open(temporaryPath, 'wx', 0o600);
453
+ await handle.writeFile(JSON.stringify(envelope), 'utf8');
454
+ await handle.sync();
455
+ await handle.close();
456
+ handle = undefined;
457
+ await rename(temporaryPath, this.filePath);
458
+ this.entries = merged;
459
+ }
460
+ finally {
461
+ if (handle)
462
+ await handle.close().catch(() => undefined);
463
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
464
+ }
465
+ });
466
+ }
467
+ async withLock(work) {
468
+ const lockPath = `${this.filePath}.lock`;
469
+ const lock = await acquireExclusiveLock(lockPath, 'RUNTIME_FACT_INBOX_DEDUPE_LOCK_TIMEOUT');
470
+ try {
471
+ return await work();
472
+ }
473
+ finally {
474
+ await lock.close().catch(() => undefined);
475
+ await rm(lockPath, { force: true }).catch(() => undefined);
476
+ }
477
+ }
478
+ }
479
+ /** Durable SQLite membership archive for compacted FactInbox ids. */
480
+ export class SqliteRuntimeFactInboxDedupeArchive {
481
+ filePath;
482
+ archiveId = SQLITE_FACT_INBOX_DEDUPE_ARCHIVE_ID;
483
+ database;
484
+ tail = Promise.resolve();
485
+ entries = new Map();
486
+ constructor(filePath) {
487
+ this.filePath = filePath;
488
+ mkdirSync(dirname(filePath), { recursive: true });
489
+ const rows = this.open().prepare('SELECT received_seq, event_id FROM runtime_fact_inbox_dedupe ORDER BY received_seq').all();
490
+ this.entries = validateDedupeEntries(rows.map((row) => ({ receivedSeq: Number(row.received_seq), eventId: String(row.event_id) })), this.archiveId, rows.length, factInboxDedupeDigest(rows.map((row) => ({ receivedSeq: Number(row.received_seq), eventId: String(row.event_id) }))));
491
+ }
492
+ get watermark() { return this.entries.size; }
493
+ contains(eventId, receivedSeq) {
494
+ if (receivedSeq !== undefined)
495
+ return this.entries.get(receivedSeq) === eventId;
496
+ return [...this.entries.values()].includes(eventId);
497
+ }
498
+ digestThrough(through) {
499
+ if (!Number.isInteger(through) || through < 0 || through > this.watermark)
500
+ return '';
501
+ return factInboxDedupeDigest(sortedDedupeEntries([...this.entries.entries()].filter(([receivedSeq]) => receivedSeq <= through)));
502
+ }
503
+ async append(batch) {
504
+ await this.enqueue(async () => {
505
+ const merged = mergeDedupeBatch(this.entries, this.archiveId, batch);
506
+ if (merged.size === this.entries.size && [...merged.entries()].every(([seq, eventId]) => this.entries.get(seq) === eventId))
507
+ return;
508
+ const database = this.open();
509
+ database.exec('BEGIN IMMEDIATE');
510
+ try {
511
+ for (const [receivedSeq, eventId] of [...merged.entries()].filter(([seq]) => !this.entries.has(seq)))
512
+ database.prepare('INSERT INTO runtime_fact_inbox_dedupe (received_seq, event_id) VALUES (?, ?)').run(receivedSeq, eventId);
513
+ database.exec('COMMIT');
514
+ this.entries = merged;
515
+ }
516
+ catch (cause) {
517
+ try {
518
+ database.exec('ROLLBACK');
519
+ }
520
+ catch { /* transaction already closed */ }
521
+ throw cause;
522
+ }
523
+ });
524
+ }
525
+ async close() { await this.enqueue(async () => { this.database?.close(); this.database = undefined; }); }
526
+ open() {
527
+ if (this.database)
528
+ return this.database;
529
+ const require = createRequire(import.meta.url);
530
+ const { DatabaseSync } = require('node:sqlite');
531
+ this.database = new DatabaseSync(this.filePath);
532
+ this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS runtime_fact_inbox_dedupe (received_seq INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE)');
533
+ return this.database;
534
+ }
535
+ enqueue(work) {
536
+ const operation = this.tail.then(work, work);
537
+ this.tail = operation.then(() => undefined, () => undefined);
538
+ return operation;
539
+ }
540
+ }
541
+ function hasTarget(state, target) {
542
+ return target.kind === 'lane' ? state.lanes.some(([id]) => id === target.id) : target.kind === 'effect' ? state.effects.some(([id]) => id === target.id) : false;
543
+ }
544
+ function withoutIntegrity(snapshot) {
545
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
546
+ return snapshot;
547
+ const copy = structuredClone(snapshot);
548
+ delete copy.integrity;
549
+ return copy;
550
+ }
551
+ function integrityDigest(snapshot) {
552
+ return createHash('sha256').update(JSON.stringify(withoutIntegrity(snapshot))).digest('hex');
553
+ }
554
+ export function withRuntimePersistenceIntegrity(snapshot) {
555
+ const copy = structuredClone(snapshot);
556
+ delete copy.integrity;
557
+ return { ...copy, integrity: { algorithm: 'sha256', digest: integrityDigest(copy) } };
558
+ }
559
+ function hasDerivedReference(ref, ownerLaneId, agents, lanes, results, artifacts) {
560
+ const id = provenanceRefId(ref);
561
+ const kind = provenanceRefKind(ref);
562
+ if (kind !== 'artifact' && results.has(id))
563
+ return true;
564
+ const artifact = kind === 'result' ? undefined : artifacts.get(id);
565
+ if (artifact) {
566
+ const lane = lanes.get(ownerLaneId);
567
+ return artifact.agentId === undefined || artifact.agentId === lane?.agentId;
568
+ }
569
+ if (kind === 'result' || kind === 'artifact')
570
+ return false;
571
+ const parsed = parseContextSnapshotRef(id);
572
+ if (!parsed)
573
+ return false;
574
+ if (parsed.kind === 'global') {
575
+ const lane = lanes.get(ownerLaneId);
576
+ const agent = lane ? agents.get(lane.agentId) : undefined;
577
+ return Boolean(agent && (parsed.agentId === undefined || parsed.agentId === agent.id) && agent.globalVersions.some(([version]) => version === parsed.version));
578
+ }
579
+ const lane = lanes.get(ownerLaneId);
580
+ // A lane may retain provenance to an earlier immutable context snapshot
581
+ // after later commits advance its current context version. Those snapshots
582
+ // are persisted/pinned by the storage policy and remain valid references.
583
+ return Boolean(lane && parsed.laneId === lane.id && Number.isInteger(parsed.version) && parsed.version >= 0 && parsed.version <= lane.context.version);
584
+ }
585
+ function validateExternalBodyReferences(snapshot) {
586
+ const sessions = [snapshot.state, ...(snapshot.checkpoint === undefined ? [] : [snapshot.checkpoint.state])];
587
+ if (snapshot.snapshotBodies === 'external') {
588
+ const refs = snapshot.externalSnapshotRefs;
589
+ if (!Array.isArray(refs) || refs.length !== new Set(refs).size || refs.some((ref) => typeof ref !== 'string' || ref.length === 0))
590
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
591
+ for (const session of sessions) {
592
+ for (const [agentId, agent] of session.state.agents)
593
+ for (const entry of agent.globalVersions)
594
+ if (entry[1] === null && !refs.includes(`global:${agentId}:${entry[0]}`))
595
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:global:${agentId}:${entry[0]}`);
596
+ for (const [laneId, lane] of session.state.lanes)
597
+ if (lane.context.state === null && !refs.includes(`lane:${laneId}:${lane.context.version}`))
598
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane:${laneId}:${lane.context.version}`);
599
+ }
600
+ }
601
+ if (snapshot.resultBodies === 'external') {
602
+ const refs = snapshot.externalResultRefs;
603
+ if (!Array.isArray(refs) || refs.length !== new Set(refs).size || refs.some((ref) => typeof ref !== 'string' || ref.length === 0))
604
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
605
+ for (const session of sessions)
606
+ for (const [ref, result] of session.state.results)
607
+ if (result.value === undefined && !refs.includes(ref))
608
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:result:${ref}`);
609
+ }
610
+ }
611
+ export function validateRuntimePersistenceSnapshot(snapshot) {
612
+ const value = snapshot;
613
+ if (value?.compatibility !== undefined) {
614
+ const compatibility = value.compatibility;
615
+ if (compatibility.schemaVersion !== 1 || !compatibility.programVersions || !compatibility.toolVersions || Object.entries(compatibility.programVersions).some(([key, version]) => !key || typeof version !== 'string' || version.length === 0) || Object.entries(compatibility.toolVersions).some(([key, version]) => !key || typeof version !== 'string' || version.length === 0) || (compatibility.policyVersion !== undefined && typeof compatibility.policyVersion !== 'string') || (compatibility.routerVersion !== undefined && typeof compatibility.routerVersion !== 'string'))
616
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_COMPATIBILITY');
617
+ }
618
+ if (value?.checkpoint?.eventWatermark !== undefined && (!Number.isInteger(value.checkpoint.eventWatermark) || value.checkpoint.eventWatermark < 0))
619
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
620
+ if (value?.factInbox !== undefined)
621
+ try {
622
+ const ledger = value.factInbox.dedupeLedger;
623
+ if (ledger !== undefined && ledger.archivedThrough > 0) {
624
+ // Persistence validation can only validate the envelope shape here. The
625
+ // Runtime constructor performs the real archive identity/digest check
626
+ // when the host supplies its durable dedupe view.
627
+ const validationArchive = {
628
+ archiveId: ledger.archiveId ?? '',
629
+ watermark: ledger.archivedThrough,
630
+ contains: () => true,
631
+ digestThrough: (through) => through === ledger.archivedThrough ? ledger.archiveDigest ?? '' : '',
632
+ };
633
+ FactInbox.fromSnapshot(value.factInbox, { dedupeArchive: validationArchive });
634
+ }
635
+ else
636
+ FactInbox.fromSnapshot(value.factInbox);
637
+ }
638
+ catch {
639
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
640
+ }
641
+ const state = value?.checkpoint?.state?.state ?? value?.state?.state;
642
+ if (!value || value.schemaVersion !== 1 || !value.state || !value.state.state || !value.mutationLog || !value.outbox || !Array.isArray(state?.agents) || !Array.isArray(state?.lanes) || !Array.isArray(state?.effects) || !Array.isArray(state?.waits) || !Array.isArray(state?.results) || !Array.isArray(state?.mergeProposals))
643
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
644
+ const agents = new Map(state.agents);
645
+ const lanes = new Map(state.lanes);
646
+ const effects = new Map(state.effects);
647
+ const waits = new Map(state.waits);
648
+ const results = new Map(state.results);
649
+ const artifacts = new Map(state.artifacts ?? []);
650
+ validateExternalBodyReferences(value);
651
+ for (const [id, agent] of agents)
652
+ if (!lanes.has(agent.rootLaneId))
653
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:agent.rootLaneId:${id}`);
654
+ for (const [ref, artifact] of artifacts) {
655
+ if (artifact.ref !== ref || !artifact.mediaType || !Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0 || typeof artifact.contentBase64 !== 'string' || typeof artifact.contentHash !== 'string' || artifact.pinCount < 0)
656
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_ARTIFACT:${ref}`);
657
+ if (artifact.agentId !== undefined && !agents.has(artifact.agentId))
658
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:artifact.agentId:${ref}`);
659
+ }
660
+ for (const [id, result] of results) {
661
+ if (result.id !== id || (result.kind === 'finding' && (!result.statement || !Array.isArray(result.evidenceRefs) || result.evidenceRefs.length === 0)))
662
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_RESULT:${id}`);
663
+ if (result.kind === 'finding')
664
+ for (const ref of result.evidenceRefs ?? [])
665
+ if (!ref || (ref.kind !== 'result' && ref.kind !== 'artifact') || !hasDerivedReference(ref, result.laneId ?? (result.effectId ? effects.get(result.effectId)?.ownerLaneId ?? '' : ''), agents, lanes, results, artifacts))
666
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:finding.evidenceRefs:${id}`);
667
+ }
668
+ for (const [id, lane] of lanes) {
669
+ if (!agents.has(lane.agentId))
670
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.agentId:${id}`);
671
+ if (lane.ownerLaneId !== undefined && !lanes.has(lane.ownerLaneId))
672
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.ownerLaneId:${id}`);
673
+ if (lane.activeWaitId !== undefined && !waits.has(lane.activeWaitId))
674
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.activeWaitId:${id}`);
675
+ if (lane.resultRef !== undefined && !results.has(lane.resultRef))
676
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.resultRef:${id}`);
677
+ for (const childId of lane.children)
678
+ if (!lanes.has(childId))
679
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.children:${id}`);
680
+ for (const effectId of lane.ownedEffectIds)
681
+ if (!effects.has(effectId))
682
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.ownedEffectIds:${id}`);
683
+ for (const resultRef of lane.visibleResultRefs ?? [])
684
+ if (!results.has(resultRef))
685
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:lane.visibleResultRefs:${id}`);
686
+ }
687
+ for (const [id, effect] of effects) {
688
+ if (!lanes.has(effect.ownerLaneId))
689
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:effect.ownerLaneId:${id}`);
690
+ if (effect.childAgentId !== undefined && !agents.has(effect.childAgentId))
691
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:effect.childAgentId:${id}`);
692
+ for (const resultRef of effect.derivedFrom ?? [])
693
+ if (!hasDerivedReference(resultRef, effect.ownerLaneId, agents, lanes, results, artifacts))
694
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:effect.derivedFrom:${id}`);
695
+ }
696
+ for (const [id, wait] of waits) {
697
+ if (!lanes.has(wait.laneId))
698
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:wait.laneId:${id}`);
699
+ for (const dependency of wait.spec.dependencies)
700
+ if (!hasTarget(state, dependency.target))
701
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:wait.target:${id}`);
702
+ if (wait.resolution)
703
+ for (const dependency of Object.values(wait.resolution.dependencies))
704
+ if (!hasTarget(state, dependency.target))
705
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:wait.resolution:${id}`);
706
+ }
707
+ for (const [id, proposal] of new Map(state.mergeProposals)) {
708
+ if (!agents.has(proposal.agentId) || !lanes.has(proposal.sourceLaneId))
709
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:mergeProposal:${id}`);
710
+ for (const ref of proposal.delta.derivedFrom ?? [])
711
+ if (!hasDerivedReference(ref, proposal.sourceLaneId, agents, lanes, results, artifacts))
712
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:mergeProposal.derivedFrom:${id}`);
713
+ }
714
+ const quarantineIds = new Set();
715
+ for (const entry of value.quarantine ?? []) {
716
+ if (!entry || typeof entry.effectId !== 'string' || entry.effectId.length === 0 || quarantineIds.has(entry.effectId) || !Number.isFinite(entry.unresolvedAt) || typeof entry.reason !== 'string' || entry.reason.length === 0)
717
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_QUARANTINE');
718
+ const effect = effects.get(entry.effectId);
719
+ if (!effect)
720
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_REFERENCE:quarantine:${entry.effectId}`);
721
+ if (effect.state !== 'reconcile_required' || effect.sideEffectState !== 'unknown')
722
+ throw new Error(`INVALID_RUNTIME_PERSISTENCE_QUARANTINE_STATE:${entry.effectId}`);
723
+ quarantineIds.add(entry.effectId);
724
+ }
725
+ if (value.integrity !== undefined && (value.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(value.integrity.digest) || value.integrity.digest !== integrityDigest(value)))
726
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_INTEGRITY');
727
+ }
728
+ export class FileRuntimePersistenceBackend {
729
+ filePath;
730
+ pending = Promise.resolve();
731
+ sessionStore;
732
+ factInboxDedupeArchive;
733
+ constructor(filePath) {
734
+ this.filePath = filePath;
735
+ this.sessionStore = new FileRuntimeSessionStore(`${filePath}.sessions.json`);
736
+ this.factInboxDedupeArchive = new FileRuntimeFactInboxDedupeArchive(`${filePath}.fact-inbox-dedupe`);
737
+ }
738
+ async load() {
739
+ try {
740
+ return JSON.parse(await readFile(this.filePath, 'utf8'));
741
+ }
742
+ catch (error) {
743
+ if (error.code === 'ENOENT')
744
+ return undefined;
745
+ throw error;
746
+ }
747
+ }
748
+ async save(snapshot, expectedDigest) {
749
+ const operation = this.pending.then(async () => {
750
+ await mkdir(dirname(this.filePath), { recursive: true });
751
+ const lockPath = `${this.filePath}.lock`;
752
+ const lock = await acquireExclusiveLock(lockPath, 'RUNTIME_PERSISTENCE_LOCK_TIMEOUT');
753
+ try {
754
+ const current = await this.load();
755
+ if (expectedDigest !== undefined && (current === undefined || current.integrity?.digest !== expectedDigest))
756
+ throw new Error('RUNTIME_PERSISTENCE_CONFLICT');
757
+ const temporaryPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${process.hrtime.bigint().toString()}`;
758
+ let handle;
759
+ try {
760
+ handle = await open(temporaryPath, 'wx', 0o600);
761
+ await handle.writeFile(JSON.stringify(snapshot), 'utf8');
762
+ await handle.sync();
763
+ await handle.close();
764
+ handle = undefined;
765
+ await rename(temporaryPath, this.filePath);
766
+ try {
767
+ const directory = await open(dirname(this.filePath), 'r');
768
+ try {
769
+ await directory.sync();
770
+ }
771
+ finally {
772
+ await directory.close();
773
+ }
774
+ }
775
+ catch {
776
+ // Directory fsync is not available on every supported filesystem; the rename remains atomic.
777
+ }
778
+ }
779
+ finally {
780
+ if (handle)
781
+ await handle.close().catch(() => undefined);
782
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
783
+ }
784
+ }
785
+ finally {
786
+ await lock.close().catch(() => undefined);
787
+ await rm(lockPath, { force: true }).catch(() => undefined);
788
+ }
789
+ });
790
+ this.pending = operation.catch(() => undefined);
791
+ await operation;
792
+ }
793
+ }
794
+ /** Durable single-snapshot backend using Node's built-in SQLite transaction support. */
795
+ export class SqliteRuntimePersistenceBackend {
796
+ filePath;
797
+ database;
798
+ tail = Promise.resolve();
799
+ resultStore;
800
+ snapshotStore;
801
+ eventArchive;
802
+ factInboxDedupeArchive;
803
+ sessionStore;
804
+ constructor(filePath) {
805
+ this.filePath = filePath;
806
+ this.resultStore = new SqliteRuntimeContentStore(filePath, 'result');
807
+ this.snapshotStore = new SqliteRuntimeContentStore(filePath, 'snapshot');
808
+ this.eventArchive = new SqliteRuntimeEventArchive(filePath);
809
+ this.factInboxDedupeArchive = new SqliteRuntimeFactInboxDedupeArchive(filePath);
810
+ this.sessionStore = new SqliteRuntimeSessionStore(filePath);
811
+ }
812
+ async load() {
813
+ return this.enqueue(async () => {
814
+ await mkdir(dirname(this.filePath), { recursive: true });
815
+ const row = this.open().prepare('SELECT payload FROM runtime_snapshot WHERE id = 1').get();
816
+ if (!row)
817
+ return undefined;
818
+ if (typeof row.payload !== 'string')
819
+ throw new Error('INVALID_RUNTIME_PERSISTENCE_SNAPSHOT');
820
+ return JSON.parse(row.payload);
821
+ });
822
+ }
823
+ async save(snapshot, expectedDigest) {
824
+ await this.enqueue(async () => {
825
+ await mkdir(dirname(this.filePath), { recursive: true });
826
+ const database = this.open();
827
+ database.exec('BEGIN IMMEDIATE');
828
+ try {
829
+ const current = database.prepare('SELECT digest FROM runtime_snapshot WHERE id = 1').get();
830
+ const currentDigest = current && typeof current.digest === 'string' ? current.digest : undefined;
831
+ if (expectedDigest !== undefined && currentDigest !== expectedDigest)
832
+ throw new Error('RUNTIME_PERSISTENCE_CONFLICT');
833
+ const payload = JSON.stringify(snapshot);
834
+ database.prepare('INSERT INTO runtime_snapshot (id, payload, digest) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, digest = excluded.digest').run(payload, snapshot.integrity?.digest ?? null);
835
+ database.exec('COMMIT');
836
+ }
837
+ catch (cause) {
838
+ try {
839
+ database.exec('ROLLBACK');
840
+ }
841
+ catch { /* transaction already closed */ }
842
+ throw cause;
843
+ }
844
+ });
845
+ }
846
+ async close() {
847
+ await this.enqueue(async () => {
848
+ this.database?.close();
849
+ this.database = undefined;
850
+ });
851
+ await Promise.all([this.resultStore.close(), this.snapshotStore.close(), this.eventArchive.close(), this.factInboxDedupeArchive.close()]);
852
+ this.sessionStore.close();
853
+ }
854
+ open() {
855
+ if (this.database)
856
+ return this.database;
857
+ const require = createRequire(import.meta.url);
858
+ const { DatabaseSync } = require('node:sqlite');
859
+ this.database = new DatabaseSync(this.filePath);
860
+ this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS runtime_snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), payload TEXT NOT NULL, digest TEXT)');
861
+ return this.database;
862
+ }
863
+ enqueue(work) {
864
+ const operation = this.tail.then(work, work);
865
+ this.tail = operation.then(() => undefined, () => undefined);
866
+ return operation;
867
+ }
868
+ }
869
+ export function exportRuntimePersistence(state, mutationLog, outbox, quarantine, storagePolicy, factInbox, compatibility) {
870
+ const snapshot = { schemaVersion: 1, state: exportRuntimeState(state), mutationLog: mutationLog.snapshot(), outbox: outbox.snapshot(), ...(quarantine === undefined ? {} : { quarantine: quarantine.snapshot() }), ...(storagePolicy === undefined ? {} : { storage: storagePolicy.snapshot() }), ...(factInbox === undefined ? {} : { factInbox: structuredClone(factInbox) }), ...(compatibility === undefined ? {} : { compatibility: structuredClone(compatibility) }) };
871
+ return { ...snapshot, integrity: { algorithm: 'sha256', digest: integrityDigest(snapshot) } };
872
+ }
873
+ export function exportRuntimeCheckpoint(state, mutationLog, outbox, quarantine, storagePolicy, options = {}, factInbox, compatibility) {
874
+ const watermark = mutationLog.lastSequence;
875
+ const checkpointLog = new MutationLog([], watermark);
876
+ const checkpointState = exportRuntimeState(state);
877
+ const eventWatermark = options.compactEventsThrough;
878
+ if (eventWatermark !== undefined) {
879
+ checkpointState.state.events = checkpointState.state.events.filter((event) => event.seq > eventWatermark);
880
+ checkpointState.state.eventsCompactedThrough = Math.max(checkpointState.state.eventsCompactedThrough ?? 0, eventWatermark);
881
+ }
882
+ const snapshot = { schemaVersion: 1, state: exportRuntimeState(state), mutationLog: checkpointLog.snapshot(), outbox: outbox.snapshot(), ...(quarantine === undefined ? {} : { quarantine: quarantine.snapshot() }), ...(storagePolicy === undefined ? {} : { storage: storagePolicy.snapshot() }), ...(factInbox === undefined ? {} : { factInbox: structuredClone(factInbox) }), ...(compatibility === undefined ? {} : { compatibility: structuredClone(compatibility) }), checkpoint: { schemaVersion: 1, logWatermark: watermark, ...(eventWatermark === undefined ? {} : { eventWatermark }), state: checkpointState } };
883
+ return { ...snapshot, integrity: { algorithm: 'sha256', digest: integrityDigest(snapshot) } };
884
+ }
885
+ export async function externalizeRuntimeResultBodies(snapshot, store) {
886
+ const copy = structuredClone(snapshot);
887
+ const refs = new Set(copy.externalResultRefs ?? []);
888
+ const states = [copy.state, ...(copy.checkpoint === undefined ? [] : [copy.checkpoint.state])];
889
+ for (const session of states) {
890
+ for (const [ref, result] of session.state.results) {
891
+ if (result.value === undefined)
892
+ continue;
893
+ await store.save(ref, result.value);
894
+ delete result.value;
895
+ refs.add(ref);
896
+ }
897
+ }
898
+ copy.resultBodies = 'external';
899
+ copy.externalResultRefs = [...refs].sort();
900
+ delete copy.integrity;
901
+ return withRuntimePersistenceIntegrity(copy);
902
+ }
903
+ function snapshotSessions(snapshot) { return [snapshot.state, ...(snapshot.checkpoint === undefined ? [] : [snapshot.checkpoint.state])]; }
904
+ export async function externalizeRuntimeSnapshotBodies(snapshot, store) {
905
+ const copy = structuredClone(snapshot);
906
+ const refs = new Set(copy.externalSnapshotRefs ?? []);
907
+ for (const session of snapshotSessions(copy)) {
908
+ for (const [agentId, agent] of session.state.agents) {
909
+ for (const entry of agent.globalVersions) {
910
+ const version = entry[0];
911
+ const ref = `global:${agentId}:${version}`;
912
+ await store.save(ref, entry[1]);
913
+ entry[1] = null;
914
+ refs.add(ref);
915
+ }
916
+ }
917
+ for (const [laneId, lane] of session.state.lanes) {
918
+ const ref = `lane:${laneId}:${lane.context.version}`;
919
+ await store.save(ref, lane.context.state);
920
+ lane.context.state = null;
921
+ refs.add(ref);
922
+ }
923
+ }
924
+ copy.snapshotBodies = 'external';
925
+ copy.externalSnapshotRefs = [...refs].sort();
926
+ delete copy.integrity;
927
+ return withRuntimePersistenceIntegrity(copy);
928
+ }
929
+ export async function hydrateRuntimeSnapshotBodies(snapshot, store) {
930
+ if (snapshot.snapshotBodies !== 'external')
931
+ return snapshot;
932
+ const copy = structuredClone(snapshot);
933
+ validateExternalBodyReferences(copy);
934
+ const refs = new Set(copy.externalSnapshotRefs ?? []);
935
+ for (const session of snapshotSessions(copy)) {
936
+ for (const [agentId, agent] of session.state.agents) {
937
+ for (const entry of agent.globalVersions) {
938
+ const ref = `global:${agentId}:${entry[0]}`;
939
+ if (!refs.has(ref))
940
+ throw new Error(`RUNTIME_SNAPSHOT_REFERENCE_MISSING:${ref}`);
941
+ const value = await store.load(ref);
942
+ if (value === undefined)
943
+ throw new Error(`RUNTIME_SNAPSHOT_NOT_FOUND:${ref}`);
944
+ entry[1] = value;
945
+ }
946
+ }
947
+ for (const [laneId, lane] of session.state.lanes) {
948
+ const ref = `lane:${laneId}:${lane.context.version}`;
949
+ if (!refs.has(ref))
950
+ throw new Error(`RUNTIME_SNAPSHOT_REFERENCE_MISSING:${ref}`);
951
+ const value = await store.load(ref);
952
+ if (value === undefined)
953
+ throw new Error(`RUNTIME_SNAPSHOT_NOT_FOUND:${ref}`);
954
+ lane.context.state = value;
955
+ }
956
+ }
957
+ copy.snapshotBodies = 'inline';
958
+ delete copy.externalSnapshotRefs;
959
+ delete copy.integrity;
960
+ return withRuntimePersistenceIntegrity(copy);
961
+ }
962
+ export async function hydrateRuntimeResultBodies(snapshot, store) {
963
+ if (snapshot.resultBodies !== 'external')
964
+ return snapshot;
965
+ const copy = structuredClone(snapshot);
966
+ validateExternalBodyReferences(copy);
967
+ const refs = copy.externalResultRefs ?? [];
968
+ const sessions = snapshotSessions(copy);
969
+ for (const session of sessions) {
970
+ for (const [ref, result] of session.state.results) {
971
+ if (result.value !== undefined)
972
+ continue;
973
+ if (!refs.includes(ref))
974
+ throw new Error(`RUNTIME_RESULT_REFERENCE_MISSING:${ref}`);
975
+ const value = await store.load(ref);
976
+ if (value === undefined)
977
+ throw new Error(`RUNTIME_RESULT_NOT_FOUND:${ref}`);
978
+ result.value = value;
979
+ }
980
+ }
981
+ copy.resultBodies = 'inline';
982
+ delete copy.externalResultRefs;
983
+ delete copy.integrity;
984
+ return withRuntimePersistenceIntegrity(copy);
985
+ }
986
+ export function serializeRuntimePersistence(state, mutationLog, outbox, storagePolicy) {
987
+ return exportRuntimePersistence(state, mutationLog, outbox, undefined, storagePolicy);
988
+ }
989
+ export function importRuntimePersistence(snapshot) {
990
+ const value = snapshot;
991
+ validateRuntimePersistenceSnapshot(value);
992
+ if (value.snapshotBodies === 'external')
993
+ throw new Error('RUNTIME_SNAPSHOT_STORE_REQUIRED');
994
+ const mutationLog = MutationLog.fromSnapshot(value.mutationLog);
995
+ const state = importRuntimeState(value.checkpoint?.state ?? value.state);
996
+ if (value.checkpoint)
997
+ mutationLog.replay(state);
998
+ return { state, mutationLog, outbox: EffectOutbox.fromSnapshot(value.outbox), ...(value.quarantine === undefined ? {} : { quarantine: value.quarantine.map((entry) => ({ ...entry })) }), ...(value.storage === undefined ? {} : { storagePolicy: SessionStoragePolicy.fromSnapshot(value.storage) }), ...(value.factInbox === undefined ? {} : { factInbox: structuredClone(value.factInbox) }), ...(value.compatibility === undefined ? {} : { compatibility: structuredClone(value.compatibility) }) };
999
+ }