@earendil-works/pi-durable 1.0.2 → 1.0.3

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +15 -1
  3. package/dist/env/decode.d.ts +16 -0
  4. package/dist/env/decode.d.ts.map +1 -0
  5. package/dist/env/decode.js +28 -0
  6. package/dist/env/decode.js.map +1 -0
  7. package/dist/env/index.d.ts +155 -3
  8. package/dist/env/index.d.ts.map +1 -1
  9. package/dist/env/index.js +2 -0
  10. package/dist/env/index.js.map +1 -1
  11. package/dist/env/line-scan.d.ts +13 -0
  12. package/dist/env/line-scan.d.ts.map +1 -0
  13. package/dist/env/line-scan.js +147 -0
  14. package/dist/env/line-scan.js.map +1 -0
  15. package/dist/env/node-watch.d.ts +29 -0
  16. package/dist/env/node-watch.d.ts.map +1 -0
  17. package/dist/env/node-watch.js +431 -0
  18. package/dist/env/node-watch.js.map +1 -0
  19. package/dist/env/node.d.ts +11 -2
  20. package/dist/env/node.d.ts.map +1 -1
  21. package/dist/env/node.js +284 -21
  22. package/dist/env/node.js.map +1 -1
  23. package/dist/harness/agent.d.ts +2 -1
  24. package/dist/harness/agent.d.ts.map +1 -1
  25. package/dist/harness/agent.js +5 -0
  26. package/dist/harness/agent.js.map +1 -1
  27. package/dist/harness/generation.d.ts.map +1 -1
  28. package/dist/harness/generation.js +6 -5
  29. package/dist/harness/generation.js.map +1 -1
  30. package/dist/harness/output.d.ts +13 -6
  31. package/dist/harness/output.d.ts.map +1 -1
  32. package/dist/harness/output.js +61 -16
  33. package/dist/harness/output.js.map +1 -1
  34. package/dist/harness/tool.d.ts.map +1 -1
  35. package/dist/harness/tool.js +12 -4
  36. package/dist/harness/tool.js.map +1 -1
  37. package/dist/harness/types.d.ts +24 -3
  38. package/dist/harness/types.d.ts.map +1 -1
  39. package/dist/harness/types.js.map +1 -1
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/testing/env-conformance.d.ts +7 -0
  45. package/dist/testing/env-conformance.d.ts.map +1 -0
  46. package/dist/testing/env-conformance.js +397 -0
  47. package/dist/testing/env-conformance.js.map +1 -0
  48. package/dist/testing/index.d.ts +3 -2
  49. package/dist/testing/index.d.ts.map +1 -1
  50. package/dist/testing/index.js +2 -1
  51. package/dist/testing/index.js.map +1 -1
  52. package/dist/testing/runner.d.ts +4 -2
  53. package/dist/testing/runner.d.ts.map +1 -1
  54. package/dist/testing/runner.js +9 -0
  55. package/dist/testing/runner.js.map +1 -1
  56. package/dist/testing/types.d.ts +18 -0
  57. package/dist/testing/types.d.ts.map +1 -1
  58. package/dist/testing/types.js.map +1 -1
  59. package/dist/tools/bash.d.ts +2 -1
  60. package/dist/tools/bash.d.ts.map +1 -1
  61. package/dist/tools/bash.js +5 -2
  62. package/dist/tools/bash.js.map +1 -1
  63. package/dist/tools/image.d.ts +10 -0
  64. package/dist/tools/image.d.ts.map +1 -1
  65. package/dist/tools/image.js +39 -0
  66. package/dist/tools/image.js.map +1 -1
  67. package/dist/tools/read.d.ts.map +1 -1
  68. package/dist/tools/read.js +148 -75
  69. package/dist/tools/read.js.map +1 -1
  70. package/dist/truncate.d.ts +9 -0
  71. package/dist/truncate.d.ts.map +1 -1
  72. package/dist/truncate.js +12 -4
  73. package/dist/truncate.js.map +1 -1
  74. package/package.json +3 -3
