pi-supernova 0.0.15 → 0.2.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/vfs.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { isString } from "./decode.js";
4
+ import { randomUUID } from "node:crypto";
3
5
 
4
6
  const VFS_CACHE_MAX = 1024;
5
7
 
@@ -8,13 +10,17 @@ export class CausalVfs {
8
10
  this.cache = new Map();
9
11
  this.overlays = [];
10
12
  this.onNewFile = onNewFile;
13
+ this.closed = false;
14
+ this.signal = undefined;
15
+ }
16
+
17
+ assertWritable() {
18
+ if (this.closed) throw new Error("program is already complete");
19
+ this.signal?.throwIfAborted();
11
20
  }
12
21
 
13
22
  setCache(target, content) {
14
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
15
- const oldest = this.cache.keys().next().value;
16
- if (oldest !== undefined) this.cache.delete(oldest);
17
- }
23
+ if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
18
24
  this.cache.set(target, content);
19
25
  }
20
26
 
@@ -22,34 +28,25 @@ export class CausalVfs {
22
28
  for (let i = this.overlays.length - 1; i >= 0; i--) {
23
29
  if (this.overlays[i].has(target)) return this.overlays[i].get(target);
24
30
  }
25
- return undefined;
26
31
  }
27
32
 
28
33
  getOverlayPaths() {
29
- const paths = new Set();
30
- for (const overlay of this.overlays) {
31
- for (const target of overlay.keys()) paths.add(target);
32
- }
33
- return [...paths];
34
+ return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
34
35
  }
35
36
 
