@telnyx/agent-harness 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,736 @@
1
+ // dist/node-adapter.js
2
+ import { applyMergePatch, decode, encode } from "@telnyx/edge-runtime/internal";
3
+ import { mkdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ var sqlite3Complete = null;
7
+ var initStarted = false;
8
+ function initWasmSplitter() {
9
+ if (initStarted) return;
10
+ initStarted = true;
11
+ void (async () => {
12
+ try {
13
+ const mod = await import("@sqlite.org/sqlite-wasm");
14
+ const s3 = await mod.default();
15
+ sqlite3Complete = s3.capi.sqlite3_complete;
16
+ } catch {
17
+ }
18
+ })();
19
+ }
20
+ function isWasmSplitterReady() {
21
+ return sqlite3Complete !== null;
22
+ }
23
+ function hasSqlContent(sql) {
24
+ let s = sql;
25
+ s = s.replace(/--[^\n]*/g, "");
26
+ s = s.replace(/\/\*[\s\S]*?\*\//g, "");
27
+ return s.trim().length > 0;
28
+ }
29
+ function splitSqlStatementsExact(sql) {
30
+ if (!sqlite3Complete) return null;
31
+ const complete = sqlite3Complete;
32
+ const statements = [];
33
+ let remaining = sql.trim();
34
+ while (remaining) {
35
+ let found = -1;
36
+ let pos = 0;
37
+ while ((pos = remaining.indexOf(";", pos)) !== -1) {
38
+ if (complete(remaining.substring(0, pos + 1))) {
39
+ found = pos + 1;
40
+ break;
41
+ }
42
+ pos++;
43
+ }
44
+ if (found === -1) {
45
+ const seg2 = remaining.replace(/;\s*$/, "").trim();
46
+ if (seg2 && hasSqlContent(seg2)) statements.push(seg2);
47
+ break;
48
+ }
49
+ const seg = remaining.substring(0, found).replace(/;$/, "").trim();
50
+ if (seg && hasSqlContent(seg)) statements.push(seg);
51
+ remaining = remaining.substring(found).trim();
52
+ }
53
+ return statements;
54
+ }
55
+ var require2 = createRequire(import.meta.url);
56
+ initWasmSplitter();
57
+ var MAX_PARAM_SIZE = 2 * 1024 * 1024;
58
+ function adaptBinding(value) {
59
+ if (typeof value === "boolean") {
60
+ return value ? 1n : 0n;
61
+ }
62
+ if (typeof value === "number" && Number.isInteger(value) && Number.isSafeInteger(value)) {
63
+ return BigInt(value);
64
+ }
65
+ if (value instanceof ArrayBuffer) {
66
+ return new Uint8Array(value);
67
+ }
68
+ return value;
69
+ }
70
+ var SqlParameterTooLargeError = class extends Error {
71
+ constructor(actualSize) {
72
+ super(`SQL parameter size ${actualSize} bytes exceeds the 2 MiB limit`);
73
+ this.name = "SqlParameterTooLargeError";
74
+ }
75
+ };
76
+ var SqlTransactionControlError = class extends Error {
77
+ constructor(statement) {
78
+ super(
79
+ `SQL transaction control ("${statement}") is not allowed in exec(); use ctx.storage.transactionSync(() => { ... }) for atomic SQL`
80
+ );
81
+ this.name = "SqlTransactionControlError";
82
+ }
83
+ };
84
+ var SqlAsyncTransactionError = class extends Error {
85
+ constructor() {
86
+ super(
87
+ "transactionSync() callback must be synchronous; it returned a promise. Awaiting inside a SQL transaction would run statements outside it \u2014 do the async work before or after the transaction."
88
+ );
89
+ this.name = "SqlAsyncTransactionError";
90
+ }
91
+ };
92
+ function isPromiseLike(v) {
93
+ return v !== null && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
94
+ }
95
+ function assertSyncCallback(fn) {
96
+ if (fn.constructor && fn.constructor.name === "AsyncFunction") {
97
+ throw new SqlAsyncTransactionError();
98
+ }
99
+ }
100
+ var TXN_CONTROL_RE = /^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
101
+ function stripLeadingComments(sql) {
102
+ let s = sql;
103
+ for (; ; ) {
104
+ const before = s;
105
+ s = s.replace(/^\s+/, "");
106
+ if (s.startsWith("--")) {
107
+ const nl = s.indexOf("\n");
108
+ s = nl === -1 ? "" : s.slice(nl + 1);
109
+ } else if (s.startsWith("/*")) {
110
+ const end = s.indexOf("*/");
111
+ s = end === -1 ? "" : s.slice(end + 2);
112
+ }
113
+ if (s === before) return s;
114
+ }
115
+ }
116
+ function getStatements(query) {
117
+ const exact = isWasmSplitterReady() ? splitSqlStatementsExact(query) : null;
118
+ return exact ?? splitSqlStatements(query);
119
+ }
120
+ function assertNoTransactionControl(query) {
121
+ for (const statement of getStatements(query)) {
122
+ const match = TXN_CONTROL_RE.exec(stripLeadingComments(statement));
123
+ if (match) throw new SqlTransactionControlError(match[1].toUpperCase());
124
+ }
125
+ }
126
+ var SqliteSqlStorage = class {
127
+ db = null;
128
+ savepointDepth = 0;
129
+ asyncMisusePoisoned = false;
130
+ dbPath;
131
+ driver;
132
+ constructor(dbPath, opts) {
133
+ this.dbPath = dbPath;
134
+ this.driver = opts?.driver;
135
+ }
136
+ ensureOpen() {
137
+ if (this.db) return;
138
+ const dir = dirname(this.dbPath);
139
+ mkdirSync(dir, { recursive: true });
140
+ this.db = this.openDb(this.dbPath);
141
+ this.db.exec(`PRAGMA max_page_count = ${Math.floor(1073741824 / 4096)}`);
142
+ }
143
+ /**
144
+ * Opens the underlying database handle.
145
+ *
146
+ * Split out from ensureOpen so the DRIVER is substitutable (COMPUTE-691). The
147
+ * class only ever asks a driver for `prepare()`, `exec()` and `close()`, and a
148
+ * prepared statement for `run()` and `all()` — a deliberately small surface, so
149
+ * the metered SQLite addon can stand in for `node:sqlite` without this class
150
+ * knowing which one it has. That matters because the row counters SQLDB bills on
151
+ * are produced inside the engine and are unobtainable from `node:sqlite`.
152
+ *
153
+ * Default stays `node:sqlite`, so a caller that passes no driver behaves exactly
154
+ * as before.
155
+ */
156
+ openDb(dbPath) {
157
+ if (this.driver) return this.driver(dbPath);
158
+ const { DatabaseSync } = require2("node:sqlite");
159
+ return new DatabaseSync(dbPath);
160
+ }
161
+ exec(query, ...bindings) {
162
+ assertNoTransactionControl(query);
163
+ return this.execInternal(query, ...bindings);
164
+ }
165
+ /** `exec()` without the transaction-control guard — runtime-issued SQL only. */
166
+ execInternal(query, ...bindings) {
167
+ if (this.asyncMisusePoisoned) {
168
+ throw new SqlAsyncTransactionError();
169
+ }
170
+ for (const p of bindings) {
171
+ const size = getParamSize(p);
172
+ if (size > MAX_PARAM_SIZE) {
173
+ throw new SqlParameterTooLargeError(size);
174
+ }
175
+ }
176
+ this.ensureOpen();
177
+ const bound = bindings.map(adaptBinding);
178
+ const statements = getStatements(query);
179
+ if (statements.length === 0) {
180
+ return makeSinglePassCursor([]);
181
+ }
182
+ for (let i = 0; i < statements.length - 1; i++) {
183
+ this.db.prepare(statements[i]).run();
184
+ }
185
+ const last = this.db.prepare(statements[statements.length - 1]);
186
+ const rawRows = last.all(...bound);
187
+ const rows = rawRows.map(normalizeRowBlobs);
188
+ return makeSinglePassCursor(rows);
189
+ }
190
+ close() {
191
+ if (this.db) {
192
+ this.db.close();
193
+ this.db = null;
194
+ }
195
+ }
196
+ /**
197
+ * Run `fn` inside a runtime-owned SQLite savepoint: released on return,
198
+ * rolled back if `fn` throws. Savepoints (not `BEGIN`) so nesting works and
199
+ * an outer runtime transaction is never disturbed. Synchronous by design —
200
+ * an `await` inside the callback could interleave another turn's statements
201
+ * into this transaction, so the callback must be sync.
202
+ *
203
+ * An `async` callback (`transactionSync(async () => …)`) is rejected before it
204
+ * runs at all, so nothing it does happens inside or after the transaction.
205
+ *
206
+ * A plain callback that internally starts async work and returns its promise
207
+ * (`() => helper()`) is rejected after the fact (`isPromiseLike`), rolling the
208
+ * savepoint back. `helper`'s continuation still exists; `composeSqliteStorage`
209
+ * poisons the turn so that continuation's later `exec()` fails closed IF it
210
+ * resumes within the same turn. A continuation that resumes after the turn
211
+ * boundary is the documented residual — hence the hard rule: do not start
212
+ * async work inside a `transactionSync` body.
213
+ */
214
+ transactionSync(fn) {
215
+ this.ensureOpen();
216
+ assertSyncCallback(fn);
217
+ const name = `__telnyx_txn_${this.savepointDepth++}`;
218
+ this.db.exec(`SAVEPOINT ${name}`);
219
+ let released = false;
220
+ try {
221
+ const result = fn();
222
+ if (isPromiseLike(result)) {
223
+ this.asyncMisusePoisoned = true;
224
+ throw new SqlAsyncTransactionError();
225
+ }
226
+ this.db.exec(`RELEASE ${name}`);
227
+ released = true;
228
+ return result;
229
+ } finally {
230
+ this.savepointDepth--;
231
+ if (!released) {
232
+ try {
233
+ this.db.exec(`ROLLBACK TO ${name}`);
234
+ this.db.exec(`RELEASE ${name}`);
235
+ } catch {
236
+ }
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Clear the async-misuse poison. Called at each turn boundary (via the
242
+ * composed storage's `_consumeInitDirty`), so the fail-closed state set by an
243
+ * escaped async `transactionSync` continuation lasts only for the remainder of
244
+ * the turn (or init phase) that caused it — never permanently.
245
+ */
246
+ clearAsyncMisusePoison() {
247
+ this.asyncMisusePoisoned = false;
248
+ }
249
+ /**
250
+ * Open a named savepoint and return its name. The low-level seam the
251
+ * sqlite-sidecar server (COMPUTE-484) uses to implement a nested
252
+ * `transactionSync` over HTTP: the client calls `/txn/begin` to open a
253
+ * savepoint, `/txn/exec` to run statements inside it, then `/txn/commit`
254
+ * (RELEASE) or `/txn/rollback` (ROLLBACK TO + RELEASE). Each level gets its
255
+ * own savepoint name (stacked), mirroring this class's own `transactionSync`
256
+ * nesting. NOT for in-process use — `transactionSync` is the supported
257
+ * in-process API; this is the escape hatch the sidecar server needs because
258
+ * the sync `fn` body lives in the client process, not here.
259
+ *
260
+ * The async-misuse poison is NOT checked here: the client checks it client-
261
+ * side and the server-side `transactionSync` (used by `/transaction`) checks
262
+ * it for its own path. `/txn/exec` goes through `exec()` (with the
263
+ * transaction-control guard), not `execInternal`.
264
+ */
265
+ beginSavepoint() {
266
+ this.ensureOpen();
267
+ const name = `__telnyx_txn_${this.savepointDepth++}`;
268
+ this.db.exec(`SAVEPOINT ${name}`);
269
+ return name;
270
+ }
271
+ /** Release (commit) a savepoint opened by `beginSavepoint`. */
272
+ releaseSavepoint(name) {
273
+ if (!this.db) return;
274
+ this.savepointDepth--;
275
+ try {
276
+ this.db.exec(`RELEASE ${name}`);
277
+ } catch {
278
+ }
279
+ }
280
+ /**
281
+ * Roll back to a savepoint opened by `beginSavepoint`, then release it so the
282
+ * name does not leak and (at depth 0) no transaction is left open.
283
+ */
284
+ rollbackSavepoint(name) {
285
+ if (!this.db) return;
286
+ this.savepointDepth--;
287
+ try {
288
+ this.db.exec(`ROLLBACK TO ${name}`);
289
+ this.db.exec(`RELEASE ${name}`);
290
+ } catch {
291
+ }
292
+ }
293
+ /**
294
+ * True if the connection currently has an open transaction. `VACUUM INTO`
295
+ * cannot run inside one, so the shipper checks this before vacuuming
296
+ * (COMPUTE-458). node:sqlite exposes no `inTransaction` flag, so probe it:
297
+ * a nested `BEGIN` throws iff a transaction is already open.
298
+ */
299
+ inTransaction() {
300
+ if (!this.db) return false;
301
+ try {
302
+ this.db.exec("BEGIN");
303
+ } catch {
304
+ return true;
305
+ }
306
+ try {
307
+ this.db.exec("ROLLBACK");
308
+ } catch {
309
+ }
310
+ return false;
311
+ }
312
+ /** Roll back an open transaction. No-op if none is open. */
313
+ rollback() {
314
+ if (!this.db) return;
315
+ try {
316
+ this.db.exec("ROLLBACK");
317
+ } catch {
318
+ }
319
+ }
320
+ vacuumInto(path) {
321
+ this.ensureOpen();
322
+ const escaped = path.replace(/'/g, "''");
323
+ this.db.exec("VACUUM INTO '" + escaped + "'");
324
+ }
325
+ };
326
+ function splitSqlStatements(sql) {
327
+ const statements = [];
328
+ let current = "";
329
+ let hasContent = false;
330
+ let inTriggerBody = false;
331
+ let atStatementStart = false;
332
+ let firstWord = null;
333
+ let isCreateTrigger = false;
334
+ let word = "";
335
+ const finishWord = () => {
336
+ if (!word) return;
337
+ const w = word.toUpperCase();
338
+ word = "";
339
+ if (firstWord === null) firstWord = w;
340
+ if (w === "TRIGGER" && firstWord === "CREATE") isCreateTrigger = true;
341
+ if (w === "BEGIN" && isCreateTrigger && !inTriggerBody) {
342
+ inTriggerBody = true;
343
+ atStatementStart = true;
344
+ return;
345
+ }
346
+ if (w === "END" && inTriggerBody && atStatementStart) {
347
+ inTriggerBody = false;
348
+ }
349
+ atStatementStart = false;
350
+ };
351
+ const flush = () => {
352
+ finishWord();
353
+ if (hasContent) statements.push(current.trim());
354
+ current = "";
355
+ hasContent = false;
356
+ inTriggerBody = false;
357
+ atStatementStart = true;
358
+ firstWord = null;
359
+ isCreateTrigger = false;
360
+ };
361
+ let i = 0;
362
+ const n = sql.length;
363
+ while (i < n) {
364
+ const ch = sql[i];
365
+ const next = sql[i + 1];
366
+ if (ch === "-" && next === "-") {
367
+ finishWord();
368
+ while (i < n && sql[i] !== "\n") current += sql[i++];
369
+ continue;
370
+ }
371
+ if (ch === "/" && next === "*") {
372
+ finishWord();
373
+ current += "/*";
374
+ i += 2;
375
+ while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) current += sql[i++];
376
+ if (i < n) {
377
+ current += "*/";
378
+ i += 2;
379
+ }
380
+ continue;
381
+ }
382
+ if (ch === "'" || ch === '"' || ch === "`") {
383
+ finishWord();
384
+ const quote = ch;
385
+ current += ch;
386
+ i++;
387
+ hasContent = true;
388
+ while (i < n) {
389
+ current += sql[i];
390
+ if (sql[i] === quote) {
391
+ if (sql[i + 1] === quote) {
392
+ current += sql[i + 1];
393
+ i += 2;
394
+ continue;
395
+ }
396
+ i++;
397
+ break;
398
+ }
399
+ i++;
400
+ }
401
+ continue;
402
+ }
403
+ if (ch === "[") {
404
+ finishWord();
405
+ current += ch;
406
+ i++;
407
+ hasContent = true;
408
+ while (i < n) {
409
+ current += sql[i];
410
+ if (sql[i] === "]") {
411
+ i++;
412
+ break;
413
+ }
414
+ i++;
415
+ }
416
+ continue;
417
+ }
418
+ if (/[A-Za-z0-9_]/.test(ch)) {
419
+ word += ch;
420
+ current += ch;
421
+ hasContent = true;
422
+ i++;
423
+ continue;
424
+ }
425
+ finishWord();
426
+ if (ch === ";") {
427
+ if (inTriggerBody) {
428
+ current += ch;
429
+ atStatementStart = true;
430
+ i++;
431
+ continue;
432
+ }
433
+ flush();
434
+ i++;
435
+ continue;
436
+ }
437
+ current += ch;
438
+ if (!/\s/.test(ch)) hasContent = true;
439
+ i++;
440
+ }
441
+ flush();
442
+ return statements;
443
+ }
444
+ function getParamSize(p) {
445
+ if (p === null || p === void 0) return 0;
446
+ if (typeof p === "string") return Buffer.byteLength(p, "utf8");
447
+ if (p instanceof ArrayBuffer) return p.byteLength;
448
+ if (ArrayBuffer.isView(p)) return p.byteLength;
449
+ if (typeof p === "number" || typeof p === "boolean") return 8;
450
+ try {
451
+ return Buffer.byteLength(JSON.stringify(p), "utf8");
452
+ } catch {
453
+ return 0;
454
+ }
455
+ }
456
+ function normalizeRowBlobs(row) {
457
+ let copy;
458
+ for (const key of Object.keys(row)) {
459
+ const v = row[key];
460
+ if (v instanceof Uint8Array) {
461
+ copy ??= { ...row };
462
+ copy[key] = v.buffer.slice(
463
+ v.byteOffset,
464
+ v.byteOffset + v.byteLength
465
+ );
466
+ }
467
+ }
468
+ return copy ?? row;
469
+ }
470
+ function makeSinglePassCursor(rows) {
471
+ let pos = 0;
472
+ return {
473
+ toArray() {
474
+ const rest = rows.slice(pos);
475
+ pos = rows.length;
476
+ return rest;
477
+ },
478
+ [Symbol.iterator]() {
479
+ return {
480
+ next() {
481
+ return pos < rows.length ? { value: rows[pos++], done: false } : { value: void 0, done: true };
482
+ }
483
+ };
484
+ }
485
+ };
486
+ }
487
+ var AGENT_HARNESS_PORTS_VERSION = 2;
488
+ var EVENT_RETAIN = 1e3;
489
+ var TASK_DRAIN_BATCH = 100;
490
+ var TASK_BACKOFF_BASE_MS = 1e3;
491
+ var TASK_BACKOFF_CAP_MS = 5 * 6e4;
492
+ function taskBackoffMs(attempts) {
493
+ return Math.min(TASK_BACKOFF_CAP_MS, TASK_BACKOFF_BASE_MS * 2 ** (attempts - 1));
494
+ }
495
+ function assertSchedulableDue(due) {
496
+ if (!Number.isFinite(due) || due < 0 || Math.floor(due) !== due) {
497
+ throw new Error(`task due must be a non-negative integer, got ${due}`);
498
+ }
499
+ if (String(due).length > 16) {
500
+ throw new Error(`task due ${due} exceeds 16 digits`);
501
+ }
502
+ }
503
+ function clone(value) {
504
+ return decode(encode(value));
505
+ }
506
+ function newBacking(databasePath) {
507
+ return {
508
+ messageSeq: 0,
509
+ messages: [],
510
+ eventSeq: 0,
511
+ events: [],
512
+ state: void 0,
513
+ tasks: /* @__PURE__ */ new Map(),
514
+ nextTaskId: 1,
515
+ sql: new SqliteSqlStorage(databasePath),
516
+ closed: false
517
+ };
518
+ }
519
+ var NodeAgentHarnessPorts = class _NodeAgentHarnessPorts {
520
+ options;
521
+ backing;
522
+ version = AGENT_HARNESS_PORTS_VERSION;
523
+ identity;
524
+ activation = Object.freeze({});
525
+ clock;
526
+ authorization;
527
+ environment;
528
+ messages;
529
+ state;
530
+ events;
531
+ tasks;
532
+ sql;
533
+ constructor(options, backing = newBacking(options.databasePath)) {
534
+ this.options = options;
535
+ this.backing = backing;
536
+ this.identity = Object.freeze({ id: options.id });
537
+ this.clock = Object.freeze({ now: () => options.now() });
538
+ this.authorization = options.authorization;
539
+ this.environment = Object.freeze({ ...options.environment ?? {} });
540
+ const last = ((count) => {
541
+ if (count === void 0) {
542
+ const message = this.backing.messages.at(-1);
543
+ return Promise.resolve(message === void 0 ? void 0 : clone(message));
544
+ }
545
+ if (count <= 0)
546
+ return Promise.resolve([]);
547
+ return Promise.resolve(clone(this.backing.messages.slice(-count)));
548
+ });
549
+ this.messages = Object.freeze({
550
+ append: async (message) => {
551
+ const safe = clone(message);
552
+ const stored = {
553
+ ...safe,
554
+ seq: this.backing.messageSeq + 1,
555
+ at: new Date(this.clock.now())
556
+ };
557
+ this.backing.messages.push(stored);
558
+ this.backing.messageSeq = stored.seq;
559
+ return stored.seq;
560
+ },
561
+ appendMany: async (messages) => {
562
+ const safe = clone(messages);
563
+ if (safe.length === 0)
564
+ return this.backing.messageSeq;
565
+ const base = this.backing.messageSeq;
566
+ const stored = safe.map((message, index) => ({
567
+ ...message,
568
+ seq: base + index + 1,
569
+ at: new Date(this.clock.now())
570
+ }));
571
+ this.backing.messages.push(...stored);
572
+ this.backing.messageSeq = base + stored.length;
573
+ return this.backing.messageSeq;
574
+ },
575
+ all: async () => clone(this.backing.messages),
576
+ last,
577
+ count: async () => this.backing.messageSeq
578
+ });
579
+ const currentState = () => this.backing.state === void 0 ? this.options.initialState() : clone(this.backing.state);
580
+ this.state = Object.freeze({
581
+ get: async () => currentState(),
582
+ merge: async (patch) => {
583
+ const current = currentState();
584
+ const next = applyMergePatch({ ...current }, patch);
585
+ this.backing.state = clone(next);
586
+ await this.options.onStateChanged?.(next, current);
587
+ return clone(next);
588
+ },
589
+ replace: async (state) => {
590
+ const prev = currentState();
591
+ const safe = clone(state);
592
+ this.backing.state = clone(safe);
593
+ await this.options.onStateChanged?.(safe, prev);
594
+ return clone(safe);
595
+ }
596
+ });
597
+ this.events = Object.freeze({
598
+ emit: async (type, payload) => {
599
+ const stored = {
600
+ seq: this.backing.eventSeq + 1,
601
+ type,
602
+ payload: clone(payload),
603
+ at: new Date(this.clock.now())
604
+ };
605
+ this.backing.events.push(stored);
606
+ if (this.backing.events.length > EVENT_RETAIN) {
607
+ this.backing.events.splice(0, this.backing.events.length - EVENT_RETAIN);
608
+ }
609
+ this.backing.eventSeq = stored.seq;
610
+ return stored.seq;
611
+ },
612
+ read: async (afterSeq = 0, limit) => {
613
+ if (limit !== void 0 && limit <= 0)
614
+ return [];
615
+ const selected = this.backing.events.filter((event) => event.seq > afterSeq);
616
+ return clone(limit === void 0 ? selected : selected.slice(0, limit));
617
+ },
618
+ count: async () => this.backing.eventSeq
619
+ });
620
+ const schedule = async (delaySeconds, method, payload, scheduleOptions) => {
621
+ const due = Math.floor(this.clock.now() + Math.max(0, delaySeconds * 1e3));
622
+ assertSchedulableDue(due);
623
+ const id = scheduleOptions?.id ?? `task-${this.backing.nextTaskId++}`;
624
+ const record = {
625
+ id,
626
+ name: method,
627
+ payload: clone(payload),
628
+ due,
629
+ everyMs: scheduleOptions?.everyMs,
630
+ attempts: 0,
631
+ maxRetries: scheduleOptions?.maxRetries ?? 5,
632
+ createdAt: new Date(this.clock.now())
633
+ };
634
+ this.backing.tasks.set(id, record);
635
+ return id;
636
+ };
637
+ this.tasks = Object.freeze({
638
+ delivery: "at-least-once",
639
+ queue: (method, payload, scheduleOptions) => schedule(0, method, payload, scheduleOptions),
640
+ schedule,
641
+ every: async (intervalSeconds, method, payload, scheduleOptions) => {
642
+ if (!(intervalSeconds > 0)) {
643
+ throw new Error(`every() requires a positive intervalSeconds, got ${intervalSeconds}`);
644
+ }
645
+ return schedule(intervalSeconds, method, payload, {
646
+ ...scheduleOptions,
647
+ everyMs: intervalSeconds * 1e3
648
+ });
649
+ },
650
+ cancel: async (id) => this.backing.tasks.delete(id),
651
+ list: async () => clone(Array.from(this.backing.tasks.values()))
652
+ });
653
+ this.sql = Object.freeze({
654
+ exec: (query, ...bindings) => this.backing.sql.exec(query, ...bindings),
655
+ transactionSync: (fn) => this.backing.sql.transactionSync(fn)
656
+ });
657
+ }
658
+ /** Reconstruct the adapter while retaining its in-process durable backing. */
659
+ reopen() {
660
+ return new _NodeAgentHarnessPorts(this.options, this.backing);
661
+ }
662
+ /**
663
+ * Pump due tasks for deterministic Node tests. This is a host control, not an
664
+ * AgentHarnessPorts method or a durable run-status API.
665
+ */
666
+ async runDue(dispatch) {
667
+ const now = this.clock.now();
668
+ const due = Array.from(this.backing.tasks.values()).sort((left, right) => left.due - right.due || left.id.localeCompare(right.id)).slice(0, TASK_DRAIN_BATCH);
669
+ let ran = 0;
670
+ for (const task of due) {
671
+ if (task.due > now)
672
+ break;
673
+ try {
674
+ await dispatch(clone(task));
675
+ if (this.backing.tasks.get(task.id) !== task) {
676
+ ran += 1;
677
+ continue;
678
+ }
679
+ if (task.everyMs && task.everyMs > 0) {
680
+ this.backing.tasks.set(task.id, {
681
+ ...task,
682
+ due: now + task.everyMs,
683
+ attempts: 0
684
+ });
685
+ } else {
686
+ this.backing.tasks.delete(task.id);
687
+ }
688
+ } catch {
689
+ const attempts = task.attempts + 1;
690
+ const current = this.backing.tasks.get(task.id);
691
+ if (current !== task) {
692
+ if (current === void 0) {
693
+ ran += 1;
694
+ continue;
695
+ }
696
+ if (attempts > current.maxRetries) {
697
+ this.backing.tasks.delete(task.id);
698
+ } else {
699
+ this.backing.tasks.set(task.id, {
700
+ ...current,
701
+ attempts,
702
+ due: Math.max(current.due, now + taskBackoffMs(attempts))
703
+ });
704
+ }
705
+ ran += 1;
706
+ continue;
707
+ }
708
+ if (attempts > task.maxRetries) {
709
+ this.backing.tasks.delete(task.id);
710
+ } else {
711
+ this.backing.tasks.set(task.id, {
712
+ ...task,
713
+ attempts,
714
+ due: now + taskBackoffMs(attempts)
715
+ });
716
+ }
717
+ }
718
+ ran += 1;
719
+ }
720
+ return ran;
721
+ }
722
+ /** Close the fixture-owned SQLite database. Idempotent. */
723
+ close() {
724
+ if (this.backing.closed)
725
+ return;
726
+ this.backing.closed = true;
727
+ this.backing.sql.close();
728
+ }
729
+ };
730
+ function createNodeAgentHarnessPorts(options) {
731
+ return new NodeAgentHarnessPorts(options);
732
+ }
733
+ export {
734
+ NodeAgentHarnessPorts,
735
+ createNodeAgentHarnessPorts
736
+ };