package/dist/env/node.js CHANGED
@@ -1,15 +1,20 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { constants, createWriteStream } from "node:fs";
4
- import { access, appendFile, lstat, mkdir, mkdtemp, open as openFile, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { access, appendFile, lstat, mkdir, mkdtemp, opendir, open as openFile, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
5
5
  import { homedir, constants as osConstants, tmpdir } from "node:os";
6
6
  import { basename, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { StreamDecoder } from "./decode.js";
8
9
  import { ExecutionError, err, FileError, ok, toError, } from "./index.js";
10
+ import { LineScanner } from "./line-scan.js";
11
+ import { NodeFileWatcher } from "./node-watch.js";
9
12
  const MAX_TIMEOUT_MS = 2_147_483_647;
10
13
  const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
11
14
  const EXIT_STDIO_GRACE_MS = 100;
12
15
  const SPILL_HIGH_WATER_MARK = 1024 * 1024;
16
+ /** Largest single read of a `BinaryReader`, so a huge `length` allocates only as much as the file yields. */
17
+ const BINARY_READ_CHUNK = 1024 * 1024;
13
18
  function resolveTimeoutMs(timeout) {
14
19
  if (timeout === undefined)
15
20
  return ok(undefined);
@@ -314,7 +319,7 @@ function waitForChildProcess(child, spillIsDraining) {
314
319
  class NodeTextLineReader {
315
320
  file;
316
321
  path;
317
- decoder = new TextDecoder();
322
+ decoder = new StreamDecoder();
318
323
  chunk = new Uint8Array(64 * 1024);
319
324
  byteOffset = 0;
320
325
  buffered = "";
@@ -356,7 +361,7 @@ class NodeTextLineReader {
356
361
  this.ended = true;
357
362
  }
358
363
  else {
359
- this.buffered += this.decoder.decode(this.chunk.subarray(0, bytesRead), { stream: true });
364
+ this.buffered += this.decoder.decode(this.chunk.subarray(0, bytesRead));
360
365
  }
361
366
  }
362
367
  }
@@ -377,17 +382,194 @@ class NodeTextLineReader {
377
382
  }
378
383
  }
379
384
  }
385
+ function closedResult(what, path) {
386
+ return err(new FileError("invalid", `${what} is closed`, path));
387
+ }
388
+ class NodeBinaryReader {
389
+ file;
390
+ path;
391
+ closed = false;
392
+ constructor(file, path) {
393
+ this.file = file;
394
+ this.path = path;
395
+ }
396
+ async info(context) {
397
+ const aborted = abortResult(context.abortSignal, this.path);
398
+ if (aborted)
399
+ return aborted;
400
+ if (this.closed)
401
+ return closedResult("Binary reader", this.path);
402
+ try {
403
+ return fileInfoFromStats(this.path, await this.file.stat());
404
+ }
405
+ catch (error) {
406
+ return err(toFileError(error, this.path));
407
+ }
408
+ }
409
+ async read(offset, length, context) {
410
+ const aborted = abortResult(context.abortSignal, this.path);
411
+ if (aborted)
412
+ return aborted;
413
+ if (this.closed)
414
+ return closedResult("Binary reader", this.path);
415
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0) {
416
+ return err(new FileError("invalid", "Offset and length must be non-negative safe integers", this.path));
417
+ }
418
+ const chunks = [];
419
+ let total = 0;
420
+ try {
421
+ while (total < length) {
422
+ const chunk = new Uint8Array(Math.min(length - total, BINARY_READ_CHUNK));
423
+ const { bytesRead } = await this.file.read(chunk, 0, chunk.length, offset + total);
424
+ const afterReadAbort = abortResult(context.abortSignal, this.path);
425
+ if (afterReadAbort)
426
+ return afterReadAbort;
427
+ if (bytesRead === 0)
428
+ break;
429
+ chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead));
430
+ total += bytesRead;
431
+ }
432
+ }
433
+ catch (error) {
434
+ return err(toFileError(error, this.path));
435
+ }
436
+ if (chunks.length === 1)
437
+ return ok(chunks[0]);
438
+ const bytes = new Uint8Array(total);
439
+ let position = 0;
440
+ for (const chunk of chunks) {
441
+ bytes.set(chunk, position);
442
+ position += chunk.length;
443
+ }
444
+ return ok(bytes);
445
+ }
446
+ async scanLines(options, context) {
447
+ const aborted = abortResult(context.abortSignal, this.path);
448
+ if (aborted)
449
+ return aborted;
450
+ if (this.closed)
451
+ return closedResult("Binary reader", this.path);
452
+ let scanner;
453
+ try {
454
+ scanner = new LineScanner(options.startLine, options.endLine);
455
+ }
456
+ catch {
457
+ return err(new FileError("invalid", "Invalid line range", this.path));
458
+ }
459
+ const chunk = new Uint8Array(64 * 1024);
460
+ try {
461
+ for (let position = 0;;) {
462
+ const { bytesRead } = await this.file.read(chunk, 0, chunk.length, position);
463
+ const afterReadAbort = abortResult(context.abortSignal, this.path);
464
+ if (afterReadAbort)
465
+ return afterReadAbort;
466
+ if (bytesRead === 0)
467
+ return ok(scanner.finish());
468
+ scanner.push(chunk.subarray(0, bytesRead));
469
+ position += bytesRead;
470
+ }
471
+ }
472
+ catch (error) {
473
+ return err(toFileError(error, this.path));
474
+ }
475
+ }
476
+ async close(_context) {
477
+ if (this.closed)
478
+ return;
479
+ this.closed = true;
480
+ await this.file.close().catch(() => undefined);
481
+ }
482
+ }
483
+ class NodeDirReader {
484
+ dir;
485
+ path;
486
+ done = false;
487
+ closed = false;
488
+ constructor(dir, path) {
489
+ this.dir = dir;
490
+ this.path = path;
491
+ }
492
+ async next(maxEntries, context) {
493
+ const aborted = abortResult(context.abortSignal, this.path);
494
+ if (aborted)
495
+ return aborted;
496
+ if (this.closed)
497
+ return closedResult("Directory reader", this.path);
498
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
499
+ return err(new FileError("invalid", "maxEntries must be a positive safe integer", this.path));
500
+ }
501
+ const entries = [];
502
+ try {
503
+ while (!this.done && entries.length < maxEntries) {
504
+ const entry = await this.dir.read();
505
+ if (entry === null) {
506
+ this.done = true;
507
+ break;
508
+ }
509
+ const loopAbort = abortResult(context.abortSignal, this.path);
510
+ if (loopAbort)
511
+ return loopAbort;
512
+ const entryPath = resolve(this.path, entry.name);
513
+ let stats;
514
+ try {
515
+ stats = await lstat(entryPath);
516
+ }
517
+ catch (error) {
518
+ // Removed between enumeration and lstat: not part of the listing any more.
519
+ if (isNodeError(error) && error.code === "ENOENT")
520
+ continue;
521
+ return err(toFileError(error, entryPath));
522
+ }
523
+ const info = fileInfoFromStats(entryPath, stats);
524
+ if (info.ok)
525
+ entries.push(info.value);
526
+ }
527
+ return ok({ entries, done: this.done });
528
+ }
529
+ catch (error) {
530
+ return err(toFileError(error, this.path));
531
+ }
532
+ }
533
+ async close(_context) {
534
+ if (this.closed)
535
+ return;
536
+ this.closed = true;
537
+ await this.dir.close().catch(() => undefined);
538
+ }
539
+ }
540
+ function symlinkRefused(path, cause) {
541
+ return new FileError("invalid", "Refusing to follow a symbolic link", path, cause);
542
+ }
380
543
  export class NodeExecutionEnv {
381
544
  /** Every local environment sees the same files. */
382
545
  id = "node:local";
383
546
  cwd;
384
547
  shellPath;
385
548
  shellEnv;
549
+ watchOptions;
386
550
  activeChildPids = new Set();
387
551
  constructor(options) {
388
552
  this.cwd = options.cwd;
389
553
  this.shellPath = options.shellPath;
390
554
  this.shellEnv = options.shellEnv;
555
+ this.watchOptions = options.watch ?? {};
556
+ }
557
+ async watch(targets, onChange, context) {
558
+ const aborted = abortResult(context.abortSignal);
559
+ if (aborted)
560
+ return aborted;
561
+ try {
562
+ const watcher = await NodeFileWatcher.open(targets, (path) => resolvePath(this.cwd, path), onChange, this.watchOptions);
563
+ const afterOpenAbort = abortResult(context.abortSignal);
564
+ if (afterOpenAbort) {
565
+ await watcher.close(context);
566
+ return afterOpenAbort;
567
+ }
568
+ return ok(watcher);
569
+ }
570
+ catch (error) {
571
+ return err(toFileError(error));
572
+ }
391
573
  }
392
574
  async absolutePath(path, _context) {
393
575
  return ok(resolvePath(this.cwd, path));
@@ -404,9 +586,26 @@ export class NodeExecutionEnv {
404
586
  return err(timeoutMsResult.error);
405
587
  const timeoutMs = timeoutMsResult.value;
406
588
  const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
407
- const shellConfig = await getShellConfig(this.shellPath);
408
- if (!shellConfig.ok)
409
- return shellConfig;
589
+ // A string runs through the shell; an argv array runs its program directly, without shell parsing.
590
+ let program;
591
+ let args;
592
+ let stdinCommand;
593
+ if (typeof command === "string") {
594
+ const shellConfig = await getShellConfig(this.shellPath);
595
+ if (!shellConfig.ok)
596
+ return shellConfig;
597
+ const commandFromStdin = shellConfig.value.commandTransport === "stdin";
598
+ program = shellConfig.value.shell;
599
+ args = commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command];
600
+ stdinCommand = commandFromStdin ? command : undefined;
601
+ }
602
+ else {
603
+ const [first, ...rest] = command;
604
+ if (first === undefined)
605
+ return err(new ExecutionError("spawn_error", "Empty argv: no program to run"));
606
+ program = first;
607
+ args = rest;
608
+ }
410
609
  try {
411
610
  await access(cwd, constants.F_OK);
412
611
  }
@@ -439,14 +638,14 @@ export class NodeExecutionEnv {
439
638
  onAbort();
440
639
  };
441
640
  // One decoder per stream, so a character split across chunks of one stream survives interleaving.
442
- const stdoutDecoder = new TextDecoder();
443
- const stderrDecoder = new TextDecoder();
641
+ const stdoutDecoder = new StreamDecoder();
642
+ const stderrDecoder = new StreamDecoder();
444
643
  // No output reaches the caller after exec() settled, for example from a descendant holding stdio open.
445
- const emit = (text) => {
644
+ const emit = (text, stream) => {
446
645
  if (settled || text === "" || options?.onOutput === undefined || callbackError !== undefined)
447
646
  return;
448
647
  try {
449
- options.onOutput(text, context);
648
+ options.onOutput(text, context, { stream });
450
649
  }
451
650
  catch (error) {
452
651
  failCallback(error);
@@ -532,19 +731,18 @@ export class NodeExecutionEnv {
532
731
  });
533
732
  };
534
733
  try {
535
- const commandFromStdin = shellConfig.value.commandTransport === "stdin";
536
- child = spawn(shellConfig.value.shell, commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command], {
734
+ child = spawn(program, args, {
537
735
  cwd,
538
736
  detached: process.platform !== "win32",
539
737
  env: getShellEnv(this.shellEnv, options?.env, options?.inheritEnv),
540
- stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
738
+ stdio: [stdinCommand === undefined ? "ignore" : "pipe", "pipe", "pipe"],
541
739
  windowsHide: true,
542
740
  });
543
741
  if (child.pid)
544
742
  this.activeChildPids.add(child.pid);
545
- if (commandFromStdin) {
743
+ if (stdinCommand !== undefined) {
546
744
  child.stdin?.on("error", () => { });
547
- child.stdin?.end(command);
745
+ child.stdin?.end(stdinCommand);
548
746
  }
549
747
  }
550
748
  catch (error) {
@@ -565,8 +763,8 @@ export class NodeExecutionEnv {
565
763
  else
566
764
  signal.addEventListener("abort", onAbort, { once: true });
567
765
  }
568
- const feed = (decoder) => (chunk) => {
569
- emit(decoder.decode(chunk, { stream: true }));
766
+ const feed = (source, decoder) => (chunk) => {
767
+ emit(decoder.decode(chunk), source);
570
768
  const spill = options?.spill;
571
769
  if (spill === undefined || chunk.length === 0)
572
770
  return;
@@ -587,14 +785,14 @@ export class NodeExecutionEnv {
587
785
  spillPrefix.length = 0;
588
786
  startSpill(chunk);
589
787
  };
590
- child.stdout?.on("data", feed(stdoutDecoder));
591
- child.stderr?.on("data", feed(stderrDecoder));
788
+ child.stdout?.on("data", feed("stdout", stdoutDecoder));
789
+ child.stderr?.on("data", feed("stderr", stderrDecoder));
592
790
  void waitForChildProcess(child, () => spillError === undefined &&
593
791
  spillStart !== undefined &&
594
792
  (spillStream === undefined || spillBackpressured)).then(async ({ code, signal: exitSignal }) => {
595
793
  await finishSpill();
596
- emit(stdoutDecoder.decode());
597
- emit(stderrDecoder.decode());
794
+ emit(stdoutDecoder.decode(), "stdout");
795
+ emit(stderrDecoder.decode(), "stderr");
598
796
  if (callbackError) {
599
797
  settle(err(callbackError));
600
798
  return;
@@ -688,6 +886,50 @@ export class NodeExecutionEnv {
688
886
  return err(toFileError(error, resolved));
689
887
  }
690
888
  }
889
+ async openBinaryReader(path, options, context) {
890
+ const resolved = resolvePath(this.cwd, path);
891
+ const aborted = abortResult(context.abortSignal, resolved);
892
+ if (aborted)
893
+ return aborted;
894
+ const noFollow = options?.noFollow === true;
895
+ let file;
896
+ try {
897
+ let flags = constants.O_RDONLY;
898
+ if (process.platform === "win32") {
899
+ // Windows has no O_NOFOLLOW; check the final component before opening (not race-free).
900
+ if (noFollow && (await lstat(resolved)).isSymbolicLink())
901
+ return err(symlinkRefused(resolved));
902
+ }
903
+ else {
904
+ // Nonblocking, so opening a FIFO does not wait for a writer.
905
+ flags |= constants.O_NONBLOCK;
906
+ if (noFollow)
907
+ flags |= constants.O_NOFOLLOW;
908
+ }
909
+ file = await openFile(resolved, flags);
910
+ const stats = await file.stat();
911
+ if (!stats.isFile()) {
912
+ await file.close().catch(() => undefined);
913
+ return err(stats.isDirectory()
914
+ ? new FileError("is_directory", "EISDIR: illegal operation on a directory, read", resolved)
915
+ : new FileError("invalid", "Not a regular file", resolved));
916
+ }
917
+ const afterOpenAbort = abortResult(context.abortSignal, resolved);
918
+ if (afterOpenAbort) {
919
+ await file.close().catch(() => undefined);
920
+ return afterOpenAbort;
921
+ }
922
+ return ok(new NodeBinaryReader(file, resolved));
923
+ }
924
+ catch (error) {
925
+ await file?.close().catch(() => undefined);
926
+ // O_NOFOLLOW reports a final-component symlink as ELOOP (EMLINK on some BSDs).
927
+ if (noFollow && isNodeError(error) && (error.code === "ELOOP" || error.code === "EMLINK")) {
928
+ return err(symlinkRefused(resolved, toError(error)));
929
+ }
930
+ return err(toFileError(error, resolved));
931
+ }
932
+ }
691
933
  async writeFile(path, content, context) {
692
934
  const resolved = resolvePath(this.cwd, path);
693
935
  const signal = context.abortSignal;
@@ -757,6 +999,9 @@ export class NodeExecutionEnv {
757
999
  let file;
758
1000
  try {
759
1001
  file = await openFile(resolved, "r+");
1002
+ // POSIX refuses to open a directory for writing; Windows opens it, so check explicitly.
1003
+ if ((await file.stat()).isDirectory())
1004
+ return err(new FileError("is_directory", "Is a directory", resolved));
760
1005
  await file.sync();
761
1006
  const afterSyncAbort = abortResult(signal, resolved);
762
1007
  return afterSyncAbort ?? ok(undefined);
@@ -823,6 +1068,24 @@ export class NodeExecutionEnv {
823
1068
  return err(toFileError(error, resolved));
824
1069
  }
825
1070
  }
1071
+ async openDirReader(path, context) {
1072
+ const resolved = resolvePath(this.cwd, path);
1073
+ const aborted = abortResult(context.abortSignal, resolved);
1074
+ if (aborted)
1075
+ return aborted;
1076
+ try {
1077
+ const dir = await opendir(resolved);
1078
+ const afterOpenAbort = abortResult(context.abortSignal, resolved);
1079
+ if (afterOpenAbort) {
1080
+ await dir.close().catch(() => undefined);
1081
+ return afterOpenAbort;
1082
+ }
1083
+ return ok(new NodeDirReader(dir, resolved));
1084
+ }
1085
+ catch (error) {
1086
+ return err(toFileError(error, resolved));
1087
+ }
1088
+ }
826
1089
  async canonicalPath(path, context) {
827
1090
  const resolved = resolvePath(this.cwd, path);
828
1091
  const aborted = abortResult(context.abortSignal, resolved);