pi-supernova 0.10.2 → 0.11.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/README.md +365 -90
- package/docs/CHANGELOG.md +89 -5
- package/docs/TOKEN_COSTS.md +149 -324
- package/index.js +40 -11
- package/package.json +4 -3
- package/src/adapters/bash.js +40 -10
- package/src/adapters/edit.js +77 -23
- package/src/adapters/errors.js +4 -0
- package/src/adapters/index.js +1 -0
- package/src/adapters/list.js +6 -3
- package/src/adapters/read-focus.js +6 -0
- package/src/adapters/read-image.js +17 -9
- package/src/adapters/read-indexed.js +72 -0
- package/src/adapters/read-text.js +19 -2
- package/src/adapters/read.js +57 -13
- package/src/adapters/refs.js +17 -7
- package/src/adapters/write.js +14 -21
- package/src/bridge/host-bridge.js +152 -39
- package/src/bridge/invoke.js +3 -1
- package/src/bridge/tool-registry.js +132 -16
- package/src/context/evidence-graph.js +1 -0
- package/src/context/evidence-rank.js +1 -0
- package/src/context/evidence.js +4 -2
- package/src/context/fuzzy.js +31 -8
- package/src/context/outline.js +33 -9
- package/src/context/query.js +7 -2
- package/src/context/repo-index.js +8 -3
- package/src/context/search-files.js +20 -0
- package/src/context/search.js +25 -10
- package/src/context/snap-search.js +28 -6
- package/src/context/snap.js +8 -3
- package/src/context/source-entry.js +64 -28
- package/src/context/spans.js +1 -0
- package/src/contract/bash.js +16 -7
- package/src/contract/edit.js +26 -7
- package/src/contract/program.js +2 -1
- package/src/contract/read.js +46 -46
- package/src/fs/background.js +10 -2
- package/src/fs/check.js +844 -46
- package/src/fs/commit.js +89 -15
- package/src/fs/diff.js +12 -9
- package/src/fs/directory.js +14 -4
- package/src/fs/file-io.js +28 -7
- package/src/fs/glob.js +93 -11
- package/src/fs/json-read.js +3 -2
- package/src/fs/json-size.js +2 -1
- package/src/fs/lines.js +49 -2
- package/src/fs/patch.js +26 -10
- package/src/fs/process-tree.js +19 -1
- package/src/fs/read-window.js +36 -20
- package/src/fs/source-window.js +5 -10
- package/src/fs/text-ops.js +38 -18
- package/src/fs/vfs.js +296 -41
- package/src/fs/workspace.js +24 -6
- package/src/output/bottleneck.js +50 -25
- package/src/output/final.js +20 -0
- package/src/output/format.js +97 -36
- package/src/output/outcome.js +16 -4
- package/src/runtime/batch-input.js +17 -3
- package/src/runtime/guest-api.js +32 -7
- package/src/runtime/guest-worker.js +22 -4
- package/src/runtime/parallel.js +9 -49
- package/src/runtime/program-batch.js +53 -28
- package/src/runtime/program-file.js +2 -1
- package/src/runtime/program.js +94 -41
- package/src/runtime/reference.js +10 -9
- package/src/runtime/runtime.js +69 -18
- package/src/runtime/worker-pool.js +10 -1
- package/src/shared/decode.js +9 -8
- package/src/shared/image-worker.js +7 -0
- package/src/shared/image.js +58 -15
- package/src/shared/png.js +18 -0
- package/src/shared/result.js +30 -9
- package/src/shared/syntax-context.js +7 -1
- package/src/shared/utf8.js +11 -13
- package/src/ui/host-render.js +4 -0
- package/src/ui/omp-frame.js +2 -0
- package/src/ui/progress.js +2 -1
- package/src/ui/render-measure.js +11 -0
- package/src/ui/render.js +92 -31
- package/src/ui/trace.js +8 -5
package/src/fs/vfs.js
CHANGED
|
@@ -1,25 +1,66 @@
|
|
|
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';
|
|
1
|
+
import {textSignature,sameSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
|
|
2
|
+
import {resolveFileIdentity,resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
|
|
3
3
|
|
|
4
4
|
export {resolveCommitTarget} from './commit.js';
|
|
5
5
|
|
|
6
6
|
import * as fs from "node:fs/promises";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
9
|
import { isString } from "../shared/decode.js";
|
|
9
10
|
import { decodeUtf8Strict } from "../shared/utf8.js";
|
|
10
11
|
|
|
11
12
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
12
13
|
let commitTail = Promise.resolve();
|
|
13
14
|
|
|
15
|
+
const readScopes = new Set();
|
|
16
|
+
|
|
17
|
+
let installingPaths = null;
|
|
18
|
+
|
|
19
|
+
function scopeContains(scope, target) {
|
|
20
|
+
const relative = path.relative(scope, target);
|
|
21
|
+
|
|
22
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function noteCommitPath(target) {
|
|
26
|
+
installingPaths.add(target);
|
|
27
|
+
|
|
28
|
+
for (const read of readScopes) {
|
|
29
|
+
if (scopeContains(read.scope, target)) read.changed = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Readers validate after every in-process commit they could have observed.
|
|
34
|
+
// An aborted reader must not retain its buffers until another writer finishes.
|
|
35
|
+
async function waitForCommits(signal) {
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
|
|
38
|
+
if (!signal) return commitTail;
|
|
39
|
+
let abort;
|
|
40
|
+
|
|
41
|
+
const cancelled = new Promise((_, reject) => {
|
|
42
|
+
abort = () => reject(signal.reason);
|
|
43
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try { await Promise.race([commitTail, cancelled]); }
|
|
47
|
+
finally { signal.removeEventListener("abort", abort); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
14
51
|
export class CausalVfs {
|
|
15
52
|
constructor(onNewFile, validateWrite, assertCurrent) {
|
|
16
53
|
this.validateWrite = validateWrite;
|
|
17
54
|
this.assertCurrent = assertCurrent;
|
|
18
55
|
// No body cache: every read hits disk (or its overlay) so observed bytes
|
|
19
|
-
// are never stale. CAS
|
|
20
|
-
//
|
|
56
|
+
// are never stale. CAS signatures and observed logical-to-physical bindings
|
|
57
|
+
// survive between operations, but clear at external-mutation boundaries.
|
|
21
58
|
this.overlays = [];
|
|
22
59
|
this.expected = new Map();
|
|
60
|
+
this.observedPaths = new Map();
|
|
61
|
+
this.pathScope = new AsyncLocalStorage();
|
|
62
|
+
this.bestEffort = false;
|
|
63
|
+
this.pendingSignatures = new Map();
|
|
23
64
|
this.onNewFile = onNewFile;
|
|
24
65
|
this.closed = false;
|
|
25
66
|
this.signal = undefined;
|
|
@@ -33,25 +74,118 @@ export class CausalVfs {
|
|
|
33
74
|
this.signal?.throwIfAborted();
|
|
34
75
|
}
|
|
35
76
|
|
|
36
|
-
|
|
77
|
+
// Reuse identity only within one native operation. Commit resolution is
|
|
78
|
+
// always fresh, and no path/body cache survives into the next operation.
|
|
79
|
+
withPathScope(fn) { return this.pathScope.run({paths:new Map()}, fn); }
|
|
80
|
+
|
|
81
|
+
bindMutationRecord(record) {
|
|
82
|
+
const scope = this.pathScope.getStore();
|
|
83
|
+
|
|
84
|
+
if (scope) scope.record = record;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async resolvePath(target, allowMissing = true) {
|
|
88
|
+
const scope = this.pathScope.getStore()?.paths;
|
|
89
|
+
let pending = scope?.get(target);
|
|
90
|
+
|
|
91
|
+
if (!pending) {
|
|
92
|
+
pending = resolveFileIdentity(target);
|
|
93
|
+
scope?.set(target, pending);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const identity = await pending;
|
|
97
|
+
|
|
98
|
+
if (!allowMissing && identity.missing) throw identity.missing;
|
|
99
|
+
|
|
100
|
+
return identity.path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#assertWriteAlias(logicalPath, entry) {
|
|
104
|
+
if (entry && entry.logicalPath !== logicalPath) throw new Error("conflicting write aliases: " + logicalPath);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#overlay(target) {
|
|
37
108
|
for (let i = this.overlays.length - 1; i >= 0; i--) {
|
|
38
|
-
|
|
109
|
+
const entry = this.overlays[i].get(target);
|
|
110
|
+
|
|
111
|
+
if (entry) return entry;
|
|
39
112
|
}
|
|
40
113
|
}
|
|
41
114
|
|
|
42
|
-
|
|
43
|
-
|
|
115
|
+
async getOverlay(target) {
|
|
116
|
+
this.signal?.throwIfAborted();
|
|
117
|
+
|
|
118
|
+
if (!this.overlays.some(layer => layer.size)) return undefined;
|
|
119
|
+
|
|
120
|
+
return this.#overlay(await this.resolvePath(target))?.content;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async getOverlayPaths(scope) {
|
|
124
|
+
const entries = new Map(this.overlays.flatMap(overlay => [...overlay]));
|
|
125
|
+
|
|
126
|
+
if (!scope || !entries.size) return [...entries.values()].map(entry => entry.logicalPath);
|
|
127
|
+
const canonical = await this.resolvePath(scope);
|
|
128
|
+
|
|
129
|
+
// Expose the caller's scope spelling, including directories not yet on disk.
|
|
130
|
+
const paths = [];
|
|
131
|
+
|
|
132
|
+
for (const target of entries.keys()) {
|
|
133
|
+
if (scopeContains(canonical, target)) paths.push(path.resolve(scope, path.relative(canonical, target)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return paths;
|
|
44
137
|
}
|
|
45
138
|
|
|
46
|
-
|
|
47
|
-
const
|
|
139
|
+
#assertIdentity(logicalPath, target) {
|
|
140
|
+
const observed = this.observedPaths.get(logicalPath);
|
|
141
|
+
|
|
142
|
+
if (observed !== undefined && observed !== target) throw new Error("write conflict: path target changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#observe(logicalPath, target, signature, preserveRead = false) {
|
|
146
|
+
if (preserveRead) this.#assertIdentity(logicalPath, target);
|
|
147
|
+
|
|
148
|
+
if (!preserveRead || !this.expected.has(target)) this.expected.set(target, signature);
|
|
149
|
+
this.observedPaths.set(logicalPath, target);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async readRevision(scope) {
|
|
153
|
+
let canonical;
|
|
154
|
+
|
|
155
|
+
try { canonical = await this.resolvePath(scope); }
|
|
156
|
+
catch (error) { await remapReadError(error, scope); }
|
|
157
|
+
|
|
158
|
+
const read = { scope: canonical, changed: [...(installingPaths ?? [])].some(target => scopeContains(canonical, target)) };
|
|
159
|
+
readScopes.add(read);
|
|
160
|
+
|
|
161
|
+
return read;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
releaseRead(read) { readScopes.delete(read); }
|
|
165
|
+
|
|
166
|
+
async assertReadCommitted(read) {
|
|
167
|
+
try {
|
|
168
|
+
if (read.changed) await waitForCommits(this.signal);
|
|
169
|
+
this.signal?.throwIfAborted();
|
|
170
|
+
|
|
171
|
+
if (read.changed) throw new Error("workspace may have changed while reading; retry the read");
|
|
172
|
+
} finally { this.releaseRead(read); }
|
|
173
|
+
}
|
|
48
174
|
|
|
49
|
-
|
|
175
|
+
async read(target, { preserveRead = false, forWrite = false, maxBytes, label = "read input", strict = true } = {}) {
|
|
176
|
+
this.signal?.throwIfAborted();
|
|
177
|
+
let revision;
|
|
50
178
|
|
|
51
179
|
// External editors and captured tools can change a file between any two reads.
|
|
52
180
|
// Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
|
|
53
181
|
try {
|
|
54
|
-
|
|
182
|
+
revision = await this.readRevision(target);
|
|
183
|
+
const overlay = this.#overlay(revision.scope);
|
|
184
|
+
|
|
185
|
+
if (forWrite) this.#assertWriteAlias(target, overlay);
|
|
186
|
+
|
|
187
|
+
if (overlay) return overlayOrThrow(overlay.content, maxBytes, label, target);
|
|
188
|
+
const file = await fs.open(revision.scope, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
55
189
|
let bytes;
|
|
56
190
|
|
|
57
191
|
try {
|
|
@@ -64,13 +198,15 @@ export class CausalVfs {
|
|
|
64
198
|
if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
|
|
65
199
|
} finally { await file.close(); }
|
|
66
200
|
|
|
201
|
+
await this.assertReadCommitted(revision);
|
|
202
|
+
|
|
67
203
|
// Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
|
|
68
|
-
|
|
204
|
+
this.#observe(target, revision.scope, textSignature(bytes), preserveRead || forWrite);
|
|
69
205
|
|
|
70
206
|
return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
|
|
71
207
|
} catch (err) {
|
|
72
208
|
await remapReadError(err, target);
|
|
73
|
-
}
|
|
209
|
+
} finally { this.releaseRead(revision); }
|
|
74
210
|
}
|
|
75
211
|
|
|
76
212
|
async #diskSignature(target) {
|
|
@@ -83,15 +219,56 @@ export class CausalVfs {
|
|
|
83
219
|
}
|
|
84
220
|
|
|
85
221
|
async captureExpected(target) {
|
|
86
|
-
|
|
222
|
+
const canonical = await this.resolvePath(target);
|
|
223
|
+
await this.#captureExpected(target, canonical);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async #captureExpected(logicalPath, target) {
|
|
227
|
+
this.#assertIdentity(logicalPath, target);
|
|
87
228
|
|
|
88
|
-
this.expected.set(target, await this.#diskSignature(target));
|
|
229
|
+
if (!this.#overlay(target) && !this.expected.has(target)) this.expected.set(target, await this.#diskSignature(target));
|
|
230
|
+
this.observedPaths.set(logicalPath, target);
|
|
89
231
|
}
|
|
90
232
|
|
|
91
|
-
async recordExpected(target, observed) {
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
233
|
+
async recordExpected(target, observed, bytes) {
|
|
234
|
+
this.signal?.throwIfAborted();
|
|
235
|
+
const revision = await this.readRevision(target), canonical = revision.scope;
|
|
236
|
+
let job;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (this.#overlay(canonical)) return;
|
|
240
|
+
|
|
241
|
+
if (!observed) {
|
|
242
|
+
this.#observe(target, canonical, await this.#diskSignature(canonical));
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Windows timestamps can alias same-size rewrites; loaded bytes must
|
|
248
|
+
// still agree with disk. Other hosts can reuse a complete byte snapshot.
|
|
249
|
+
if (bytes !== undefined && (!Buffer.isBuffer(bytes) || bytes.length !== observed.size)) throw new Error("file changed while reading: " + target + "; expected complete bytes");
|
|
250
|
+
job = this.pendingSignatures.get(canonical);
|
|
251
|
+
|
|
252
|
+
if (!job || !sameFileVersion(job.observed, observed)) {
|
|
253
|
+
job = { observed, promise: bytes === undefined || process.platform === "win32"
|
|
254
|
+
? fileSignature(canonical, this.signal, observed)
|
|
255
|
+
: Promise.resolve(textSignature(bytes)) };
|
|
256
|
+
this.pendingSignatures.set(canonical, job);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const signature = await job.promise;
|
|
260
|
+
|
|
261
|
+
if (bytes !== undefined && process.platform === "win32" && !sameSignature(signature, textSignature(bytes))) throw new Error("file changed while reading: " + target);
|
|
262
|
+
await this.assertReadCommitted(revision);
|
|
263
|
+
|
|
264
|
+
// Each window retains its own post-read check even when hashing overlaps.
|
|
265
|
+
if (!sameFileVersion(observed, await fs.stat(canonical))) throw new Error("file changed while reading: " + target);
|
|
266
|
+
this.#observe(target, canonical, signature);
|
|
267
|
+
} finally {
|
|
268
|
+
this.releaseRead(revision);
|
|
269
|
+
|
|
270
|
+
if (job && this.pendingSignatures.get(canonical) === job) this.pendingSignatures.delete(canonical);
|
|
271
|
+
}
|
|
95
272
|
}
|
|
96
273
|
|
|
97
274
|
async write(target, content) {
|
|
@@ -99,25 +276,51 @@ export class CausalVfs {
|
|
|
99
276
|
|
|
100
277
|
if (!isString(content)) throw new Error("write requires string content");
|
|
101
278
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
279
|
+
if (!content.isWellFormed()) throw new Error("write requires well-formed Unicode text: unpaired UTF-16 surrogate in " + target + "; avoid splitting a Unicode character");
|
|
280
|
+
|
|
281
|
+
const canonical = await this.resolvePath(target);
|
|
282
|
+
const previous = this.#overlay(canonical);
|
|
283
|
+
|
|
284
|
+
this.#assertWriteAlias(target, previous);
|
|
285
|
+
|
|
286
|
+
if (!previous) {
|
|
287
|
+
try {
|
|
288
|
+
if ((await fs.stat(canonical)).isDirectory()) throw new Error("cannot write to a directory: " + target);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err.code !== "ENOENT") throw err;
|
|
291
|
+
}
|
|
106
292
|
}
|
|
107
293
|
|
|
108
294
|
this.assertWritable();
|
|
109
295
|
|
|
110
|
-
await this
|
|
296
|
+
await this.#captureExpected(target, canonical);
|
|
111
297
|
|
|
112
298
|
this.assertWritable();
|
|
299
|
+
const latest = this.#overlay(canonical);
|
|
300
|
+
|
|
301
|
+
// Concurrent aliases may have staged while signature capture awaited I/O.
|
|
302
|
+
this.#assertWriteAlias(target, latest);
|
|
113
303
|
|
|
114
|
-
|
|
115
|
-
|
|
304
|
+
const record = this.pathScope.getStore()?.record;
|
|
305
|
+
|
|
306
|
+
// The host retains an empty outer layer for program lifecycle bookkeeping.
|
|
307
|
+
// Best-effort mode stages only inside an explicit inner checkpoint.
|
|
308
|
+
if (this.overlays.length && (!this.bestEffort || this.overlays.length > 1)) {
|
|
309
|
+
this.overlays.at(-1).set(canonical, { logicalPath: target, content });
|
|
310
|
+
|
|
311
|
+
if (record) { record.mutationState = "pending"; record.mutationDepth = this.overlays.length; }
|
|
116
312
|
|
|
117
313
|
return { speculative: true };
|
|
118
314
|
}
|
|
119
315
|
|
|
120
|
-
|
|
316
|
+
try {
|
|
317
|
+
await this.flush(new Map([[target, content]]), new Map([[target, canonical]]));
|
|
318
|
+
|
|
319
|
+
if (record) record.mutationState = "saved";
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (record && this.mutations.recoveryFailed) record.mutationState = "uncertain";
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
121
324
|
|
|
122
325
|
return { speculative: false };
|
|
123
326
|
}
|
|
@@ -130,15 +333,61 @@ export class CausalVfs {
|
|
|
130
333
|
}
|
|
131
334
|
|
|
132
335
|
/** Stage every file and its backup before replacing any destination. */
|
|
133
|
-
async
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
336
|
+
async #flushOverlay(overlay) {
|
|
337
|
+
const writes = new Map(), targets = new Map();
|
|
338
|
+
|
|
339
|
+
for (const [target, entry] of overlay) {
|
|
340
|
+
writes.set(entry.logicalPath, entry.content);
|
|
341
|
+
targets.set(entry.logicalPath, target);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return this.flush(writes, targets);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async flush(writes, pinnedTargets = new Map()) {
|
|
348
|
+
this.assertCurrent?.();
|
|
349
|
+
this.signal?.throwIfAborted();
|
|
137
350
|
|
|
138
|
-
return
|
|
351
|
+
if (!writes.size) return;
|
|
352
|
+
this.mutations.pendingCommits++;
|
|
353
|
+
const signal = this.signal;
|
|
354
|
+
let started = false, cancelled = false, pendingWrites = writes, pendingTargets = pinnedTargets;
|
|
355
|
+
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const abort = () => {
|
|
358
|
+
if (started || cancelled) return;
|
|
359
|
+
cancelled = true;
|
|
360
|
+
pendingWrites = null;
|
|
361
|
+
pendingTargets = null;
|
|
362
|
+
this.mutations.pendingCommits--;
|
|
363
|
+
signal.removeEventListener("abort", abort);
|
|
364
|
+
reject(signal.reason);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Withdrawal does not release the predecessor's lock. A later live
|
|
368
|
+
// transaction still waits for it; only this caller can settle early.
|
|
369
|
+
const work = commitTail.then(async () => {
|
|
370
|
+
if (cancelled) return;
|
|
371
|
+
started = true;
|
|
372
|
+
signal?.removeEventListener("abort", abort);
|
|
373
|
+
|
|
374
|
+
installingPaths = new Set();
|
|
375
|
+
|
|
376
|
+
for (const target of pendingWrites.keys()) noteCommitPath(target);
|
|
377
|
+
|
|
378
|
+
try { resolve(await this.flushWrites(pendingWrites, pendingTargets)); }
|
|
379
|
+
catch (error) { reject(error); }
|
|
380
|
+
finally { installingPaths = null; pendingWrites = null; pendingTargets = null; this.mutations.pendingCommits--; }
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
commitTail = work.catch(() => {});
|
|
384
|
+
signal?.addEventListener("abort", abort, {once:true});
|
|
385
|
+
|
|
386
|
+
if (signal?.aborted) abort();
|
|
387
|
+
});
|
|
139
388
|
}
|
|
140
389
|
|
|
141
|
-
async flushWrites(writes) {
|
|
390
|
+
async flushWrites(writes, pinnedTargets = new Map()) {
|
|
142
391
|
const staged = [];
|
|
143
392
|
const targets = new Set();
|
|
144
393
|
const createdDirs = [];
|
|
@@ -151,7 +400,12 @@ export class CausalVfs {
|
|
|
151
400
|
for (const [logicalPath, content] of writes) {
|
|
152
401
|
this.signal?.throwIfAborted();
|
|
153
402
|
const { target, stat } = await resolveCommitTarget(logicalPath);
|
|
403
|
+
// Validation may wait or cancel; publish the physical read hazard first.
|
|
404
|
+
noteCommitPath(target);
|
|
154
405
|
await this.validateWrite?.(logicalPath);
|
|
406
|
+
this.#assertIdentity(logicalPath, target);
|
|
407
|
+
|
|
408
|
+
if (pinnedTargets.has(logicalPath) && pinnedTargets.get(logicalPath) !== target) throw new Error("write conflict: path target changed while staged: " + logicalPath);
|
|
155
409
|
|
|
156
410
|
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
157
411
|
targets.add(target);
|
|
@@ -179,12 +433,12 @@ export class CausalVfs {
|
|
|
179
433
|
if (!this.overlays.length) return { committed: 0, depth: 0 };
|
|
180
434
|
const top = this.overlays.at(-1);
|
|
181
435
|
|
|
182
|
-
if (this.overlays.length > 1) {
|
|
436
|
+
if (this.overlays.length > 1 && !(this.bestEffort && this.overlays.length === 2)) {
|
|
183
437
|
const parent = this.overlays[this.overlays.length - 2];
|
|
184
438
|
|
|
185
439
|
for (const [key, value] of top) parent.set(key, value);
|
|
186
440
|
} else {
|
|
187
|
-
await this
|
|
441
|
+
await this.#flushOverlay(top);
|
|
188
442
|
}
|
|
189
443
|
|
|
190
444
|
this.overlays.pop();
|
|
@@ -216,24 +470,25 @@ export class CausalVfs {
|
|
|
216
470
|
return false; }
|
|
217
471
|
|
|
218
472
|
const pending = this.overlays[0];
|
|
219
|
-
await this
|
|
220
|
-
|
|
473
|
+
await this.#flushOverlay(pending);
|
|
474
|
+
// Committed writes no longer belong to the speculative rollback set.
|
|
221
475
|
this.overlays[0] = new Map();
|
|
476
|
+
this.assertWritable();
|
|
222
477
|
this.mutations.external++;
|
|
223
478
|
|
|
224
479
|
return pending.size > 0;
|
|
225
480
|
}
|
|
226
481
|
|
|
227
482
|
/** External-mutation boundary: drop CAS baselines so the next access re-observes disk. */
|
|
228
|
-
invalidateObserved() { this.expected.clear(); }
|
|
483
|
+
invalidateObserved() { this.expected.clear(); this.observedPaths.clear(); this.pathScope.getStore()?.paths.clear(); }
|
|
229
484
|
getOverlayDepth() { return this.overlays.length; }
|
|
230
485
|
describeOverlays() {
|
|
231
486
|
let files = 0, bytes = 0;
|
|
232
487
|
|
|
233
488
|
for (const layer of this.overlays) {
|
|
234
|
-
for (const
|
|
489
|
+
for (const entry of layer.values()) {
|
|
235
490
|
files++;
|
|
236
|
-
bytes += Buffer.byteLength(content, "utf8");
|
|
491
|
+
bytes += Buffer.byteLength(entry.content, "utf8");
|
|
237
492
|
}
|
|
238
493
|
}
|
|
239
494
|
|
package/src/fs/workspace.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { constants } from "node:os";
|
|
7
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
8
|
import { isString } from "../shared/decode.js";
|
|
8
9
|
import { truncateChars } from "../output/format.js";
|
|
9
10
|
|
|
@@ -92,11 +93,13 @@ function rejectUriPath(trimmed, opName, allowSessionRead) {
|
|
|
92
93
|
|
|
93
94
|
/** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
|
|
94
95
|
export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
|
|
95
|
-
if (inputPath == null || !isString(inputPath) ||
|
|
96
|
+
if (inputPath == null || !isString(inputPath) || inputPath.length === 0) {
|
|
96
97
|
throw new Error(`${opName} requires path`);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
if (!inputPath.isWellFormed()) throw new Error(`${opName} requires a well-formed Unicode path: unpaired UTF-16 surrogate`);
|
|
101
|
+
|
|
102
|
+
return rejectUriPath(String(inputPath), opName, allowSessionRead);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
|
|
@@ -206,6 +209,23 @@ function appendCommandOutput(state, current, chunk) {
|
|
|
206
209
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
function captureCommandStream(state, name) {
|
|
213
|
+
const stream = state.child[name], decoder = new StringDecoder("utf8");
|
|
214
|
+
|
|
215
|
+
stream.on("data", chunk => {
|
|
216
|
+
// Keep draining after the shared cap fills, without decoding discarded text
|
|
217
|
+
// or backpressuring a child that still needs to finish its work.
|
|
218
|
+
if (state.stdout.length + state.stderr.length >= state.maxOutputChars) {
|
|
219
|
+
if (chunk.length) state.outputTruncated = true;
|
|
220
|
+
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
state[name] = appendCommandOutput(state, state[name], decoder.write(chunk));
|
|
225
|
+
});
|
|
226
|
+
stream.once("end", () => { state[name] = appendCommandOutput(state, state[name], decoder.end()); });
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
function attachCommandIO(state, options, argv, timeoutMs) {
|
|
210
230
|
const { child } = state;
|
|
211
231
|
const onAbort = () => terminateCommand(state, new Error("aborted"));
|
|
@@ -218,10 +238,8 @@ function attachCommandIO(state, options, argv, timeoutMs) {
|
|
|
218
238
|
"command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
|
|
219
239
|
+ "\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."
|
|
220
240
|
)), timeoutMs);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
|
|
224
|
-
child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
|
|
241
|
+
captureCommandStream(state, "stdout");
|
|
242
|
+
captureCommandStream(state, "stderr");
|
|
225
243
|
child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
|
|
226
244
|
child.once("exit", (code, signal) => {
|
|
227
245
|
state.processExited = true;
|
package/src/output/bottleneck.js
CHANGED
|
@@ -65,7 +65,9 @@ function fitContainer(input, limit) {
|
|
|
65
65
|
const out = Array.isArray(input) ? [] : { truncated: true };
|
|
66
66
|
const entries = Object.entries(input);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
const lengths = Array.isArray(input) ? null : new Map(entries.map(([key, child]) => [key, json(child).length]));
|
|
69
|
+
|
|
70
|
+
if (lengths) entries.sort((a, b) => lengths.get(a[0]) - lengths.get(b[0]));
|
|
69
71
|
|
|
70
72
|
for (const [key, child] of entries) {
|
|
71
73
|
const used = json(out).length;
|
|
@@ -73,7 +75,7 @@ function fitContainer(input, limit) {
|
|
|
73
75
|
const available = limit - used - overhead;
|
|
74
76
|
|
|
75
77
|
if (available < 4) break;
|
|
76
|
-
assignBounded(out, key, fit(child, available));
|
|
78
|
+
assignBounded(out, key, fit(child, available, lengths?.get(key)));
|
|
77
79
|
|
|
78
80
|
if (json(out).length > limit) rollbackBounded(out, key);
|
|
79
81
|
}
|
|
@@ -81,10 +83,8 @@ function fitContainer(input, limit) {
|
|
|
81
83
|
return out;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
function fit(input, limit) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (serialized.length <= limit) return input;
|
|
86
|
+
function fit(input, limit, encodedLength = json(input).length) {
|
|
87
|
+
if (encodedLength <= limit) return input;
|
|
88
88
|
|
|
89
89
|
if (isString(input)) return fitString(input, limit);
|
|
90
90
|
|
|
@@ -99,7 +99,7 @@ function summarizeDetails(value, budget = 2000) {
|
|
|
99
99
|
|
|
100
100
|
if (encoded.length <= budget) return encoded;
|
|
101
101
|
|
|
102
|
-
return json(fit(JSON.parse(encoded), budget));
|
|
102
|
+
return json(fit(JSON.parse(encoded), budget, encoded.length));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function spill(fullText, config) {
|
|
@@ -222,36 +222,53 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
|
|
|
222
222
|
attachSpill(result, batch, text, config, maxChars, capped);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
/** Already materialized batches retain items; never budget them a second time. */
|
|
226
|
+
function attachTypedBatch(result, batch, details) {
|
|
227
|
+
result.items = batch;
|
|
228
|
+
result.itemErrors = details.itemErrors ?? [];
|
|
227
229
|
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
|
|
231
|
-
attachDetails(result, details, batch);
|
|
230
|
+
if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
|
|
231
|
+
}
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
function typedHostResult(raw, details) {
|
|
234
|
+
const batch = batchFromDetails(details);
|
|
235
|
+
const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
|
|
236
|
+
attachDetails(result, details, batch);
|
|
234
237
|
|
|
235
|
-
|
|
236
|
-
result.items = batch;
|
|
237
|
-
result.itemErrors = details.itemErrors ?? [];
|
|
238
|
+
if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
|
|
238
239
|
|
|
239
|
-
|
|
240
|
-
}
|
|
240
|
+
if (batch) attachTypedBatch(result, batch, details);
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
if (details?.streamed) result.streamed = true;
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hasStructuredResult(raw, acceptsStructured) {
|
|
248
|
+
return acceptsStructured && !hostResultFailed(raw) && raw?.structuredContent !== undefined;
|
|
249
|
+
}
|
|
246
250
|
|
|
251
|
+
function displayedHostText(raw, batch, structured) {
|
|
252
|
+
return batch ? "" : structured ? json(raw.structuredContent) : extractRawString(raw);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function boundedStructuredValue(raw, capped) {
|
|
256
|
+
return capped.truncated ? capped.text : raw.structuredContent;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function boundedHostResult(raw, details, config, acceptsStructured) {
|
|
247
260
|
const maxChars = config.maxCallResultChars ?? 65536;
|
|
248
|
-
const
|
|
249
|
-
const
|
|
261
|
+
const structured = hasStructuredResult(raw, acceptsStructured);
|
|
262
|
+
const batch = structured ? undefined : batchFromDetails(details);
|
|
263
|
+
const text = displayedHostText(raw, batch, structured);
|
|
250
264
|
const capped = truncateChars(text, maxChars, "host-result");
|
|
251
265
|
let truncated = hostTruncated(capped, details);
|
|
252
266
|
const image = hostImage(raw);
|
|
253
267
|
const directoryEntries = directoryEntriesIfFit(image, details, maxChars);
|
|
254
|
-
const result = { ok: !hostResultFailed(raw), value: hostResultValue(image, directoryEntries, capped), truncated };
|
|
268
|
+
const result = { ok: !hostResultFailed(raw), value: structured ? boundedStructuredValue(raw, capped) : hostResultValue(image, directoryEntries, capped), truncated };
|
|
269
|
+
|
|
270
|
+
// Even a clipped JSON display is already materialized; read must not parse it.
|
|
271
|
+
if (structured) result.typed = true;
|
|
255
272
|
attachDetails(result, details, batch);
|
|
256
273
|
truncated ||= attachBatch(result, batch, details, maxChars);
|
|
257
274
|
attachTruncation(result, truncated, batch, text, config, maxChars, capped);
|
|
@@ -259,4 +276,12 @@ export function packageHostResult(raw, config) {
|
|
|
259
276
|
return result;
|
|
260
277
|
}
|
|
261
278
|
|
|
279
|
+
export function packageHostResult(raw, config, acceptsStructured = false) {
|
|
280
|
+
const details = detailsOf(raw);
|
|
281
|
+
|
|
282
|
+
return raw && Object.hasOwn(raw, READ_VALUE)
|
|
283
|
+
? typedHostResult(raw, details)
|
|
284
|
+
: boundedHostResult(raw, details, config, acceptsStructured);
|
|
285
|
+
}
|
|
286
|
+
|
|
262
287
|
export {packageFinalReturn} from "./final.js";
|