@rulvar/executor 1.89.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 +64 -7
- package/dist/index.js +154 -6
- package/package.json +3 -3
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
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
|
-
/**
|
|
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?:
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
+
"@rulvar/testing": "1.91.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|