pi-supernova 0.9.0 → 0.10.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/src/fs/commit.js CHANGED
@@ -60,7 +60,9 @@ async function writeTemporary(entry, content, stat) {
60
60
  } catch (error) {
61
61
  // Never leak the temporary name: name the destination and the real cause.
62
62
  if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
63
+
63
64
  if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
65
+
64
66
  if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
65
67
 
66
68
  throw error;
@@ -130,6 +132,9 @@ async function assertExpectedSignature(vfs, logicalPath, target, stat) {
130
132
 
131
133
  async function installStaged(vfs, staged) {
132
134
  for (const entry of staged) {
135
+ // Staging and earlier renames await I/O. Recheck before each disk effect;
136
+ // failCommit restores earlier replacements if ownership changed mid-commit.
137
+ vfs.assertCurrent?.();
133
138
  vfs.signal?.throwIfAborted();
134
139
  await fs.rename(entry.temporary, entry.target);
135
140
  entry.replaced = true;
@@ -158,4 +163,5 @@ async function failCommit(vfs, staged, error) {
158
163
  if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
159
164
  throw error;
160
165
  }
166
+
161
167
  export {assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged};
package/src/fs/file-io.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
2
3
  import {createHash} from 'node:crypto';
3
4
 
4
5
  function textSignature(text) {
@@ -19,6 +20,7 @@ async function* fileChunks(file, signal, maxBytes = Infinity) {
19
20
  signal?.throwIfAborted();
20
21
  const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, remaining), null);
21
22
  signal?.throwIfAborted();
23
+
22
24
  if (!bytesRead) break;
23
25
  remaining -= bytesRead;
24
26
  // Consumers retaining a chunk must copy it before the next read.
@@ -33,6 +35,7 @@ async function fileSignature(target, signal, observed) {
33
35
  const actual = await file.stat();
34
36
 
35
37
  if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
38
+
36
39
  if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
37
40
  const hash = createHash("sha256");
38
41
 
@@ -83,10 +86,21 @@ async function readLimitedBytes(file, stat, maxBytes, label, signal, overflow =
83
86
  return Buffer.concat(chunks);
84
87
  }
85
88
 
86
- function remapReadError(err, target) {
89
+ async function hasFileParent(target) {
90
+ for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
91
+ try { return !(await fs.stat(parent)).isDirectory(); }
92
+ catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return false; }
93
+
94
+ if (parent === path.dirname(parent)) return false;
95
+ }
96
+ }
97
+
98
+ async function remapReadError(err, target) {
87
99
  if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
88
100
 
89
- if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
101
+ // Windows returns ENOENT, not ENOTDIR, for a file used as a parent.
102
+ if (err.code === "ENOTDIR" || err.code === "ENOENT" && await hasFileParent(target)) throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
103
+
90
104
  if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
91
105
 
92
106
  if (err.code === "ENOENT") {
@@ -97,4 +111,5 @@ function remapReadError(err, target) {
97
111
 
98
112
  throw err;
99
113
  }
114
+
100
115
  export { textSignature, sameFileVersion, fileChunks, fileSignature, sameSignature, tooLargeRead, overlayOrThrow, assertReadableFile, readLimitedBytes, remapReadError };
@@ -0,0 +1,92 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+
5
+ const exec = promisify(execFile);
6
+
7
+ // PTYs and shell job control can create groups distinct from the original
8
+ // leader. Record those groups while ancestry exists, before signalling parents.
9
+ async function descendantGroups(pid) {
10
+ const { stdout } = await exec("ps", ["-ax", "-o", "pid=,ppid=,pgid="], {timeout:1000, maxBuffer:4*1024*1024});
11
+ const children = new Map();
12
+
13
+ for (const line of stdout.trim().split("\n")) {
14
+ const [child, parent, group] = line.trim().split(/\s+/).map(Number);
15
+
16
+ if (!children.has(parent)) children.set(parent, []);
17
+ children.get(parent).push({pid:child,group});
18
+ }
19
+
20
+ const found = [];
21
+ const pending = [...(children.get(pid) ?? [])];
22
+
23
+ while (pending.length) {
24
+ const child = pending.pop();
25
+
26
+ if (child.group > 0) found.push(child.group);
27
+ pending.push(...(children.get(child.pid) ?? []));
28
+ }
29
+
30
+ return found;
31
+ }
32
+
33
+ function signalGroups(job, signal) {
34
+ for (const group of job.groups) {
35
+ // Once SIGKILL was delivered, wait for reaping. Darwin can return EPERM
36
+ // when signalling a zombie again; newly discovered groups still need a kill.
37
+ if (signal === "SIGKILL" && job.killedGroups.has(group)) continue;
38
+
39
+ try {
40
+ process.kill(-group, signal);
41
+
42
+ if (signal === "SIGKILL") job.killedGroups.add(group);
43
+ } catch (error) {
44
+ if (error.code === "EPERM") {
45
+ // Even signal 0 can encounter that race. Keep ownership until ESRCH;
46
+ // an inaccessible live group must fail at the deadline, not look gone.
47
+ job.signalError = `cannot signal process group ${group} with ${signal}: ${error.message}`;
48
+ continue;
49
+ }
50
+
51
+ if (error.code !== "ESRCH") throw error;
52
+ job.groups.delete(group);
53
+ }
54
+ }
55
+ }
56
+
57
+ // Both foreground and background commands own their POSIX groups until they
58
+ // disappear, not merely until the leader exits or inherited output pipes close.
59
+ // Callers update processExited/processClosed from the real child events and may
60
+ // add PTY group identities while cleanup awaits. Windows remains best-effort.
61
+ export async function retireProcessTree(job) {
62
+ let discoveryError;
63
+
64
+ if (process.platform === "win32") {
65
+ if (!job.processExited) await exec("taskkill", ["/pid", String(job.child.pid), "/t", "/f"], {timeout:2000,windowsHide:true});
66
+ } else {
67
+ if (!job.processExited) {
68
+ try { for (const group of await descendantGroups(job.child.pid)) job.groups.add(group); }
69
+ catch (error) { discoveryError = error; }
70
+ }
71
+
72
+ signalGroups(job, "SIGTERM");
73
+
74
+ if (job.groups.size) await delay(150);
75
+ signalGroups(job, "SIGKILL");
76
+ }
77
+
78
+ const deadline = Date.now() + 1000;
79
+
80
+ while (true) {
81
+ if (process.platform !== "win32") signalGroups(job, 0);
82
+
83
+ if (job.processClosed && (process.platform === "win32" || job.groups.size === 0)) break;
84
+
85
+ if (Date.now() >= deadline) throw new Error("owned process group or output pipes did not close" + (job.signalError ? ": " + job.signalError : ""));
86
+
87
+ if (process.platform !== "win32") signalGroups(job, "SIGKILL");
88
+ await delay(10);
89
+ }
90
+
91
+ if (discoveryError) throw new Error("descendant discovery failed: " + discoveryError.message);
92
+ }
@@ -7,67 +7,84 @@ import { fileChunks, remapReadError } from "./file-io.js";
7
7
  function advanceLines(bytes, start, remaining) {
8
8
  while (remaining > 0) {
9
9
  const newline = bytes.indexOf(10, start);
10
+
10
11
  if (newline < 0) return { offset: bytes.length, remaining };
11
12
  start = newline + 1;
12
13
  remaining--;
13
14
  }
15
+
14
16
  return { offset: start, remaining };
15
17
  }
16
18
 
17
19
  async function scanWindow(file, stat, startLine, lineCount, maxBytes, signal) {
18
20
  const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined };
19
21
  let skip = startLine - 1, take = lineCount ?? Infinity, position = 0;
22
+
20
23
  for await (const chunk of fileChunks(file, signal, stat.size)) {
21
24
  const head = advanceLines(chunk, 0, skip);
22
25
  skip = head.remaining;
23
26
  const start = position;
24
27
  position += chunk.length;
28
+
25
29
  if (skip) continue;
26
30
  scan.startByte ??= start + head.offset;
27
31
  const tail = advanceLines(chunk, head.offset, take);
28
32
  take = tail.remaining;
33
+
29
34
  if (take === 0) scan.endByte = start + tail.offset;
30
35
  const end = Math.min(tail.offset, head.offset + maxBytes + 1 - scan.collected);
36
+
31
37
  if (end > head.offset) {
32
38
  scan.parts.push(Buffer.from(chunk.subarray(head.offset, end)));
33
39
  scan.collected += end - head.offset;
34
40
  }
41
+
35
42
  if (take === 0 || scan.collected > maxBytes) break;
36
43
  }
44
+
37
45
  return scan;
38
46
  }
39
47
 
40
48
  function overlayWindow(overlay, startLine, lineCount) {
41
49
  const window = sliceLinesRawInfo(overlay, startLine, lineCount);
42
50
  const satisfied = lineCount === undefined || lineCount === 0 || window.text === "" || window.count >= lineCount || window.eof;
51
+
43
52
  return { text: window.text, satisfied, whole: window.whole };
44
53
  }
45
54
 
46
55
  async function openReadFile(target) {
47
56
  try { return await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
48
- catch (error) { remapReadError(error, target); }
57
+ catch (error) { await remapReadError(error, target); }
49
58
  }
50
59
 
51
60
  export function createWindowReader(vfs) {
52
61
  return async function readWindow(target, startLine, lineCount, maxBytes, signal) {
53
62
  const overlay = vfs.getOverlay(target);
63
+
54
64
  if (overlay !== undefined) {
55
65
  const window = overlayWindow(overlay,startLine,lineCount);
56
66
  window.satisfied &&= Buffer.byteLength(window.text,"utf8") <= maxBytes;
67
+
57
68
  return window;
58
69
  }
70
+
59
71
  const file = await openReadFile(target);
72
+
60
73
  try {
61
74
  const stat = await file.stat();
75
+
62
76
  if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
77
+
63
78
  if (lineCount === 0) return { text: "", satisfied: true, whole: stat.size === 0 };
64
79
  const scan = await scanWindow(file, stat, startLine, lineCount, maxBytes, signal);
80
+
65
81
  if (scan.startByte === undefined) return { text: "", satisfied: true, whole: stat.size === 0 };
66
82
  const bytes = Buffer.concat(scan.parts, scan.collected);
67
83
  const end = scan.startByte + scan.collected;
68
84
  const eof = end >= stat.size;
69
85
  const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes);
70
86
  await vfs.recordExpected(target, stat);
87
+
71
88
  return { text, satisfied: end >= scan.endByte || eof, whole: startLine === 1 && scan.startByte === 0 && eof };
72
89
  } finally { await file.close(); }
73
90
  };
package/src/fs/vfs.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {textSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
2
2
  import {resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
3
+
3
4
  export {resolveCommitTarget} from './commit.js';
5
+
4
6
  import * as fs from "node:fs/promises";
5
7
  import * as path from "node:path";
6
8
  import { isString } from "../shared/decode.js";
@@ -10,8 +12,9 @@ import { decodeUtf8Strict } from "../shared/utf8.js";
10
12
  let commitTail = Promise.resolve();
11
13
 
12
14
  export class CausalVfs {
13
- constructor(onNewFile, validateWrite) {
15
+ constructor(onNewFile, validateWrite, assertCurrent) {
14
16
  this.validateWrite = validateWrite;
17
+ this.assertCurrent = assertCurrent;
15
18
  // No body cache: every read hits disk (or its overlay) so observed bytes
16
19
  // are never stale. CAS baselines in `expected` are the only retained
17
20
  // per-file state, cleared only at external-mutation boundaries.
@@ -24,6 +27,8 @@ export class CausalVfs {
24
27
  }
25
28
 
26
29
  assertWritable() {
30
+ this.assertCurrent?.();
31
+
27
32
  if (this.closed) throw new Error("program is already complete");
28
33
  this.signal?.throwIfAborted();
29
34
  }
@@ -55,6 +60,7 @@ export class CausalVfs {
55
60
  bytes = maxBytes === undefined
56
61
  ? await file.readFile({ signal: this.signal })
57
62
  : await readLimitedBytes(file, stat, maxBytes, label, this.signal, () => tooLargeRead(label,maxBytes,target));
63
+
58
64
  if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
59
65
  } finally { await file.close(); }
60
66
 
@@ -63,7 +69,7 @@ export class CausalVfs {
63
69
 
64
70
  return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
65
71
  } catch (err) {
66
- remapReadError(err, target);
72
+ await remapReadError(err, target);
67
73
  }
68
74
  }
69
75
 
@@ -139,6 +145,9 @@ export class CausalVfs {
139
145
  let failed = false;
140
146
 
141
147
  try {
148
+ // Admission may have preceded another transaction's asynchronous commit.
149
+ this.assertCurrent?.();
150
+
142
151
  for (const [logicalPath, content] of writes) {
143
152
  this.signal?.throwIfAborted();
144
153
  const { target, stat } = await resolveCommitTarget(logicalPath);
@@ -193,10 +202,14 @@ export class CausalVfs {
193
202
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
194
203
  }
195
204
 
196
- async prepareExternalMutation(name) {
205
+ assertExternalAllowed(name) {
197
206
  this.assertWritable();
198
207
 
199
- if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
208
+ if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back; run bash after the checkpoint, or use explicit restoration outside checkpoints for mutation tests");
209
+ }
210
+
211
+ async prepareExternalMutation(name) {
212
+ this.assertExternalAllowed(name);
200
213
 
201
214
  if (!this.overlays.length) { this.mutations.external++;
202
215
 
@@ -1,4 +1,5 @@
1
1
  import {remapReadError} from "./file-io.js";
2
+ import { retireProcessTree } from "./process-tree.js";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { spawn } from "node:child_process";
@@ -45,7 +46,7 @@ async function realpathNearest(target) {
45
46
  try {
46
47
  return await fs.realpath(probe);
47
48
  } catch (err) {
48
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
49
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") await remapReadError(err, target);
49
50
  const parent = path.dirname(probe);
50
51
 
51
52
  if (parent === probe) throw err;
@@ -137,21 +138,22 @@ function commandTimeoutMs(options) {
137
138
  return Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
138
139
  }
139
140
 
140
- function spawnCommand(argv, options, cwd) {
141
- return spawn(argv[0], argv.slice(1), {
142
- cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
143
- });
144
- }
141
+ export function commandSpawnError(error, command) {
142
+ if (error?.code === "EACCES" || error?.code === "EPERM") return new Error("cannot execute " + command + ": permission denied (is it executable?)");
145
143
 
146
- function signalProcessTree(child, signal) {
147
- if (!child.pid) return;
144
+ if (error?.code === "ENOENT") return new Error("command not found: " + command);
148
145
 
149
- if (process.platform === "win32") {
150
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
151
- killer.on("error", () => child.kill(signal));
152
- } else {
153
- try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
154
- }
146
+ if (error?.code === "ENOTDIR") return new Error("cannot run " + command + ": the working directory is not a directory");
147
+
148
+ return error;
149
+ }
150
+
151
+ export function spawnCommand(argv, options, cwd = options.cwd) {
152
+ try {
153
+ return spawn(argv[0], argv.slice(1), {
154
+ cwd, env: options.env ?? process.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide:true,
155
+ });
156
+ } catch (error) { throw commandSpawnError(error,argv[0]); }
155
157
  }
156
158
 
157
159
  function failCommand(state, error) {
@@ -171,11 +173,29 @@ function failCommand(state, error) {
171
173
  }
172
174
 
173
175
  function terminateCommand(state, error) {
174
- if (state.settled || state.terminationError) return;
175
- state.terminationError = error;
176
- signalProcessTree(state.child, "SIGTERM");
177
- // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
178
- state.escalation = setTimeout(() => { signalProcessTree(state.child, "SIGKILL"); failCommand(state, error); }, 150);
176
+ if (state.settled) return;
177
+ state.terminationError ??= error;
178
+
179
+ if (state.stopping) return;
180
+ clearTimeout(state.timer);
181
+ state.stopping = retireProcessTree(state).then(()=>{
182
+ if (state.terminationError) {
183
+ failCommand(state,state.terminationError);
184
+
185
+ return;
186
+ }
187
+
188
+ state.settled = true;
189
+ state.cleanup();
190
+ state.resolve({ stdout:state.stdout, stderr:state.stderr,
191
+ exitCode:state.exitCode ?? (128 + (constants.signals[state.signal] ?? 1)),
192
+ signal:state.signal, outputTruncated:state.outputTruncated });
193
+ }).catch(cause=>{
194
+ state.child.stdout.destroy();
195
+ state.child.stderr.destroy();
196
+ const prefix = state.terminationError ? state.terminationError.message + "; " : "";
197
+ failCommand(state,new Error(prefix + "command cleanup failed: " + cause.message));
198
+ });
179
199
  }
180
200
 
181
201
  function appendCommandOutput(state, current, chunk) {
@@ -186,33 +206,14 @@ function appendCommandOutput(state, current, chunk) {
186
206
  return remaining ? current + chunk.slice(0, remaining) : current;
187
207
  }
188
208
 
189
- function onCommandClose(state, code, signal) {
190
- if (state.settled) return;
191
-
192
- if (state.terminationError) {
193
- // A closed pipe alone says nothing about descendants. Only ESRCH proves
194
- // the owned POSIX group is gone; otherwise retain the escalation timer.
195
- if (process.platform !== "win32" && state.child.pid) {
196
- try { process.kill(-state.child.pid, 0); }
197
- catch (error) { if (error.code === "ESRCH") failCommand(state, state.terminationError); }
198
- }
199
-
200
- return;
201
- }
202
-
203
- state.settled = true;
204
- state.cleanup();
205
- state.resolve({ stdout: state.stdout, stderr: state.stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated: state.outputTruncated });
206
- }
207
-
208
209
  function attachCommandIO(state, options, argv, timeoutMs) {
209
210
  const { child } = state;
210
211
  const onAbort = () => terminateCommand(state, new Error("aborted"));
211
212
  state.cleanup = () => {
212
213
  clearTimeout(state.timer);
213
- clearTimeout(state.escalation);
214
214
  options.signal?.removeEventListener("abort", onAbort);
215
215
  };
216
+
216
217
  state.timer = setTimeout(() => terminateCommand(state, new Error(
217
218
  "command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
218
219
  + "\nhint: Increase this bash timeoutMs and the outer supernova timeoutMs, or split the work. Sleeps and every command in a shell chain share the same limit."
@@ -221,13 +222,16 @@ function attachCommandIO(state, options, argv, timeoutMs) {
221
222
  child.stderr.setEncoding("utf8");
222
223
  child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
223
224
  child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
224
- child.on("error", error => {
225
- if (error?.code === "EACCES" || error?.code === "EPERM") failCommand(state, new Error("cannot execute " + argv[0] + ": permission denied (is it executable?)"));
226
- else if (error?.code === "ENOENT") failCommand(state, new Error("command not found: " + argv[0]));
227
- else if (error?.code === "ENOTDIR") failCommand(state, new Error("cannot run " + argv[0] + ": the working directory is not a directory"));
228
- else failCommand(state, error);
225
+ child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
226
+ child.once("exit", (code, signal) => {
227
+ state.processExited = true;
228
+ state.exitCode = code;
229
+ state.signal = signal;
230
+ // An orphan can retain the pipes forever. Begin retirement at leader exit,
231
+ // then wait for close and group disappearance before reporting completion.
232
+ terminateCommand(state);
229
233
  });
230
- child.on("close", (code, signal) => onCommandClose(state, code, signal));
234
+ child.once("close", () => { state.processClosed = true; });
231
235
  options.signal?.addEventListener("abort", onAbort, { once: true });
232
236
 
233
237
  if (options.signal?.aborted) onAbort();
@@ -243,7 +247,8 @@ export async function runCommand(argv, options = {}) {
243
247
  const child = spawnCommand(argv, options, cwd);
244
248
  attachCommandIO({
245
249
  child, resolve, reject, stdout: "", stderr: "", settled: false,
246
- outputTruncated: false, terminationError: undefined, escalation: undefined,
250
+ outputTruncated: false, terminationError: undefined, stopping: undefined,
251
+ groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(),
247
252
  maxOutputChars, timer: undefined, cleanup() {},
248
253
  }, options, argv, timeoutMs);
249
254
  });
@@ -167,6 +167,7 @@ function packageBatchItems(batch, details, maxChars) {
167
167
  const itemErrors = (details.itemErrors ?? []).map(error => boundItemError(error, maxChars, batch.length));
168
168
  let remaining = maxChars;
169
169
  let truncated = false;
170
+
170
171
  const items = batch.map((item, index) => {
171
172
  const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
172
173
  const bounded = boundBatchItem(item, share);
@@ -223,14 +224,26 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
223
224
 
224
225
  export function packageHostResult(raw, config) {
225
226
  const details = detailsOf(raw);
227
+
226
228
  if (raw && Object.hasOwn(raw, READ_VALUE)) {
227
229
  const batch = batchFromDetails(details);
228
230
  const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
229
231
  attachDetails(result, details, batch);
230
- if (batch) { result.items = batch; result.itemErrors = details.itemErrors ?? []; }
232
+
233
+ if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
234
+
235
+ if (batch) {
236
+ result.items = batch;
237
+ result.itemErrors = details.itemErrors ?? [];
238
+
239
+ if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
240
+ }
241
+
231
242
  if (details?.streamed) result.streamed = true;
243
+
232
244
  return result;
233
245
  }
246
+
234
247
  const maxChars = config.maxCallResultChars ?? 65536;
235
248
  const batch = batchFromDetails(details);
236
249
  const text = batch ? "" : extractRawString(raw);
@@ -15,16 +15,20 @@ function unwrapValue(res) {
15
15
  return res;
16
16
  }
17
17
 
18
- function unwrapRead(res, args) {
18
+ function unwrapRead(res, args, observe) {
19
19
  const value = unwrapValue(res);
20
20
 
21
21
  if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
22
22
 
23
- return decodeReadItem(args, value, res);
23
+ const decoded = decodeReadItem(args, value, res);
24
+ observe?.(args,res);
25
+
26
+ return decoded;
24
27
  }
25
28
 
26
29
  function decodeReadItem(args, value, res) {
27
30
  if (!res?.typed) return decodeReadValue(args, value);
31
+
28
32
  // JSON selectors used to cross RPC as separately encoded JSON values. Keep
29
33
  // their mutable results independent, but put the copies in the guest heap.
30
34
  return res.cloneItems ? value.map(item => structuredClone(item)) : value;
@@ -59,18 +63,22 @@ function missingResolvedIndex(values, paths) {
59
63
 
60
64
  function settleReadItem(wave, index, res) {
61
65
  const job = wave[index];
66
+
62
67
  if (!job) throw new Error("invalid streamed read index: " + index);
63
68
  wave[index] = null; // Release resolver closures and their potentially large values.
64
- try { job.resolve(unwrapRead(res,job.args)); }
69
+
70
+ try { job.resolve(unwrapRead(res,job.args,job.observe)); }
65
71
  catch (error) { job.reject(error); }
66
72
  }
67
73
 
68
74
  function settleInlineWave(wave,res,received) {
69
75
  unwrapValue(res);
76
+
70
77
  if (received || !Array.isArray(res.items) || res.items.length !== wave.length) throw new Error("invalid batch read response");
78
+
71
79
  for (let i=0;i<wave.length;i++) {
72
80
  const error = res.itemErrors?.[i];
73
- settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],items:undefined});
81
+ settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],sourcePath:res.sourcePaths?.[i],items:undefined});
74
82
  }
75
83
  }
76
84
 
@@ -78,24 +86,32 @@ async function rpcReadWave(rpc, wave) {
78
86
  if (wave.length === 1) {
79
87
  const res = leanEnvelope(await rpc("call",["read",wave[0].args]));
80
88
  settleReadItem(wave,0,res);
89
+
81
90
  return;
82
91
  }
92
+
83
93
  const args = {...wave[0].args,path:wave.map(job=>job.args.path),_independent:true};
84
94
  let received = 0, last;
95
+
85
96
  const onItem = (index,res) => {
86
97
  if (!Number.isInteger(index) || !wave[index] || last?.index === index) throw new Error("invalid streamed read response");
87
98
  received++;
99
+
88
100
  // Keep the final read pending until the host RPC/barrier itself has settled.
89
101
  // An awaited batch must never look complete while its host call is running.
90
102
  if (received === wave.length) last = {index,res};
91
103
  else settleReadItem(wave,index,res);
92
104
  };
105
+
93
106
  const res = leanEnvelope(await rpc("call",["read",args],onItem));
107
+
94
108
  if (res?.streamed) {
95
109
  if (received !== wave.length || !last) throw new Error("incomplete streamed read response");
96
110
  settleReadItem(wave,last.index,last.res);
111
+
97
112
  return;
98
113
  }
114
+
99
115
  settleInlineWave(wave,res,received);
100
116
  }
101
117
 
@@ -118,7 +134,7 @@ function enqueueCompatibleRead(readState, flushReads, args) {
118
134
  if (readState.queued.length && readState.queued[0].key !== key) flushReads();
119
135
 
120
136
  const promise = new Promise((resolve, reject) => {
121
- readState.queued.push({ args, key, resolve, reject });
137
+ readState.queued.push({ args, key, resolve, reject, observe:readState.observe });
122
138
 
123
139
  if (readState.queued.length === 1) queueMicrotask(flushReads);
124
140
  });
@@ -128,12 +144,16 @@ function enqueueCompatibleRead(readState, flushReads, args) {
128
144
 
129
145
  async function readManyPaths(readOne, args, paths) {
130
146
  assertReadPaths(paths);
147
+
131
148
  const values = await Promise.all(paths.map(async item => {
132
149
  try { return await readOne({...args,path:item}); }
133
150
  catch (error) { throwReadPathError(item,error.message); }
134
151
  }));
152
+
135
153
  const missing = args.resolve ? missingResolvedIndex(values,paths) : -1;
154
+
136
155
  if (missing >= 0) throwReadPathError(paths[missing],"not_found");
156
+
137
157
  return values;
138
158
  }
139
159
 
@@ -193,12 +213,13 @@ export function buildGuestApi(rpc, batchRead) {
193
213
  if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
194
214
  const token = {};
195
215
  checkpoint = token;
216
+
196
217
  try { return await runSpeculation(fn, token, checkpointScope, drainReads, rpc); }
197
218
  finally { checkpoint = null; }
198
219
  }
199
220
 
200
221
  // Coalesce already-started compatible reads without rewriting JS control flow.
201
- const readState = { queued: [], waves: new Set() };
222
+ const readState = { queued: [], waves: new Set(), observe:noteReadPath };
202
223
 
203
224
  function flushReads() {
204
225
  const pending = readState.queued;
@@ -227,26 +248,21 @@ export function buildGuestApi(rpc, batchRead) {
227
248
  const args = normalizeRead(gatherReadArgs(p, a, b));
228
249
  p = args.path;
229
250
 
230
- if (Array.isArray(p)) {
231
- const values = await readManyPaths(read, args, p);
232
- for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
233
-
234
- return values;
235
- }
251
+ if (Array.isArray(p)) return await readManyPaths(read, args, p);
236
252
 
237
- const readValue = !batchRead
238
- ? unwrapRead(await invoke("read", args), args)
253
+ return !batchRead
254
+ ? unwrapRead(await invoke("read", args), args, noteReadPath)
239
255
  : await enqueueCompatibleRead(readState, flushReads, args);
240
- noteReadPath(args, readValue);
241
-
242
- return readValue;
243
256
  };
244
257
 
245
258
  const readFiles = new Set();
246
259
 
247
- function noteReadPath(args, value) {
248
- if (isString(args.path) && looksLikePath(args.path)) readFiles.add(args.path);
249
- if (isObject(value) && isString(value.path) && looksLikePath(value.path)) readFiles.add(value.path);
260
+ function noteReadPath(args, envelope) {
261
+ if (isString(args.path) && args.resolve !== true && args.query === undefined && args.evidence !== true) readFiles.add(args.path);
262
+
263
+ // A document may itself contain path/status fields. Only adapter provenance,
264
+ // carried separately through scalar, inline and streamed replies, names a read.
265
+ if (isString(envelope.sourcePath)) readFiles.add(envelope.sourcePath);
250
266
  }
251
267
 
252
268
  const write = async (p, content) => {