pi-supernova 0.3.2 → 0.5.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 +148 -29
- package/docs/CHANGELOG.md +52 -0
- package/docs/TOKEN_COSTS.md +173 -0
- package/index.js +155 -41
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +411 -50
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +102 -4
- package/src/context/search.js +45 -0
- package/src/context/snap.js +118 -6
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +33 -6
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +91 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +102 -5
- package/src/fs/workspace.js +62 -3
- package/src/output/bottleneck.js +63 -4
- package/src/output/format.js +136 -17
- package/src/runtime/guest-worker.js +169 -31
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +18 -0
- package/src/runtime/runtime.js +90 -8
- package/src/shared/decode.js +40 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/fs/vfs.js
CHANGED
|
@@ -4,18 +4,23 @@ import { isString } from "../shared/decode.js";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
const VFS_CACHE_MAX = 1024;
|
|
7
|
+
|
|
7
8
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
8
9
|
let commitTail = Promise.resolve();
|
|
9
10
|
|
|
10
11
|
export class CausalVfs {
|
|
11
12
|
constructor(onNewFile, validateWrite) {
|
|
12
13
|
this.validateWrite = validateWrite;
|
|
14
|
+
// Last-seen original bytes for write CAS, not a read cache. read() always
|
|
15
|
+
// hits disk unless an overlay is staged. Serving cache on read would be a
|
|
16
|
+
// false-valid against editors/git between two reads.
|
|
13
17
|
this.cache = new Map();
|
|
14
18
|
this.overlays = [];
|
|
15
19
|
this.expected = new Map();
|
|
16
20
|
this.onNewFile = onNewFile;
|
|
17
21
|
this.closed = false;
|
|
18
22
|
this.signal = undefined;
|
|
23
|
+
this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
assertWritable() {
|
|
@@ -38,60 +43,110 @@ export class CausalVfs {
|
|
|
38
43
|
return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
async read(target, { preserveRead = false } = {}) {
|
|
46
|
+
async read(target, { preserveRead = false, maxBytes } = {}) {
|
|
42
47
|
const overlay = this.getOverlay(target);
|
|
43
|
-
|
|
48
|
+
|
|
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
|
+
}
|
|
54
|
+
|
|
44
55
|
// External editors and captured tools can change a file between any two reads.
|
|
45
56
|
try {
|
|
46
|
-
|
|
57
|
+
let text;
|
|
58
|
+
|
|
59
|
+
if (maxBytes === undefined) text = await fs.readFile(target, "utf8");
|
|
60
|
+
else {
|
|
61
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const stat = await file.stat();
|
|
65
|
+
|
|
66
|
+
if (!stat.isFile()) throw new Error("JSON read requires a regular file: " + target);
|
|
67
|
+
const tooLarge = () => new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
68
|
+
|
|
69
|
+
if (stat.size > maxBytes) throw tooLarge();
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let size = 0;
|
|
72
|
+
|
|
73
|
+
for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
|
|
74
|
+
size += chunk.length;
|
|
75
|
+
|
|
76
|
+
if (size > maxBytes) throw tooLarge();
|
|
77
|
+
chunks.push(chunk);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
text = Buffer.concat(chunks).toString("utf8");
|
|
81
|
+
} finally { await file.close(); }
|
|
82
|
+
}
|
|
83
|
+
|
|
47
84
|
if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
|
|
85
|
+
|
|
48
86
|
return text;
|
|
49
87
|
} catch (err) {
|
|
50
88
|
this.cache.delete(target);
|
|
89
|
+
|
|
51
90
|
if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
|
|
91
|
+
|
|
52
92
|
if (err.code === "ENOENT") {
|
|
53
93
|
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
54
94
|
missing.code = "ENOENT";
|
|
55
95
|
throw missing;
|
|
56
96
|
}
|
|
97
|
+
|
|
57
98
|
throw err;
|
|
58
99
|
}
|
|
59
100
|
}
|
|
60
101
|
|
|
61
102
|
async write(target, content) {
|
|
62
103
|
this.assertWritable();
|
|
104
|
+
|
|
63
105
|
if (!isString(content)) throw new Error("write requires string content");
|
|
106
|
+
|
|
64
107
|
try {
|
|
65
108
|
if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
|
|
66
109
|
} catch (err) {
|
|
67
110
|
if (err.code !== "ENOENT") throw err;
|
|
68
111
|
}
|
|
112
|
+
|
|
69
113
|
this.assertWritable();
|
|
114
|
+
|
|
70
115
|
if (this.getOverlay(target) === undefined) {
|
|
71
116
|
let original;
|
|
117
|
+
|
|
72
118
|
try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
|
|
73
119
|
catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
|
|
120
|
+
|
|
74
121
|
this.expected.set(target, original);
|
|
75
122
|
}
|
|
123
|
+
|
|
76
124
|
this.assertWritable();
|
|
125
|
+
|
|
77
126
|
if (this.overlays.length) {
|
|
78
127
|
this.overlays.at(-1).set(target, content);
|
|
128
|
+
|
|
79
129
|
return { speculative: true };
|
|
80
130
|
}
|
|
131
|
+
|
|
81
132
|
await this.flush(new Map([[target, content]]));
|
|
133
|
+
|
|
82
134
|
return { speculative: false };
|
|
83
135
|
}
|
|
84
136
|
|
|
85
137
|
begin() {
|
|
86
138
|
this.assertWritable();
|
|
87
139
|
this.overlays.push(new Map());
|
|
140
|
+
|
|
88
141
|
return this.overlays.length;
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
/** Stage every file and its backup before replacing any destination. */
|
|
92
145
|
async flush(writes) {
|
|
93
|
-
|
|
146
|
+
this.mutations.pendingCommits++;
|
|
147
|
+
const work = commitTail.then(() => this.flushWrites(writes)).finally(() => { this.mutations.pendingCommits--; });
|
|
94
148
|
commitTail = work.catch(() => {});
|
|
149
|
+
|
|
95
150
|
return work;
|
|
96
151
|
}
|
|
97
152
|
|
|
@@ -100,30 +155,39 @@ export class CausalVfs {
|
|
|
100
155
|
const targets = new Set();
|
|
101
156
|
const createdDirs = [];
|
|
102
157
|
let failed = false;
|
|
158
|
+
|
|
103
159
|
try {
|
|
104
160
|
for (const [logicalPath, content] of writes) {
|
|
105
161
|
this.signal?.throwIfAborted();
|
|
106
162
|
let target = logicalPath;
|
|
107
163
|
let stat;
|
|
164
|
+
|
|
108
165
|
try {
|
|
109
166
|
target = await fs.realpath(logicalPath);
|
|
110
167
|
stat = await fs.stat(target);
|
|
168
|
+
|
|
111
169
|
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
112
170
|
} catch (err) {
|
|
113
171
|
if (err.code !== "ENOENT") throw err;
|
|
114
172
|
}
|
|
173
|
+
|
|
115
174
|
await this.validateWrite?.(logicalPath);
|
|
175
|
+
|
|
116
176
|
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
117
177
|
targets.add(target);
|
|
178
|
+
|
|
118
179
|
if (this.expected.has(logicalPath)) {
|
|
119
180
|
const current = stat ? await fs.readFile(target, "utf8") : null;
|
|
181
|
+
|
|
120
182
|
if (current !== this.expected.get(logicalPath)) {
|
|
121
183
|
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
122
184
|
}
|
|
123
185
|
}
|
|
186
|
+
|
|
124
187
|
const parent = path.dirname(target);
|
|
125
188
|
const missing = [];
|
|
126
189
|
let probe = parent;
|
|
190
|
+
|
|
127
191
|
for (;;) {
|
|
128
192
|
try { await fs.stat(probe); break; } catch (err) {
|
|
129
193
|
if (err.code !== "ENOENT") throw err;
|
|
@@ -131,38 +195,50 @@ export class CausalVfs {
|
|
|
131
195
|
probe = path.dirname(probe);
|
|
132
196
|
}
|
|
133
197
|
}
|
|
198
|
+
|
|
134
199
|
await fs.mkdir(parent, { recursive: true });
|
|
135
200
|
createdDirs.push(...missing.reverse());
|
|
136
201
|
const token = ".supernova-" + randomUUID();
|
|
137
202
|
const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
138
203
|
staged.push(entry);
|
|
204
|
+
|
|
139
205
|
const replacement = (async () => {
|
|
140
206
|
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
207
|
+
|
|
141
208
|
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
142
209
|
})();
|
|
210
|
+
|
|
143
211
|
// These touch separate staging files. Settle both before cleanup, even
|
|
144
212
|
// on failure: Promise.all could leave a late backup after rollback.
|
|
145
213
|
const staging = [replacement];
|
|
214
|
+
|
|
146
215
|
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
147
216
|
const outcomes = await Promise.allSettled(staging);
|
|
148
217
|
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
218
|
+
|
|
149
219
|
if (failure) throw failure.reason;
|
|
150
220
|
}
|
|
221
|
+
|
|
151
222
|
for (const entry of staged) {
|
|
152
223
|
this.signal?.throwIfAborted();
|
|
153
224
|
await fs.rename(entry.temporary, entry.target);
|
|
154
225
|
entry.replaced = true;
|
|
155
226
|
}
|
|
227
|
+
|
|
156
228
|
for (const entry of staged) {
|
|
157
229
|
this.setCache(entry.logicalPath, entry.content);
|
|
158
230
|
this.expected.delete(entry.logicalPath);
|
|
159
231
|
}
|
|
232
|
+
|
|
160
233
|
if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
|
|
234
|
+
this.mutations.committed += staged.length;
|
|
161
235
|
} catch (error) {
|
|
162
236
|
failed = true;
|
|
163
237
|
const recoveryErrors = [];
|
|
238
|
+
|
|
164
239
|
for (const entry of staged.toReversed()) {
|
|
165
240
|
if (!entry.replaced) continue;
|
|
241
|
+
|
|
166
242
|
try {
|
|
167
243
|
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
168
244
|
else await fs.unlink(entry.target);
|
|
@@ -172,8 +248,13 @@ export class CausalVfs {
|
|
|
172
248
|
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
173
249
|
}
|
|
174
250
|
}
|
|
251
|
+
|
|
175
252
|
this.invalidateCache();
|
|
253
|
+
|
|
254
|
+
if (recoveryErrors.length) this.mutations.recoveryFailed = true;
|
|
255
|
+
|
|
176
256
|
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
257
|
+
|
|
177
258
|
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
178
259
|
throw error;
|
|
179
260
|
} finally {
|
|
@@ -181,8 +262,10 @@ export class CausalVfs {
|
|
|
181
262
|
// A successful rename consumed the temporary path. These are known
|
|
182
263
|
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
183
264
|
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
265
|
+
|
|
184
266
|
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
185
267
|
}
|
|
268
|
+
|
|
186
269
|
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
187
270
|
}
|
|
188
271
|
}
|
|
@@ -190,32 +273,46 @@ export class CausalVfs {
|
|
|
190
273
|
async commit() {
|
|
191
274
|
if (!this.overlays.length) return { committed: 0, depth: 0 };
|
|
192
275
|
const top = this.overlays.at(-1);
|
|
276
|
+
|
|
193
277
|
if (this.overlays.length > 1) {
|
|
194
278
|
const parent = this.overlays[this.overlays.length - 2];
|
|
279
|
+
|
|
195
280
|
for (const [key, value] of top) parent.set(key, value);
|
|
196
281
|
} else {
|
|
197
282
|
await this.flush(top);
|
|
198
283
|
}
|
|
284
|
+
|
|
199
285
|
this.overlays.pop();
|
|
286
|
+
|
|
200
287
|
return { committed: top.size, depth: this.overlays.length };
|
|
201
288
|
}
|
|
202
289
|
|
|
203
290
|
rollback() {
|
|
204
291
|
const top = this.overlays.pop();
|
|
292
|
+
this.mutations.rolledBack += top?.size ?? 0;
|
|
293
|
+
|
|
205
294
|
for (const target of top?.keys() ?? []) {
|
|
206
295
|
if (this.getOverlay(target) === undefined) this.expected.delete(target);
|
|
207
296
|
}
|
|
297
|
+
|
|
208
298
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
209
299
|
}
|
|
210
300
|
|
|
211
301
|
async prepareExternalMutation(name) {
|
|
212
302
|
this.assertWritable();
|
|
303
|
+
|
|
213
304
|
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
|
|
214
|
-
|
|
305
|
+
|
|
306
|
+
if (!this.overlays.length) { this.mutations.external++;
|
|
307
|
+
|
|
308
|
+
return false; }
|
|
309
|
+
|
|
215
310
|
const pending = this.overlays[0];
|
|
216
311
|
await this.flush(pending);
|
|
217
312
|
this.assertWritable();
|
|
218
313
|
this.overlays[0] = new Map();
|
|
314
|
+
this.mutations.external++;
|
|
315
|
+
|
|
219
316
|
return pending.size > 0;
|
|
220
317
|
}
|
|
221
318
|
|
package/src/fs/workspace.js
CHANGED
|
@@ -5,10 +5,14 @@ import { constants } from "node:os";
|
|
|
5
5
|
import { isString } from "../shared/decode.js";
|
|
6
6
|
|
|
7
7
|
let cachedCwd = null;
|
|
8
|
+
|
|
8
9
|
let cachedResolvedCwd = null;
|
|
10
|
+
|
|
9
11
|
// realpath results per program: two syscalls per call otherwise dominate a cached read.
|
|
10
12
|
const realRoots = new Map();
|
|
13
|
+
|
|
11
14
|
const realNearest = new Map();
|
|
15
|
+
|
|
12
16
|
const PATH_CACHE_MAX = 2048;
|
|
13
17
|
|
|
14
18
|
export function clearPathCache() {
|
|
@@ -19,6 +23,7 @@ function getResolvedCwd(cwd) {
|
|
|
19
23
|
if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
|
|
20
24
|
cachedCwd = cwd;
|
|
21
25
|
cachedResolvedCwd = path.resolve(cwd);
|
|
26
|
+
|
|
22
27
|
return cachedResolvedCwd;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -30,12 +35,14 @@ function assertInside(rel, message) {
|
|
|
30
35
|
|
|
31
36
|
async function realpathNearest(target) {
|
|
32
37
|
let probe = target;
|
|
38
|
+
|
|
33
39
|
while (true) {
|
|
34
40
|
try {
|
|
35
41
|
return await fs.realpath(probe);
|
|
36
42
|
} catch (err) {
|
|
37
43
|
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
38
44
|
const parent = path.dirname(probe);
|
|
45
|
+
|
|
39
46
|
if (parent === probe) throw err;
|
|
40
47
|
probe = parent;
|
|
41
48
|
}
|
|
@@ -53,32 +60,66 @@ const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
|
|
|
53
60
|
export function isTestPath(filePath) {
|
|
54
61
|
const segments = filePath.split(/[\\/]/);
|
|
55
62
|
const base = segments[segments.length - 1];
|
|
63
|
+
|
|
56
64
|
return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
/** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
|
|
68
|
+
export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
|
|
60
69
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
61
70
|
throw new Error(`${opName} requires path`);
|
|
62
71
|
}
|
|
63
|
-
|
|
72
|
+
|
|
73
|
+
const trimmed = inputPath.trim();
|
|
74
|
+
|
|
75
|
+
if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
|
|
76
|
+
if (allowSessionRead) return trimmed;
|
|
77
|
+
throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
|
|
81
|
+
|
|
82
|
+
if (uri) {
|
|
83
|
+
const scheme = uri[1];
|
|
84
|
+
const rest = uri[2];
|
|
85
|
+
const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
|
|
86
|
+
|
|
87
|
+
if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
|
|
88
|
+
throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return trimmed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
|
|
96
|
+
const trimmed = assertFilesystemPath(inputPath, opName);
|
|
64
97
|
const resolvedCwd = getResolvedCwd(cwd);
|
|
65
|
-
const target = path.resolve(resolvedCwd,
|
|
98
|
+
const target = path.resolve(resolvedCwd, trimmed);
|
|
66
99
|
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
100
|
+
|
|
67
101
|
if (!allowRoot && target === resolvedCwd) {
|
|
68
102
|
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
69
103
|
}
|
|
104
|
+
|
|
70
105
|
let realRoot = realRoots.get(resolvedCwd);
|
|
106
|
+
|
|
71
107
|
if (!realRoot) {
|
|
72
108
|
realRoot = await fs.realpath(resolvedCwd);
|
|
73
109
|
realRoots.set(resolvedCwd, realRoot);
|
|
74
110
|
}
|
|
111
|
+
|
|
75
112
|
let probe = fresh ? undefined : realNearest.get(target);
|
|
113
|
+
|
|
76
114
|
if (!probe) {
|
|
77
115
|
probe = await realpathNearest(target);
|
|
116
|
+
|
|
78
117
|
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
79
118
|
realNearest.set(target, probe);
|
|
80
119
|
}
|
|
120
|
+
|
|
81
121
|
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
122
|
+
|
|
82
123
|
return target;
|
|
83
124
|
}
|
|
84
125
|
|
|
@@ -87,21 +128,25 @@ export async function runCommand(argv, options = {}) {
|
|
|
87
128
|
const cwd = options.cwd || process.cwd();
|
|
88
129
|
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
89
130
|
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
131
|
+
|
|
90
132
|
return new Promise((resolve, reject) => {
|
|
91
133
|
const child = spawn(argv[0], argv.slice(1), {
|
|
92
134
|
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
93
135
|
});
|
|
136
|
+
|
|
94
137
|
let stdout = "";
|
|
95
138
|
let stderr = "";
|
|
96
139
|
let settled = false;
|
|
97
140
|
let outputTruncated = false;
|
|
98
141
|
let terminationError;
|
|
99
142
|
let escalation;
|
|
143
|
+
|
|
100
144
|
const cleanup = () => {
|
|
101
145
|
clearTimeout(timer);
|
|
102
146
|
clearTimeout(escalation);
|
|
103
147
|
options.signal?.removeEventListener("abort", onAbort);
|
|
104
148
|
};
|
|
149
|
+
|
|
105
150
|
const fail = error => {
|
|
106
151
|
if (settled) return;
|
|
107
152
|
settled = true;
|
|
@@ -111,12 +156,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
111
156
|
error.stderr = stderr;
|
|
112
157
|
error.outputTruncated = outputTruncated;
|
|
113
158
|
const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
|
|
159
|
+
|
|
114
160
|
if (output) error.message += "\n" + output;
|
|
161
|
+
|
|
115
162
|
if (outputTruncated) error.message += "\n[output truncated]";
|
|
116
163
|
reject(error);
|
|
117
164
|
};
|
|
165
|
+
|
|
118
166
|
const signalTree = signal => {
|
|
119
167
|
if (!child.pid) return;
|
|
168
|
+
|
|
120
169
|
if (process.platform === "win32") {
|
|
121
170
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
|
|
122
171
|
killer.on("error", () => child.kill(signal));
|
|
@@ -124,6 +173,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
124
173
|
try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
|
|
125
174
|
}
|
|
126
175
|
};
|
|
176
|
+
|
|
127
177
|
const terminate = error => {
|
|
128
178
|
if (settled || terminationError) return;
|
|
129
179
|
terminationError = error;
|
|
@@ -131,13 +181,18 @@ export async function runCommand(argv, options = {}) {
|
|
|
131
181
|
// Keep ownership after the direct child exits: descendants may ignore SIGTERM.
|
|
132
182
|
escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
|
|
133
183
|
};
|
|
184
|
+
|
|
134
185
|
const onAbort = () => terminate(new Error("aborted"));
|
|
135
186
|
const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
|
|
187
|
+
|
|
136
188
|
const append = (current, chunk) => {
|
|
137
189
|
const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
|
|
190
|
+
|
|
138
191
|
if (chunk.length > remaining) outputTruncated = true;
|
|
192
|
+
|
|
139
193
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
140
194
|
};
|
|
195
|
+
|
|
141
196
|
child.stdout.setEncoding("utf8");
|
|
142
197
|
child.stderr.setEncoding("utf8");
|
|
143
198
|
child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
|
|
@@ -145,6 +200,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
145
200
|
child.on("error", fail);
|
|
146
201
|
child.on("close", (code, signal) => {
|
|
147
202
|
if (settled) return;
|
|
203
|
+
|
|
148
204
|
if (terminationError) {
|
|
149
205
|
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
150
206
|
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
@@ -152,13 +208,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
152
208
|
try { process.kill(-child.pid, 0); }
|
|
153
209
|
catch (error) { if (error.code === "ESRCH") fail(terminationError); }
|
|
154
210
|
}
|
|
211
|
+
|
|
155
212
|
return;
|
|
156
213
|
}
|
|
214
|
+
|
|
157
215
|
settled = true;
|
|
158
216
|
cleanup();
|
|
159
217
|
resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
|
|
160
218
|
});
|
|
161
219
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
220
|
+
|
|
162
221
|
if (options.signal?.aborted) onAbort();
|
|
163
222
|
});
|
|
164
223
|
}
|
package/src/output/bottleneck.js
CHANGED
|
@@ -2,66 +2,92 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { isString, isObject } from "../shared/decode.js";
|
|
5
|
-
import { truncateChars, formatReturn } from "./format.js";
|
|
5
|
+
import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
|
|
6
6
|
|
|
7
7
|
function json(value) {
|
|
8
8
|
try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
|
|
9
9
|
}
|
|
10
|
+
|
|
10
11
|
function detailsOf(raw) {
|
|
11
12
|
const details = raw?.details;
|
|
13
|
+
|
|
12
14
|
if (!isString(details)) return details;
|
|
15
|
+
|
|
13
16
|
try { return JSON.parse(details); } catch { return details; }
|
|
14
17
|
}
|
|
18
|
+
|
|
15
19
|
export function hostResultFailed(raw) {
|
|
16
20
|
const details = detailsOf(raw);
|
|
21
|
+
|
|
17
22
|
return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
|
|
18
23
|
}
|
|
24
|
+
|
|
19
25
|
function extractRawString(raw) {
|
|
20
26
|
if (raw == null) return "";
|
|
27
|
+
|
|
21
28
|
if (isString(raw)) return raw;
|
|
29
|
+
|
|
22
30
|
if (!isObject(raw)) return String(raw);
|
|
31
|
+
|
|
23
32
|
if (Array.isArray(raw.content)) return raw.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n");
|
|
33
|
+
|
|
24
34
|
if (isString(raw.text)) return raw.text;
|
|
35
|
+
|
|
25
36
|
return json(raw);
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/** Bound JSON before serialization; preserve small scalar fields such as exitCode. */
|
|
29
40
|
function summarizeDetails(value, budget = 2000) {
|
|
30
41
|
const encoded = json(value);
|
|
42
|
+
|
|
31
43
|
if (encoded.length <= budget) return encoded;
|
|
32
44
|
const snapshot = JSON.parse(encoded);
|
|
45
|
+
|
|
33
46
|
const fit = (input, limit) => {
|
|
34
47
|
const serialized = json(input);
|
|
48
|
+
|
|
35
49
|
if (serialized.length <= limit) return input;
|
|
50
|
+
|
|
36
51
|
if (isString(input)) {
|
|
37
52
|
let low = 0;
|
|
38
53
|
let high = Math.min(input.length, limit);
|
|
54
|
+
|
|
39
55
|
while (low < high) {
|
|
40
56
|
const mid = Math.ceil((low + high) / 2);
|
|
57
|
+
|
|
41
58
|
if (json(truncateChars(input, mid).text).length <= limit) low = mid;
|
|
42
59
|
else high = mid - 1;
|
|
43
60
|
}
|
|
61
|
+
|
|
44
62
|
return truncateChars(input, low).text;
|
|
45
63
|
}
|
|
64
|
+
|
|
46
65
|
if (!isObject(input) && !Array.isArray(input)) return null;
|
|
47
66
|
const out = Array.isArray(input) ? [] : { truncated: true };
|
|
48
67
|
const entries = Object.entries(input);
|
|
68
|
+
|
|
49
69
|
if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
|
|
70
|
+
|
|
50
71
|
for (const [key, child] of entries) {
|
|
51
72
|
const used = json(out).length;
|
|
52
73
|
const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
|
|
53
74
|
const available = limit - used - overhead;
|
|
75
|
+
|
|
54
76
|
if (available < 4) break;
|
|
55
77
|
const bounded = fit(child, available);
|
|
78
|
+
|
|
56
79
|
if (Array.isArray(out)) out.push(bounded);
|
|
57
80
|
else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
|
|
81
|
+
|
|
58
82
|
if (json(out).length > limit) {
|
|
59
83
|
if (Array.isArray(out)) out.pop();
|
|
60
84
|
else delete out[key];
|
|
61
85
|
}
|
|
62
86
|
}
|
|
87
|
+
|
|
63
88
|
return out;
|
|
64
89
|
};
|
|
90
|
+
|
|
65
91
|
return json(fit(snapshot, budget));
|
|
66
92
|
}
|
|
67
93
|
|
|
@@ -70,6 +96,7 @@ function spill(fullText, config) {
|
|
|
70
96
|
fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
|
|
71
97
|
const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
|
|
72
98
|
fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
99
|
+
|
|
73
100
|
return file;
|
|
74
101
|
}
|
|
75
102
|
|
|
@@ -82,7 +109,9 @@ export function packageHostResult(raw, config) {
|
|
|
82
109
|
let truncated = capped.truncated || details?.outputTruncated === true;
|
|
83
110
|
const image = raw?.content?.find(part => part?.type === "image");
|
|
84
111
|
const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
|
|
112
|
+
|
|
85
113
|
if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
|
|
114
|
+
|
|
86
115
|
if (batch) {
|
|
87
116
|
result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
|
|
88
117
|
let remaining = maxChars;
|
|
@@ -91,16 +120,22 @@ export function packageHostResult(raw, config) {
|
|
|
91
120
|
const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
|
|
92
121
|
remaining -= bounded.text.length;
|
|
93
122
|
truncated ||= bounded.truncated;
|
|
123
|
+
|
|
94
124
|
return bounded.text;
|
|
95
125
|
});
|
|
96
126
|
}
|
|
127
|
+
|
|
97
128
|
result.truncated = truncated;
|
|
129
|
+
|
|
98
130
|
if (truncated) {
|
|
99
131
|
result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
|
|
132
|
+
|
|
100
133
|
if (config.spillDir) {
|
|
101
134
|
const pointer = spill(batch ? batch.join("\n---\n") : text, config);
|
|
135
|
+
|
|
102
136
|
if (pointer) {
|
|
103
137
|
result.spill = pointer;
|
|
138
|
+
|
|
104
139
|
if (!batch) {
|
|
105
140
|
const footer = "\n[full output spilled to " + pointer + "]";
|
|
106
141
|
result.value = footer.length <= maxChars
|
|
@@ -110,32 +145,56 @@ export function packageHostResult(raw, config) {
|
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
147
|
}
|
|
148
|
+
|
|
113
149
|
return result;
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
export function packageFinalReturn(value, logs, config) {
|
|
117
153
|
const images = [];
|
|
118
154
|
let imageBytes = 0;
|
|
155
|
+
let imageOverflow = false;
|
|
156
|
+
|
|
119
157
|
const collect = input => {
|
|
120
158
|
if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
|
|
121
|
-
|
|
122
|
-
|
|
159
|
+
const size = Buffer.byteLength(input.data, "base64");
|
|
160
|
+
|
|
161
|
+
if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
|
|
162
|
+
imageOverflow = true;
|
|
163
|
+
|
|
164
|
+
return "[image omitted: exceeds 16 attachments or 20 MiB]";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
imageBytes += size;
|
|
123
168
|
images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
169
|
+
|
|
124
170
|
return `[image ${images.length}: ${input.mimeType}]`;
|
|
125
171
|
}
|
|
172
|
+
|
|
126
173
|
if (Array.isArray(input)) return input.map(collect);
|
|
174
|
+
|
|
127
175
|
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
|
|
176
|
+
|
|
128
177
|
return input;
|
|
129
178
|
};
|
|
179
|
+
|
|
130
180
|
value = collect(value);
|
|
131
|
-
const
|
|
181
|
+
const maxReturn = config.maxReturnChars ?? 32000;
|
|
182
|
+
const formatted = formatReturn(value);
|
|
183
|
+
const serialized = formatted.length <= maxReturn
|
|
184
|
+
? { text: formatted, truncated: imageOverflow }
|
|
185
|
+
: Array.isArray(value) && value.length && value.every(isString)
|
|
186
|
+
? { text: formatBoundedStringArray(value, maxReturn), truncated: true }
|
|
187
|
+
: { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
|
|
132
188
|
const maxLines = config.maxLogLines ?? 100;
|
|
133
189
|
let logTruncated = logs.length > maxLines;
|
|
190
|
+
|
|
134
191
|
const clipped = logs.slice(0, maxLines).map(line => {
|
|
135
192
|
const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
|
|
136
193
|
logTruncated ||= result.truncated;
|
|
194
|
+
|
|
137
195
|
return result.text;
|
|
138
196
|
});
|
|
197
|
+
|
|
139
198
|
return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
|
|
140
199
|
returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
|
|
141
200
|
}
|