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/runtime/runtime.js
CHANGED
|
@@ -5,6 +5,7 @@ import { performance } from "node:perf_hooks";
|
|
|
5
5
|
import { packageFinalReturn } from "../output/bottleneck.js";
|
|
6
6
|
import { truncateChars } from "../output/format.js";
|
|
7
7
|
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
8
|
+
import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
|
|
8
9
|
|
|
9
10
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
10
11
|
|
|
@@ -16,6 +17,25 @@ const MEMORY_SLACK = 1.5;
|
|
|
16
17
|
|
|
17
18
|
const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
|
|
18
19
|
|
|
20
|
+
const toMb = bytes => Math.max(0, bytes / 1048576);
|
|
21
|
+
|
|
22
|
+
/** Pure attribution for a memory-limit trip: which operation, and how much of the RSS growth supernova can account for. */
|
|
23
|
+
export function formatMemoryAttribution({ limitMb, rssBytes: now, startBytes, ms, op, calls, tracked }) {
|
|
24
|
+
const deltaMb = toMb(now - startBytes);
|
|
25
|
+
const parts = tracked
|
|
26
|
+
? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
|
|
27
|
+
: [];
|
|
28
|
+
const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
|
|
29
|
+
const untrackedMb = Math.max(0, deltaMb - trackedMb);
|
|
30
|
+
const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
|
|
31
|
+
const seen = parts.length
|
|
32
|
+
? `; supernova-tracked host bytes: ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
|
|
33
|
+
: "";
|
|
34
|
+
|
|
35
|
+
return `guest exceeded memory limit (maxHeapMb=${limitMb}): process RSS +${deltaMb.toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}; `
|
|
36
|
+
+ `~${untrackedMb.toFixed(1)}MB untracked (worker heap, transient buffers, or host/concurrent growth outside supernova)`;
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
let idleWorker = null;
|
|
20
40
|
|
|
21
41
|
let runSeq = 0;
|
|
@@ -34,37 +54,65 @@ function hasReturn(node) {
|
|
|
34
54
|
return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
|
|
35
55
|
}
|
|
36
56
|
|
|
37
|
-
function
|
|
38
|
-
let program;
|
|
39
|
-
let expression;
|
|
40
|
-
let expressionSource;
|
|
41
|
-
|
|
57
|
+
function parseExpressionFunction(code) {
|
|
42
58
|
try {
|
|
43
|
-
program = parse(code, PARSE_OPTIONS);
|
|
59
|
+
const program = parse(code, PARSE_OPTIONS);
|
|
44
60
|
const statements = program.body.filter(node => node.type !== "EmptyStatement");
|
|
45
61
|
const statement = statements.length === 1 ? statements[0] : undefined;
|
|
46
62
|
const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
|
|
47
63
|
|
|
48
64
|
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
49
|
-
expression
|
|
50
|
-
expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
|
|
65
|
+
return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
|
|
51
66
|
}
|
|
67
|
+
|
|
68
|
+
return { program };
|
|
52
69
|
} catch (bodyError) {
|
|
53
|
-
expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
70
|
+
const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
54
71
|
|
|
55
72
|
try {
|
|
56
73
|
const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
|
|
57
|
-
expression = wrapped.body[0]?.expression;
|
|
74
|
+
const expression = wrapped.body[0]?.expression;
|
|
58
75
|
|
|
59
76
|
if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
|
|
77
|
+
|
|
78
|
+
return { expression, expressionSource };
|
|
60
79
|
} catch { throw bodyError; }
|
|
61
80
|
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function deniedSpecifier(node) {
|
|
84
|
+
if (node?.type === "Literal" && isString(node.value)) return node.value;
|
|
85
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertGuestImports(ast) {
|
|
89
|
+
function walk(node) {
|
|
90
|
+
if (Array.isArray(node)) { node.forEach(walk); return; }
|
|
91
|
+
if (!isObject(node)) return;
|
|
92
|
+
if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
|
|
93
|
+
const spec = deniedSpecifier(node.source);
|
|
94
|
+
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
95
|
+
}
|
|
96
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
|
|
97
|
+
const spec = deniedSpecifier(node.arguments?.[0]);
|
|
98
|
+
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
99
|
+
}
|
|
100
|
+
for (const key of Object.keys(node)) {
|
|
101
|
+
if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
102
|
+
walk(node[key]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
62
105
|
|
|
63
|
-
|
|
106
|
+
walk(ast);
|
|
107
|
+
}
|
|
64
108
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
109
|
+
function prepareProgram(code) {
|
|
110
|
+
const parsed = parseExpressionFunction(code);
|
|
111
|
+
assertGuestImports(parsed.program ?? parsed.expression);
|
|
112
|
+
const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
|
|
113
|
+
const returns = parsed.expression
|
|
114
|
+
? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
|
|
115
|
+
: hasReturn(parsed.program);
|
|
68
116
|
|
|
69
117
|
return { body, hasReturn: returns };
|
|
70
118
|
}
|
|
@@ -128,6 +176,13 @@ function acquireWorker(config) {
|
|
|
128
176
|
const handle = reusable ? candidate : spawnWorker(config);
|
|
129
177
|
handle.worker.ref?.();
|
|
130
178
|
|
|
179
|
+
// Pipeline the successor while this run executes. A consumed worker's
|
|
180
|
+
// replacement starts at once; a cold start's replacement waits for ready,
|
|
181
|
+
// so the two constructions never overlap. The finish-time warm usually
|
|
182
|
+
// becomes a no-op, so steady-state spawn count is unchanged.
|
|
183
|
+
if (reusable) warmGuestWorker(config).catch(() => {});
|
|
184
|
+
else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
|
|
185
|
+
|
|
131
186
|
return handle;
|
|
132
187
|
}
|
|
133
188
|
|
|
@@ -159,203 +214,299 @@ const RPC_METHODS = {
|
|
|
159
214
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
160
215
|
};
|
|
161
216
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const wall = () => Math.round(performance.now() - started);
|
|
165
|
-
const logs = [];
|
|
166
|
-
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
167
|
-
let logTruncated = false;
|
|
217
|
+
function admitData(data, cap) {
|
|
218
|
+
if (data === undefined) return { data };
|
|
168
219
|
|
|
169
|
-
|
|
220
|
+
try {
|
|
221
|
+
const encoded = JSON.stringify(data);
|
|
170
222
|
|
|
171
|
-
|
|
223
|
+
if (encoded === undefined) return { error: "data must be JSON-serializable" };
|
|
224
|
+
if (encoded.length > cap) return { error: "data exceeds " + cap + " characters; split literal inputs across invocations" };
|
|
172
225
|
|
|
173
|
-
|
|
226
|
+
return { data: JSON.parse(encoded) };
|
|
227
|
+
} catch { return { error: "data must be JSON-serializable" }; }
|
|
228
|
+
}
|
|
174
229
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
230
|
+
function admitCode({ code, file, cap }) {
|
|
231
|
+
if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
|
|
232
|
+
if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
|
|
233
|
+
if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
|
|
234
|
+
}
|
|
178
235
|
|
|
179
|
-
|
|
236
|
+
function admitTimeout(config) {
|
|
237
|
+
const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
|
|
180
238
|
|
|
181
|
-
|
|
182
|
-
data = JSON.parse(encoded);
|
|
183
|
-
} catch { return fail("data must be JSON-serializable"); }
|
|
184
|
-
}
|
|
239
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
|
|
185
240
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const timeoutMs = config.timeoutMs ?? 60000;
|
|
189
|
-
const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
|
|
190
|
-
|
|
191
|
-
return new Promise((resolve) => {
|
|
192
|
-
let handle;
|
|
193
|
-
let finished = false;
|
|
194
|
-
let accepting = true;
|
|
195
|
-
let completing = false;
|
|
196
|
-
let hostError;
|
|
197
|
-
let notifyingHost = false;
|
|
198
|
-
const pending = new Set();
|
|
199
|
-
const inputController = new AbortController();
|
|
241
|
+
return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
|
|
242
|
+
}
|
|
200
243
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
clearInterval(memTimer);
|
|
205
|
-
signal?.removeEventListener("abort", signalAbort);
|
|
206
|
-
handle?.worker.off("message", onMessage);
|
|
207
|
-
handle?.worker.off("error", onError);
|
|
208
|
-
handle?.worker.off("exit", onExit);
|
|
209
|
-
};
|
|
244
|
+
function admitGuest({ code, file, data, config }) {
|
|
245
|
+
const cap = config.maxCodeChars ?? 48000;
|
|
246
|
+
const codeError = admitCode({ code, file, cap });
|
|
210
247
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
finished = true;
|
|
214
|
-
accepting = false;
|
|
215
|
-
cleanup();
|
|
216
|
-
void killWorker(handle);
|
|
217
|
-
resolve({ ...outcome, wallMs: wall() });
|
|
218
|
-
};
|
|
248
|
+
if (codeError) return codeError;
|
|
249
|
+
const admitted = admitData(data, cap);
|
|
219
250
|
|
|
220
|
-
|
|
221
|
-
|
|
251
|
+
if (admitted.error) return admitted;
|
|
252
|
+
const timeout = admitTimeout(config);
|
|
222
253
|
|
|
223
|
-
|
|
224
|
-
};
|
|
254
|
+
if (timeout.error) return timeout;
|
|
225
255
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
cancelHost();
|
|
256
|
+
return { data: admitted.data, timeoutMs: timeout.timeoutMs };
|
|
257
|
+
}
|
|
229
258
|
|
|
230
|
-
|
|
259
|
+
class GuestRun {
|
|
260
|
+
constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs, rssLimit, rssStart }) {
|
|
261
|
+
this.code = code;
|
|
262
|
+
this.file = file;
|
|
263
|
+
this.cwd = cwd;
|
|
264
|
+
this.data = data;
|
|
265
|
+
this.nova = nova;
|
|
266
|
+
this.config = config;
|
|
267
|
+
this.signal = signal;
|
|
268
|
+
this.onTimeout = onTimeout;
|
|
269
|
+
this.runId = runId;
|
|
270
|
+
this.timeoutMs = timeoutMs;
|
|
271
|
+
this.rssLimit = rssLimit;
|
|
272
|
+
this.rssStart = rssStart;
|
|
273
|
+
this.started = performance.now();
|
|
274
|
+
this.logs = [];
|
|
275
|
+
this.logTruncated = false;
|
|
276
|
+
this.handle = undefined;
|
|
277
|
+
this.finished = false;
|
|
278
|
+
this.accepting = true;
|
|
279
|
+
this.completing = false;
|
|
280
|
+
this.hostError = undefined;
|
|
281
|
+
this.notifyingHost = false;
|
|
282
|
+
this.aborting = false;
|
|
283
|
+
this.pending = new Set();
|
|
284
|
+
this.inputController = new AbortController();
|
|
285
|
+
this.rpcCount = 0;
|
|
286
|
+
this.lastRpcMethod = null;
|
|
287
|
+
}
|
|
231
288
|
|
|
232
|
-
|
|
233
|
-
|
|
289
|
+
wall() {
|
|
290
|
+
return Math.round(performance.now() - this.started);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fail(error) {
|
|
294
|
+
return { ok: false, error: truncateChars(String(error), this.config.maxReturnChars ?? 32000, "error").text, logs: this.logs, logTruncated: this.logTruncated, wallMs: this.wall() };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
cleanup() {
|
|
298
|
+
this.inputController.abort();
|
|
299
|
+
clearTimeout(this.timer);
|
|
300
|
+
clearInterval(this.memTimer);
|
|
301
|
+
this.signal?.removeEventListener("abort", this.signalAbort);
|
|
302
|
+
this.handle?.worker.off("message", this.onMessage);
|
|
303
|
+
this.handle?.worker.off("error", this.onError);
|
|
304
|
+
this.handle?.worker.off("exit", this.onExit);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
finish(outcome) {
|
|
308
|
+
if (this.finished) return;
|
|
309
|
+
this.finished = true;
|
|
310
|
+
this.accepting = false;
|
|
311
|
+
this.cleanup();
|
|
312
|
+
void killWorker(this.handle);
|
|
313
|
+
this.resolve({ ...outcome, wallMs: this.wall() });
|
|
314
|
+
}
|
|
234
315
|
|
|
235
|
-
|
|
316
|
+
cancelHost() {
|
|
317
|
+
this.notifyingHost = true;
|
|
236
318
|
|
|
237
|
-
|
|
319
|
+
try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
|
|
320
|
+
}
|
|
238
321
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}, MEMORY_POLL_MS);
|
|
322
|
+
abort() {
|
|
323
|
+
if (this.finished || this.aborting) return;
|
|
324
|
+
this.aborting = true;
|
|
325
|
+
this.cancelHost();
|
|
244
326
|
|
|
245
|
-
|
|
327
|
+
try { this.onTimeout?.(); } catch {}
|
|
246
328
|
|
|
247
|
-
|
|
248
|
-
|
|
329
|
+
this.aborting = false;
|
|
330
|
+
this.finish(this.fail(ABORT_MESSAGE));
|
|
331
|
+
}
|
|
249
332
|
|
|
333
|
+
postResult(message) {
|
|
334
|
+
if (!this.accepting || this.finished) return;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
this.handle.worker.postMessage({ ...message, runId: this.runId });
|
|
338
|
+
} catch (err) {
|
|
250
339
|
try {
|
|
251
|
-
handle.worker.postMessage({
|
|
252
|
-
} catch (
|
|
253
|
-
|
|
254
|
-
handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
|
|
255
|
-
} catch (error) {
|
|
256
|
-
onError(error);
|
|
257
|
-
}
|
|
340
|
+
this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
|
|
341
|
+
} catch (error) {
|
|
342
|
+
this.onError(error);
|
|
258
343
|
}
|
|
259
|
-
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
260
346
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
handle?.worker.off("error", onError);
|
|
267
|
-
handle?.worker.off("exit", onExit);
|
|
268
|
-
void killWorker(handle);
|
|
347
|
+
async drainPending(outcome) {
|
|
348
|
+
if (this.pending.size || !outcome.ok) this.cancelHost();
|
|
349
|
+
await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => setTimeout(resolve, 250))]);
|
|
350
|
+
if (this.pending.size && outcome.ok) this.hostError ??= "program completed with a host call still running";
|
|
351
|
+
}
|
|
269
352
|
|
|
270
|
-
|
|
271
|
-
|
|
353
|
+
async complete(outcome) {
|
|
354
|
+
if (this.finished || this.completing) return;
|
|
355
|
+
this.completing = true;
|
|
356
|
+
this.accepting = false;
|
|
357
|
+
this.handle?.worker.off("error", this.onError);
|
|
358
|
+
this.handle?.worker.off("exit", this.onExit);
|
|
359
|
+
void killWorker(this.handle);
|
|
360
|
+
await this.drainPending(outcome);
|
|
361
|
+
if (this.finished) return;
|
|
362
|
+
this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
|
|
363
|
+
}
|
|
272
364
|
|
|
273
|
-
|
|
274
|
-
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
275
|
-
};
|
|
365
|
+
onError = (err) => { void this.complete(this.fail("guest crashed: " + err.message)); };
|
|
276
366
|
|
|
277
|
-
|
|
367
|
+
onExit = (exitCode) => { void this.complete(this.fail("guest exited (code " + exitCode + ")")); };
|
|
278
368
|
|
|
279
|
-
|
|
369
|
+
onLog(msg) {
|
|
370
|
+
if (this.logs.length < (this.config.maxLogLines ?? 100)) this.logs.push(msg.line);
|
|
371
|
+
else this.logTruncated = true;
|
|
372
|
+
this.logTruncated ||= msg.truncated === true;
|
|
373
|
+
}
|
|
280
374
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
work.then(
|
|
301
|
-
value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
302
|
-
err => {
|
|
303
|
-
// An awaited, handled host error must not poison the whole program.
|
|
304
|
-
if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
|
|
305
|
-
postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
306
|
-
},
|
|
307
|
-
).finally(() => pending.delete(work));
|
|
308
|
-
} else if (msg.op === "done") {
|
|
309
|
-
try {
|
|
310
|
-
const packed = packageFinalReturn(msg.value, logs, config);
|
|
311
|
-
void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
312
|
-
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
313
|
-
logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
|
|
314
|
-
} catch (err) {
|
|
315
|
-
void complete(fail(err.message));
|
|
316
|
-
}
|
|
317
|
-
} else if (msg.op === "error") {
|
|
318
|
-
const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
|
|
319
|
-
void complete(fail(msg.message + where));
|
|
320
|
-
}
|
|
321
|
-
};
|
|
375
|
+
onRpc(msg) {
|
|
376
|
+
const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
|
|
377
|
+
this.rpcCount++;
|
|
378
|
+
this.lastRpcMethod = isString(msg.method) ? msg.method : null;
|
|
379
|
+
const work = Promise.resolve().then(() => {
|
|
380
|
+
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
381
|
+
|
|
382
|
+
return method(this.nova, msg.args);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
this.pending.add(work);
|
|
386
|
+
work.then(
|
|
387
|
+
value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
388
|
+
err => {
|
|
389
|
+
if (!this.accepting) this.hostError ??= err instanceof Error ? err.message : String(err);
|
|
390
|
+
this.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
391
|
+
},
|
|
392
|
+
).finally(() => this.pending.delete(work));
|
|
393
|
+
}
|
|
322
394
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
395
|
+
onDone(msg) {
|
|
396
|
+
try {
|
|
397
|
+
const packed = packageFinalReturn(msg.value, this.logs, this.config);
|
|
398
|
+
void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
399
|
+
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
400
|
+
logs: packed.logs, logTruncated: this.logTruncated || packed.logTruncated });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
void this.complete(this.fail(err.message));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
326
405
|
|
|
327
|
-
|
|
406
|
+
guestError(msg) {
|
|
407
|
+
const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
|
|
408
|
+
void this.complete(this.fail(msg.message + where));
|
|
409
|
+
}
|
|
328
410
|
|
|
329
|
-
|
|
411
|
+
dispatchGuest(msg) {
|
|
412
|
+
if (msg.op === "log") this.onLog(msg);
|
|
413
|
+
else if (msg.op === "logTruncated") this.logTruncated = true;
|
|
414
|
+
else if (msg.op === "rpc") this.onRpc(msg);
|
|
415
|
+
else if (msg.op === "done") this.onDone(msg);
|
|
416
|
+
else if (msg.op === "error") this.guestError(msg);
|
|
417
|
+
}
|
|
330
418
|
|
|
331
|
-
|
|
332
|
-
|
|
419
|
+
onMessage = (msg) => {
|
|
420
|
+
if (this.finished || !this.accepting || !isObject(msg) || msg.runId !== this.runId) return;
|
|
421
|
+
this.dispatchGuest(msg);
|
|
422
|
+
};
|
|
333
423
|
|
|
334
|
-
|
|
335
|
-
catch (error) { return finish(fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); }
|
|
424
|
+
signalAbort = () => { if (!this.notifyingHost) this.abort(); };
|
|
336
425
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
426
|
+
async loadCode() {
|
|
427
|
+
if (this.file !== undefined) this.code = await readProgramFile(this.file, this.cwd, this.config.maxCodeChars ?? 48000, this.inputController.signal);
|
|
428
|
+
if (this.finished) return false;
|
|
429
|
+
if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
|
|
340
430
|
|
|
341
|
-
|
|
342
|
-
|
|
431
|
+
try { this.prepared = prepareProgram(this.code); }
|
|
432
|
+
catch (error) { this.finish(this.fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); return false; }
|
|
343
433
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
handle.worker.on("error", onError);
|
|
347
|
-
handle.worker.on("exit", onExit);
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
348
436
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
437
|
+
async attachWorker() {
|
|
438
|
+
if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
|
|
439
|
+
this.handle = acquireWorker(this.config);
|
|
440
|
+
await this.handle.ready;
|
|
441
|
+
if (this.finished || this.signal?.aborted) { this.abort(); return false; }
|
|
442
|
+
let available = isFunction(this.nova.names) ? await this.nova.names() : [];
|
|
443
|
+
|
|
444
|
+
if (!Array.isArray(available)) available = [];
|
|
445
|
+
if (this.finished || this.signal?.aborted) { this.abort(); return false; }
|
|
446
|
+
this.handle.worker.on("message", this.onMessage);
|
|
447
|
+
this.handle.worker.on("error", this.onError);
|
|
448
|
+
this.handle.worker.on("exit", this.onExit);
|
|
449
|
+
if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
|
|
450
|
+
this.available = available;
|
|
451
|
+
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
postRun() {
|
|
456
|
+
this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
|
|
457
|
+
batchRead: this.nova.batchRead !== false,
|
|
458
|
+
nativeArgv: this.nova.nativeArgv === true,
|
|
459
|
+
limits: { maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async boot() {
|
|
463
|
+
try {
|
|
464
|
+
if (this.signal?.aborted) return this.abort();
|
|
465
|
+
if (!await this.loadCode()) return;
|
|
466
|
+
if (!await this.attachWorker()) return;
|
|
467
|
+
this.postRun();
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (this.finished) return;
|
|
470
|
+
this.cancelHost();
|
|
471
|
+
this.finish(this.fail("program failed to start: " + err.message + "; no commands ran"));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
start() {
|
|
476
|
+
return new Promise((resolve) => {
|
|
477
|
+
this.resolve = resolve;
|
|
478
|
+
this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
|
|
479
|
+
this.memTimer = setInterval(() => {
|
|
480
|
+
const now = rssBytes();
|
|
481
|
+
|
|
482
|
+
if (now <= this.rssLimit) return;
|
|
483
|
+
this.cancelHost();
|
|
484
|
+
let tracked = null;
|
|
485
|
+
|
|
486
|
+
try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
|
|
487
|
+
this.finish(this.fail(formatMemoryAttribution({
|
|
488
|
+
limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
|
|
489
|
+
ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
|
|
490
|
+
})));
|
|
491
|
+
}, MEMORY_POLL_MS);
|
|
492
|
+
this.signal?.addEventListener("abort", this.signalAbort, { once: true });
|
|
493
|
+
void this.boot();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
|
|
499
|
+
const admitted = admitGuest({ code, file, data, config });
|
|
500
|
+
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs: [], logTruncated: false, wallMs: 0 });
|
|
501
|
+
|
|
502
|
+
if (admitted.error) return fail(admitted.error);
|
|
503
|
+
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
504
|
+
const rssStart = rssBytes();
|
|
505
|
+
|
|
506
|
+
return new GuestRun({
|
|
507
|
+
code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
|
|
508
|
+
runId: ++runSeq,
|
|
509
|
+
timeoutMs: admitted.timeoutMs,
|
|
510
|
+
rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
|
|
511
|
+
}).start();
|
|
361
512
|
}
|