@rulvar/executor 1.103.0 → 1.104.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
@@ -244,11 +244,22 @@ declare function containerExecutor(options: ContainerExecutorOptions): ToolExecu
244
244
  * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
245
245
  * scan it back with {@link loadEffectLedger}. The first append lazily
246
246
  * repairs a torn tail left by a crashed predecessor (RV502).
247
+ *
248
+ * Writer contract (RV606), stated publicly: appends are whole-line
249
+ * O_APPEND writes, and the destructive tail repair is mutually
250
+ * exclusive across processes (a sidecar `<path>.repair-lock` taken with
251
+ * O_EXCL, the file re-read after capture, a stale lock stolen after a
252
+ * ten-second TTL), so several writer processes on one LOCAL path can no
253
+ * longer truncate away each other's confirmed rows while repairing.
254
+ * Still, prefer ONE WRITER PER PATH, a `effects.<worker>.jsonl` file
255
+ * per worker process merged at reconciliation time: per-line append
256
+ * atomicity is a local-filesystem property, and neither O_APPEND nor
257
+ * O_EXCL is dependable on network filesystems.
247
258
  */
248
259
  declare function jsonlEffectLedger(path: string, options?: {
249
260
  now?: () => number;
250
261
  }): ToolEffectLedger;
251
- /** One unparseable interior line of the ledger file, surfaced for triage. */
262
+ /** One malformed line of the ledger file, surfaced for triage. */
252
263
  interface CorruptLedgerLine {
253
264
  /** 1-based physical line number in the file. */
254
265
  line: number;
@@ -256,7 +267,8 @@ interface CorruptLedgerLine {
256
267
  offset: number;
257
268
  /** sha256 (hex) of the raw line bytes: forensics without re-reading. */
258
269
  sha256: string;
259
- /** The first 120 characters of the line. */
270
+ /** The first 120 characters of the line (lossy-decoded when the bytes
271
+ * are not valid UTF-8; the hash pins the exact bytes). */
260
272
  preview: string;
261
273
  }
262
274
  /** A torn fragment the writer quarantined while repairing a tail (RV502). */
@@ -267,12 +279,15 @@ interface TornLedgerArtifact {
267
279
  recoveredAt: number;
268
280
  }
269
281
  /**
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).
282
+ * The fail-closed refusal of {@link loadEffectLedger} (RV502, widened
283
+ * by RV607): the file holds at least one line the scan cannot admit,
284
+ * unparseable bytes on an interior line, invalid UTF-8, a JSON value
285
+ * that is not an object, a missing or mistyped required field, or an
286
+ * unknown phase, none of which the writer's tail repair can produce, so
287
+ * it means external damage or a foreign writer, never a normal crash
288
+ * artifact. Reconciling from a partial scan would silently drop
289
+ * intents; triage the named lines instead (`tolerateCorrupt: true`
290
+ * surfaces them as data).
276
291
  */
277
292
  declare class LedgerCorruptionError extends Error {
278
293
  readonly lines: CorruptLedgerLine[];
@@ -296,9 +311,11 @@ interface EffectLedgerScan {
296
311
  */
297
312
  orphanedIntents: ToolEffectIntent[];
298
313
  /**
299
- * Unparseable interior lines, populated only under `tolerateCorrupt`
300
- * (the default scan throws {@link LedgerCorruptionError} instead).
301
- * Empty on a healthy file.
314
+ * Lines the scan refused to admit (RV607): unparseable interior
315
+ * bytes, invalid UTF-8, non-object JSON, a missing or mistyped
316
+ * required field, or an unknown phase. Populated only under
317
+ * `tolerateCorrupt` (the default scan throws
318
+ * {@link LedgerCorruptionError} instead). Empty on a healthy file.
302
319
  */
303
320
  corrupt: CorruptLedgerLine[];
304
321
  /** Fragments the writer quarantined while repairing torn tails (RV502). */
@@ -306,7 +323,10 @@ interface EffectLedgerScan {
306
323
  /**
307
324
  * A live unterminated, unparseable trailing fragment: the artifact of
308
325
  * a crash mid-write no writer has repaired yet. Tolerated and named,
309
- * never silent.
326
+ * never silent. (An unterminated line that PARSES but fails the shape
327
+ * is corruption instead: a torn prefix of the writer's own flat
328
+ * record can never parse, so such a line is foreign, not a crash
329
+ * artifact.)
310
330
  */
311
331
  tornTail?: {
312
332
  preview: string;
@@ -316,9 +336,12 @@ interface EffectLedgerScan {
316
336
  * Scans a JSONL ledger file into intents, outcomes, and the orphaned
317
337
  * intents a host must reconcile, pairing attempts exactly (RV501). A
318
338
  * 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).
339
+ * and reported; everything else the scan cannot decode, parse, and
340
+ * validate, invalid UTF-8, non-object JSON, a missing required field,
341
+ * an unknown phase (RV607), fails the scan closed with a typed
342
+ * {@link LedgerCorruptionError} unless `tolerateCorrupt` asks for the
343
+ * lines as data (RV502). Under `tolerateCorrupt` the scan never throws
344
+ * anything rawer than that: a malformed line is data, not an exception.
322
345
  */
323
346
  declare function loadEffectLedger(path: string, options?: {
324
347
  tolerateCorrupt?: boolean;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { appendFile, mkdtemp, readFile, rm, truncate } from "node:fs/promises";
2
+ import { appendFile, mkdtemp, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { tool } from "@rulvar/core";
@@ -580,11 +580,12 @@ function containerExecutor(options) {
580
580
  //#region src/ledger.ts
581
581
  /**
582
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
583
+ * hardened by RV501/RV502, cross-process repair exclusion RV606,
584
+ * fail-closed scan validation RV607): one JSON line per phase, appended
585
+ * and awaited, so the intent row is on disk before the external effect
586
+ * is dispatched and survives a host process crash between the effect
587
+ * and the outcome write. What it is NOT: a transactional outbox,
588
+ * business authorization, or monetary reconciliation; those stay host
588
589
  * obligations, this file is the strict interface the host reconciles
589
590
  * FROM.
590
591
  *
@@ -595,12 +596,15 @@ function containerExecutor(options) {
595
596
  * the logical idempotency key is the host reconciler's job, against
596
597
  * the effect provider's receipt.
597
598
  *
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.
599
+ * Recovery (RV502, exclusive per RV606): before its first append the
600
+ * writer repairs a torn tail (the artifact of a crash mid-write): a
601
+ * complete record missing only its newline is terminated in place, an
602
+ * unparseable fragment is truncated and quarantined verbatim as a
603
+ * `{"phase":"torn"}` line. The destructive step runs under a sidecar
604
+ * O_EXCL lock and re-reads the file after capture, so two writer
605
+ * processes repairing the same file can no longer erase each other's
606
+ * confirmed rows. A later scan therefore treats an unparseable INTERIOR
607
+ * line as real damage and fails closed instead of skipping it silently.
604
608
  *
605
609
  * Durability boundary, stated honestly: an awaited append survives a
606
610
  * process crash (the write has entered the kernel), not necessarily a
@@ -608,12 +612,96 @@ function containerExecutor(options) {
608
612
  * durability wraps the seam over its own fsync or database write.
609
613
  */
610
614
  const wallClock = Date.now.bind(globalThis);
615
+ /** How stale a repair lock's mtime must be before a writer may presume
616
+ * its holder crashed and steal it. Repairs take milliseconds; ten
617
+ * seconds is a crash verdict, not a performance budget. */
618
+ const REPAIR_LOCK_TTL_MS = 1e4;
619
+ /** How often a writer waiting on another process's repair re-checks. */
620
+ const REPAIR_POLL_MS = 25;
621
+ const delay = (ms) => new Promise((resolve) => {
622
+ setTimeout(resolve, ms);
623
+ });
624
+ /**
625
+ * The destructive half of the tail repair, mutually exclusive between
626
+ * processes (RV606): a sidecar `<path>.repair-lock` created with O_EXCL
627
+ * serializes repairers, and the ledger file is re-read AFTER the lock
628
+ * is held, so a boundary computed from a read another repairer has
629
+ * since invalidated is never truncated. A writer that loses the race
630
+ * polls; a lock whose mtime is further than the TTL from now (either
631
+ * direction, so a skewed clock cannot pin the file forever) is presumed
632
+ * abandoned by a crashed holder and stolen. The residual window (a
633
+ * holder stalled past the TTL, stolen mid-repair) is shrunk by
634
+ * re-verifying ownership immediately before the truncate; against a
635
+ * millisecond repair and a ten-second TTL it needs a scheduler pause no
636
+ * healthy host exhibits.
637
+ */
638
+ async function repairUnderLock(path, now) {
639
+ const lockPath = `${path}.repair-lock`;
640
+ const token = `${String(process.pid)}-${randomUUID()}`;
641
+ for (;;) {
642
+ let acquired = true;
643
+ try {
644
+ await writeFile(lockPath, token, { flag: "wx" });
645
+ } catch (err) {
646
+ if (err.code !== "EEXIST") throw err;
647
+ acquired = false;
648
+ }
649
+ if (!acquired) {
650
+ let held;
651
+ try {
652
+ held = await stat(lockPath);
653
+ } catch (err) {
654
+ if (err.code !== "ENOENT") throw err;
655
+ continue;
656
+ }
657
+ if (Math.abs(wallClock() - held.mtimeMs) > REPAIR_LOCK_TTL_MS) {
658
+ await rm(lockPath, { force: true });
659
+ continue;
660
+ }
661
+ await delay(REPAIR_POLL_MS);
662
+ continue;
663
+ }
664
+ try {
665
+ let raw;
666
+ try {
667
+ raw = await readFile(path);
668
+ } catch (err) {
669
+ if (err.code === "ENOENT") return;
670
+ throw err;
671
+ }
672
+ if (raw.length === 0 || raw[raw.length - 1] === 10) return;
673
+ const boundary = raw.lastIndexOf(10) + 1;
674
+ const fragment = raw.subarray(boundary).toString("utf8");
675
+ let parseable = true;
676
+ try {
677
+ JSON.parse(fragment);
678
+ } catch {
679
+ parseable = false;
680
+ }
681
+ if (parseable) {
682
+ await appendFile(path, "\n", "utf8");
683
+ return;
684
+ }
685
+ if (await readFile(lockPath, "utf8").catch(() => "") !== token) continue;
686
+ await truncate(path, boundary);
687
+ await appendFile(path, `${JSON.stringify({
688
+ phase: "torn",
689
+ bytes: fragment,
690
+ recoveredAt: now()
691
+ })}\n`, "utf8");
692
+ return;
693
+ } finally {
694
+ if (await readFile(lockPath, "utf8").catch(() => "") === token) await rm(lockPath, { force: true });
695
+ }
696
+ }
697
+ }
611
698
  /**
612
699
  * Repairs the file's tail so an append can never glue onto a torn
613
700
  * fragment (RV502): a parseable unterminated record gets its newline, a
614
701
  * 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.
702
+ * line old readers parse and skip. A clean file returns untouched, byte
703
+ * for byte, without the lock ever existing; the repair itself runs
704
+ * under the cross-process exclusion of {@link repairUnderLock} (RV606).
617
705
  */
618
706
  async function repairTail(path, now) {
619
707
  let raw;
@@ -624,20 +712,7 @@ async function repairTail(path, now) {
624
712
  throw err;
625
713
  }
626
714
  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");
715
+ await repairUnderLock(path, now);
641
716
  }
642
717
  /**
643
718
  * A two-phase ToolEffectLedger appending JSON lines to `path`
@@ -645,6 +720,17 @@ async function repairTail(path, now) {
645
720
  * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
646
721
  * scan it back with {@link loadEffectLedger}. The first append lazily
647
722
  * repairs a torn tail left by a crashed predecessor (RV502).
723
+ *
724
+ * Writer contract (RV606), stated publicly: appends are whole-line
725
+ * O_APPEND writes, and the destructive tail repair is mutually
726
+ * exclusive across processes (a sidecar `<path>.repair-lock` taken with
727
+ * O_EXCL, the file re-read after capture, a stale lock stolen after a
728
+ * ten-second TTL), so several writer processes on one LOCAL path can no
729
+ * longer truncate away each other's confirmed rows while repairing.
730
+ * Still, prefer ONE WRITER PER PATH, a `effects.<worker>.jsonl` file
731
+ * per worker process merged at reconciliation time: per-line append
732
+ * atomicity is a local-filesystem property, and neither O_APPEND nor
733
+ * O_EXCL is dependable on network filesystems.
648
734
  */
649
735
  function jsonlEffectLedger(path, options) {
650
736
  const now = options?.now ?? wallClock;
@@ -670,29 +756,60 @@ function jsonlEffectLedger(path, options) {
670
756
  };
671
757
  }
672
758
  /**
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).
759
+ * The fail-closed refusal of {@link loadEffectLedger} (RV502, widened
760
+ * by RV607): the file holds at least one line the scan cannot admit,
761
+ * unparseable bytes on an interior line, invalid UTF-8, a JSON value
762
+ * that is not an object, a missing or mistyped required field, or an
763
+ * unknown phase, none of which the writer's tail repair can produce, so
764
+ * it means external damage or a foreign writer, never a normal crash
765
+ * artifact. Reconciling from a partial scan would silently drop
766
+ * intents; triage the named lines instead (`tolerateCorrupt: true`
767
+ * surfaces them as data).
679
768
  */
680
769
  var LedgerCorruptionError = class extends Error {
681
770
  lines;
682
771
  constructor(path, lines) {
683
772
  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).`);
773
+ super(`effect ledger '${path}' has ${String(lines.length)} corrupt line(s); first at line ${String(first?.line)}, byte ${String(first?.offset)}, sha256 ${String(first?.sha256)}. A line the scan cannot decode, parse, and validate means external damage or a foreign writer; reconciliation must not proceed from a partial scan (loadEffectLedger with { tolerateCorrupt: true } surfaces the lines for triage).`);
685
774
  this.name = "LedgerCorruptionError";
686
775
  this.lines = lines;
687
776
  }
688
777
  };
778
+ /** Strict per-line decode (RV607): invalid UTF-8 is damage, never a
779
+ * replacement character that forges a key. */
780
+ const decodeStrict = (bytes) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
781
+ const isRecordObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
782
+ const hasIntentShape = (value) => typeof value.idempotencyKey === "string" && typeof value.runId === "string" && typeof value.spanId === "string" && typeof value.tool === "string" && typeof value.argsHash === "string" && typeof value.executor === "string" && typeof value.workdir === "string" && typeof value.startedAt === "number" && (value.attemptId === void 0 || typeof value.attemptId === "string");
783
+ const OUTCOME_CLASSES = /* @__PURE__ */ new Set([
784
+ "ok",
785
+ "error",
786
+ "timeout"
787
+ ]);
788
+ /**
789
+ * Validates one parsed line's shape BEFORE anything downstream touches
790
+ * it (RV607): the phase must be exactly one of the three the writer
791
+ * emits, and every required field of that phase must carry its type
792
+ * (extra fields pass through untouched). Anything else, a primitive, a
793
+ * null, a missing field, an unknown phase, returns undefined and
794
+ * becomes a {@link CorruptLedgerLine}: forward compatibility with
795
+ * future phases is versioning's job, never silence's.
796
+ */
797
+ function asLedgerLine(parsed) {
798
+ if (!isRecordObject(parsed)) return void 0;
799
+ if (parsed.phase === "intent") return hasIntentShape(parsed) ? parsed : void 0;
800
+ if (parsed.phase === "outcome") return hasIntentShape(parsed) && typeof parsed.durationMs === "number" && OUTCOME_CLASSES.has(parsed.outcome) && (typeof parsed.exitCode === "number" || parsed.exitCode === null) && (typeof parsed.signal === "string" || parsed.signal === null) ? parsed : void 0;
801
+ if (parsed.phase === "torn") return typeof parsed.bytes === "string" && typeof parsed.recoveredAt === "number" ? parsed : void 0;
802
+ }
689
803
  /**
690
804
  * Scans a JSONL ledger file into intents, outcomes, and the orphaned
691
805
  * intents a host must reconcile, pairing attempts exactly (RV501). A
692
806
  * 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).
807
+ * and reported; everything else the scan cannot decode, parse, and
808
+ * validate, invalid UTF-8, non-object JSON, a missing required field,
809
+ * an unknown phase (RV607), fails the scan closed with a typed
810
+ * {@link LedgerCorruptionError} unless `tolerateCorrupt` asks for the
811
+ * lines as data (RV502). Under `tolerateCorrupt` the scan never throws
812
+ * anything rawer than that: a malformed line is data, not an exception.
696
813
  */
697
814
  async function loadEffectLedger(path, options) {
698
815
  const raw = await readFile(path);
@@ -703,6 +820,14 @@ async function loadEffectLedger(path, options) {
703
820
  let tornTail;
704
821
  let line = 0;
705
822
  let start = 0;
823
+ const markCorrupt = (lineNo, offset, bytes, preview) => {
824
+ corrupt.push({
825
+ line: lineNo,
826
+ offset,
827
+ sha256: createHash("sha256").update(bytes).digest("hex"),
828
+ preview: preview.slice(0, 120)
829
+ });
830
+ };
706
831
  for (let i = 0; i <= raw.length; i += 1) {
707
832
  const atEnd = i === raw.length;
708
833
  if (!atEnd && raw[i] !== 10) continue;
@@ -712,30 +837,37 @@ async function loadEffectLedger(path, options) {
712
837
  start = i + 1;
713
838
  if (atEnd && bytes.length === 0) break;
714
839
  line += 1;
715
- const text = bytes.toString("utf8");
840
+ let text;
841
+ try {
842
+ text = decodeStrict(bytes);
843
+ } catch {
844
+ if (terminated) markCorrupt(line, offset, bytes, bytes.toString("utf8"));
845
+ else tornTail = { preview: bytes.toString("utf8").slice(0, 120) };
846
+ continue;
847
+ }
716
848
  if (text.trim() === "") continue;
717
849
  let parsed;
718
850
  try {
719
851
  parsed = JSON.parse(text);
720
852
  } 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
- });
853
+ if (terminated) markCorrupt(line, offset, bytes, text);
727
854
  else tornTail = { preview: text.slice(0, 120) };
728
855
  continue;
729
856
  }
730
- if (parsed.phase === "intent") {
731
- const { phase: _phase, ...entry } = parsed;
732
- intents.push(entry);
733
- } else if (parsed.phase === "outcome") {
734
- const { phase: _phase, ...entry } = parsed;
735
- outcomes.push(entry);
736
- } else if (parsed.phase === "torn") tornArtifacts.push({
737
- bytes: parsed.bytes,
738
- recoveredAt: parsed.recoveredAt
857
+ const entry = asLedgerLine(parsed);
858
+ if (entry === void 0) {
859
+ markCorrupt(line, offset, bytes, text);
860
+ continue;
861
+ }
862
+ if (entry.phase === "intent") {
863
+ const { phase: _phase, ...rest } = entry;
864
+ intents.push(rest);
865
+ } else if (entry.phase === "outcome") {
866
+ const { phase: _phase, ...rest } = entry;
867
+ outcomes.push(rest);
868
+ } else tornArtifacts.push({
869
+ bytes: entry.bytes,
870
+ recoveredAt: entry.recoveredAt
739
871
  });
740
872
  }
741
873
  if (corrupt.length > 0 && options?.tolerateCorrupt !== true) throw new LedgerCorruptionError(path, corrupt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/executor",
3
- "version": "1.103.0",
3
+ "version": "1.104.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.103.0"
25
+ "@rulvar/core": "1.104.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.103.0"
31
+ "@rulvar/testing": "1.104.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",