pi-supernova 0.8.2 → 0.9.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.
Files changed (66) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +6 -1
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -227
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +2 -1
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +21 -75
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +6 -5
  55. package/src/runtime/runtime.js +77 -253
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -0,0 +1,50 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import {isString} from '../shared/decode.js';
4
+
5
+ function sessionUriParts(uri) {
6
+ const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
7
+
8
+ if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
9
+ const kind = match[1].toLowerCase();
10
+ const id = decodeURIComponent(match[2]);
11
+
12
+ if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
13
+
14
+ return { kind, id };
15
+ }
16
+
17
+ async function findArtifactFile(root, id, uri, signal) {
18
+ const matches = [];
19
+ let count = 0;
20
+
21
+ for await (const entry of await fs.opendir(root)) {
22
+ signal?.throwIfAborted();
23
+
24
+ if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
25
+
26
+ if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
27
+ }
28
+
29
+ if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
30
+
31
+ return matches[0];
32
+ }
33
+
34
+ export async function resolveSessionResource(uri, signal, hooks) {
35
+ const { kind, id } = sessionUriParts(uri);
36
+ const dir = hooks.artifactsDir?.();
37
+
38
+ if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
39
+ signal?.throwIfAborted();
40
+ const root = await fs.realpath(dir);
41
+ const file = kind === "artifact" ? await findArtifactFile(root, id, uri, signal) : id + ".md";
42
+ const target = await fs.realpath(path.join(root, file));
43
+
44
+ if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
45
+
46
+ if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
47
+ signal?.throwIfAborted();
48
+
49
+ return target;
50
+ }
@@ -1,3 +1,9 @@
1
+ import { fileChunks } from "./file-io.js";
2
+ export {MAX_DIRECTORY_ENTRIES,formatDirectoryEntry,formatLsEntry} from './directory.js';
3
+ export {textResult,resultDiff} from '../shared/result.js';
4
+ import {sliceLinesRaw,lineNumberAt,formatNumberedLine,numberedPreview,lineStartIndex,lineEndIndex,lineTextRange,contentLineInfo} from './lines.js';
5
+ export * from './lines.js';
6
+ export {jsonStringLength,maxJsonStringPrefix} from './json-size.js';
1
7
  import * as fs from "node:fs/promises";
2
8
  import * as path from "node:path";
3
9
  import { homedir } from "node:os";
@@ -5,124 +11,6 @@ import { isString, isNumber, isObject } from "../shared/decode.js";
5
11
  import { assertFilesystemPath } from "./workspace.js";
6
12
  import { MAX_DIFF_MATCHES } from "./diff.js";
7
13
 
