pi-supernova 0.5.0 → 0.7.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 +97 -11
- package/docs/CHANGELOG.md +150 -0
- package/docs/TOKEN_COSTS.md +71 -29
- package/index.js +126 -82
- package/package.json +2 -2
- 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 +30 -220
- package/src/bridge/host-bridge.js +142 -1032
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -188
- package/src/context/evidence.js +142 -70
- package/src/context/fuzzy.js +61 -22
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +26 -12
- package/src/context/repo-index.js +242 -71
- package/src/context/search.js +189 -56
- package/src/context/snap.js +306 -150
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +29 -14
- 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 +19 -7
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +97 -51
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +289 -162
- package/src/fs/workspace.js +122 -105
- package/src/output/bottleneck.js +211 -107
- package/src/output/format.js +112 -63
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +306 -213
- package/src/runtime/parallel.js +99 -63
- package/src/runtime/program-batch.js +189 -69
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +13 -12
- package/src/runtime/runtime.js +327 -176
- package/src/shared/decode.js +61 -27
- package/src/ui/omp-frame.js +70 -46
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +242 -146
package/src/fs/vfs.js
CHANGED
|
@@ -1,20 +1,240 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isString } from "../shared/decode.js";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
-
|
|
6
|
-
const VFS_CACHE_MAX = 1024;
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
5
|
|
|
8
6
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
9
7
|
let commitTail = Promise.resolve();
|
|
10
8
|
|
|
9
|
+
function textSignature(text) {
|
|
10
|
+
return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameFileVersion(a, b) {
|
|
14
|
+
return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fileSignature(target, signal, observed) {
|
|
18
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const actual = await file.stat();
|
|
22
|
+
|
|
23
|
+
if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
|
|
24
|
+
if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
|
|
25
|
+
const hash = createHash("sha256");
|
|
26
|
+
|
|
27
|
+
for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
|
|
28
|
+
const after = await file.stat();
|
|
29
|
+
|
|
30
|
+
if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
|
|
31
|
+
|
|
32
|
+
return { size: actual.size, sha256: hash.digest("hex") };
|
|
33
|
+
} finally {
|
|
34
|
+
await file.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
|
|
39
|
+
// ancestor so two symlink spellings still share one commit destination.
|
|
40
|
+
async function canonicalNewPath(target) {
|
|
41
|
+
let ancestor = path.dirname(target);
|
|
42
|
+
|
|
43
|
+
for (;;) {
|
|
44
|
+
try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "ENOENT") throw error;
|
|
47
|
+
const parent = path.dirname(ancestor);
|
|
48
|
+
|
|
49
|
+
if (parent === ancestor) throw error;
|
|
50
|
+
ancestor = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sameSignature(a, b) {
|
|
56
|
+
return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
|
|
57
|
+
}
|
|
58
|
+
|
|
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);
|
|
85
|
+
}
|
|
86
|
+
|
|
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;
|
|
97
|
+
}
|
|
98
|
+
|
|
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;
|
|
116
|
+
|
|
117
|
+
return { target: await canonicalNewPath(logicalPath), stat: undefined };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function collectMissingAncestors(parent) {
|
|
122
|
+
const missing = [];
|
|
123
|
+
let probe = parent;
|
|
124
|
+
|
|
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);
|
|
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
|
+
}
|
|
160
|
+
|
|
161
|
+
async function recoverReplaced(staged) {
|
|
162
|
+
const recoveryErrors = [];
|
|
163
|
+
|
|
164
|
+
for (const entry of staged.toReversed()) {
|
|
165
|
+
if (!entry.replaced) continue;
|
|
166
|
+
|
|
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 + ")");
|
|
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
|
+
}
|
|
207
|
+
|
|
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
|
+
|
|
11
232
|
export class CausalVfs {
|
|
12
233
|
constructor(onNewFile, validateWrite) {
|
|
13
234
|
this.validateWrite = validateWrite;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
this.cache = new Map();
|
|
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.
|
|
18
238
|
this.overlays = [];
|
|
19
239
|
this.expected = new Map();
|
|
20
240
|
this.onNewFile = onNewFile;
|
|
@@ -28,11 +248,6 @@ export class CausalVfs {
|
|
|
28
248
|
this.signal?.throwIfAborted();
|
|
29
249
|
}
|
|
30
250
|
|
|
31
|
-
setCache(target, content) {
|
|
32
|
-
if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
|
|
33
|
-
this.cache.set(target, content);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
251
|
getOverlay(target) {
|
|
37
252
|
for (let i = this.overlays.length - 1; i >= 0; i--) {
|
|
38
253
|
if (this.overlays[i].has(target)) return this.overlays[i].get(target);
|
|
@@ -43,60 +258,54 @@ export class CausalVfs {
|
|
|
43
258
|
return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
|
|
44
259
|
}
|
|
45
260
|
|
|
46
|
-
async read(target, { preserveRead = false, maxBytes } = {}) {
|
|
261
|
+
async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
|
|
47
262
|
const overlay = this.getOverlay(target);
|
|
48
263
|
|
|
49
|
-
if (overlay !== undefined)
|
|
50
|
-
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
51
|
-
|
|
52
|
-
return overlay;
|
|
53
|
-
}
|
|
264
|
+
if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
|
|
54
265
|
|
|
55
266
|
// External editors and captured tools can change a file between any two reads.
|
|
267
|
+
// Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
|
|
56
268
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
269
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
270
|
+
let bytes;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const stat = await file.stat();
|
|
274
|
+
assertReadableFile(stat, target);
|
|
275
|
+
bytes = maxBytes === undefined
|
|
276
|
+
? await file.readFile({ signal: this.signal })
|
|
277
|
+
: await readLimitedBytes(file, stat, maxBytes, label, this.signal);
|
|
278
|
+
if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
|
|
279
|
+
} finally { await file.close(); }
|
|
280
|
+
|
|
281
|
+
// Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
|
|
282
|
+
if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
|
|
283
|
+
|
|
284
|
+
return bytes.toString("utf8");
|
|
285
|
+
} catch (err) {
|
|
286
|
+
remapReadError(err, target);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
75
289
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
290
|
+
async #diskSignature(target) {
|
|
291
|
+
let stat;
|
|
79
292
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
293
|
+
try { stat = await fs.stat(target); }
|
|
294
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
83
295
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return text;
|
|
87
|
-
} catch (err) {
|
|
88
|
-
this.cache.delete(target);
|
|
296
|
+
return stat?.isFile() ? await fileSignature(target, this.signal, stat) : null;
|
|
297
|
+
}
|
|
89
298
|
|
|
90
|
-
|
|
299
|
+
async captureExpected(target) {
|
|
300
|
+
if (this.getOverlay(target) !== undefined || this.expected.has(target)) return;
|
|
91
301
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
missing.code = "ENOENT";
|
|
95
|
-
throw missing;
|
|
96
|
-
}
|
|
302
|
+
this.expected.set(target, await this.#diskSignature(target));
|
|
303
|
+
}
|
|
97
304
|
|
|
98
|
-
|
|
99
|
-
|
|
305
|
+
async recordExpected(target, observed) {
|
|
306
|
+
if (this.getOverlay(target) !== undefined) return;
|
|
307
|
+
const signature = observed ? await fileSignature(target, this.signal, observed) : await this.#diskSignature(target);
|
|
308
|
+
this.expected.set(target, signature);
|
|
100
309
|
}
|
|
101
310
|
|
|
102
311
|
async write(target, content) {
|
|
@@ -112,14 +321,7 @@ export class CausalVfs {
|
|
|
112
321
|
|
|
113
322
|
this.assertWritable();
|
|
114
323
|
|
|
115
|
-
|
|
116
|
-
let original;
|
|
117
|
-
|
|
118
|
-
try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
|
|
119
|
-
catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
|
|
120
|
-
|
|
121
|
-
this.expected.set(target, original);
|
|
122
|
-
}
|
|
324
|
+
await this.captureExpected(target);
|
|
123
325
|
|
|
124
326
|
this.assertWritable();
|
|
125
327
|
|
|
@@ -159,114 +361,28 @@ export class CausalVfs {
|
|
|
159
361
|
try {
|
|
160
362
|
for (const [logicalPath, content] of writes) {
|
|
161
363
|
this.signal?.throwIfAborted();
|
|
162
|
-
|
|
163
|
-
let stat;
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
target = await fs.realpath(logicalPath);
|
|
167
|
-
stat = await fs.stat(target);
|
|
168
|
-
|
|
169
|
-
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
if (err.code !== "ENOENT") throw err;
|
|
172
|
-
}
|
|
173
|
-
|
|
364
|
+
const { target, stat } = await resolveCommitTarget(logicalPath);
|
|
174
365
|
await this.validateWrite?.(logicalPath);
|
|
175
366
|
|
|
176
367
|
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
177
368
|
targets.add(target);
|
|
178
|
-
|
|
179
|
-
if (this.expected.has(logicalPath)) {
|
|
180
|
-
const current = stat ? await fs.readFile(target, "utf8") : null;
|
|
181
|
-
|
|
182
|
-
if (current !== this.expected.get(logicalPath)) {
|
|
183
|
-
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
369
|
+
await assertExpectedSignature(this, logicalPath, target, stat);
|
|
187
370
|
const parent = path.dirname(target);
|
|
188
|
-
const missing =
|
|
189
|
-
let probe = parent;
|
|
190
|
-
|
|
191
|
-
for (;;) {
|
|
192
|
-
try { await fs.stat(probe); break; } catch (err) {
|
|
193
|
-
if (err.code !== "ENOENT") throw err;
|
|
194
|
-
missing.push(probe);
|
|
195
|
-
probe = path.dirname(probe);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
371
|
+
const missing = await collectMissingAncestors(parent);
|
|
199
372
|
await fs.mkdir(parent, { recursive: true });
|
|
200
373
|
createdDirs.push(...missing.reverse());
|
|
201
|
-
const
|
|
202
|
-
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);
|
|
203
375
|
staged.push(entry);
|
|
204
|
-
|
|
205
|
-
const replacement = (async () => {
|
|
206
|
-
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
207
|
-
|
|
208
|
-
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
209
|
-
})();
|
|
210
|
-
|
|
211
|
-
// These touch separate staging files. Settle both before cleanup, even
|
|
212
|
-
// on failure: Promise.all could leave a late backup after rollback.
|
|
213
|
-
const staging = [replacement];
|
|
214
|
-
|
|
215
|
-
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
216
|
-
const outcomes = await Promise.allSettled(staging);
|
|
217
|
-
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
218
|
-
|
|
219
|
-
if (failure) throw failure.reason;
|
|
376
|
+
await stageReplacement(entry, content, stat, target);
|
|
220
377
|
}
|
|
221
378
|
|
|
222
|
-
|
|
223
|
-
this.signal?.throwIfAborted();
|
|
224
|
-
await fs.rename(entry.temporary, entry.target);
|
|
225
|
-
entry.replaced = true;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
for (const entry of staged) {
|
|
229
|
-
this.setCache(entry.logicalPath, entry.content);
|
|
230
|
-
this.expected.delete(entry.logicalPath);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
|
|
379
|
+
await installStaged(this, staged);
|
|
234
380
|
this.mutations.committed += staged.length;
|
|
235
381
|
} catch (error) {
|
|
236
382
|
failed = true;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
for (const entry of staged.toReversed()) {
|
|
240
|
-
if (!entry.replaced) continue;
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
244
|
-
else await fs.unlink(entry.target);
|
|
245
|
-
} catch (err) {
|
|
246
|
-
// Keep the backup if recovery fails; never delete the remaining original.
|
|
247
|
-
entry.keepBackup = true;
|
|
248
|
-
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
this.invalidateCache();
|
|
253
|
-
|
|
254
|
-
if (recoveryErrors.length) this.mutations.recoveryFailed = true;
|
|
255
|
-
|
|
256
|
-
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
257
|
-
|
|
258
|
-
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
259
|
-
throw error;
|
|
383
|
+
await failCommit(this, staged, error);
|
|
260
384
|
} finally {
|
|
261
|
-
|
|
262
|
-
// A successful rename consumed the temporary path. These are known
|
|
263
|
-
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
264
|
-
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
265
|
-
|
|
266
|
-
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
385
|
+
await cleanupStaged(staged, failed, createdDirs);
|
|
270
386
|
}
|
|
271
387
|
}
|
|
272
388
|
|
|
@@ -291,9 +407,8 @@ export class CausalVfs {
|
|
|
291
407
|
const top = this.overlays.pop();
|
|
292
408
|
this.mutations.rolledBack += top?.size ?? 0;
|
|
293
409
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
410
|
+
// Rolling back staged writes does not undo observations of disk. Keep the
|
|
411
|
+
// read snapshot, including for files without a surviving parent overlay.
|
|
297
412
|
|
|
298
413
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
299
414
|
}
|
|
@@ -316,7 +431,19 @@ export class CausalVfs {
|
|
|
316
431
|
return pending.size > 0;
|
|
317
432
|
}
|
|
318
433
|
|
|
319
|
-
|
|
320
|
-
|
|
434
|
+
/** External-mutation boundary: drop CAS baselines so the next access re-observes disk. */
|
|
435
|
+
invalidateObserved() { this.expected.clear(); }
|
|
321
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
|
+
}
|
|
322
449
|
}
|