@rulvar/executor 1.95.0 → 1.96.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 +87 -17
- package/dist/index.js +180 -44
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -18,8 +18,11 @@ declare class ExecutorError extends Error {
|
|
|
18
18
|
* the executor knows BEFORE the external effect is dispatched, which is
|
|
19
19
|
* exactly the set a host needs to reconcile an orphaned effect with the
|
|
20
20
|
* effect's provider (look the idempotency key up, correlate by tool and
|
|
21
|
-
* argsHash). `
|
|
22
|
-
* the same attempt carries the identical value.
|
|
21
|
+
* argsHash). `attemptId` is the attempt join key (RV501): the outcome
|
|
22
|
+
* record of the same attempt carries the identical value. `startedAt`
|
|
23
|
+
* remains the documented legacy join for rows written before the id
|
|
24
|
+
* shipped; a wall-clock millisecond is not unique, which is why the id
|
|
25
|
+
* exists.
|
|
23
26
|
*/
|
|
24
27
|
interface ToolEffectIntent {
|
|
25
28
|
/** The stable per-call idempotency key (createEngine derives it). */
|
|
@@ -33,6 +36,15 @@ interface ToolEffectIntent {
|
|
|
33
36
|
/** The ephemeral working directory the dispatch runs in. */
|
|
34
37
|
workdir: string;
|
|
35
38
|
startedAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* Unique id of this dispatch ATTEMPT (RV501): the reference executors
|
|
41
|
+
* mint one before the intent row is written and copy it verbatim onto
|
|
42
|
+
* the same attempt's outcome row, so the two phases pair exactly.
|
|
43
|
+
* Optional because rows written before v1.96.0 (and third-party
|
|
44
|
+
* ledgers) may omit it; {@link loadEffectLedger} then falls back to
|
|
45
|
+
* the legacy (idempotencyKey, startedAt) join.
|
|
46
|
+
*/
|
|
47
|
+
attemptId?: string;
|
|
36
48
|
}
|
|
37
49
|
/** One dispatch's side-effect facts, for the ledger. */
|
|
38
50
|
interface ToolEffectRecord extends ToolEffectIntent {
|
|
@@ -59,9 +71,9 @@ interface ToolEffectLedger {
|
|
|
59
71
|
* with the typed `ledger` code) and the outcome `record` after it. A
|
|
60
72
|
* host crash between the effect and the outcome row then leaves an
|
|
61
73
|
* orphan intent, the reconciliation signal, instead of an untracked
|
|
62
|
-
* effect: an intent whose
|
|
63
|
-
* "look this key up with the effect's provider before retrying
|
|
64
|
-
* compensating". Absent, the ledger keeps the historical
|
|
74
|
+
* effect: an intent whose OWN attempt has no outcome row (RV501)
|
|
75
|
+
* means "look this key up with the effect's provider before retrying
|
|
76
|
+
* or compensating". Absent, the ledger keeps the historical
|
|
65
77
|
* single-record contract and executor behavior is byte-identical.
|
|
66
78
|
*/
|
|
67
79
|
intent?(entry: ToolEffectIntent): void | Promise<void>;
|
|
@@ -230,29 +242,87 @@ declare function containerExecutor(options: ContainerExecutorOptions): ToolExecu
|
|
|
230
242
|
* A two-phase ToolEffectLedger appending JSON lines to `path`
|
|
231
243
|
* (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
|
|
232
244
|
* `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
|
|
233
|
-
* scan it back with {@link loadEffectLedger}.
|
|
245
|
+
* scan it back with {@link loadEffectLedger}. The first append lazily
|
|
246
|
+
* repairs a torn tail left by a crashed predecessor (RV502).
|
|
247
|
+
*/
|
|
248
|
+
declare function jsonlEffectLedger(path: string, options?: {
|
|
249
|
+
now?: () => number;
|
|
250
|
+
}): ToolEffectLedger;
|
|
251
|
+
/** One unparseable interior line of the ledger file, surfaced for triage. */
|
|
252
|
+
interface CorruptLedgerLine {
|
|
253
|
+
/** 1-based physical line number in the file. */
|
|
254
|
+
line: number;
|
|
255
|
+
/** Byte offset of the line's first byte within the file. */
|
|
256
|
+
offset: number;
|
|
257
|
+
/** sha256 (hex) of the raw line bytes: forensics without re-reading. */
|
|
258
|
+
sha256: string;
|
|
259
|
+
/** The first 120 characters of the line. */
|
|
260
|
+
preview: string;
|
|
261
|
+
}
|
|
262
|
+
/** A torn fragment the writer quarantined while repairing a tail (RV502). */
|
|
263
|
+
interface TornLedgerArtifact {
|
|
264
|
+
/** The raw torn bytes, preserved verbatim. */
|
|
265
|
+
bytes: string;
|
|
266
|
+
/** Wall-clock ms when the writer quarantined the fragment. */
|
|
267
|
+
recoveredAt: number;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
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).
|
|
234
276
|
*/
|
|
235
|
-
declare
|
|
277
|
+
declare class LedgerCorruptionError extends Error {
|
|
278
|
+
readonly lines: CorruptLedgerLine[];
|
|
279
|
+
constructor(path: string, lines: CorruptLedgerLine[]);
|
|
280
|
+
}
|
|
236
281
|
/** What {@link loadEffectLedger} reads back from a JSONL ledger file. */
|
|
237
282
|
interface EffectLedgerScan {
|
|
238
283
|
intents: ToolEffectIntent[];
|
|
239
284
|
outcomes: ToolEffectRecord[];
|
|
240
285
|
/**
|
|
241
|
-
* The reconciliation signal (
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
286
|
+
* The reconciliation signal (RV501): every intent whose OWN attempt
|
|
287
|
+
* never got an outcome row. Pairing is exact: an outcome resolves the
|
|
288
|
+
* intent carrying the same `attemptId` (rows written before the id
|
|
289
|
+
* shipped pair by the legacy (idempotencyKey, startedAt) join), and
|
|
290
|
+
* an outcome of ANY class resolves only its own attempt. A sibling
|
|
291
|
+
* retry's outcome, ok or error, says nothing about THIS attempt, so
|
|
292
|
+
* it never clears it: closing the logical key belongs to the host
|
|
293
|
+
* reconciler, against the effect provider's receipt. For each orphan,
|
|
294
|
+
* look the key up with the effect's provider before retrying or
|
|
295
|
+
* compensating.
|
|
246
296
|
*/
|
|
247
297
|
orphanedIntents: ToolEffectIntent[];
|
|
298
|
+
/**
|
|
299
|
+
* Unparseable interior lines, populated only under `tolerateCorrupt`
|
|
300
|
+
* (the default scan throws {@link LedgerCorruptionError} instead).
|
|
301
|
+
* Empty on a healthy file.
|
|
302
|
+
*/
|
|
303
|
+
corrupt: CorruptLedgerLine[];
|
|
304
|
+
/** Fragments the writer quarantined while repairing torn tails (RV502). */
|
|
305
|
+
tornArtifacts: TornLedgerArtifact[];
|
|
306
|
+
/**
|
|
307
|
+
* A live unterminated, unparseable trailing fragment: the artifact of
|
|
308
|
+
* a crash mid-write no writer has repaired yet. Tolerated and named,
|
|
309
|
+
* never silent.
|
|
310
|
+
*/
|
|
311
|
+
tornTail?: {
|
|
312
|
+
preview: string;
|
|
313
|
+
};
|
|
248
314
|
}
|
|
249
315
|
/**
|
|
250
316
|
* Scans a JSONL ledger file into intents, outcomes, and the orphaned
|
|
251
|
-
* intents a host must reconcile
|
|
252
|
-
*
|
|
253
|
-
*
|
|
317
|
+
* intents a host must reconcile, pairing attempts exactly (RV501). A
|
|
318
|
+
* 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).
|
|
254
322
|
*/
|
|
255
|
-
declare function loadEffectLedger(path: string
|
|
323
|
+
declare function loadEffectLedger(path: string, options?: {
|
|
324
|
+
tolerateCorrupt?: boolean;
|
|
325
|
+
}): Promise<EffectLedgerScan>;
|
|
256
326
|
//#endregion
|
|
257
327
|
//#region src/conformance.d.ts
|
|
258
328
|
/** The executor options the shared contract exercises. */
|
|
@@ -337,4 +407,4 @@ interface ChildResult {
|
|
|
337
407
|
*/
|
|
338
408
|
declare function runChildProcess(spec: ChildSpec): Promise<ChildResult>;
|
|
339
409
|
//#endregion
|
|
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 };
|
|
410
|
+
export { type ChildResult, type ChildSpec, type ChildStopReason, type ConformanceExecutorConfig, type ConformanceExecutorFactory, type ContainerExecutorOptions, type CorruptLedgerLine, type EffectLedgerScan, type ExecutorConformanceCheck, type ExecutorConformanceSuite, ExecutorError, type ExecutorErrorCode, type ExecutorTestRegistrar, LedgerCorruptionError, type SubprocessCommandSpec, type SubprocessExecutorOptions, type SubprocessToolInit, type ToolEffectIntent, type ToolEffectLedger, type ToolEffectRecord, type TornLedgerArtifact, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdtemp, readFile, rm, truncate } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { tool } from "@rulvar/core";
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
7
|
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
//#region src/child.ts
|
|
9
9
|
/**
|
|
@@ -208,7 +208,7 @@ function parseToolResult(stdout, tool) {
|
|
|
208
208
|
*
|
|
209
209
|
* Docs: https://docs.rulvar.com/guide/isolated-executor.
|
|
210
210
|
*/
|
|
211
|
-
const wallClock$
|
|
211
|
+
const wallClock$2 = Date.now.bind(globalThis);
|
|
212
212
|
function resolveCommand(request, options) {
|
|
213
213
|
const spec = request.spec ?? {};
|
|
214
214
|
const command = typeof spec.command === "string" ? spec.command : options.command;
|
|
@@ -230,12 +230,13 @@ function subprocessExecutor(options = {}) {
|
|
|
230
230
|
const killGraceMs = options.killGraceMs ?? 2e3;
|
|
231
231
|
const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
|
|
232
232
|
const workdirBase = options.workdirBase ?? tmpdir();
|
|
233
|
-
const now = options.now ?? wallClock$
|
|
233
|
+
const now = options.now ?? wallClock$2;
|
|
234
234
|
return { async run(request) {
|
|
235
235
|
const { command, args } = resolveCommand(request, options);
|
|
236
236
|
const workdir = await mkdtemp(join(workdirBase, `rulvar-exec-${request.tool}-`));
|
|
237
237
|
const startedAt = now();
|
|
238
238
|
const argsHash = hashArgs(request.args);
|
|
239
|
+
const attemptId = randomUUID();
|
|
239
240
|
if (options.ledger?.intent !== void 0) try {
|
|
240
241
|
await options.ledger.intent({
|
|
241
242
|
idempotencyKey: request.ctx.idempotencyKey,
|
|
@@ -245,7 +246,8 @@ function subprocessExecutor(options = {}) {
|
|
|
245
246
|
argsHash,
|
|
246
247
|
executor: request.executor,
|
|
247
248
|
workdir,
|
|
248
|
-
startedAt
|
|
249
|
+
startedAt,
|
|
250
|
+
attemptId
|
|
249
251
|
});
|
|
250
252
|
} catch (err) {
|
|
251
253
|
await rm(workdir, {
|
|
@@ -257,6 +259,9 @@ function subprocessExecutor(options = {}) {
|
|
|
257
259
|
let outcome = "error";
|
|
258
260
|
let exitCode = null;
|
|
259
261
|
let signal = null;
|
|
262
|
+
let settled;
|
|
263
|
+
let bodyThrew = false;
|
|
264
|
+
let thrownBody;
|
|
260
265
|
try {
|
|
261
266
|
const env = {};
|
|
262
267
|
for (const name of options.allowEnv ?? []) {
|
|
@@ -312,9 +317,14 @@ function subprocessExecutor(options = {}) {
|
|
|
312
317
|
}
|
|
313
318
|
const result = parseToolResult(child.stdout, request.tool);
|
|
314
319
|
outcome = "ok";
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
320
|
+
settled = result;
|
|
321
|
+
} catch (thrown) {
|
|
322
|
+
bodyThrew = true;
|
|
323
|
+
thrownBody = thrown;
|
|
324
|
+
}
|
|
325
|
+
const durationMs = now() - startedAt;
|
|
326
|
+
let ledgerFailure;
|
|
327
|
+
try {
|
|
318
328
|
if (options.ledger !== void 0) await options.ledger.record({
|
|
319
329
|
idempotencyKey: request.ctx.idempotencyKey,
|
|
320
330
|
runId: request.ctx.runId,
|
|
@@ -324,16 +334,22 @@ function subprocessExecutor(options = {}) {
|
|
|
324
334
|
executor: request.executor,
|
|
325
335
|
workdir,
|
|
326
336
|
startedAt,
|
|
337
|
+
attemptId,
|
|
327
338
|
durationMs,
|
|
328
339
|
outcome,
|
|
329
340
|
exitCode,
|
|
330
341
|
signal
|
|
331
342
|
});
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
force: true
|
|
335
|
-
});
|
|
343
|
+
} catch (recordErr) {
|
|
344
|
+
ledgerFailure = new ExecutorError("ledger", `tool '${request.tool}' outcome was not recorded: the ledger record write failed (${recordErr instanceof Error ? recordErr.message : String(recordErr)})` + (bodyThrew ? `; the dispatch itself had already failed (${thrownBody instanceof Error ? thrownBody.message : String(thrownBody)})` : ""));
|
|
336
345
|
}
|
|
346
|
+
await rm(workdir, {
|
|
347
|
+
recursive: true,
|
|
348
|
+
force: true
|
|
349
|
+
});
|
|
350
|
+
if (ledgerFailure !== void 0) throw ledgerFailure;
|
|
351
|
+
if (bodyThrew) throw thrownBody;
|
|
352
|
+
return settled;
|
|
337
353
|
} };
|
|
338
354
|
}
|
|
339
355
|
/**
|
|
@@ -384,7 +400,7 @@ function subprocessTool(init) {
|
|
|
384
400
|
* ToolExecutorProvider seam; this docker adapter is the batteries-included
|
|
385
401
|
* reference. Docs: https://docs.rulvar.com/guide/isolated-executor.
|
|
386
402
|
*/
|
|
387
|
-
const wallClock = Date.now.bind(globalThis);
|
|
403
|
+
const wallClock$1 = Date.now.bind(globalThis);
|
|
388
404
|
/** The default host variables the docker CLI needs to reach its daemon. */
|
|
389
405
|
const DEFAULT_DAEMON_ENV = [
|
|
390
406
|
"PATH",
|
|
@@ -414,7 +430,7 @@ function containerExecutor(options) {
|
|
|
414
430
|
const killGraceMs = options.killGraceMs ?? 5e3;
|
|
415
431
|
const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
|
|
416
432
|
const workdirBase = options.workdirBase ?? tmpdir();
|
|
417
|
-
const now = options.now ?? wallClock;
|
|
433
|
+
const now = options.now ?? wallClock$1;
|
|
418
434
|
return { async run(request) {
|
|
419
435
|
const spec = request.spec ?? {};
|
|
420
436
|
const command = typeof spec.command === "string" ? spec.command : options.command;
|
|
@@ -424,6 +440,7 @@ function containerExecutor(options) {
|
|
|
424
440
|
const workdir = await mkdtemp(join(workdirBase, `rulvar-cexec-${request.tool}-`));
|
|
425
441
|
const startedAt = now();
|
|
426
442
|
const argsHash = hashArgs(request.args);
|
|
443
|
+
const attemptId = randomUUID();
|
|
427
444
|
if (options.ledger?.intent !== void 0) try {
|
|
428
445
|
await options.ledger.intent({
|
|
429
446
|
idempotencyKey: request.ctx.idempotencyKey,
|
|
@@ -433,7 +450,8 @@ function containerExecutor(options) {
|
|
|
433
450
|
argsHash,
|
|
434
451
|
executor: request.executor,
|
|
435
452
|
workdir,
|
|
436
|
-
startedAt
|
|
453
|
+
startedAt,
|
|
454
|
+
attemptId
|
|
437
455
|
});
|
|
438
456
|
} catch (err) {
|
|
439
457
|
await rm(workdir, {
|
|
@@ -445,6 +463,9 @@ function containerExecutor(options) {
|
|
|
445
463
|
let outcome = "error";
|
|
446
464
|
let exitCode = null;
|
|
447
465
|
let signal = null;
|
|
466
|
+
let settled;
|
|
467
|
+
let bodyThrew = false;
|
|
468
|
+
let thrownBody;
|
|
448
469
|
try {
|
|
449
470
|
const daemonEnv = options.daemonEnv ?? DEFAULT_DAEMON_ENV;
|
|
450
471
|
const env = {};
|
|
@@ -520,9 +541,14 @@ function containerExecutor(options) {
|
|
|
520
541
|
}
|
|
521
542
|
const result = parseToolResult(child.stdout, request.tool);
|
|
522
543
|
outcome = "ok";
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
544
|
+
settled = result;
|
|
545
|
+
} catch (thrown) {
|
|
546
|
+
bodyThrew = true;
|
|
547
|
+
thrownBody = thrown;
|
|
548
|
+
}
|
|
549
|
+
const durationMs = now() - startedAt;
|
|
550
|
+
let ledgerFailure;
|
|
551
|
+
try {
|
|
526
552
|
if (options.ledger !== void 0) await options.ledger.record({
|
|
527
553
|
idempotencyKey: request.ctx.idempotencyKey,
|
|
528
554
|
runId: request.ctx.runId,
|
|
@@ -532,42 +558,102 @@ function containerExecutor(options) {
|
|
|
532
558
|
executor: request.executor,
|
|
533
559
|
workdir,
|
|
534
560
|
startedAt,
|
|
561
|
+
attemptId,
|
|
535
562
|
durationMs,
|
|
536
563
|
outcome,
|
|
537
564
|
exitCode,
|
|
538
565
|
signal
|
|
539
566
|
});
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
force: true
|
|
543
|
-
});
|
|
567
|
+
} catch (recordErr) {
|
|
568
|
+
ledgerFailure = new ExecutorError("ledger", `container tool '${request.tool}' outcome was not recorded: the ledger record write failed (${recordErr instanceof Error ? recordErr.message : String(recordErr)})` + (bodyThrew ? `; the dispatch itself had already failed (${thrownBody instanceof Error ? thrownBody.message : String(thrownBody)})` : ""));
|
|
544
569
|
}
|
|
570
|
+
await rm(workdir, {
|
|
571
|
+
recursive: true,
|
|
572
|
+
force: true
|
|
573
|
+
});
|
|
574
|
+
if (ledgerFailure !== void 0) throw ledgerFailure;
|
|
575
|
+
if (bodyThrew) throw thrownBody;
|
|
576
|
+
return settled;
|
|
545
577
|
} };
|
|
546
578
|
}
|
|
547
579
|
//#endregion
|
|
548
580
|
//#region src/ledger.ts
|
|
549
581
|
/**
|
|
550
|
-
* The durable JSONL reference of the two-phase effect ledger (RV404
|
|
551
|
-
* one JSON line per phase, appended and
|
|
552
|
-
* on disk before the external effect is
|
|
553
|
-
* process crash between the effect and
|
|
554
|
-
* NOT: a transactional outbox, business
|
|
555
|
-
* reconciliation; those stay host
|
|
556
|
-
* interface the host reconciles
|
|
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
|
|
588
|
+
* obligations, this file is the strict interface the host reconciles
|
|
589
|
+
* FROM.
|
|
590
|
+
*
|
|
591
|
+
* Identity (RV501): every reference-executor dispatch is one ATTEMPT
|
|
592
|
+
* with its own `attemptId`, and an outcome resolves exactly the intent
|
|
593
|
+
* of its own attempt. A sibling retry's outcome, whatever its class,
|
|
594
|
+
* says nothing about another attempt, so it never clears one: closing
|
|
595
|
+
* the logical idempotency key is the host reconciler's job, against
|
|
596
|
+
* the effect provider's receipt.
|
|
597
|
+
*
|
|
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.
|
|
557
604
|
*
|
|
558
605
|
* Durability boundary, stated honestly: an awaited append survives a
|
|
559
606
|
* process crash (the write has entered the kernel), not necessarily a
|
|
560
607
|
* power loss before the OS flushes; a host that needs power-loss
|
|
561
608
|
* durability wraps the seam over its own fsync or database write.
|
|
562
609
|
*/
|
|
610
|
+
const wallClock = Date.now.bind(globalThis);
|
|
611
|
+
/**
|
|
612
|
+
* Repairs the file's tail so an append can never glue onto a torn
|
|
613
|
+
* fragment (RV502): a parseable unterminated record gets its newline, a
|
|
614
|
+
* 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.
|
|
617
|
+
*/
|
|
618
|
+
async function repairTail(path, now) {
|
|
619
|
+
let raw;
|
|
620
|
+
try {
|
|
621
|
+
raw = await readFile(path);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if (err.code === "ENOENT") return;
|
|
624
|
+
throw err;
|
|
625
|
+
}
|
|
626
|
+
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");
|
|
641
|
+
}
|
|
563
642
|
/**
|
|
564
643
|
* A two-phase ToolEffectLedger appending JSON lines to `path`
|
|
565
644
|
* (`{ phase: 'intent' | 'outcome', ... }`). Pass it to
|
|
566
645
|
* `subprocessExecutor({ ledger })` or `containerExecutor({ ledger })`;
|
|
567
|
-
* scan it back with {@link loadEffectLedger}.
|
|
646
|
+
* scan it back with {@link loadEffectLedger}. The first append lazily
|
|
647
|
+
* repairs a torn tail left by a crashed predecessor (RV502).
|
|
568
648
|
*/
|
|
569
|
-
function jsonlEffectLedger(path) {
|
|
570
|
-
const
|
|
649
|
+
function jsonlEffectLedger(path, options) {
|
|
650
|
+
const now = options?.now ?? wallClock;
|
|
651
|
+
let boundaryReady;
|
|
652
|
+
const append = async (line) => {
|
|
653
|
+
boundaryReady ??= repairTail(path, now);
|
|
654
|
+
await boundaryReady;
|
|
655
|
+
await appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
|
|
656
|
+
};
|
|
571
657
|
return {
|
|
572
658
|
intent(entry) {
|
|
573
659
|
return append({
|
|
@@ -584,21 +670,61 @@ function jsonlEffectLedger(path) {
|
|
|
584
670
|
};
|
|
585
671
|
}
|
|
586
672
|
/**
|
|
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).
|
|
679
|
+
*/
|
|
680
|
+
var LedgerCorruptionError = class extends Error {
|
|
681
|
+
lines;
|
|
682
|
+
constructor(path, lines) {
|
|
683
|
+
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).`);
|
|
685
|
+
this.name = "LedgerCorruptionError";
|
|
686
|
+
this.lines = lines;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
/**
|
|
587
690
|
* Scans a JSONL ledger file into intents, outcomes, and the orphaned
|
|
588
|
-
* intents a host must reconcile
|
|
589
|
-
*
|
|
590
|
-
*
|
|
691
|
+
* intents a host must reconcile, pairing attempts exactly (RV501). A
|
|
692
|
+
* 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).
|
|
591
696
|
*/
|
|
592
|
-
async function loadEffectLedger(path) {
|
|
593
|
-
const raw = await readFile(path
|
|
697
|
+
async function loadEffectLedger(path, options) {
|
|
698
|
+
const raw = await readFile(path);
|
|
594
699
|
const intents = [];
|
|
595
700
|
const outcomes = [];
|
|
596
|
-
|
|
597
|
-
|
|
701
|
+
const corrupt = [];
|
|
702
|
+
const tornArtifacts = [];
|
|
703
|
+
let tornTail;
|
|
704
|
+
let line = 0;
|
|
705
|
+
let start = 0;
|
|
706
|
+
for (let i = 0; i <= raw.length; i += 1) {
|
|
707
|
+
const atEnd = i === raw.length;
|
|
708
|
+
if (!atEnd && raw[i] !== 10) continue;
|
|
709
|
+
const bytes = raw.subarray(start, i);
|
|
710
|
+
const offset = start;
|
|
711
|
+
const terminated = !atEnd;
|
|
712
|
+
start = i + 1;
|
|
713
|
+
if (atEnd && bytes.length === 0) break;
|
|
714
|
+
line += 1;
|
|
715
|
+
const text = bytes.toString("utf8");
|
|
716
|
+
if (text.trim() === "") continue;
|
|
598
717
|
let parsed;
|
|
599
718
|
try {
|
|
600
|
-
parsed = JSON.parse(
|
|
719
|
+
parsed = JSON.parse(text);
|
|
601
720
|
} 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
|
+
});
|
|
727
|
+
else tornTail = { preview: text.slice(0, 120) };
|
|
602
728
|
continue;
|
|
603
729
|
}
|
|
604
730
|
if (parsed.phase === "intent") {
|
|
@@ -607,13 +733,23 @@ async function loadEffectLedger(path) {
|
|
|
607
733
|
} else if (parsed.phase === "outcome") {
|
|
608
734
|
const { phase: _phase, ...entry } = parsed;
|
|
609
735
|
outcomes.push(entry);
|
|
610
|
-
}
|
|
736
|
+
} else if (parsed.phase === "torn") tornArtifacts.push({
|
|
737
|
+
bytes: parsed.bytes,
|
|
738
|
+
recoveredAt: parsed.recoveredAt
|
|
739
|
+
});
|
|
611
740
|
}
|
|
612
|
-
|
|
741
|
+
if (corrupt.length > 0 && options?.tolerateCorrupt !== true) throw new LedgerCorruptionError(path, corrupt);
|
|
742
|
+
const resolvedAttempts = /* @__PURE__ */ new Set();
|
|
743
|
+
const resolvedLegacy = /* @__PURE__ */ new Set();
|
|
744
|
+
for (const outcome of outcomes) if (outcome.attemptId === void 0) resolvedLegacy.add(JSON.stringify([outcome.idempotencyKey, outcome.startedAt]));
|
|
745
|
+
else resolvedAttempts.add(outcome.attemptId);
|
|
613
746
|
return {
|
|
614
747
|
intents,
|
|
615
748
|
outcomes,
|
|
616
|
-
orphanedIntents: intents.filter((entry) => !
|
|
749
|
+
orphanedIntents: intents.filter((entry) => entry.attemptId === void 0 ? !resolvedLegacy.has(JSON.stringify([entry.idempotencyKey, entry.startedAt])) : !resolvedAttempts.has(entry.attemptId)),
|
|
750
|
+
corrupt,
|
|
751
|
+
tornArtifacts,
|
|
752
|
+
...tornTail === void 0 ? {} : { tornTail }
|
|
617
753
|
};
|
|
618
754
|
}
|
|
619
755
|
//#endregion
|
|
@@ -961,4 +1097,4 @@ function executorConformance(factory, options = {}) {
|
|
|
961
1097
|
};
|
|
962
1098
|
}
|
|
963
1099
|
//#endregion
|
|
964
|
-
export { ExecutorError, containerExecutor, executorConformance, hashArgs, jsonlEffectLedger, loadEffectLedger, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
|
|
1100
|
+
export { ExecutorError, LedgerCorruptionError, 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.96.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.96.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.96.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|