@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 +64 -7
- package/dist/index.js +166 -40
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
+
"@rulvar/testing": "1.92.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|