@rulvar/executor 1.94.0 → 1.96.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.
package/dist/index.d.ts CHANGED
@@ -18,8 +18,11 @@ declare class ExecutorError extends Error {
18
18
  * the executor knows BEFORE the external effect is dispatched, which is
19
19
  * exactly the set a host needs to reconcile an orphaned effect with the
20
20
  * effect's provider (look the idempotency key up, correlate by tool and
21
- * argsHash). `startedAt` is the attempt join key: the outcome record of
22
- * the same attempt carries the identical value.
21
+ * argsHash). `attemptId` is the attempt join key (RV501): the outcome
22
+ * record of the same attempt carries the identical value. `startedAt`
23
+ * remains the documented legacy join for rows written before the id
24
+ * shipped; a wall-clock millisecond is not unique, which is why the id
25
+ * exists.
23
26
  */
24
27
  interface ToolEffectIntent {
25
28
  /** The stable per-call idempotency key (createEngine derives it). */
@@ -33,6 +36,15 @@ interface ToolEffectIntent {
33
36
  /** The ephemeral working directory the dispatch runs in. */
34
37
  workdir: string;
35
38
  startedAt: number;
39
+ /**
40
+ * Unique id of this dispatch ATTEMPT (RV501): the reference executors
41
+ * mint one before the intent row is written and copy it verbatim onto
42
+ * the same attempt's outcome row, so the two phases pair exactly.
43
+ * Optional because rows written before v1.96.0 (and third-party
44
+ * ledgers) may omit it; {@link loadEffectLedger} then falls back to
45
+ * the legacy (idempotencyKey, startedAt) join.
46
+ */
47
+ attemptId?: string;
36
48
  }
37
49
  /** One dispatch's side-effect facts, for the ledger. */
38
50
  interface ToolEffectRecord extends ToolEffectIntent {
@@ -59,9 +71,9 @@ interface ToolEffectLedger {
59
71
  * with the typed `ledger` code) and the outcome `record` after it. A
60
72
  * host crash between the effect and the outcome row then leaves an
61
73
  * orphan intent, the reconciliation signal, instead of an untracked
62
- * effect: an intent whose idempotency key has no outcome row means
63
- * "look this key up with the effect's provider before retrying or
64
- * compensating". Absent, the ledger keeps the historical
74
+ * effect: an intent whose OWN attempt has no outcome row (RV501)
75
+ * means "look this key up with the effect's provider before retrying
76
+ * or compensating". Absent, the ledger keeps the historical
65
77
  * single-record contract and executor behavior is byte-identical.
66
78
  */
67
79
  intent?(entry: ToolEffectIntent): void | Promise<void>;
@@ -230,29 +242,87 @@ declare function containerExecutor(options: ContainerExecutorOptions): ToolExecu
230
242
  * A two-phase ToolEffectLedger appending JSON lines to `path`
231
243
  * (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
232
244
  * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
233
- * scan it back with {@link loadEffectLedger}.
245
+ * scan it back with {@link loadEffectLedger}. The first append lazily
246
+ * repairs a torn tail left by a crashed predecessor (RV502).
247
+ */
248
+ declare function jsonlEffectLedger(path: string, options?: {
249
+ now?: () => number;
250
+ }): ToolEffectLedger;
251
+ /** One unparseable interior line of the ledger file, surfaced for triage. */
252
+ interface CorruptLedgerLine {
253
+ /** 1-based physical line number in the file. */
254
+ line: number;
255
+ /** Byte offset of the line's first byte within the file. */
256
+ offset: number;
257
+ /** sha256 (hex) of the raw line bytes: forensics without re-reading. */
258
+ sha256: string;
259
+ /** The first 120 characters of the line. */
260
+ preview: string;
261
+ }
262
+ /** A torn fragment the writer quarantined while repairing a tail (RV502). */
263
+ interface TornLedgerArtifact {
264
+ /** The raw torn bytes, preserved verbatim. */
265
+ bytes: string;
266
+ /** Wall-clock ms when the writer quarantined the fragment. */
267
+ recoveredAt: number;
268
+ }
269
+ /**
270
+ * The fail-closed refusal of {@link loadEffectLedger} (RV502): the file
271
+ * holds at least one unparseable INTERIOR line, which the writer's tail
272
+ * repair can never produce, so it means external damage or a second
273
+ * writer, never a normal crash artifact. Reconciling from a partial
274
+ * scan would silently drop intents; triage the named lines instead
275
+ * (`tolerateCorrupt: true` surfaces them as data).
234
276
  */
235
- declare function jsonlEffectLedger(path: string): ToolEffectLedger;
277
+ declare class LedgerCorruptionError extends Error {
278
+ readonly lines: CorruptLedgerLine[];
279
+ constructor(path: string, lines: CorruptLedgerLine[]);
280
+ }
236
281
  /** What {@link loadEffectLedger} reads back from a JSONL ledger file. */
237
282
  interface EffectLedgerScan {
238
283
  intents: ToolEffectIntent[];
239
284
  outcomes: ToolEffectRecord[];
240
285
  /**
241
- * The reconciliation signal (RV404): every intent whose idempotency
242
- * key has NO outcome row at all. A key with a later outcome (an
243
- * at-least-once retry of the same logical call that completed) is not
244
- * orphaned: the retry resolved it. For each orphan, look the key up
245
- * with the effect's provider before retrying or compensating.
286
+ * The reconciliation signal (RV501): every intent whose OWN attempt
287
+ * never got an outcome row. Pairing is exact: an outcome resolves the
288
+ * intent carrying the same `attemptId` (rows written before the id
289
+ * shipped pair by the legacy (idempotencyKey, startedAt) join), and
290
+ * an outcome of ANY class resolves only its own attempt. A sibling
291
+ * retry's outcome, ok or error, says nothing about THIS attempt, so
292
+ * it never clears it: closing the logical key belongs to the host
293
+ * reconciler, against the effect provider's receipt. For each orphan,
294
+ * look the key up with the effect's provider before retrying or
295
+ * compensating.
246
296
  */
247
297
  orphanedIntents: ToolEffectIntent[];
298
+ /**
299
+ * Unparseable interior lines, populated only under `tolerateCorrupt`
300
+ * (the default scan throws {@link LedgerCorruptionError} instead).
301
+ * Empty on a healthy file.
302
+ */
303
+ corrupt: CorruptLedgerLine[];
304
+ /** Fragments the writer quarantined while repairing torn tails (RV502). */
305
+ tornArtifacts: TornLedgerArtifact[];
306
+ /**
307
+ * A live unterminated, unparseable trailing fragment: the artifact of
308
+ * a crash mid-write no writer has repaired yet. Tolerated and named,
309
+ * never silent.
310
+ */
311
+ tornTail?: {
312
+ preview: string;
313
+ };
248
314
  }
249
315
  /**
250
316
  * Scans a JSONL ledger file into intents, outcomes, and the orphaned
251
- * intents a host must reconcile. A torn trailing line (the artifact of
252
- * a crash mid-write) is skipped, never a scan failure: the durable rows
253
- * before it are exactly what reconciliation needs.
317
+ * intents a host must reconcile, pairing attempts exactly (RV501). A
318
+ * torn TRAILING fragment (the crash-mid-write artifact) is tolerated
319
+ * and reported; an unparseable INTERIOR line fails the scan closed with
320
+ * a typed {@link LedgerCorruptionError} unless `tolerateCorrupt` asks
321
+ * for the lines as data (RV502).
254
322
  */
255
- declare function loadEffectLedger(path: string): Promise<EffectLedgerScan>;
323
+ declare function loadEffectLedger(path: string, options?: {
324
+ tolerateCorrupt?: boolean;
325
+ }): Promise<EffectLedgerScan>;
256
326
  //#endregion
257
327
  //#region src/conformance.d.ts
258
328
  /** The executor options the shared contract exercises. */
@@ -337,4 +407,4 @@ interface ChildResult {
337
407
  */
338
408
  declare function runChildProcess(spec: ChildSpec): Promise<ChildResult>;
339
409
  //#endregion
340
- export { type ChildResult, type ChildSpec, type ChildStopReason, type ConformanceExecutorConfig, type ConformanceExecutorFactory, type ContainerExecutorOptions, type EffectLedgerScan, type ExecutorConformanceCheck, type ExecutorConformanceSuite, ExecutorError, type ExecutorErrorCode, type ExecutorTestRegistrar, type SubprocessCommandSpec, type SubprocessExecutorOptions, type SubprocessToolInit, type ToolEffectIntent, type ToolEffectLedger, type ToolEffectRecord, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
410
+ export { type ChildResult, type ChildSpec, type ChildStopReason, type ConformanceExecutorConfig, type ConformanceExecutorFactory, type ContainerExecutorOptions, type CorruptLedgerLine, type EffectLedgerScan, type ExecutorConformanceCheck, type ExecutorConformanceSuite, ExecutorError, type ExecutorErrorCode, type ExecutorTestRegistrar, LedgerCorruptionError, type SubprocessCommandSpec, type SubprocessExecutorOptions, type SubprocessToolInit, type ToolEffectIntent, type ToolEffectLedger, type ToolEffectRecord, type TornLedgerArtifact, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { appendFile, mkdtemp, readFile, rm } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { appendFile, mkdtemp, readFile, rm, truncate } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { tool } from "@rulvar/core";
5
6
  import { spawn } from "node:child_process";
6
- import { createHash } from "node:crypto";
7
7
  import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
8
8
  //#region src/child.ts
9
9
  /**
@@ -208,7 +208,7 @@ function parseToolResult(stdout, tool) {
208
208
  *
209
209
  * Docs: https://docs.rulvar.com/guide/isolated-executor.
210
210
  */
211
- const wallClock$1 = Date.now.bind(globalThis);
211
+ const wallClock$2 = Date.now.bind(globalThis);
212
212
  function resolveCommand(request, options) {
213
213
  const spec = request.spec ?? {};
214
214
  const command = typeof spec.command === "string" ? spec.command : options.command;
@@ -230,12 +230,13 @@ function subprocessExecutor(options = {}) {
230
230
  const killGraceMs = options.killGraceMs ?? 2e3;
231
231
  const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
232
232
  const workdirBase = options.workdirBase ?? tmpdir();
233
- const now = options.now ?? wallClock$1;
233
+ const now = options.now ?? wallClock$2;
234
234
  return { async run(request) {
235
235
  const { command, args } = resolveCommand(request, options);
236
236
  const workdir = await mkdtemp(join(workdirBase, `rulvar-exec-${request.tool}-`));
237
237
  const startedAt = now();
238
238
  const argsHash = hashArgs(request.args);
239
+ const attemptId = randomUUID();
239
240
  if (options.ledger?.intent !== void 0) try {
240
241
  await options.ledger.intent({
241
242
  idempotencyKey: request.ctx.idempotencyKey,
@@ -245,7 +246,8 @@ function subprocessExecutor(options = {}) {
245
246
  argsHash,
246
247
  executor: request.executor,
247
248
  workdir,
248
- startedAt
249
+ startedAt,
250
+ attemptId
249
251
  });
250
252
  } catch (err) {
251
253
  await rm(workdir, {
@@ -257,6 +259,9 @@ function subprocessExecutor(options = {}) {
257
259
  let outcome = "error";
258
260
  let exitCode = null;
259
261
  let signal = null;
262
+ let settled;
263
+ let bodyThrew = false;
264
+ let thrownBody;
260
265
  try {
261
266
  const env = {};
262
267
  for (const name of options.allowEnv ?? []) {
@@ -312,9 +317,14 @@ function subprocessExecutor(options = {}) {
312
317
  }
313
318
  const result = parseToolResult(child.stdout, request.tool);
314
319
  outcome = "ok";
315
- return result;
316
- } finally {
317
- const durationMs = now() - startedAt;
320
+ settled = result;
321
+ } catch (thrown) {
322
+ bodyThrew = true;
323
+ thrownBody = thrown;
324
+ }
325
+ const durationMs = now() - startedAt;
326
+ let ledgerFailure;
327
+ try {
318
328
  if (options.ledger !== void 0) await options.ledger.record({
319
329
  idempotencyKey: request.ctx.idempotencyKey,
320
330
  runId: request.ctx.runId,
@@ -324,16 +334,22 @@ function subprocessExecutor(options = {}) {
324
334
  executor: request.executor,
325
335
  workdir,
326
336
  startedAt,
337
+ attemptId,
327
338
  durationMs,
328
339
  outcome,
329
340
  exitCode,
330
341
  signal
331
342
  });
332
- await rm(workdir, {
333
- recursive: true,
334
- force: true
335
- });
343
+ } catch (recordErr) {
344
+ ledgerFailure = new ExecutorError("ledger", `tool '${request.tool}' outcome was not recorded: the ledger record write failed (${recordErr instanceof Error ? recordErr.message : String(recordErr)})` + (bodyThrew ? `; the dispatch itself had already failed (${thrownBody instanceof Error ? thrownBody.message : String(thrownBody)})` : ""));
336
345
  }
346
+ await rm(workdir, {
347
+ recursive: true,
348
+ force: true
349
+ });
350
+ if (ledgerFailure !== void 0) throw ledgerFailure;
351
+ if (bodyThrew) throw thrownBody;
352
+ return settled;
337
353
  } };
338
354
  }
339
355
  /**
@@ -384,7 +400,7 @@ function subprocessTool(init) {
384
400
  * ToolExecutorProvider seam; this docker adapter is the batteries-included
385
401
  * reference. Docs: https://docs.rulvar.com/guide/isolated-executor.
386
402
  */
387
- const wallClock = Date.now.bind(globalThis);
403
+ const wallClock$1 = Date.now.bind(globalThis);
388
404
  /** The default host variables the docker CLI needs to reach its daemon. */
389
405
  const DEFAULT_DAEMON_ENV = [
390
406
  "PATH",
@@ -414,7 +430,7 @@ function containerExecutor(options) {
414
430
  const killGraceMs = options.killGraceMs ?? 5e3;
415
431
  const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
416
432
  const workdirBase = options.workdirBase ?? tmpdir();
417
- const now = options.now ?? wallClock;
433
+ const now = options.now ?? wallClock$1;
418
434
  return { async run(request) {
419
435
  const spec = request.spec ?? {};
420
436
  const command = typeof spec.command === "string" ? spec.command : options.command;
@@ -424,6 +440,7 @@ function containerExecutor(options) {
424
440
  const workdir = await mkdtemp(join(workdirBase, `rulvar-cexec-${request.tool}-`));
425
441
  const startedAt = now();
426
442
  const argsHash = hashArgs(request.args);
443
+ const attemptId = randomUUID();
427
444
  if (options.ledger?.intent !== void 0) try {
428
445
  await options.ledger.intent({
429
446
  idempotencyKey: request.ctx.idempotencyKey,
@@ -433,7 +450,8 @@ function containerExecutor(options) {
433
450
  argsHash,
434
451
  executor: request.executor,
435
452
  workdir,
436
- startedAt
453
+ startedAt,
454
+ attemptId
437
455
  });
438
456
  } catch (err) {
439
457
  await rm(workdir, {
@@ -445,6 +463,9 @@ function containerExecutor(options) {
445
463
  let outcome = "error";
446
464
  let exitCode = null;
447
465
  let signal = null;
466
+ let settled;
467
+ let bodyThrew = false;
468
+ let thrownBody;
448
469
  try {
449
470
  const daemonEnv = options.daemonEnv ?? DEFAULT_DAEMON_ENV;
450
471
  const env = {};
@@ -520,9 +541,14 @@ function containerExecutor(options) {
520
541
  }
521
542
  const result = parseToolResult(child.stdout, request.tool);
522
543
  outcome = "ok";
523
- return result;
524
- } finally {
525
- const durationMs = now() - startedAt;
544
+ settled = result;
545
+ } catch (thrown) {
546
+ bodyThrew = true;
547
+ thrownBody = thrown;
548
+ }
549
+ const durationMs = now() - startedAt;
550
+ let ledgerFailure;
551
+ try {
526
552
  if (options.ledger !== void 0) await options.ledger.record({
527
553
  idempotencyKey: request.ctx.idempotencyKey,
528
554
  runId: request.ctx.runId,
@@ -532,42 +558,102 @@ function containerExecutor(options) {
532
558
  executor: request.executor,
533
559
  workdir,
534
560
  startedAt,
561
+ attemptId,
535
562
  durationMs,
536
563
  outcome,
537
564
  exitCode,
538
565
  signal
539
566
  });
540
- await rm(workdir, {
541
- recursive: true,
542
- force: true
543
- });
567
+ } catch (recordErr) {
568
+ ledgerFailure = new ExecutorError("ledger", `container tool '${request.tool}' outcome was not recorded: the ledger record write failed (${recordErr instanceof Error ? recordErr.message : String(recordErr)})` + (bodyThrew ? `; the dispatch itself had already failed (${thrownBody instanceof Error ? thrownBody.message : String(thrownBody)})` : ""));
544
569
  }
570
+ await rm(workdir, {
571
+ recursive: true,
572
+ force: true
573
+ });
574
+ if (ledgerFailure !== void 0) throw ledgerFailure;
575
+ if (bodyThrew) throw thrownBody;
576
+ return settled;
545
577
  } };
546
578
  }
547
579
  //#endregion
548
580
  //#region src/ledger.ts
549
581
  /**
550
- * The durable JSONL reference of the two-phase effect ledger (RV404):
551
- * one JSON line per phase, appended and awaited, so the intent row is
552
- * on disk before the external effect is dispatched and survives a host
553
- * process crash between the effect and the outcome write. What it is
554
- * NOT: a transactional outbox, business authorization, or monetary
555
- * reconciliation; those stay host obligations, this file is the strict
556
- * interface the host reconciles FROM.
582
+ * The durable JSONL reference of the two-phase effect ledger (RV404,
583
+ * hardened by RV501/RV502): one JSON line per phase, appended and
584
+ * awaited, so the intent row is on disk before the external effect is
585
+ * dispatched and survives a host process crash between the effect and
586
+ * the outcome write. What it is NOT: a transactional outbox, business
587
+ * authorization, or monetary reconciliation; those stay host
588
+ * obligations, this file is the strict interface the host reconciles
589
+ * FROM.
590
+ *
591
+ * Identity (RV501): every reference-executor dispatch is one ATTEMPT
592
+ * with its own `attemptId`, and an outcome resolves exactly the intent
593
+ * of its own attempt. A sibling retry's outcome, whatever its class,
594
+ * says nothing about another attempt, so it never clears one: closing
595
+ * the logical idempotency key is the host reconciler's job, against
596
+ * the effect provider's receipt.
597
+ *
598
+ * Recovery (RV502): before its first append the writer repairs a torn
599
+ * tail (the artifact of a crash mid-write): a complete record missing
600
+ * only its newline is terminated in place, an unparseable fragment is
601
+ * truncated and quarantined verbatim as a `{"phase":"torn"}` line. A
602
+ * later scan therefore treats an unparseable INTERIOR line as real
603
+ * damage and fails closed instead of skipping it silently.
557
604
  *
558
605
  * Durability boundary, stated honestly: an awaited append survives a
559
606
  * process crash (the write has entered the kernel), not necessarily a
560
607
  * power loss before the OS flushes; a host that needs power-loss
561
608
  * durability wraps the seam over its own fsync or database write.
562
609
  */
610
+ const wallClock = Date.now.bind(globalThis);
611
+ /**
612
+ * Repairs the file's tail so an append can never glue onto a torn
613
+ * fragment (RV502): a parseable unterminated record gets its newline, a
614
+ * torn fragment is truncated and preserved verbatim in a quarantine
615
+ * line old readers parse and skip. Runs under the ledger's documented
616
+ * single-writer discipline.
617
+ */
618
+ async function repairTail(path, now) {
619
+ let raw;
620
+ try {
621
+ raw = await readFile(path);
622
+ } catch (err) {
623
+ if (err.code === "ENOENT") return;
624
+ throw err;
625
+ }
626
+ if (raw.length === 0 || raw[raw.length - 1] === 10) return;
627
+ const boundary = raw.lastIndexOf(10) + 1;
628
+ const fragment = raw.subarray(boundary).toString("utf8");
629
+ try {
630
+ JSON.parse(fragment);
631
+ } catch {
632
+ await truncate(path, boundary);
633
+ await appendFile(path, `${JSON.stringify({
634
+ phase: "torn",
635
+ bytes: fragment,
636
+ recoveredAt: now()
637
+ })}\n`, "utf8");
638
+ return;
639
+ }
640
+ await appendFile(path, "\n", "utf8");
641
+ }
563
642
  /**
564
643
  * A two-phase ToolEffectLedger appending JSON lines to `path`
565
644
  * (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
566
645
  * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
567
- * scan it back with {@link loadEffectLedger}.
646
+ * scan it back with {@link loadEffectLedger}. The first append lazily
647
+ * repairs a torn tail left by a crashed predecessor (RV502).
568
648
  */
569
- function jsonlEffectLedger(path) {
570
- const append = (line) => appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
649
+ function jsonlEffectLedger(path, options) {
650
+ const now = options?.now ?? wallClock;
651
+ let boundaryReady;
652
+ const append = async (line) => {
653
+ boundaryReady ??= repairTail(path, now);
654
+ await boundaryReady;
655
+ await appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
656
+ };
571
657
  return {
572
658
  intent(entry) {
573
659
  return append({
@@ -584,21 +670,61 @@ function jsonlEffectLedger(path) {
584
670
  };
585
671
  }
586
672
  /**
673
+ * The fail-closed refusal of {@link loadEffectLedger} (RV502): the file
674
+ * holds at least one unparseable INTERIOR line, which the writer's tail
675
+ * repair can never produce, so it means external damage or a second
676
+ * writer, never a normal crash artifact. Reconciling from a partial
677
+ * scan would silently drop intents; triage the named lines instead
678
+ * (`tolerateCorrupt: true` surfaces them as data).
679
+ */
680
+ var LedgerCorruptionError = class extends Error {
681
+ lines;
682
+ constructor(path, lines) {
683
+ const first = lines[0];
684
+ super(`effect ledger '${path}' has ${String(lines.length)} corrupt interior line(s); first at line ${String(first?.line)}, byte ${String(first?.offset)}, sha256 ${String(first?.sha256)}. An interior line the scan cannot parse means external damage or a second writer; reconciliation must not proceed from a partial scan (loadEffectLedger with { tolerateCorrupt: true } surfaces the lines for triage).`);
685
+ this.name = "LedgerCorruptionError";
686
+ this.lines = lines;
687
+ }
688
+ };
689
+ /**
587
690
  * Scans a JSONL ledger file into intents, outcomes, and the orphaned
588
- * intents a host must reconcile. A torn trailing line (the artifact of
589
- * a crash mid-write) is skipped, never a scan failure: the durable rows
590
- * before it are exactly what reconciliation needs.
691
+ * intents a host must reconcile, pairing attempts exactly (RV501). A
692
+ * torn TRAILING fragment (the crash-mid-write artifact) is tolerated
693
+ * and reported; an unparseable INTERIOR line fails the scan closed with
694
+ * a typed {@link LedgerCorruptionError} unless `tolerateCorrupt` asks
695
+ * for the lines as data (RV502).
591
696
  */
592
- async function loadEffectLedger(path) {
593
- const raw = await readFile(path, "utf8");
697
+ async function loadEffectLedger(path, options) {
698
+ const raw = await readFile(path);
594
699
  const intents = [];
595
700
  const outcomes = [];
596
- for (const line of raw.split("\n")) {
597
- if (line.trim() === "") continue;
701
+ const corrupt = [];
702
+ const tornArtifacts = [];
703
+ let tornTail;
704
+ let line = 0;
705
+ let start = 0;
706
+ for (let i = 0; i <= raw.length; i += 1) {
707
+ const atEnd = i === raw.length;
708
+ if (!atEnd && raw[i] !== 10) continue;
709
+ const bytes = raw.subarray(start, i);
710
+ const offset = start;
711
+ const terminated = !atEnd;
712
+ start = i + 1;
713
+ if (atEnd && bytes.length === 0) break;
714
+ line += 1;
715
+ const text = bytes.toString("utf8");
716
+ if (text.trim() === "") continue;
598
717
  let parsed;
599
718
  try {
600
- parsed = JSON.parse(line);
719
+ parsed = JSON.parse(text);
601
720
  } catch {
721
+ if (terminated) corrupt.push({
722
+ line,
723
+ offset,
724
+ sha256: createHash("sha256").update(bytes).digest("hex"),
725
+ preview: text.slice(0, 120)
726
+ });
727
+ else tornTail = { preview: text.slice(0, 120) };
602
728
  continue;
603
729
  }
604
730
  if (parsed.phase === "intent") {
@@ -607,13 +733,23 @@ async function loadEffectLedger(path) {
607
733
  } else if (parsed.phase === "outcome") {
608
734
  const { phase: _phase, ...entry } = parsed;
609
735
  outcomes.push(entry);
610
- }
736
+ } else if (parsed.phase === "torn") tornArtifacts.push({
737
+ bytes: parsed.bytes,
738
+ recoveredAt: parsed.recoveredAt
739
+ });
611
740
  }
612
- const resolvedKeys = new Set(outcomes.map((entry) => entry.idempotencyKey));
741
+ if (corrupt.length > 0 && options?.tolerateCorrupt !== true) throw new LedgerCorruptionError(path, corrupt);
742
+ const resolvedAttempts = /* @__PURE__ */ new Set();
743
+ const resolvedLegacy = /* @__PURE__ */ new Set();
744
+ for (const outcome of outcomes) if (outcome.attemptId === void 0) resolvedLegacy.add(JSON.stringify([outcome.idempotencyKey, outcome.startedAt]));
745
+ else resolvedAttempts.add(outcome.attemptId);
613
746
  return {
614
747
  intents,
615
748
  outcomes,
616
- orphanedIntents: intents.filter((entry) => !resolvedKeys.has(entry.idempotencyKey))
749
+ orphanedIntents: intents.filter((entry) => entry.attemptId === void 0 ? !resolvedLegacy.has(JSON.stringify([entry.idempotencyKey, entry.startedAt])) : !resolvedAttempts.has(entry.attemptId)),
750
+ corrupt,
751
+ tornArtifacts,
752
+ ...tornTail === void 0 ? {} : { tornTail }
617
753
  };
618
754
  }
619
755
  //#endregion
@@ -961,4 +1097,4 @@ function executorConformance(factory, options = {}) {
961
1097
  };
962
1098
  }
963
1099
  //#endregion
964
- export { ExecutorError, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
1100
+ export { ExecutorError, LedgerCorruptionError, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/executor",
3
- "version": "1.94.0",
3
+ "version": "1.96.0",
4
4
  "description": "Rulvar isolated tool executors: reference ToolExecutorProvider adapters that run tool work out of process (subprocess and container) so hostile or model-generated scripts cannot reach host capabilities.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.94.0"
25
+ "@rulvar/core": "1.96.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.1",
29
29
  "tsdown": "^0.22.14",
30
30
  "typescript": "~6.0.3",
31
- "@rulvar/testing": "1.94.0"
31
+ "@rulvar/testing": "1.96.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",