8
- const JSON_TWO_BYTE = new Set([0x22, 0x5c, 8, 9, 10, 12, 13]);
9
-
10
- function jsonAsciiWidth(c) {
11
- if (JSON_TWO_BYTE.has(c)) return 2;
12
-
13
- if (c < 32) return 6;
14
-
15
- return 1;
16
- }
17
-
18
- function jsonUnitWidth(s, i) {
19
- const c = s.charCodeAt(i);
20
-
21
- if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) {
22
- const d = s.charCodeAt(i + 1);
23
-
24
- if (d >= 0xDC00 && d <= 0xDFFF) return { add: 2, skip: 2 };
25
-
26
- return { add: 6, skip: 1 };
27
- }
28
-
29
- if (c >= 0xD800 && c <= 0xDFFF) return { add: 6, skip: 1 };
30
-
31
- return { add: jsonAsciiWidth(c), skip: 1 };
32
- }
33
-
34
- /** UTF-16 length of JSON.stringify(s) for a string, without allocating the JSON. */
35
- export function jsonStringLength(s) {
36
- let n = 2;
37
-
38
- for (let i = 0; i < s.length; ) {
39
- const unit = jsonUnitWidth(s, i);
40
- n += unit.add;
41
- i += unit.skip;
42
- }
43
-
44
- return n;
45
- }
46
-
47
- /** Largest prefix whose JSON.stringify length is <= limit. */
48
- export function maxJsonStringPrefix(s, limit) {
49
- let used = 2;
50
- let i = 0;
51
-
52
- while (i < s.length) {
53
- const unit = jsonUnitWidth(s, i);
54
-
55
- if (used + unit.add > limit) break;
56
- used += unit.add;
57
- i += unit.skip;
58
- }
59
-
60
- return i;
61
- }
62
-
63
- export function textResult(text, details) {
64
- return {
65
- content: [{ type: "text", text: String(text ?? "") }],
66
- details: details || {},
67
- };
68
- }
69
-
70
- export function resultDiff(response) {
71
- let details = response?.details;
72
-
73
- if (isString(details)) {
74
- try {
75
- details = JSON.parse(details);
76
- } catch {
77
- return undefined;
78
- }
79
- }
80
-
81
- return isObject(details) ? details.diff : undefined;
82
- }
83
-
84
- function totalContentLines(text) {
85
- if (text === "") return 1;
86
-
87
- return contentLineInfo(text).count + (text.endsWith("\n") ? 1 : 0);
88
- }
89
-
90
- function emptySliceInfo(text, totalLines) {
91
- return { text: "", end: totalLines, total: totalLines, count: 0, eof: true, whole: totalLines === 1 && text === "" };
92
- }
93
-
94
- function sliceWindow(text, startIndex, count, totalLines) {
95
- const endExclusive = Math.min(totalLines, startIndex + count);
96
- const start = lineStartIndex(text, startIndex + 1);
97
- const end = lineEndIndex(text, start, endExclusive - startIndex);
98
- let selected = text.slice(start, end);
99
- const eof = endExclusive >= totalLines || (endExclusive === totalLines - 1 && text.endsWith("\n"));
100
-
101
- if (endExclusive < totalLines && !selected.endsWith("\n")) selected += "\n";
102
-
103
- return { text: selected, end: endExclusive, total: totalLines, count: endExclusive - startIndex, eof, whole: startIndex === 0 && eof };
104
- }
105
-
106
- export function sliceLinesRawInfo(text, offset, limit) {
107
- const totalLines = totalContentLines(text);
108
-
109
- if (!isNumber(offset) && !isNumber(limit)) {
110
- return { text, end: totalLines, total: totalLines, count: totalLines, eof: true, whole: true };
111
- }
112
-
113
- const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
114
- const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : totalLines;
115
-
116
- if (count === 0 || startIndex >= totalLines) return emptySliceInfo(text, totalLines);
117
-
118
- return sliceWindow(text, startIndex, count, totalLines);
119
- }
120
-
121
- /** Read-window slicing preserves the selected lines' own line ending. */
122
- export function sliceLinesRaw(text, offset, limit) {
123
- return sliceLinesRawInfo(text, offset, limit).text;
124
- }
125
-
126
14
  export function readLineParam(value, name) {
127
15
  if (value === undefined) return undefined;
128
16
  const number = isNumber(value) ? value : isString(value) && value.trim() !== "" ? Number(value) : NaN;
@@ -173,40 +61,6 @@ export async function probeExistingPath(cwd, targetParam, vfs) {
173
61
  }
174
62
  }
175
63
 
