@rulvar/executor 1.90.0 → 1.91.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,6 +235,25 @@ 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();
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
+ }
227
257
  let outcome = "ok";
228
258
  let exitCode = null;
229
259
  let signal = null;
@@ -301,7 +331,7 @@ function subprocessExecutor(options = {}) {
301
331
  runId: request.ctx.runId,
302
332
  spanId: request.ctx.spanId,
303
333
  tool: request.tool,
304
- argsHash: hashArgs(request.args),
334
+ argsHash,
305
335
  executor: request.executor,
306
336
  workdir,
307
337
  startedAt,
@@ -404,6 +434,25 @@ function containerExecutor(options) {
404
434
  const toolArgs = [...options.args ?? [], ...specArgs];
405
435
  const workdir = await mkdtemp(join(workdirBase, `rulvar-cexec-${request.tool}-`));
406
436
  const startedAt = now();
437
+ const argsHash = hashArgs(request.args);
438
+ if (options.ledger?.intent !== void 0) try {
439
+ await options.ledger.intent({
440
+ idempotencyKey: request.ctx.idempotencyKey,
441
+ runId: request.ctx.runId,
442
+ spanId: request.ctx.spanId,
443
+ tool: request.tool,
444
+ argsHash,
445
+ executor: request.executor,
446
+ workdir,
447
+ startedAt
448
+ });
449
+ } catch (err) {
450
+ await rm(workdir, {
451
+ recursive: true,
452
+ force: true
453
+ });
454
+ 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)})`);
455
+ }
407
456
  let outcome = "ok";
408
457
  let exitCode = null;
409
458
  let signal = null;
@@ -501,7 +550,7 @@ function containerExecutor(options) {
501
550
  runId: request.ctx.runId,
502
551
  spanId: request.ctx.spanId,
503
552
  tool: request.tool,
504
- argsHash: hashArgs(request.args),
553
+ argsHash,
505
554
  executor: request.executor,
506
555
  workdir,
507
556
  startedAt,
@@ -518,6 +567,78 @@ function containerExecutor(options) {
518
567
  } };
519
568
  }
520
569
  //#endregion
570
+ //#region src/ledger.ts
571
+ /**
572
+ * The durable JSONL reference of the two-phase effect ledger (RV404):
573
+ * one JSON line per phase, appended and awaited, so the intent row is
574
+ * on disk before the external effect is dispatched and survives a host
575
+ * process crash between the effect and the outcome write. What it is
576
+ * NOT: a transactional outbox, business authorization, or monetary
577
+ * reconciliation; those stay host obligations, this file is the strict
578
+ * interface the host reconciles FROM.
579
+ *
580
+ * Durability boundary, stated honestly: an awaited append survives a
581
+ * process crash (the write has entered the kernel), not necessarily a
582
+ * power loss before the OS flushes; a host that needs power-loss
583
+ * durability wraps the seam over its own fsync or database write.
584
+ */
585
+ /**
586
+ * A two-phase ToolEffectLedger appending JSON lines to `path`
587
+ * (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
588
+ * `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
589
+ * scan it back with {@link loadEffectLedger}.
590
+ */
591
+ function jsonlEffectLedger(path) {
592
+ const append = (line) => appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
593
+ return {
594
+ intent(entry) {
595
+ return append({
596
+ phase: "intent",
597
+ ...entry
598
+ });
599
+ },
600
+ record(entry) {
601
+ return append({
602
+ phase: "outcome",
603
+ ...entry
604
+ });
605
+ }
606
+ };
607
+ }
608
+ /**
609
+ * Scans a JSONL ledger file into intents, outcomes, and the orphaned
610
+ * intents a host must reconcile. A torn trailing line (the artifact of
611
+ * a crash mid-write) is skipped, never a scan failure: the durable rows
612
+ * before it are exactly what reconciliation needs.
613
+ */
614
+ async function loadEffectLedger(path) {
615
+ const raw = await readFile(path, "utf8");
616
+ const intents = [];
617
+ const outcomes = [];
618
+ for (const line of raw.split("\n")) {
619
+ if (line.trim() === "") continue;
620
+ let parsed;
621
+ try {
622
+ parsed = JSON.parse(line);
623
+ } catch {
624
+ continue;
625
+ }
626
+ if (parsed.phase === "intent") {
627
+ const { phase: _phase, ...entry } = parsed;
628
+ intents.push(entry);
629
+ } else if (parsed.phase === "outcome") {
630
+ const { phase: _phase, ...entry } = parsed;
631
+ outcomes.push(entry);
632
+ }
633
+ }
634
+ const resolvedKeys = new Set(outcomes.map((entry) => entry.idempotencyKey));
635
+ return {
636
+ intents,
637
+ outcomes,
638
+ orphanedIntents: intents.filter((entry) => !resolvedKeys.has(entry.idempotencyKey))
639
+ };
640
+ }
641
+ //#endregion
521
642
  //#region src/conformance.ts
522
643
  /**
523
644
  * The executable executor conformance kit (RV-216): the shared-contract
@@ -824,6 +945,33 @@ function executorConformance(factory, options = {}) {
824
945
  ensure(rows[0]?.outcome === "error", "e12", `a protocol failure must ledger outcome 'error', got '${String(rows[0]?.outcome)}'`);
825
946
  ensure(rows[0]?.exitCode === 0, "e12", "the clean exit code must still be recorded");
826
947
  }
948
+ },
949
+ {
950
+ id: "e13",
951
+ title: "a kill between the effect and the outcome write leaves the orphan intent (RV404)",
952
+ async run() {
953
+ const intents = [];
954
+ const emptyAtIntent = [];
955
+ let outcomeWrites = 0;
956
+ ensure((await factory({
957
+ command: runtime,
958
+ args: baseArgs,
959
+ ledger: {
960
+ intent(entry) {
961
+ emptyAtIntent.push(readdirSync(entry.workdir).length === 0);
962
+ intents.push(entry);
963
+ },
964
+ record() {
965
+ outcomeWrites += 1;
966
+ }
967
+ }
968
+ }).run(request("effector", { behavior: "workdir" }, { idempotencyKey: "k-orphan" }))).before === 0, "e13", "the effect did not run");
969
+ ensure(intents.length === 1, "e13", `expected 1 intent, got ${intents.length}`);
970
+ ensure(emptyAtIntent[0] === true, "e13", "the intent must be recorded BEFORE the effect (the workdir already had content)");
971
+ const orphan = intents[0];
972
+ 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");
973
+ ensure(outcomeWrites === 1, "e13", "the executor must still attempt the outcome write exactly once");
974
+ }
827
975
  }
828
976
  ];
829
977
  return {
@@ -835,4 +983,4 @@ function executorConformance(factory, options = {}) {
835
983
  };
836
984
  }
837
985
  //#endregion
838
- export { ExecutorError, containerExecutor, executorConformance, hashArgs, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
986
+ 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.91.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.91.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.91.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",