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.
Files changed (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +114 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +296 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. 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
- export class CausalVfs {
64
- constructor(onNewFile, validateWrite) {
65
- this.validateWrite = validateWrite;
66
- // Optional receipt bodies, never the authority for CAS. Signatures survive
67
- // body eviction. Explicit reads hit disk and replace the observed snapshot;
68
- // internal receipt reads preserve it until an external-mutation boundary.
69
- this.cache = new Map();
70
- this.cacheBytes = 0;
71
- this.overlays = [];
72
- this.expected = new Map();
73
- this.onNewFile = onNewFile;
74
- this.closed = false;
75
- this.signal = undefined;
76
- this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
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
- assertWritable() {
80
- if (this.closed) throw new Error("program is already complete");
81
- this.signal?.throwIfAborted();
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
- dropCache(target) {
85
- const previous = this.cache.get(target);
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
- if (previous !== undefined && this.cache.delete(target)) this.cacheBytes -= Buffer.byteLength(previous, "utf8");
117
+ return { target: await canonicalNewPath(logicalPath), stat: undefined };
88
118
  }
119
+ }
89
120
 
90
- setCache(target, content) {
91
- const bytes = Buffer.byteLength(content, "utf8");
121
+ async function collectMissingAncestors(parent) {
122
+ const missing = [];
123
+ let probe = parent;
92
124
 
93
- if (bytes > VFS_CACHE_MAX_BYTES) {
94
- this.dropCache(target);
95
- return;
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
- this.dropCache(target);
161
+ async function recoverReplaced(staged) {
162
+ const recoveryErrors = [];
99
163
 
100
- while ((this.cache.size >= VFS_CACHE_MAX || this.cacheBytes + bytes > VFS_CACHE_MAX_BYTES) && this.cache.size) {
101
- const oldest = this.cache.keys().next().value;
164
+ for (const entry of staged.toReversed()) {
165
+ if (!entry.replaced) continue;
102
166
 
103
- this.cacheBytes -= Buffer.byteLength(this.cache.get(oldest), "utf8");
104
- this.cache.delete(oldest);
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
- this.cache.set(target, content);
108
- this.cacheBytes += bytes;
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
- if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
140
- if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
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 text;
284
+ return bytes.toString("utf8");
168
285
  } catch (err) {
169
- this.dropCache(target);
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
- let target = logicalPath;
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 token = ".supernova-" + randomUUID();
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
- for (const entry of staged) {
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
- const recoveryErrors = [];
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
- for (const entry of staged) {
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
- invalidateCache() { this.cache.clear(); this.cacheBytes = 0; this.expected.clear(); }
417
- getCacheSize() { return this.cache.size; }
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
  }