36
37
  async read(target) {
37
38
  const overlay = this.getOverlay(target);
38
39
  if (overlay !== undefined) return overlay;
39
-
40
- const cached = this.cache.get(target);
41
- if (cached !== undefined) return cached;
42
-
40
+ // External editors and captured tools can change a file between any two reads.
43
41
  try {
44
42
  const text = await fs.readFile(target, "utf8");
45
43
  this.setCache(target, text);
46
44
  return text;
47
45
  } catch (err) {
48
- if (err.code === "EISDIR") {
49
- throw new Error(`read path is a directory, not a file: ${target}`);
50
- }
46
+ this.cache.delete(target);
47
+ if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
51
48
  if (err.code === "ENOENT") {
52
- const missing = new Error(`no such file: ${target} (locate it with nova.call("glob", {pattern}) or snap(query))`);
49
+ const missing = new Error("no such file: " + target + ' (locate it with nova.call("glob", {pattern}) or snap(query))');
53
50
  missing.code = "ENOENT";
54
51
  throw missing;
55
52
  }
@@ -58,82 +55,129 @@ export class CausalVfs {
58
55
  }
59
56
 
60
57
  async write(target, content) {
61
- if (this.overlays.length > 0) {
62
- this.overlays[this.overlays.length - 1].set(target, content);
63
- return { speculative: true };
64
- }
65
-
66
- let existed = true;
58
+ this.assertWritable();
59
+ if (!isString(content)) throw new Error("write requires string content");
67
60
  try {
68
- const stat = await fs.stat(target);
69
- if (stat.isDirectory()) {
70
- throw new Error(`cannot write to a directory: ${target}`);
71
- }
61
+ if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
72
62
  } catch (err) {
73
63
  if (err.code !== "ENOENT") throw err;
74
- existed = false;
75
64
  }
76
-
77
- await fs.mkdir(path.dirname(target), { recursive: true });
78
- await fs.writeFile(target, content, "utf8");
79
- this.setCache(target, content);
80
- if (!existed) this.onNewFile?.(target);
65
+ this.assertWritable();
66
+ if (this.overlays.length) {
67
+ this.overlays.at(-1).set(target, content);
68
+ return { speculative: true };
69
+ }
70
+ await this.flush(new Map([[target, content]]));
81
71
  return { speculative: false };
82
72
  }
83
73
 
84
74
  begin() {
75
+ this.assertWritable();
85
76
  this.overlays.push(new Map());
86
77
  return this.overlays.length;
87
78
  }
88
79
 
89
- async commit() {
90
- if (this.overlays.length === 0) return { committed: 0, depth: 0 };
91
- const top = this.overlays.pop();
92
- if (this.overlays.length > 0) {
93
- const parent = this.overlays[this.overlays.length - 1];
94
- for (const [k, v] of top.entries()) parent.set(k, v);
95
- return { committed: top.size, depth: this.overlays.length };
80
+ /** Stage every file and its backup before replacing any destination. */
81
+ async flush(writes) {
82
+ const staged = [];
83
+ const createdDirs = [];
84
+ let failed = false;
85
+ try {
86
+ for (const [logicalPath, content] of writes) {
87
+ this.signal?.throwIfAborted();
88
+ let target = logicalPath;
89
+ let stat;
90
+ try {
91
+ target = await fs.realpath(logicalPath);
92
+ stat = await fs.stat(target);
93
+ if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
94
+ } catch (err) {
95
+ if (err.code !== "ENOENT") throw err;
96
+ }
97
+ const parent = path.dirname(target);
98
+ const missing = [];
99
+ let probe = parent;
100
+ for (;;) {
101
+ try { await fs.stat(probe); break; } catch (err) {
102
+ if (err.code !== "ENOENT") throw err;
103
+ missing.push(probe);
104
+ probe = path.dirname(probe);
105
+ }
106
+ }
107
+ await fs.mkdir(parent, { recursive: true });
108
+ createdDirs.push(...missing.reverse());
109
+ const token = ".supernova-" + randomUUID();
110
+ const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
111
+ staged.push(entry);
112
+ await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
113
+ if (stat) {
114
+ await fs.chmod(entry.temporary, stat.mode & 0o7777);
115
+ await fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL);
116
+ }
117
+ }
118
+ for (const entry of staged) {
119
+ this.signal?.throwIfAborted();
120
+ await fs.rename(entry.temporary, entry.target);
121
+ entry.replaced = true;
122
+ }
123
+ for (const entry of staged) this.setCache(entry.logicalPath, entry.content);
124
+ if (staged.length) this.onNewFile?.();
125
+ } catch (error) {
126
+ failed = true;
127
+ const recoveryErrors = [];
128
+ for (const entry of staged.toReversed()) {
129
+ if (!entry.replaced) continue;
130
+ try {
131
+ if (entry.existed) await fs.rename(entry.backup, entry.target);
132
+ else await fs.unlink(entry.target);
133
+ } catch (err) {
134
+ // Keep the backup if recovery fails; never delete the remaining original.
135
+ entry.keepBackup = true;
136
+ recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
137
+ }
138
+ }
139
+ this.invalidateCache();
140
+ if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
141
+ throw error;
142
+ } finally {
143
+ for (const entry of staged) {
144
+ await fs.rm(entry.temporary, { force: true }).catch(() => {});
145
+ if (!entry.keepBackup) await fs.rm(entry.backup, { force: true }).catch(() => {});
146
+ }
147
+ if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
96
148
  }
97
- for (const [filePath, fileContent] of top.entries()) {
98
- await fs.mkdir(path.dirname(filePath), { recursive: true });
99
- await fs.writeFile(filePath, fileContent, "utf8");
100
- this.setCache(filePath, fileContent);
149
+ }
150
+
151
+ async commit() {
152
+ if (!this.overlays.length) return { committed: 0, depth: 0 };
153
+ const top = this.overlays.at(-1);
154
+ if (this.overlays.length > 1) {
155
+ const parent = this.overlays[this.overlays.length - 2];
156
+ for (const [key, value] of top) parent.set(key, value);
157
+ } else {
158
+ await this.flush(top);
101
159
  }
102
- if (top.size > 0) this.onNewFile?.();
103
- return { committed: top.size, depth: 0 };
160
+ this.overlays.pop();
161
+ return { committed: top.size, depth: this.overlays.length };
104
162
  }
105
163
 
106
164
  rollback() {
107
- if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
108
165
  const top = this.overlays.pop();
109
- return { rolledBack: top.size, depth: this.overlays.length };
166
+ return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
110
167
  }
111
168
 
112
169
  async prepareExternalMutation(name) {
113
- if (this.overlays.length > 1) {
114
- throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
115
- }
116
- if (this.overlays.length === 0) return false;
170
+ this.assertWritable();
171
+ if (this.overlays.length > 1) throw new Error(name + " cannot run inside nova.speculate because external mutations cannot be rolled back");
172
+ if (!this.overlays.length) return false;
117
173
  const pending = this.overlays[0];
118
- for (const [filePath, fileContent] of pending.entries()) {
119
- await fs.mkdir(path.dirname(filePath), { recursive: true });
120
- await fs.writeFile(filePath, fileContent, "utf8");
121
- this.setCache(filePath, fileContent);
122
- }
123
- if (pending.size > 0) this.onNewFile?.();
174
+ await this.flush(pending);
175
+ this.assertWritable();
124
176
  this.overlays[0] = new Map();
125
177
  return pending.size > 0;
126
178
  }
127
179
 
128
- invalidateCache() {
129
- this.cache.clear();
130
- }
131
-
132
- getCacheSize() {
133
- return this.cache.size;
134
- }
135
-
136
- getOverlayDepth() {
137
- return this.overlays.length;
138
- }
180
+ invalidateCache() { this.cache.clear(); }
181
+ getCacheSize() { return this.cache.size; }
182
+ getOverlayDepth() { return this.overlays.length; }
139
183
  }
package/workspace.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { constants } from "node:os";
4
5
  import { isString } from "./decode.js";
5
6
 
6
7
  let cachedCwd = null;
@@ -81,63 +82,66 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
81
82
  }
82
83
 
83
84
  export async function runCommand(argv, options = {}) {
85
+ options.signal?.throwIfAborted();
84
86
  const cwd = options.cwd || process.cwd();
85
87
  const timeoutMs = options.timeoutMs ?? 60_000;
86
88
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
87
- return await new Promise((resolve, reject) => {
89
+ return new Promise((resolve, reject) => {
88
90
  const child = spawn(argv[0], argv.slice(1), {
89
- cwd,
90
- env: process.env,
91
- stdio: ["ignore", "pipe", "pipe"],
91
+ cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
92
92
  });
93
93
  let stdout = "";
94
94
  let stderr = "";
95
95
  let settled = false;
96
96
  let outputTruncated = false;
97
- let onAbort;
98
-
97
+ let terminationError;
98
+ let escalation;
99
99
  const cleanup = () => {
100
100
  clearTimeout(timer);
101
- if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
101
+ clearTimeout(escalation);
102
+ options.signal?.removeEventListener("abort", onAbort);
102
103
  };
103
- const fail = (err) => {
104
+ const fail = error => {
104
105
  if (settled) return;
105
106
  settled = true;
106
107
  cleanup();
107
- reject(err);
108
+ reject(error);
109
+ };
110
+ const signalTree = signal => {
111
+ if (!child.pid) return;
112
+ if (process.platform === "win32") {
113
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
114
+ killer.on("error", () => child.kill(signal));
115
+ } else {
116
+ try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
117
+ }
108
118
  };
119
+ const terminate = error => {
120
+ if (settled || terminationError) return;
121
+ terminationError = error;
122
+ signalTree("SIGTERM");
123
+ // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
124
+ escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
125
+ };
126
+ const onAbort = () => terminate(new Error("aborted"));
127
+ const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + argv.join(" "))), timeoutMs);
109
128
  const append = (current, chunk) => {
110
- const remaining = Math.max(0, maxOutputChars - current.length);
129
+ const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
111
130
  if (chunk.length > remaining) outputTruncated = true;
112
- return remaining > 0 ? current + chunk.slice(0, remaining) : current;
131
+ return remaining ? current + chunk.slice(0, remaining) : current;
113
132
  };
114
- const timer = setTimeout(() => {
115
- child.kill("SIGTERM");
116
- fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
117
- }, timeoutMs);
118
-
119
133
  child.stdout.setEncoding("utf8");
120
134
  child.stderr.setEncoding("utf8");
121
- child.stdout.on("data", (chunk) => {
122
- stdout = append(stdout, chunk);
123
- });
124
- child.stderr.on("data", (chunk) => {
125
- stderr = append(stderr, chunk);
126
- });
135
+ child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
136
+ child.stderr.on("data", chunk => { stderr = append(stderr, chunk); });
127
137
  child.on("error", fail);
128
- child.on("close", (code) => {
129
- if (settled) return;
138
+ child.on("close", (code, signal) => {
139
+ if (settled || terminationError) return;
130
140
  settled = true;
131
141
  cleanup();
132
- resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
142
+ resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
133
143
  });
134
- if (options.signal) {
135
- onAbort = () => {
136
- child.kill("SIGTERM");
137
- fail(new Error("aborted"));
138
- };
139
- if (options.signal.aborted) onAbort();
140
- else options.signal.addEventListener("abort", onAbort, { once: true });
141
- }
144
+ options.signal?.addEventListener("abort", onAbort, { once: true });
145
+ if (options.signal?.aborted) onAbort();
142
146
  });
143
147
  }