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.
- package/README.md +27 -3
- package/docs/CHANGELOG.md +114 -0
- package/docs/TOKEN_COSTS.md +13 -5
- package/index.js +120 -79
- package/package.json +1 -1
- 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 +28 -222
- package/src/bridge/host-bridge.js +113 -1668
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -198
- package/src/context/evidence.js +140 -76
- package/src/context/fuzzy.js +42 -24
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +23 -18
- package/src/context/repo-index.js +206 -170
- package/src/context/search.js +157 -77
- package/src/context/snap.js +240 -136
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +14 -5
- 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 +12 -8
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +94 -50
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +205 -175
- package/src/fs/workspace.js +119 -108
- package/src/output/bottleneck.js +195 -116
- package/src/output/format.js +101 -67
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +296 -292
- package/src/runtime/parallel.js +97 -64
- package/src/runtime/program-batch.js +178 -69
- package/src/runtime/reference.js +15 -14
- package/src/runtime/runtime.js +327 -187
- package/src/shared/decode.js +58 -36
- package/src/ui/omp-frame.js +59 -42
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +241 -145
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(statement.start, 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,214 +214,299 @@ const RPC_METHODS = {
|
|
|
159
214
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
160
215
|
};
|
|
161
216
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
let logTruncated = false;
|
|
217
|
+
function admitData(data, cap) {
|
|
218
|
+
if (data === undefined) return { data };
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const encoded = JSON.stringify(data);
|
|
168
222
|
|
|
169
|
-
|
|
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" };
|
|
170
225
|
|
|
171
|
-
|
|
226
|
+
return { data: JSON.parse(encoded) };
|
|
227
|
+
} catch { return { error: "data must be JSON-serializable" }; }
|
|
228
|
+
}
|
|
172
229
|
|
|
173
|
-
|
|
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
|
+
}
|
|
174
235
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const encoded = JSON.stringify(data);
|
|
236
|
+
function admitTimeout(config) {
|
|
237
|
+
const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
|
|
178
238
|
|
|
179
|
-
|
|
239
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
|
|
180
240
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
} catch { return fail("data must be JSON-serializable"); }
|
|
184
|
-
}
|
|
241
|
+
return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
|
|
242
|
+
}
|
|
185
243
|
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
const
|
|
244
|
+
function admitGuest({ code, file, data, config }) {
|
|
245
|
+
const cap = config.maxCodeChars ?? 48000;
|
|
246
|
+
const codeError = admitCode({ code, file, cap });
|
|
189
247
|
|
|
190
|
-
if (
|
|
191
|
-
const
|
|
192
|
-
const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
|
|
193
|
-
|
|
194
|
-
return new Promise((resolve) => {
|
|
195
|
-
let handle;
|
|
196
|
-
let finished = false;
|
|
197
|
-
let accepting = true;
|
|
198
|
-
let completing = false;
|
|
199
|
-
let hostError;
|
|
200
|
-
let notifyingHost = false;
|
|
201
|
-
let aborting = false;
|
|
202
|
-
const pending = new Set();
|
|
203
|
-
const inputController = new AbortController();
|
|
248
|
+
if (codeError) return codeError;
|
|
249
|
+
const admitted = admitData(data, cap);
|
|
204
250
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
clearTimeout(timer);
|
|
208
|
-
clearInterval(memTimer);
|
|
209
|
-
signal?.removeEventListener("abort", signalAbort);
|
|
210
|
-
handle?.worker.off("message", onMessage);
|
|
211
|
-
handle?.worker.off("error", onError);
|
|
212
|
-
handle?.worker.off("exit", onExit);
|
|
213
|
-
};
|
|
251
|
+
if (admitted.error) return admitted;
|
|
252
|
+
const timeout = admitTimeout(config);
|
|
214
253
|
|
|
215
|
-
|
|
216
|
-
if (finished) return;
|
|
217
|
-
finished = true;
|
|
218
|
-
accepting = false;
|
|
219
|
-
cleanup();
|
|
220
|
-
void killWorker(handle);
|
|
221
|
-
resolve({ ...outcome, wallMs: wall() });
|
|
222
|
-
};
|
|
254
|
+
if (timeout.error) return timeout;
|
|
223
255
|
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
return { data: admitted.data, timeoutMs: timeout.timeoutMs };
|
|
257
|
+
}
|
|
226
258
|
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
288
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
cancelHost();
|
|
289
|
+
wall() {
|
|
290
|
+
return Math.round(performance.now() - this.started);
|
|
291
|
+
}
|
|
234
292
|
|
|
235
|
-
|
|
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
|
+
}
|
|
236
296
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
315
|
|
|
241
|
-
|
|
316
|
+
cancelHost() {
|
|
317
|
+
this.notifyingHost = true;
|
|
242
318
|
|
|
243
|
-
|
|
319
|
+
try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
|
|
320
|
+
}
|
|
244
321
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}, MEMORY_POLL_MS);
|
|
322
|
+
abort() {
|
|
323
|
+
if (this.finished || this.aborting) return;
|
|
324
|
+
this.aborting = true;
|
|
325
|
+
this.cancelHost();
|
|
250
326
|
|
|
251
|
-
|
|
327
|
+
try { this.onTimeout?.(); } catch {}
|
|
252
328
|
|
|
253
|
-
|
|
254
|
-
|
|
329
|
+
this.aborting = false;
|
|
330
|
+
this.finish(this.fail(ABORT_MESSAGE));
|
|
331
|
+
}
|
|
255
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) {
|
|
256
339
|
try {
|
|
257
|
-
handle.worker.postMessage({
|
|
258
|
-
} catch (
|
|
259
|
-
|
|
260
|
-
handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
|
|
261
|
-
} catch (error) {
|
|
262
|
-
onError(error);
|
|
263
|
-
}
|
|
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);
|
|
264
343
|
}
|
|
265
|
-
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
266
346
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
handle?.worker.off("error", onError);
|
|
273
|
-
handle?.worker.off("exit", onExit);
|
|
274
|
-
void killWorker(handle);
|
|
275
|
-
|
|
276
|
-
if (pending.size || !outcome.ok) cancelHost();
|
|
277
|
-
// A host tool that ignores cancellation must not keep the result unsettled
|
|
278
|
-
// after the guest worker is gone. Give it a brief drain window only.
|
|
279
|
-
await Promise.race([Promise.allSettled(pending), new Promise(resolve => setTimeout(resolve, 250))]);
|
|
280
|
-
if (pending.size && outcome.ok) hostError ??= "program completed with a host call still running";
|
|
281
|
-
|
|
282
|
-
if (finished) return;
|
|
283
|
-
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
284
|
-
};
|
|
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
|
+
}
|
|
285
352
|
|
|
286
|
-
|
|
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
|
+
}
|
|
287
364
|
|
|
288
|
-
|
|
365
|
+
onError = (err) => { void this.complete(this.fail("guest crashed: " + err.message)); };
|
|
289
366
|
|
|
290
|
-
|
|
291
|
-
if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
|
|
292
|
-
|
|
293
|
-
if (msg.op === "log") {
|
|
294
|
-
if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
|
|
295
|
-
else logTruncated = true;
|
|
296
|
-
logTruncated ||= msg.truncated === true;
|
|
297
|
-
} else if (msg.op === "logTruncated") {
|
|
298
|
-
logTruncated = true;
|
|
299
|
-
} else if (msg.op === "rpc") {
|
|
300
|
-
const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
|
|
301
|
-
|
|
302
|
-
const work = Promise.resolve().then(() => {
|
|
303
|
-
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
304
|
-
|
|
305
|
-
return method(nova, msg.args);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
pending.add(work);
|
|
309
|
-
work.then(
|
|
310
|
-
value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
311
|
-
err => {
|
|
312
|
-
// An awaited, handled host error must not poison the whole program.
|
|
313
|
-
if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
|
|
314
|
-
postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
315
|
-
},
|
|
316
|
-
).finally(() => pending.delete(work));
|
|
317
|
-
} else if (msg.op === "done") {
|
|
318
|
-
try {
|
|
319
|
-
const packed = packageFinalReturn(msg.value, logs, config);
|
|
320
|
-
void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
321
|
-
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
322
|
-
logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
|
|
323
|
-
} catch (err) {
|
|
324
|
-
void complete(fail(err.message));
|
|
325
|
-
}
|
|
326
|
-
} else if (msg.op === "error") {
|
|
327
|
-
const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
|
|
328
|
-
void complete(fail(msg.message + where));
|
|
329
|
-
}
|
|
330
|
-
};
|
|
367
|
+
onExit = (exitCode) => { void this.complete(this.fail("guest exited (code " + exitCode + ")")); };
|
|
331
368
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
}
|
|
374
|
+
|
|
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
|
+
}
|
|
394
|
+
|
|
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
|
+
}
|
|
335
405
|
|
|
336
|
-
|
|
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
|
+
}
|
|
410
|
+
|
|
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
|
+
}
|
|
337
418
|
|
|
338
|
-
|
|
419
|
+
onMessage = (msg) => {
|
|
420
|
+
if (this.finished || !this.accepting || !isObject(msg) || msg.runId !== this.runId) return;
|
|
421
|
+
this.dispatchGuest(msg);
|
|
422
|
+
};
|
|
339
423
|
|
|
340
|
-
|
|
341
|
-
let prepared;
|
|
424
|
+
signalAbort = () => { if (!this.notifyingHost) this.abort(); };
|
|
342
425
|
|
|
343
|
-
|
|
344
|
-
|
|
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; }
|
|
345
430
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
await handle.ready;
|
|
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; }
|
|
349
433
|
|
|
350
|
-
|
|
351
|
-
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
352
436
|
|
|
353
|
-
|
|
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
|
+
}
|
|
354
454
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
}
|
|
359
461
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
}
|
|
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();
|
|
372
512
|
}
|