pi-supernova 0.6.0 → 0.7.1
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 +27 -3
- package/docs/CHANGELOG.md +114 -0
- package/docs/TOKEN_COSTS.md +13 -5
- package/index.js +120 -79
- package/package.json +1 -1
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +28 -222
- package/src/bridge/host-bridge.js +113 -1668
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -198
- package/src/context/evidence.js +140 -76
- package/src/context/fuzzy.js +42 -24
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +23 -18
- package/src/context/repo-index.js +206 -170
- package/src/context/search.js +157 -77
- package/src/context/snap.js +240 -136
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +14 -5
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +12 -8
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +94 -50
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +205 -175
- package/src/fs/workspace.js +119 -108
- package/src/output/bottleneck.js +195 -116
- package/src/output/format.js +101 -67
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +296 -292
- package/src/runtime/parallel.js +97 -64
- package/src/runtime/program-batch.js +178 -69
- package/src/runtime/reference.js +15 -14
- package/src/runtime/runtime.js +327 -187
- package/src/shared/decode.js +58 -36
- package/src/ui/omp-frame.js +59 -42
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +241 -145
package/src/fs/vfs.js
CHANGED
|
@@ -3,10 +3,6 @@ import * as path from "node:path";
|
|
|
3
3
|
import { isString } from "../shared/decode.js";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
|
-
const VFS_CACHE_MAX = 1024;
|
|
7
|
-
|
|
8
|
-
const VFS_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
9
|
-
|
|
10
6
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
11
7
|
let commitTail = Promise.resolve();
|
|
12
8
|
|
|
@@ -60,52 +56,196 @@ function sameSignature(a, b) {
|
|
|
60
56
|
return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
|
|
61
57
|
}
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
function tooLargeRead(label, maxBytes) {
|
|
60
|
+
return new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function overlayOrThrow(overlay, maxBytes, label) {
|
|
64
|
+
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
65
|
+
|
|
66
|
+
return overlay;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertReadableFile(stat, target) {
|
|
70
|
+
if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
|
|
71
|
+
|
|
72
|
+
if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readLimitedBytes(file, stat, maxBytes, label, signal) {
|
|
76
|
+
if (stat.size > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let size = 0;
|
|
79
|
+
|
|
80
|
+
for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal })) {
|
|
81
|
+
size += chunk.length;
|
|
82
|
+
|
|
83
|
+
if (size > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
84
|
+
chunks.push(chunk);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
return Buffer.concat(chunks);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function remapReadError(err, target) {
|
|
91
|
+
if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
|
|
92
|
+
|
|
93
|
+
if (err.code === "ENOENT") {
|
|
94
|
+
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
95
|
+
missing.code = "ENOENT";
|
|
96
|
+
throw missing;
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolveExistingFile(logicalPath) {
|
|
103
|
+
const target = await fs.realpath(logicalPath);
|
|
104
|
+
const stat = await fs.stat(target);
|
|
105
|
+
|
|
106
|
+
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
107
|
+
|
|
108
|
+
return { target, stat };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveCommitTarget(logicalPath) {
|
|
112
|
+
try {
|
|
113
|
+
return await resolveExistingFile(logicalPath);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code !== "ENOENT") throw err;
|
|
86
116
|
|
|
87
|
-
|
|
117
|
+
return { target: await canonicalNewPath(logicalPath), stat: undefined };
|
|
88
118
|
}
|
|
119
|
+
}
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
121
|
+
async function collectMissingAncestors(parent) {
|
|
122
|
+
const missing = [];
|
|
123
|
+
let probe = parent;
|
|
92
124
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
125
|
+
for (;;) {
|
|
126
|
+
try { await fs.stat(probe); break; } catch (err) {
|
|
127
|
+
if (err.code !== "ENOENT") throw err;
|
|
128
|
+
missing.push(probe);
|
|
129
|
+
probe = path.dirname(probe);
|
|
96
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return missing;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function writeTemporary(entry, content, stat) {
|
|
137
|
+
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
138
|
+
|
|
139
|
+
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function stageReplacement(entry, content, stat, target) {
|
|
143
|
+
const replacement = writeTemporary(entry, content, stat);
|
|
144
|
+
// These touch separate staging files. Settle both before cleanup, even
|
|
145
|
+
// on failure: Promise.all could leave a late backup after rollback.
|
|
146
|
+
const staging = [replacement];
|
|
147
|
+
|
|
148
|
+
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
149
|
+
const outcomes = await Promise.allSettled(staging);
|
|
150
|
+
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
151
|
+
|
|
152
|
+
if (failure) throw failure.reason;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function makeStageEntry(logicalPath, target, content, parent, stat) {
|
|
156
|
+
const token = ".supernova-" + randomUUID();
|
|
157
|
+
|
|
158
|
+
return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
159
|
+
}
|
|
97
160
|
|
|
98
|
-
|
|
161
|
+
async function recoverReplaced(staged) {
|
|
162
|
+
const recoveryErrors = [];
|
|
99
163
|
|
|
100
|
-
|
|
101
|
-
|
|
164
|
+
for (const entry of staged.toReversed()) {
|
|
165
|
+
if (!entry.replaced) continue;
|
|
102
166
|
|
|
103
|
-
|
|
104
|
-
|
|
167
|
+
try {
|
|
168
|
+
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
169
|
+
else await fs.unlink(entry.target);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// Keep the backup if recovery fails; never delete the remaining original.
|
|
172
|
+
entry.keepBackup = true;
|
|
173
|
+
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
105
174
|
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return recoveryErrors;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function cleanupStaged(staged, failed, createdDirs) {
|
|
181
|
+
for (const entry of staged) {
|
|
182
|
+
// A successful rename consumed the temporary path. These are known
|
|
183
|
+
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
184
|
+
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
185
|
+
|
|
186
|
+
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function assertExpectedSignature(vfs, logicalPath, target, stat) {
|
|
193
|
+
if (!vfs.expected.has(logicalPath)) return;
|
|
194
|
+
const current = stat ? await fileSignature(target, vfs.signal) : null;
|
|
195
|
+
|
|
196
|
+
if (!sameSignature(current, vfs.expected.get(logicalPath))) {
|
|
197
|
+
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function installStaged(vfs, staged) {
|
|
202
|
+
for (const entry of staged) {
|
|
203
|
+
vfs.signal?.throwIfAborted();
|
|
204
|
+
await fs.rename(entry.temporary, entry.target);
|
|
205
|
+
entry.replaced = true;
|
|
206
|
+
}
|
|
106
207
|
|
|
107
|
-
|
|
108
|
-
|
|
208
|
+
for (const entry of staged) {
|
|
209
|
+
vfs.expected.set(entry.logicalPath, textSignature(entry.content));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Canonical commit destinations must not rewrite established event paths
|
|
213
|
+
// for newly created files, whose callers supplied a logical cwd spelling.
|
|
214
|
+
if (staged.length) vfs.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function failCommit(vfs, staged, error) {
|
|
218
|
+
const recoveryErrors = await recoverReplaced(staged);
|
|
219
|
+
// Keep CAS baselines: a failed commit must not forgive conflicts on files it
|
|
220
|
+
// never touched. If recovery left disk diverging from a baseline, the next
|
|
221
|
+
// write to that path fails loudly and forces a re-read instead of silently
|
|
222
|
+
// re-capturing unknown bytes as the new truth.
|
|
223
|
+
|
|
224
|
+
if (recoveryErrors.length) vfs.mutations.recoveryFailed = true;
|
|
225
|
+
|
|
226
|
+
if (recoveryErrors.length) vfs.onNewFile?.(null);
|
|
227
|
+
|
|
228
|
+
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class CausalVfs {
|
|
233
|
+
constructor(onNewFile, validateWrite) {
|
|
234
|
+
this.validateWrite = validateWrite;
|
|
235
|
+
// No body cache: every read hits disk (or its overlay) so observed bytes
|
|
236
|
+
// are never stale. CAS baselines in `expected` are the only retained
|
|
237
|
+
// per-file state, cleared only at external-mutation boundaries.
|
|
238
|
+
this.overlays = [];
|
|
239
|
+
this.expected = new Map();
|
|
240
|
+
this.onNewFile = onNewFile;
|
|
241
|
+
this.closed = false;
|
|
242
|
+
this.signal = undefined;
|
|
243
|
+
this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
assertWritable() {
|
|
247
|
+
if (this.closed) throw new Error("program is already complete");
|
|
248
|
+
this.signal?.throwIfAborted();
|
|
109
249
|
}
|
|
110
250
|
|
|
111
251
|
getOverlay(target) {
|
|
@@ -121,11 +261,7 @@ export class CausalVfs {
|
|
|
121
261
|
async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
|
|
122
262
|
const overlay = this.getOverlay(target);
|
|
123
263
|
|
|
124
|
-
if (overlay !== undefined)
|
|
125
|
-
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
126
|
-
|
|
127
|
-
return overlay;
|
|
128
|
-
}
|
|
264
|
+
if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
|
|
129
265
|
|
|
130
266
|
// External editors and captured tools can change a file between any two reads.
|
|
131
267
|
// Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
|
|
@@ -135,48 +271,19 @@ export class CausalVfs {
|
|
|
135
271
|
|
|
136
272
|
try {
|
|
137
273
|
const stat = await file.stat();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (maxBytes === undefined) bytes = await file.readFile({ signal: this.signal });
|
|
143
|
-
else {
|
|
144
|
-
const tooLarge = () => new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
145
|
-
|
|
146
|
-
if (stat.size > maxBytes) throw tooLarge();
|
|
147
|
-
const chunks = [];
|
|
148
|
-
let size = 0;
|
|
149
|
-
|
|
150
|
-
for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
|
|
151
|
-
size += chunk.length;
|
|
152
|
-
|
|
153
|
-
if (size > maxBytes) throw tooLarge();
|
|
154
|
-
chunks.push(chunk);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
bytes = Buffer.concat(chunks);
|
|
158
|
-
}
|
|
274
|
+
assertReadableFile(stat, target);
|
|
275
|
+
bytes = maxBytes === undefined
|
|
276
|
+
? await file.readFile({ signal: this.signal })
|
|
277
|
+
: await readLimitedBytes(file, stat, maxBytes, label, this.signal);
|
|
159
278
|
if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
|
|
160
279
|
} finally { await file.close(); }
|
|
161
280
|
|
|
162
281
|
// Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
|
|
163
282
|
if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
|
|
164
|
-
const text = bytes.toString("utf8");
|
|
165
|
-
this.setCache(target, text);
|
|
166
283
|
|
|
167
|
-
return
|
|
284
|
+
return bytes.toString("utf8");
|
|
168
285
|
} catch (err) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
|
|
172
|
-
|
|
173
|
-
if (err.code === "ENOENT") {
|
|
174
|
-
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
175
|
-
missing.code = "ENOENT";
|
|
176
|
-
throw missing;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
throw err;
|
|
286
|
+
remapReadError(err, target);
|
|
180
287
|
}
|
|
181
288
|
}
|
|
182
289
|
|
|
@@ -254,117 +361,28 @@ export class CausalVfs {
|
|
|
254
361
|
try {
|
|
255
362
|
for (const [logicalPath, content] of writes) {
|
|
256
363
|
this.signal?.throwIfAborted();
|
|
257
|
-
|
|
258
|
-
let stat;
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
target = await fs.realpath(logicalPath);
|
|
262
|
-
stat = await fs.stat(target);
|
|
263
|
-
|
|
264
|
-
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
265
|
-
} catch (err) {
|
|
266
|
-
if (err.code !== "ENOENT") throw err;
|
|
267
|
-
target = await canonicalNewPath(logicalPath);
|
|
268
|
-
}
|
|
269
|
-
|
|
364
|
+
const { target, stat } = await resolveCommitTarget(logicalPath);
|
|
270
365
|
await this.validateWrite?.(logicalPath);
|
|
271
366
|
|
|
272
367
|
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
273
368
|
targets.add(target);
|
|
274
|
-
|
|
275
|
-
if (this.expected.has(logicalPath)) {
|
|
276
|
-
const current = stat ? await fileSignature(target, this.signal) : null;
|
|
277
|
-
|
|
278
|
-
if (!sameSignature(current, this.expected.get(logicalPath))) {
|
|
279
|
-
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
369
|
+
await assertExpectedSignature(this, logicalPath, target, stat);
|
|
283
370
|
const parent = path.dirname(target);
|
|
284
|
-
const missing =
|
|
285
|
-
let probe = parent;
|
|
286
|
-
|
|
287
|
-
for (;;) {
|
|
288
|
-
try { await fs.stat(probe); break; } catch (err) {
|
|
289
|
-
if (err.code !== "ENOENT") throw err;
|
|
290
|
-
missing.push(probe);
|
|
291
|
-
probe = path.dirname(probe);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
371
|
+
const missing = await collectMissingAncestors(parent);
|
|
295
372
|
await fs.mkdir(parent, { recursive: true });
|
|
296
373
|
createdDirs.push(...missing.reverse());
|
|
297
|
-
const
|
|
298
|
-
const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
374
|
+
const entry = makeStageEntry(logicalPath, target, content, parent, stat);
|
|
299
375
|
staged.push(entry);
|
|
300
|
-
|
|
301
|
-
const replacement = (async () => {
|
|
302
|
-
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
303
|
-
|
|
304
|
-
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
305
|
-
})();
|
|
306
|
-
|
|
307
|
-
// These touch separate staging files. Settle both before cleanup, even
|
|
308
|
-
// on failure: Promise.all could leave a late backup after rollback.
|
|
309
|
-
const staging = [replacement];
|
|
310
|
-
|
|
311
|
-
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
312
|
-
const outcomes = await Promise.allSettled(staging);
|
|
313
|
-
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
314
|
-
|
|
315
|
-
if (failure) throw failure.reason;
|
|
376
|
+
await stageReplacement(entry, content, stat, target);
|
|
316
377
|
}
|
|
317
378
|
|
|
318
|
-
|
|
319
|
-
this.signal?.throwIfAborted();
|
|
320
|
-
await fs.rename(entry.temporary, entry.target);
|
|
321
|
-
entry.replaced = true;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
for (const entry of staged) {
|
|
325
|
-
this.setCache(entry.logicalPath, entry.content);
|
|
326
|
-
this.expected.set(entry.logicalPath, textSignature(entry.content));
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Canonical commit destinations must not rewrite established event paths
|
|
330
|
-
// for newly created files, whose callers supplied a logical cwd spelling.
|
|
331
|
-
if (staged.length) this.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
|
|
379
|
+
await installStaged(this, staged);
|
|
332
380
|
this.mutations.committed += staged.length;
|
|
333
381
|
} catch (error) {
|
|
334
382
|
failed = true;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
for (const entry of staged.toReversed()) {
|
|
338
|
-
if (!entry.replaced) continue;
|
|
339
|
-
|
|
340
|
-
try {
|
|
341
|
-
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
342
|
-
else await fs.unlink(entry.target);
|
|
343
|
-
} catch (err) {
|
|
344
|
-
// Keep the backup if recovery fails; never delete the remaining original.
|
|
345
|
-
entry.keepBackup = true;
|
|
346
|
-
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
this.invalidateCache();
|
|
351
|
-
|
|
352
|
-
if (recoveryErrors.length) this.mutations.recoveryFailed = true;
|
|
353
|
-
|
|
354
|
-
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
355
|
-
|
|
356
|
-
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
357
|
-
throw error;
|
|
383
|
+
await failCommit(this, staged, error);
|
|
358
384
|
} finally {
|
|
359
|
-
|
|
360
|
-
// A successful rename consumed the temporary path. These are known
|
|
361
|
-
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
362
|
-
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
363
|
-
|
|
364
|
-
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
385
|
+
await cleanupStaged(staged, failed, createdDirs);
|
|
368
386
|
}
|
|
369
387
|
}
|
|
370
388
|
|
|
@@ -413,7 +431,19 @@ export class CausalVfs {
|
|
|
413
431
|
return pending.size > 0;
|
|
414
432
|
}
|
|
415
433
|
|
|
416
|
-
|
|
417
|
-
|
|
434
|
+
/** External-mutation boundary: drop CAS baselines so the next access re-observes disk. */
|
|
435
|
+
invalidateObserved() { this.expected.clear(); }
|
|
418
436
|
getOverlayDepth() { return this.overlays.length; }
|
|
437
|
+
describeOverlays() {
|
|
438
|
+
let files = 0, bytes = 0;
|
|
439
|
+
|
|
440
|
+
for (const layer of this.overlays) {
|
|
441
|
+
for (const content of layer.values()) {
|
|
442
|
+
files++;
|
|
443
|
+
bytes += Buffer.byteLength(content, "utf8");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { files, bytes };
|
|
448
|
+
}
|
|
419
449
|
}
|