@rulvar/executor 1.90.0 → 1.92.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
@@ -2,7 +2,7 @@ import { IsolatedExecRequest, IsolatedExecutorTag, SchemaSpec, ToolDef, ToolExec
2
2
 
3
3
  //#region src/spi.d.ts
4
4
  /** Why an isolated dispatch failed. */
5
- type ExecutorErrorCode = "config" | "timeout" | "aborted" | "output-cap" | "exit" | "protocol" | "spawn";
5
+ type ExecutorErrorCode = "config" | "timeout" | "aborted" | "output-cap" | "exit" | "protocol" | "spawn" | "ledger";
6
6
  /**
7
7
  * A failed isolated dispatch. The engine catches whatever a
8
8
  * ToolExecutorProvider throws and turns it into the call's error tool
@@ -13,8 +13,15 @@ declare class ExecutorError extends Error {
13
13
  readonly code: ExecutorErrorCode;
14
14
  constructor(code: ExecutorErrorCode, message: string);
15
15
  }
16
- /** One dispatch's side-effect facts, for the ledger. */
17
- interface ToolEffectRecord {
16
+ /**
17
+ * The pre-dispatch half of a two-phase ledger entry (RV404): everything
18
+ * the executor knows BEFORE the external effect is dispatched, which is
19
+ * exactly the set a host needs to reconcile an orphaned effect with the
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.
23
+ */
24
+ interface ToolEffectIntent {
18
25
  /** The stable per-call idempotency key (createEngine derives it). */
19
26
  idempotencyKey: string;
20
27
  runId: string;
@@ -23,9 +30,12 @@ interface ToolEffectRecord {
23
30
  /** sha256 of the canonical arguments: correlates without storing them. */
24
31
  argsHash: string;
25
32
  executor: IsolatedExecutorTag;
26
- /** The ephemeral working directory the dispatch ran in. */
33
+ /** The ephemeral working directory the dispatch runs in. */
27
34
  workdir: string;
28
35
  startedAt: number;
36
+ }
37
+ /** One dispatch's side-effect facts, for the ledger. */
38
+ interface ToolEffectRecord extends ToolEffectIntent {
29
39
  durationMs: number;
30
40
  outcome: "ok" | "error" | "timeout";
31
41
  /** Child exit code, or null when terminated by a signal. */
@@ -42,10 +52,28 @@ interface ToolEffectRecord {
42
52
  */
43
53
  interface ToolEffectLedger {
44
54
  record(entry: ToolEffectRecord): void | Promise<void>;
55
+ /**
56
+ * The two-phase capability (RV404): when the method is present, the
57
+ * reference executors durably record the intent BEFORE the external
58
+ * effect is dispatched (awaited; a failed write refuses the dispatch
59
+ * with the typed `ledger` code) and the outcome `record` after it. A
60
+ * host crash between the effect and the outcome row then leaves an
61
+ * 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
65
+ * single-record contract and executor behavior is byte-identical.
66
+ */
67
+ intent?(entry: ToolEffectIntent): void | Promise<void>;
45
68
  }
46
- /** An in-memory ledger for tests and single-process hosts. */
69
+ /**
70
+ * An in-memory ledger for tests and single-process hosts. It implements
71
+ * the two-phase capability: `intents()` exposes the pre-dispatch rows,
72
+ * `entries()` the outcomes, exactly as before.
73
+ */
47
74
  declare function memoryEffectLedger(): ToolEffectLedger & {
48
75
  entries(): readonly ToolEffectRecord[];
76
+ intents(): readonly ToolEffectIntent[];
49
77
  };
50
78
  /**
51
79
  * A stable content hash of the arguments for the ledger's `argsHash`. It
@@ -197,6 +225,35 @@ interface ContainerExecutorOptions {
197
225
  */
198
226
  declare function containerExecutor(options: ContainerExecutorOptions): ToolExecutorProvider;
199
227
  //#endregion
228
+ //#region src/ledger.d.ts
229
+ /**
230
+ * A two-phase ToolEffectLedger appending JSON lines to `path`
231
+ * (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
232
+ * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
233
+ * scan it back with {@link loadEffectLedger}.
234
+ */
235
+ declare function jsonlEffectLedger(path: string): ToolEffectLedger;
236
+ /** What {@link loadEffectLedger} reads back from a JSONL ledger file. */
237
+ interface EffectLedgerScan {
238
+ intents: ToolEffectIntent[];
239
+ outcomes: ToolEffectRecord[];
240
+ /**
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.
246
+ */
247
+ orphanedIntents: ToolEffectIntent[];
248
+ }
249
+ /**
250
+ * 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.
254
+ */
255
+ declare function loadEffectLedger(path: string): Promise<EffectLedgerScan>;
256
+ //#endregion
200
257
  //#region src/conformance.d.ts
201
258
  /** The executor options the shared contract exercises. */
202
259
  interface ConformanceExecutorConfig {
@@ -206,7 +263,7 @@ interface ConformanceExecutorConfig {
206
263
  credentials?: (request: IsolatedExecRequest) => Record<string, string>;
207
264
  timeoutMs?: number;
208
265
  maxOutputBytes?: number;
209
- ledger?: ReturnType<typeof memoryEffectLedger>;
266
+ ledger?: ToolEffectLedger;
210
267
  }
211
268
  /** Builds the provider under test from a shared-contract config. */
212
269
  type ConformanceExecutorFactory = (config: ConformanceExecutorConfig) => ToolExecutorProvider;
@@ -280,4 +337,4 @@ interface ChildResult {
280
337
  */
281
338
  declare function runChildProcess(spec: ChildSpec): Promise<ChildResult>;
282
339
  //#endregion
283
- export { type ChildResult, type ChildSpec, type ChildStopReason, type ConformanceExecutorConfig, type ConformanceExecutorFactory, type ContainerExecutorOptions, type ExecutorConformanceCheck, type ExecutorConformanceSuite, ExecutorError, type ExecutorErrorCode, type ExecutorTestRegistrar, type SubprocessCommandSpec, type SubprocessExecutorOptions, type SubprocessToolInit, type ToolEffectLedger, type ToolEffectRecord, containerExecutor, executorConformance, hashArgs, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
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 };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { appendFile, mkdtemp, readFile, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { tool } from "@rulvar/core";
5
5
  import { spawn } from "node:child_process";
6
6
  import { createHash } from "node:crypto";
7
- import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
8
8
  //#region src/child.ts
9
9
  /**
10
10
  * The shared child-process runner both reference executors build on. It
@@ -132,15 +132,26 @@ var ExecutorError = class extends Error {
132
132
  this.code = code;
133
133
  }
134
134
  };
135
- /** An in-memory ledger for tests and single-process hosts. */
135
+ /**
136
+ * An in-memory ledger for tests and single-process hosts. It implements
137
+ * the two-phase capability: `intents()` exposes the pre-dispatch rows,
138
+ * `entries()` the outcomes, exactly as before.
139
+ */
136
140
  function memoryEffectLedger() {
137
141
  const rows = [];
142
+ const intentRows = [];
138
143
  return {
139
144
  record(entry) {
140
145
  rows.push(entry);
141
146
  },
147
+ intent(entry) {
148
+ intentRows.push(entry);
149
+ },
142
150
  entries() {
143
151
  return rows;
152
+ },
153
+ intents() {
154
+ return intentRows;
144
155
  }
145
156
  };
146
157
  }
@@ -224,7 +235,26 @@ function subprocessExecutor(options = {}) {
224
235
  const { command, args } = resolveCommand(request, options);
225
236
  const workdir = await mkdtemp(join(workdirBase, `rulvar-exec-${request.tool}-`));
226
237
  const startedAt = now();
227
- let outcome = "ok";
238
+ const argsHash = hashArgs(request.args);
239
+ if (options.ledger?.intent !== void 0) try {
240
+ await options.ledger.intent({
241
+ idempotencyKey: request.ctx.idempotencyKey,
242
+ runId: request.ctx.runId,
243
+ spanId: request.ctx.spanId,
244
+ tool: request.tool,
245
+ argsHash,
246
+ executor: request.executor,
247
+ workdir,
248
+ startedAt
249
+ });
250
+ } catch (err) {
251
+ await rm(workdir, {
252
+ recursive: true,
253
+ force: true
254
+ });
255
+ throw new ExecutorError("ledger", `tool '${request.tool}' was not dispatched: the two-phase ledger intent write failed (${err instanceof Error ? err.message : String(err)})`);
256
+ }
257
+ let outcome = "error";
228
258
  let exitCode = null;
229
259
  let signal = null;
230
260
  try {
@@ -266,7 +296,6 @@ function subprocessExecutor(options = {}) {
266
296
  signal: request.ctx.signal
267
297
  });
268
298
  } catch (err) {
269
- outcome = "error";
270
299
  throw new ExecutorError("spawn", `tool '${request.tool}' could not be spawned: ${err instanceof Error ? err.message : String(err)}`);
271
300
  }
272
301
  exitCode = child.code;
@@ -275,25 +304,15 @@ function subprocessExecutor(options = {}) {
275
304
  outcome = "timeout";
276
305
  throw new ExecutorError("timeout", `tool '${request.tool}' exceeded ${timeoutMs}ms and was killed`);
277
306
  }
278
- if (child.stopped && child.reason === "aborted") {
279
- outcome = "error";
280
- throw new ExecutorError("aborted", `tool '${request.tool}' was cancelled`);
281
- }
282
- if (child.stopped && child.reason === "output-cap") {
283
- outcome = "error";
284
- throw new ExecutorError("output-cap", `tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
285
- }
307
+ if (child.stopped && child.reason === "aborted") throw new ExecutorError("aborted", `tool '${request.tool}' was cancelled`);
308
+ if (child.stopped && child.reason === "output-cap") throw new ExecutorError("output-cap", `tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
286
309
  if (child.code !== 0) {
287
- outcome = "error";
288
310
  const tail = child.stderr.trim().slice(-500);
289
311
  throw new ExecutorError("exit", `tool '${request.tool}' exited ${child.code ?? "null"}${child.signal === null ? "" : ` (signal ${child.signal})`}${tail === "" ? "" : `: ${tail}`}`);
290
312
  }
291
- try {
292
- return parseToolResult(child.stdout, request.tool);
293
- } catch (err) {
294
- outcome = "error";
295
- throw err;
296
- }
313
+ const result = parseToolResult(child.stdout, request.tool);
314
+ outcome = "ok";
315
+ return result;
297
316
  } finally {
298
317
  const durationMs = now() - startedAt;
299
318
  if (options.ledger !== void 0) await options.ledger.record({
@@ -301,7 +320,7 @@ function subprocessExecutor(options = {}) {
301
320
  runId: request.ctx.runId,
302
321
  spanId: request.ctx.spanId,
303
322
  tool: request.tool,
304
- argsHash: hashArgs(request.args),
323
+ argsHash,
305
324
  executor: request.executor,
306
325
  workdir,
307
326
  startedAt,
@@ -404,7 +423,26 @@ function containerExecutor(options) {
404
423
  const toolArgs = [...options.args ?? [], ...specArgs];
405
424
  const workdir = await mkdtemp(join(workdirBase, `rulvar-cexec-${request.tool}-`));
406
425
  const startedAt = now();
407
- let outcome = "ok";
426
+ const argsHash = hashArgs(request.args);
427
+ if (options.ledger?.intent !== void 0) try {
428
+ await options.ledger.intent({
429
+ idempotencyKey: request.ctx.idempotencyKey,
430
+ runId: request.ctx.runId,
431
+ spanId: request.ctx.spanId,
432
+ tool: request.tool,
433
+ argsHash,
434
+ executor: request.executor,
435
+ workdir,
436
+ startedAt
437
+ });
438
+ } catch (err) {
439
+ await rm(workdir, {
440
+ recursive: true,
441
+ force: true
442
+ });
443
+ throw new ExecutorError("ledger", `container tool '${request.tool}' was not dispatched: the two-phase ledger intent write failed (${err instanceof Error ? err.message : String(err)})`);
444
+ }
445
+ let outcome = "error";
408
446
  let exitCode = null;
409
447
  let signal = null;
410
448
  try {
@@ -466,7 +504,6 @@ function containerExecutor(options) {
466
504
  signal: request.ctx.signal
467
505
  });
468
506
  } catch (err) {
469
- outcome = "error";
470
507
  throw new ExecutorError("spawn", `container tool '${request.tool}' could not launch '${docker}': ${err instanceof Error ? err.message : String(err)}`);
471
508
  }
472
509
  exitCode = child.code;
@@ -475,25 +512,15 @@ function containerExecutor(options) {
475
512
  outcome = "timeout";
476
513
  throw new ExecutorError("timeout", `container tool '${request.tool}' exceeded ${timeoutMs}ms and was killed`);
477
514
  }
478
- if (child.stopped && child.reason === "aborted") {
479
- outcome = "error";
480
- throw new ExecutorError("aborted", `container tool '${request.tool}' was cancelled`);
481
- }
482
- if (child.stopped && child.reason === "output-cap") {
483
- outcome = "error";
484
- throw new ExecutorError("output-cap", `container tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
485
- }
515
+ if (child.stopped && child.reason === "aborted") throw new ExecutorError("aborted", `container tool '${request.tool}' was cancelled`);
516
+ if (child.stopped && child.reason === "output-cap") throw new ExecutorError("output-cap", `container tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
486
517
  if (child.code !== 0) {
487
- outcome = "error";
488
518
  const tail = child.stderr.trim().slice(-500);
489
519
  throw new ExecutorError("exit", `container tool '${request.tool}' exited ${child.code ?? "null"}${tail === "" ? "" : `: ${tail}`}`);
490
520
  }
491
- try {
492
- return parseToolResult(child.stdout, request.tool);
493
- } catch (err) {
494
- outcome = "error";
495
- throw err;
496
- }
521
+ const result = parseToolResult(child.stdout, request.tool);
522
+ outcome = "ok";
523
+ return result;
497
524
  } finally {
498
525
  const durationMs = now() - startedAt;
499
526
  if (options.ledger !== void 0) await options.ledger.record({
@@ -501,7 +528,7 @@ function containerExecutor(options) {
501
528
  runId: request.ctx.runId,
502
529
  spanId: request.ctx.spanId,
503
530
  tool: request.tool,
504
- argsHash: hashArgs(request.args),
531
+ argsHash,
505
532
  executor: request.executor,
506
533
  workdir,
507
534
  startedAt,
@@ -518,6 +545,78 @@ function containerExecutor(options) {
518
545
  } };
519
546
  }
520
547
  //#endregion
548
+ //#region src/ledger.ts
549
+ /**
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.
557
+ *
558
+ * Durability boundary, stated honestly: an awaited append survives a
559
+ * process crash (the write has entered the kernel), not necessarily a
560
+ * power loss before the OS flushes; a host that needs power-loss
561
+ * durability wraps the seam over its own fsync or database write.
562
+ */
563
+ /**
564
+ * A two-phase ToolEffectLedger appending JSON lines to `path`
565
+ * (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
566
+ * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
567
+ * scan it back with {@link loadEffectLedger}.
568
+ */
569
+ function jsonlEffectLedger(path) {
570
+ const append = (line) => appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
571
+ return {
572
+ intent(entry) {
573
+ return append({
574
+ phase: "intent",
575
+ ...entry
576
+ });
577
+ },
578
+ record(entry) {
579
+ return append({
580
+ phase: "outcome",
581
+ ...entry
582
+ });
583
+ }
584
+ };
585
+ }
586
+ /**
587
+ * 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.
591
+ */
592
+ async function loadEffectLedger(path) {
593
+ const raw = await readFile(path, "utf8");
594
+ const intents = [];
595
+ const outcomes = [];
596
+ for (const line of raw.split("\n")) {
597
+ if (line.trim() === "") continue;
598
+ let parsed;
599
+ try {
600
+ parsed = JSON.parse(line);
601
+ } catch {
602
+ continue;
603
+ }
604
+ if (parsed.phase === "intent") {
605
+ const { phase: _phase, ...entry } = parsed;
606
+ intents.push(entry);
607
+ } else if (parsed.phase === "outcome") {
608
+ const { phase: _phase, ...entry } = parsed;
609
+ outcomes.push(entry);
610
+ }
611
+ }
612
+ const resolvedKeys = new Set(outcomes.map((entry) => entry.idempotencyKey));
613
+ return {
614
+ intents,
615
+ outcomes,
616
+ orphanedIntents: intents.filter((entry) => !resolvedKeys.has(entry.idempotencyKey))
617
+ };
618
+ }
619
+ //#endregion
521
620
  //#region src/conformance.ts
522
621
  /**
523
622
  * The executable executor conformance kit (RV-216): the shared-contract
@@ -824,6 +923,33 @@ function executorConformance(factory, options = {}) {
824
923
  ensure(rows[0]?.outcome === "error", "e12", `a protocol failure must ledger outcome 'error', got '${String(rows[0]?.outcome)}'`);
825
924
  ensure(rows[0]?.exitCode === 0, "e12", "the clean exit code must still be recorded");
826
925
  }
926
+ },
927
+ {
928
+ id: "e13",
929
+ title: "a kill between the effect and the outcome write leaves the orphan intent (RV404)",
930
+ async run() {
931
+ const intents = [];
932
+ const emptyAtIntent = [];
933
+ let outcomeWrites = 0;
934
+ ensure((await factory({
935
+ command: runtime,
936
+ args: baseArgs,
937
+ ledger: {
938
+ intent(entry) {
939
+ emptyAtIntent.push(readdirSync(entry.workdir).length === 0);
940
+ intents.push(entry);
941
+ },
942
+ record() {
943
+ outcomeWrites += 1;
944
+ }
945
+ }
946
+ }).run(request("effector", { behavior: "workdir" }, { idempotencyKey: "k-orphan" }))).before === 0, "e13", "the effect did not run");
947
+ ensure(intents.length === 1, "e13", `expected 1 intent, got ${intents.length}`);
948
+ ensure(emptyAtIntent[0] === true, "e13", "the intent must be recorded BEFORE the effect (the workdir already had content)");
949
+ const orphan = intents[0];
950
+ ensure(orphan?.idempotencyKey === "k-orphan" && orphan.tool === "effector" && typeof orphan.argsHash === "string" && orphan.argsHash.length === 64 && orphan.runId === "conf-run" && orphan.spanId === "conf-span", "e13", "the orphan intent must carry the full reconciliation lookup set");
951
+ ensure(outcomeWrites === 1, "e13", "the executor must still attempt the outcome write exactly once");
952
+ }
827
953
  }
828
954
  ];
829
955
  return {
@@ -835,4 +961,4 @@ function executorConformance(factory, options = {}) {
835
961
  };
836
962
  }
837
963
  //#endregion
838
- export { ExecutorError, containerExecutor, executorConformance, hashArgs, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
964
+ export { ExecutorError, 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.90.0",
3
+ "version": "1.92.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.90.0"
25
+ "@rulvar/core": "1.92.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.90.0"
31
+ "@rulvar/testing": "1.92.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",