176
- export const EDIT_PREVIEW_LINES = 16;
177
-
178
- export const MAX_DIRECTORY_ENTRIES = 10000;
179
-
180
- export function sourceLines(content) {
181
- const raw = content.split("\n");
182
-
183
- if (raw.at(-1) === "") raw.pop();
184
-
185
- return raw;
186
- }
187
-
188
- export function lineNumberAt(content, index) {
189
- let line = 1;
190
-
191
- for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
192
-
193
- return line;
194
- }
195
-
196
- export function formatNumberedLine(n, text) {
197
- return String(n).padStart(5) + " " + text;
198
- }
199
-
200
- export function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
201
- const { count, preview } = contentLineInfo(content, cap);
202
-
203
- if (count === 0) return "0 lines";
204
- const body = preview.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
205
- const suffix = count + " lines total";
206
-
207
- return body + "\n" + suffix;
208
- }
209
-
210
64
  function lineAt(content, n) {
211
65
  const range = lineTextRange(content, n);
212
66
 
@@ -283,38 +137,6 @@ export function applyReplacements(target, content, requestedEdits) {
283
137
  return { updated, matches };
284
138
  }
285
139
 
286
- export function lineStartIndex(content, line) {
287
- let index = 0;
288
-
289
- for (let current = 1; current < line; current++) {
290
- const next = content.indexOf("\n", index);
291
-
292
- if (next < 0) return content.length;
293
- index = next + 1;
294
- }
295
-
296
- return Math.min(index, content.length);
297
- }
298
-
299
- export function lineEndIndex(content, startIndex, lineCount) {
300
- let index = startIndex;
301
-
302
- for (let i = 0; i < lineCount; i++) {
303
- const next = content.indexOf("\n", index);
304
-
305
- if (next < 0) return content.length;
306
- index = next + 1;
307
- }
308
-
309
- return index;
310
- }
311
-
312
- export function lineTextRange(content, line) {
313
- const start = lineStartIndex(content, line);
314
-
315
- return { start, end: lineEndIndex(content, start, 1) };
316
- }
317
-
318
140
  export function shiftDiffLines(diff, delta) {
319
141
  if (!diff || delta === 0) return diff;
320
142
 
@@ -429,7 +251,7 @@ export async function countContentLines(target, signal) {
429
251
  let last = -1;
430
252
  let total = 0;
431
253
 
432
- for await (const chunk of file.createReadStream({ autoClose: false, signal })) {
254
+ for await (const chunk of fileChunks(file, signal)) {
433
255
  for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) newlines++;
434
256
  last = chunk.at(-1);
435
257
  total += chunk.length;
@@ -439,26 +261,6 @@ export async function countContentLines(target, signal) {
439
261
  } finally { await file.close(); }
440
262
  }
441
263
 
442
- export function contentLineInfo(text, previewLimit = 0) {
443
- if (text === "") return { count: 0, preview: [], newlines: 0 };
444
- const preview = [];
445
- let count = 0;
446
- let start = 0;
447
-
448
- while (start <= text.length) {
449
- const newline = text.indexOf("\n", start);
450
- const end = newline < 0 ? text.length : newline;
451
-
452
- if (end === text.length && end === start && text.endsWith("\n")) break;
453
- if (preview.length < previewLimit) preview.push(text.slice(start, end).replace(/\r$/, ""));
454
- count++;
455
- if (newline < 0) break;
456
- start = newline + 1;
457
- }
458
-
459
- return { count, preview, newlines: count - Number(!text.endsWith("\n")) };
460
- }
461
-
462
264
  export function boundedEditDiff(target, original, matches) {
463
265
  const rendered = matches.slice(0, MAX_DIFF_MATCHES);
464
266
  const lines = [];
@@ -498,25 +300,3 @@ export function boundedWriteDiff(target, content, removed) {
498
300
  lines: added.preview.map((text, i) => ({ type: "add", lineNum: i + 1, text })),
499
301
  };
500
302
  }
501
-
502
- export function formatDirectoryEntry(name, type, size = 0) {
503
- const sizeSuffix = size ? `, ${size} bytes` : "";
504
-
505
- return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
506
- }
507
-
508
- export async function formatLsEntry(dirPath, entry) {
509
- const isDir = entry.isDirectory();
510
- const isSym = entry.isSymbolicLink();
511
- const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
512
- let size = 0;
513
-
514
- try {
515
- if (!isDir && !isSym) {
516
- const st = await fs.stat(path.join(dirPath, entry.name));
517
- size = st.size;
518
- }
519
- } catch {}
520
-
521
- return formatDirectoryEntry(entry.name, typeLabel, size);
522
- }
package/src/fs/vfs.js CHANGED
@@ -1,248 +1,14 @@
1
+ import {textSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
2
+ import {resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
3
+ export {resolveCommitTarget} from './commit.js';
1
4
  import * as fs from "node:fs/promises";
2
5
  import * as path from "node:path";
3
6
  import { isString } from "../shared/decode.js";
4
7
  import { decodeUtf8Strict } from "../shared/utf8.js";
5
- import { createHash, randomUUID } from "node:crypto";
6
8
 
7
9
  // Serialize validation + replacement across Supernova transactions in this host.
8
10
  let commitTail = Promise.resolve();
9
11
 
10
- function textSignature(text) {
11
- return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
12
- }
13
-
14
- function sameFileVersion(a, b) {
15
- return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
16
- }
17
-
18
- async function fileSignature(target, signal, observed) {
19
- const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
20
-
21
- try {
22
- const actual = await file.stat();
23
-
24
- if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
25
- if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
26
- const hash = createHash("sha256");
27
-
28
- for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
29
- const after = await file.stat();
30
-
31
- if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
32
-
33
- return { size: actual.size, sha256: hash.digest("hex") };
34
- } finally {
35
- await file.close();
36
- }
37
- }
38
-
39
- // realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
40
- // ancestor so two symlink spellings still share one commit destination.
41
- async function canonicalNewPath(target) {
42
- let ancestor = path.dirname(target);
43
-
44
- for (;;) {
45
- try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
46
- catch (error) {
47
- if (error.code !== "ENOENT") throw error;
48
- const parent = path.dirname(ancestor);
49
-
50
- if (parent === ancestor) throw error;
51
- ancestor = parent;
52
- }
53
- }
54
- }
55
-
56
- function sameSignature(a, b) {
57
- return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
58
- }
59
-
60
- function tooLargeRead(label, maxBytes) {
61
- return new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
62
- }
63
-
64
- function overlayOrThrow(overlay, maxBytes, label) {
65
- if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw tooLargeRead(label, maxBytes);
66
-
67
- return overlay;
68
- }
69
-
70
- function assertReadableFile(stat, target) {
71
- // Callers are reads, writes, edits and patch application: name the path, not the caller.
72
- if (stat.isDirectory()) throw new Error("path is a directory, not a file: " + target);
73
-
74
- if (!stat.isFile()) throw new Error("path is not a regular file: " + target);
75
- }
76
-
77
- async function readLimitedBytes(file, stat, maxBytes, label, signal) {
78
- if (stat.size > maxBytes) throw tooLargeRead(label, maxBytes);
79
- const chunks = [];
80
- let size = 0;
81
-
82
- for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal })) {
83
- size += chunk.length;
84
-
85
- if (size > maxBytes) throw tooLargeRead(label, maxBytes);
86
- chunks.push(chunk);
87
- }
88
-
89
- return Buffer.concat(chunks);
90
- }
91
-
92
- function remapReadError(err, target) {
93
- if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
94
-
95
- if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
96
- if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
97
-
98
- if (err.code === "ENOENT") {
99
- const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)');
100
- missing.code = "ENOENT";
101
- throw missing;
102
- }
103
-
104
- throw err;
105
- }
106
-
107
- async function resolveExistingFile(logicalPath) {
108
- const target = await fs.realpath(logicalPath);
109
- const stat = await fs.stat(target);
110
-
111
- if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
112
-
113
- return { target, stat };
114
- }
115
-
116
- async function resolveCommitTarget(logicalPath) {
117
- try {
118
- return await resolveExistingFile(logicalPath);
119
- } catch (err) {
120
- if (err.code !== "ENOENT") throw err;
121
-
122
- return { target: await canonicalNewPath(logicalPath), stat: undefined };
123
- }
124
- }
125
-
126
- async function collectMissingAncestors(parent) {
127
- const missing = [];
128
- let probe = parent;
129
-
130
- for (;;) {
131
- try { await fs.stat(probe); break; } catch (err) {
132
- if (err.code !== "ENOENT") throw err;
133
- missing.push(probe);
134
- probe = path.dirname(probe);
135
- }
136
- }
137
-
138
- return missing;
139
- }
140
-
141
- async function writeTemporary(entry, content, stat) {
142
- try {
143
- await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
144
- } catch (error) {
145
- // Never leak the temporary name: name the destination and the real cause.
146
- if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
147
- if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
148
- if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
149
-
150
- throw error;
151
- }
152
-
153
- if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
154
- }
155
-
156
- async function stageReplacement(entry, content, stat, target) {
157
- const replacement = writeTemporary(entry, content, stat);
158
- // These touch separate staging files. Settle both before cleanup, even
159
- // on failure: Promise.all could leave a late backup after rollback.
160
- const staging = [replacement];
161
-
162
- if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
163
- const outcomes = await Promise.allSettled(staging);
164
- const failure = outcomes.find(outcome => outcome.status === "rejected");
165
-
166
- if (failure) throw failure.reason;
167
- }
168
-
169
- function makeStageEntry(logicalPath, target, content, parent, stat) {
170
- const token = ".supernova-" + randomUUID();
171
-
172
- return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
173
- }
174
-
175
- async function recoverReplaced(staged) {
176
- const recoveryErrors = [];
177
-
178
- for (const entry of staged.toReversed()) {
179
- if (!entry.replaced) continue;
180
-
181
- try {
182
- if (entry.existed) await fs.rename(entry.backup, entry.target);
183
- else await fs.unlink(entry.target);
184
- } catch (err) {
185
- // Keep the backup if recovery fails; never delete the remaining original.
186
- entry.keepBackup = true;
187
- recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
188
- }
189
- }
190
-
191
- return recoveryErrors;
192
- }
193
-
194
- async function cleanupStaged(staged, failed, createdDirs) {
195
- for (const entry of staged) {
196
- // A successful rename consumed the temporary path. These are known
197
- // files, so unlink avoids rm's extra type probe; missing files stay benign.
198
- if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
199
-
200
- if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
201
- }
202
-
203
- if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
204
- }
205
-
206
- async function assertExpectedSignature(vfs, logicalPath, target, stat) {
207
- if (!vfs.expected.has(logicalPath)) return;
208
- const current = stat ? await fileSignature(target, vfs.signal) : null;
209
-
210
- if (!sameSignature(current, vfs.expected.get(logicalPath))) {
211
- throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
212
- }
213
- }
214
-
215
- async function installStaged(vfs, staged) {
216
- for (const entry of staged) {
217
- vfs.signal?.throwIfAborted();
218
- await fs.rename(entry.temporary, entry.target);
219
- entry.replaced = true;
220
- }
221
-
222
- for (const entry of staged) {
223
- vfs.expected.set(entry.logicalPath, textSignature(entry.content));
224
- }
225
-
226
- // Canonical commit destinations must not rewrite established event paths
227
- // for newly created files, whose callers supplied a logical cwd spelling.
228
- if (staged.length) vfs.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
229
- }
230
-
231
- async function failCommit(vfs, staged, error) {
232
- const recoveryErrors = await recoverReplaced(staged);
233
- // Keep CAS baselines: a failed commit must not forgive conflicts on files it
234
- // never touched. If recovery left disk diverging from a baseline, the next
235
- // write to that path fails loudly and forces a re-read instead of silently
236
- // re-capturing unknown bytes as the new truth.
237
-
238
- if (recoveryErrors.length) vfs.mutations.recoveryFailed = true;
239
-
240
- if (recoveryErrors.length) vfs.onNewFile?.(null);
241
-
242
- if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
243
- throw error;
244
- }
245
-
246
12
  export class CausalVfs {
247
13
  constructor(onNewFile, validateWrite) {
248
14
  this.validateWrite = validateWrite;
@@ -275,7 +41,7 @@ export class CausalVfs {
275
41
  async read(target, { preserveRead = false, maxBytes, label = "read input", strict = true } = {}) {
276
42
  const overlay = this.getOverlay(target);
277
43
 
278
- if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
44
+ if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label, target);
279
45
 
280
46
  // External editors and captured tools can change a file between any two reads.
281
47
  // Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
@@ -288,7 +54,7 @@ export class CausalVfs {
288
54
  assertReadableFile(stat, target);
289
55
  bytes = maxBytes === undefined
290
56
  ? await file.readFile({ signal: this.signal })
291
- : await readLimitedBytes(file, stat, maxBytes, label, this.signal);
57
+ : await readLimitedBytes(file, stat, maxBytes, label, this.signal, () => tooLargeRead(label,maxBytes,target));
292
58
  if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
293
59
  } finally { await file.close(); }
294
60
 
@@ -1,3 +1,4 @@
1
+ import {remapReadError} from "./file-io.js";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { spawn } from "node:child_process";
@@ -44,7 +45,7 @@ async function realpathNearest(target) {
44
45
  try {
45
46
  return await fs.realpath(probe);
46
47
  } catch (err) {
47
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
48
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
48
49
  const parent = path.dirname(probe);
49
50
 
50
51
  if (parent === probe) throw err;