pi-supernova 0.8.1 → 0.9.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 +244 -54
- package/docs/CHANGELOG.md +111 -1
- package/docs/TOKEN_COSTS.md +38 -0
- package/index.js +10 -175
- package/package.json +2 -1
- package/src/adapters/bash.js +23 -33
- package/src/adapters/errors.js +1 -9
- package/src/adapters/read-focus.js +98 -0
- package/src/adapters/read-image.js +51 -0
- package/src/adapters/read-json.js +42 -0
- package/src/adapters/read-text.js +71 -0
- package/src/adapters/read.js +66 -635
- package/src/bridge/catalog.js +3 -2
- package/src/bridge/host-bridge.js +35 -167
- package/src/bridge/tool-registry.js +104 -0
- package/src/bridge/trace.js +41 -0
- package/src/context/evidence-graph.js +249 -0
- package/src/context/evidence-rank.js +153 -0
- package/src/context/evidence.js +10 -424
- package/src/context/query.js +71 -0
- package/src/context/repo-index.js +8 -162
- package/src/context/search-files.js +19 -0
- package/src/context/search.js +2 -24
- package/src/context/snap-search.js +202 -0
- package/src/context/snap.js +5 -266
- package/src/context/source-entry.js +112 -0
- package/src/contract/bash.js +18 -2
- package/src/contract/program.js +36 -0
- package/src/contract/read.js +8 -53
- package/src/fs/check.js +1 -1
- package/src/fs/commit.js +161 -0
- package/src/fs/diff.js +11 -15
- package/src/fs/directory.js +79 -0
- package/src/fs/file-io.js +100 -0
- package/src/fs/glob.js +54 -0
- package/src/fs/json-size.js +54 -0
- package/src/fs/lines.js +117 -0
- package/src/fs/read-window.js +74 -0
- package/src/fs/session-resource.js +50 -0
- package/src/fs/text-ops.js +7 -242
- package/src/fs/vfs.js +5 -239
- package/src/fs/workspace.js +7 -2
- package/src/output/bottleneck.js +13 -67
- package/src/output/final.js +114 -0
- package/src/output/format.js +94 -5
- package/src/output/outcome.js +91 -0
- package/src/runtime/batch-input.js +68 -0
- package/src/runtime/guest-api.js +281 -0
- package/src/runtime/guest-worker.js +62 -333
- package/src/runtime/parallel.js +41 -39
- package/src/runtime/program-batch.js +35 -68
- package/src/runtime/program-file.js +3 -11
- package/src/runtime/program.js +141 -0
- package/src/runtime/reference.js +11 -10
- package/src/runtime/runtime.js +94 -261
- package/src/runtime/worker-pool.js +91 -0
- package/src/shared/decode.js +22 -8
- package/src/shared/image-worker.js +30 -0
- package/src/shared/image.js +78 -0
- package/src/shared/png.js +57 -0
- package/src/shared/result.js +77 -0
- package/src/shared/syntax-context.js +61 -3
- package/src/ui/host-render.js +104 -0
- package/src/ui/progress.js +51 -0
- package/src/ui/render.js +21 -421
- package/src/ui/trace.js +277 -0
package/src/runtime/runtime.js
CHANGED
|
@@ -1,210 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {acquireWorker,killWorker} from './worker-pool.js';
|
|
2
|
+
export {warmGuestWorker,stopWarmGuestWorker} from './worker-pool.js';
|
|
3
|
+
import {admitGuest,prepareProgram,normalizeGuestLocation} from './program.js';
|
|
4
|
+
|
|
2
5
|
import { readProgramFile } from "./program-file.js";
|
|
3
|
-
|
|
6
|
+
|
|
4
7
|
import { performance } from "node:perf_hooks";
|
|
5
8
|
import { packageFinalReturn } from "../output/bottleneck.js";
|
|
6
9
|
import { truncateChars } from "../output/format.js";
|
|
7
|
-
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
8
|
-
import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
|
|
9
|
-
import { errorContext } from "../shared/syntax-context.js";
|
|
10
|
+
import { errorMessage, isFunction, isObject, isString } from "../shared/decode.js";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
|
|
12
|
+
import { errorContext } from "../shared/syntax-context.js";
|
|
13
|
+
import {validateReturnedImages} from "../shared/image.js";
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const ABORT_MESSAGE = "supernova aborted";
|
|
16
|
+
const TIMEOUT_MESSAGE = "supernova timed out: increase the outer timeoutMs (and any shorter bash timeoutMs), or split the program; sleeps count toward the deadline";
|
|
16
17
|
|
|
17
18
|
const MEMORY_SLACK = 1.5;
|
|
18
19
|
|
|
19
|
-
const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
|
|
20
|
-
|
|
21
20
|
const toMb = bytes => Math.max(0, bytes / 1048576);
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
export function formatMemoryAttribution({ limitMb,
|
|
25
|
-
const deltaMb = toMb(now - startBytes);
|
|
22
|
+
/** Only worker-local usage can trip the guard; host structures are diagnostic. */
|
|
23
|
+
export function formatMemoryAttribution({ limitMb, heapBytes, externalBytes, ms, op, calls, tracked }) {
|
|
26
24
|
const parts = tracked
|
|
27
25
|
? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
|
|
28
26
|
: [];
|
|
29
|
-
const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
|
|
30
|
-
const untrackedMb = Math.max(0, deltaMb - trackedMb);
|
|
31
27
|
const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
|
|
32
28
|
const seen = parts.length
|
|
33
|
-
? `; supernova-tracked host bytes: ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
|
|
29
|
+
? `; supernova-tracked host bytes (not charged to guest): ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
|
|
34
30
|
: "";
|
|
35
31
|
|
|
36
|
-
return `guest exceeded memory limit (maxHeapMb=${limitMb}):
|
|
37
|
-
+ `~${untrackedMb.toFixed(1)}MB untracked (worker heap, transient buffers, or host/concurrent growth outside supernova)`;
|
|
32
|
+
return `guest exceeded memory limit (maxHeapMb=${limitMb}): worker heap ${toMb(heapBytes).toFixed(1)}MB + external ${toMb(externalBytes).toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}`;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
let idleWorker = null;
|
|
41
|
-
|
|
42
35
|
let runSeq = 0;
|
|
43
36
|
|
|
44
|
-
const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
|
|
45
|
-
|
|
46
|
-
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
47
|
-
|
|
48
|
-
function hasReturn(node) {
|
|
49
|
-
if (!isObject(node)) return false;
|
|
50
|
-
|
|
51
|
-
if (node.type === "ReturnStatement") return true;
|
|
52
|
-
|
|
53
|
-
if (FUNCTION_TYPES.has(node.type)) return false;
|
|
54
|
-
|
|
55
|
-
return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function parseExpressionFunction(code) {
|
|
59
|
-
try {
|
|
60
|
-
const program = parse(code, PARSE_OPTIONS);
|
|
61
|
-
const statements = program.body.filter(node => node.type !== "EmptyStatement");
|
|
62
|
-
const statement = statements.length === 1 ? statements[0] : undefined;
|
|
63
|
-
const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
|
|
64
|
-
|
|
65
|
-
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
66
|
-
return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { program };
|
|
70
|
-
} catch (bodyError) {
|
|
71
|
-
const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
|
|
75
|
-
const expression = wrapped.body[0]?.expression;
|
|
76
|
-
|
|
77
|
-
if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
|
|
78
|
-
|
|
79
|
-
return { expression, expressionSource };
|
|
80
|
-
} catch { throw bodyError; }
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function deniedSpecifier(node) {
|
|
85
|
-
if (node?.type === "Literal" && isString(node.value)) return node.value;
|
|
86
|
-
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function assertGuestImports(ast) {
|
|
90
|
-
function walk(node) {
|
|
91
|
-
if (Array.isArray(node)) { node.forEach(walk); return; }
|
|
92
|
-
if (!isObject(node)) return;
|
|
93
|
-
if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
|
|
94
|
-
const spec = deniedSpecifier(node.source);
|
|
95
|
-
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
96
|
-
}
|
|
97
|
-
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
|
|
98
|
-
const spec = deniedSpecifier(node.arguments?.[0]);
|
|
99
|
-
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
100
|
-
}
|
|
101
|
-
for (const key of Object.keys(node)) {
|
|
102
|
-
if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
103
|
-
walk(node[key]);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
walk(ast);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function prepareProgram(code) {
|
|
111
|
-
const parsed = parseExpressionFunction(code);
|
|
112
|
-
assertGuestImports(parsed.program ?? parsed.expression);
|
|
113
|
-
const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
|
|
114
|
-
const returns = parsed.expression
|
|
115
|
-
? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
|
|
116
|
-
: hasReturn(parsed.program);
|
|
117
|
-
|
|
118
|
-
return { body, hasReturn: returns };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function spawnWorker(config) {
|
|
122
|
-
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
123
|
-
// An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
|
|
124
|
-
// Keep Node's automatic flag inheritance: explicitly copying execArgv can
|
|
125
|
-
// reintroduce process-only V8 flags that Worker rejects under node --test.
|
|
126
|
-
const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
127
|
-
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
128
|
-
// This listener also owns errors between readiness and a run's listeners.
|
|
129
|
-
worker.on("error", () => { handle.dead = true; });
|
|
130
|
-
worker.on("exit", () => {
|
|
131
|
-
handle.dead = true;
|
|
132
|
-
|
|
133
|
-
if (idleWorker === handle) idleWorker = null;
|
|
134
|
-
});
|
|
135
|
-
handle.ready = new Promise((resolve, reject) => {
|
|
136
|
-
const cleanup = () => {
|
|
137
|
-
worker.off("message", onMessage);
|
|
138
|
-
worker.off("error", onFail);
|
|
139
|
-
worker.off("exit", onFail);
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const onMessage = (msg) => {
|
|
143
|
-
if (msg?.op !== "ready") return;
|
|
144
|
-
cleanup();
|
|
145
|
-
resolve();
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const onFail = (err) => {
|
|
149
|
-
cleanup();
|
|
150
|
-
reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
worker.on("message", onMessage);
|
|
154
|
-
worker.on("error", onFail);
|
|
155
|
-
worker.on("exit", onFail);
|
|
156
|
-
});
|
|
157
|
-
handle.ready.catch(() => {});
|
|
158
|
-
|
|
159
|
-
return handle;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function killWorker(handle) {
|
|
163
|
-
if (!handle) return;
|
|
164
|
-
|
|
165
|
-
if (idleWorker === handle) idleWorker = null;
|
|
166
|
-
handle.dead = true;
|
|
167
|
-
|
|
168
|
-
return handle.worker.terminate();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function acquireWorker(config) {
|
|
172
|
-
const candidate = idleWorker;
|
|
173
|
-
idleWorker = null;
|
|
174
|
-
const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
|
|
175
|
-
|
|
176
|
-
if (candidate && !reusable) void killWorker(candidate);
|
|
177
|
-
const handle = reusable ? candidate : spawnWorker(config);
|
|
178
|
-
handle.worker.ref?.();
|
|
179
|
-
|
|
180
|
-
// Pipeline the successor while this run executes. A consumed worker's
|
|
181
|
-
// replacement starts at once; a cold start's replacement waits for ready,
|
|
182
|
-
// so the two constructions never overlap. The finish-time warm usually
|
|
183
|
-
// becomes a no-op, so steady-state spawn count is unchanged.
|
|
184
|
-
if (reusable) warmGuestWorker(config).catch(() => {});
|
|
185
|
-
else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
|
|
186
|
-
|
|
187
|
-
return handle;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Only pristine workers may be prewarmed. A used worker is never pooled. */
|
|
191
|
-
export function warmGuestWorker(config = {}) {
|
|
192
|
-
if (idleWorker && !idleWorker.dead) return idleWorker.ready;
|
|
193
|
-
const handle = spawnWorker(config);
|
|
194
|
-
idleWorker = handle;
|
|
195
|
-
handle.ready.then(() => {
|
|
196
|
-
if (idleWorker === handle) handle.worker.unref?.();
|
|
197
|
-
}, () => {});
|
|
198
|
-
|
|
199
|
-
return handle.ready;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export function stopWarmGuestWorker() {
|
|
203
|
-
return killWorker(idleWorker);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
37
|
const RPC_METHODS = {
|
|
207
|
-
call: (nova, args) => nova.call(args[0], args[1]),
|
|
38
|
+
call: (nova, args, onItem) => nova.call(args[0], args[1], onItem),
|
|
208
39
|
callMany: async (nova, args) => {
|
|
209
40
|
const wave = await nova.callMany(args[0]);
|
|
210
41
|
|
|
@@ -215,50 +46,8 @@ const RPC_METHODS = {
|
|
|
215
46
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
216
47
|
};
|
|
217
48
|
|
|
218
|
-
function admitData(data, cap) {
|
|
219
|
-
if (data === undefined) return { data };
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
const encoded = JSON.stringify(data);
|
|
223
|
-
|
|
224
|
-
if (encoded === undefined) return { error: "data must be JSON-serializable" };
|
|
225
|
-
if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
|
|
226
|
-
|
|
227
|
-
return { data: JSON.parse(encoded) };
|
|
228
|
-
} catch { return { error: "data must be JSON-serializable" }; }
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function admitCode({ code, file, cap }) {
|
|
232
|
-
if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
|
|
233
|
-
if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
|
|
234
|
-
if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function admitTimeout(config) {
|
|
238
|
-
const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
|
|
239
|
-
|
|
240
|
-
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
|
|
241
|
-
|
|
242
|
-
return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function admitGuest({ code, file, data, config }) {
|
|
246
|
-
const cap = config.maxCodeChars ?? 48000;
|
|
247
|
-
const codeError = admitCode({ code, file, cap });
|
|
248
|
-
|
|
249
|
-
if (codeError) return codeError;
|
|
250
|
-
const admitted = admitData(data, cap);
|
|
251
|
-
|
|
252
|
-
if (admitted.error) return admitted;
|
|
253
|
-
const timeout = admitTimeout(config);
|
|
254
|
-
|
|
255
|
-
if (timeout.error) return timeout;
|
|
256
|
-
|
|
257
|
-
return { data: admitted.data, timeoutMs: timeout.timeoutMs };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
49
|
class GuestRun {
|
|
261
|
-
constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs
|
|
50
|
+
constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs }) {
|
|
262
51
|
this.code = code;
|
|
263
52
|
this.file = file;
|
|
264
53
|
this.cwd = cwd;
|
|
@@ -269,8 +58,6 @@ class GuestRun {
|
|
|
269
58
|
this.onTimeout = onTimeout;
|
|
270
59
|
this.runId = runId;
|
|
271
60
|
this.timeoutMs = timeoutMs;
|
|
272
|
-
this.rssLimit = rssLimit;
|
|
273
|
-
this.rssStart = rssStart;
|
|
274
61
|
this.started = performance.now();
|
|
275
62
|
this.logs = [];
|
|
276
63
|
this.logTruncated = false;
|
|
@@ -281,8 +68,14 @@ class GuestRun {
|
|
|
281
68
|
this.hostError = undefined;
|
|
282
69
|
this.notifyingHost = false;
|
|
283
70
|
this.aborting = false;
|
|
71
|
+
this.abortOutcome = undefined;
|
|
284
72
|
this.pending = new Set();
|
|
285
73
|
this.inputController = new AbortController();
|
|
74
|
+
this.deliveries = new Map();
|
|
75
|
+
this.inputController.signal.addEventListener("abort", () => {
|
|
76
|
+
for (const delivery of this.deliveries.values()) delivery.reject(new Error("aborted"));
|
|
77
|
+
this.deliveries.clear();
|
|
78
|
+
}, {once:true});
|
|
286
79
|
this.rpcCount = 0;
|
|
287
80
|
this.lastRpcMethod = null;
|
|
288
81
|
}
|
|
@@ -298,7 +91,6 @@ class GuestRun {
|
|
|
298
91
|
cleanup() {
|
|
299
92
|
this.inputController.abort();
|
|
300
93
|
clearTimeout(this.timer);
|
|
301
|
-
clearInterval(this.memTimer);
|
|
302
94
|
this.signal?.removeEventListener("abort", this.signalAbort);
|
|
303
95
|
this.handle?.worker.off("message", this.onMessage);
|
|
304
96
|
this.handle?.worker.off("error", this.onError);
|
|
@@ -320,31 +112,57 @@ class GuestRun {
|
|
|
320
112
|
try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
|
|
321
113
|
}
|
|
322
114
|
|
|
323
|
-
abort() {
|
|
115
|
+
abort(timedOut = false) {
|
|
324
116
|
if (this.finished || this.aborting) return;
|
|
325
117
|
this.aborting = true;
|
|
118
|
+
this.accepting = false;
|
|
119
|
+
this.abortOutcome = this.fail((timedOut ? TIMEOUT_MESSAGE : ABORT_MESSAGE) + " (ran " + this.wall() + "ms of " + this.timeoutMs + "ms)");
|
|
326
120
|
this.cancelHost();
|
|
327
121
|
|
|
328
|
-
try { this.onTimeout?.(); } catch {}
|
|
122
|
+
if (timedOut) { try { this.onTimeout?.(); } catch {} }
|
|
329
123
|
|
|
330
|
-
|
|
331
|
-
|
|
124
|
+
// Stop the guest immediately, then use the bounded host drain to retain
|
|
125
|
+
// shell diagnostics and wait for process-tree termination before returning.
|
|
126
|
+
void this.complete(this.abortOutcome);
|
|
332
127
|
}
|
|
333
128
|
|
|
334
129
|
postResult(message) {
|
|
335
|
-
if (!this.accepting || this.finished) return;
|
|
130
|
+
if (!this.accepting || this.finished) return false;
|
|
336
131
|
|
|
337
132
|
try {
|
|
338
133
|
this.handle.worker.postMessage({ ...message, runId: this.runId });
|
|
134
|
+
return true;
|
|
339
135
|
} catch (err) {
|
|
340
136
|
try {
|
|
341
137
|
this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
|
|
342
138
|
} catch (error) {
|
|
343
139
|
this.onError(error);
|
|
344
140
|
}
|
|
141
|
+
return false;
|
|
345
142
|
}
|
|
346
143
|
}
|
|
347
144
|
|
|
145
|
+
deliverReadItem(id, index, value) {
|
|
146
|
+
if (!this.accepting) return Promise.reject(new Error("aborted"));
|
|
147
|
+
const key = id + ":" + index;
|
|
148
|
+
return new Promise((resolve,reject) => {
|
|
149
|
+
this.deliveries.set(key,{resolve,reject});
|
|
150
|
+
if (!this.postResult({op:"rpc:item",id,index,value})) {
|
|
151
|
+
this.deliveries.delete(key);
|
|
152
|
+
reject(new Error("read result not transferable"));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onReadAck(msg) {
|
|
158
|
+
const key = msg.id + ":" + msg.index;
|
|
159
|
+
const delivery = this.deliveries.get(key);
|
|
160
|
+
if (!delivery) return;
|
|
161
|
+
this.deliveries.delete(key);
|
|
162
|
+
if (msg.error) delivery.reject(new Error(msg.error));
|
|
163
|
+
else delivery.resolve();
|
|
164
|
+
}
|
|
165
|
+
|
|
348
166
|
async drainPending(outcome) {
|
|
349
167
|
if (this.pending.size || !outcome.ok) this.cancelHost();
|
|
350
168
|
if (!this.pending.size) return;
|
|
@@ -360,11 +178,16 @@ class GuestRun {
|
|
|
360
178
|
if (this.finished || this.completing) return;
|
|
361
179
|
this.completing = true;
|
|
362
180
|
this.accepting = false;
|
|
181
|
+
this.inputController.abort();
|
|
363
182
|
this.handle?.worker.off("error", this.onError);
|
|
364
183
|
this.handle?.worker.off("exit", this.onExit);
|
|
365
184
|
void killWorker(this.handle);
|
|
366
185
|
await this.drainPending(outcome);
|
|
367
186
|
if (this.finished) return;
|
|
187
|
+
if (this.abortOutcome) {
|
|
188
|
+
const diagnostic = this.hostError && this.hostError !== "aborted" ? "\n" + this.hostError : "";
|
|
189
|
+
outcome = this.fail(this.abortOutcome.error + diagnostic);
|
|
190
|
+
}
|
|
368
191
|
this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
|
|
369
192
|
}
|
|
370
193
|
|
|
@@ -385,39 +208,63 @@ class GuestRun {
|
|
|
385
208
|
const work = Promise.resolve().then(() => {
|
|
386
209
|
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
387
210
|
|
|
388
|
-
|
|
211
|
+
const onItem = msg.streamRead && msg.method === "call" && msg.args?.[0] === "read"
|
|
212
|
+
? (index,value) => this.deliverReadItem(msg.id,index,value) : undefined;
|
|
213
|
+
return method(this.nova, msg.args, onItem);
|
|
389
214
|
});
|
|
390
215
|
|
|
391
216
|
this.pending.add(work);
|
|
392
217
|
work.then(
|
|
393
218
|
value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
394
219
|
err => {
|
|
395
|
-
if (!this.accepting) this.hostError ??=
|
|
396
|
-
this.postResult({ op: "rpc:result", id: msg.id, ok: false, error:
|
|
220
|
+
if (!this.accepting) this.hostError ??= errorMessage(err);
|
|
221
|
+
this.postResult({ op: "rpc:result", id: msg.id, ok: false, error: errorMessage(err) });
|
|
397
222
|
},
|
|
398
223
|
).finally(() => this.pending.delete(work));
|
|
399
224
|
}
|
|
400
225
|
|
|
401
|
-
onDone(msg) {
|
|
226
|
+
async onDone(msg) {
|
|
227
|
+
// A completed guest cannot submit more work while attachments are decoded.
|
|
228
|
+
// The outer timer stays armed; no commit occurs before validation completes.
|
|
229
|
+
this.accepting = false;
|
|
402
230
|
try {
|
|
403
|
-
const packed = packageFinalReturn(msg.value, this.logs, this.config);
|
|
231
|
+
const packed = msg.output ?? packageFinalReturn(msg.value, this.logs, this.config);
|
|
232
|
+
if (packed.images?.length) await validateReturnedImages(packed.images,this.inputController.signal);
|
|
404
233
|
void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
405
234
|
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
406
|
-
logs:
|
|
235
|
+
logs: this.logs, logTruncated: this.logTruncated || packed.logTruncated });
|
|
407
236
|
} catch (err) {
|
|
408
237
|
void this.complete(this.fail(err.message));
|
|
409
238
|
}
|
|
410
239
|
}
|
|
411
240
|
|
|
412
241
|
guestError(msg) {
|
|
413
|
-
const
|
|
242
|
+
const location = normalizeGuestLocation(this.prepared.body, msg.location);
|
|
243
|
+
const where = location ? " (line " + location.line + ":" + location.col + ")" : "";
|
|
414
244
|
void this.complete(this.fail(msg.message + where));
|
|
415
245
|
}
|
|
416
246
|
|
|
247
|
+
onMemory(msg) {
|
|
248
|
+
const { heapUsed, external } = msg;
|
|
249
|
+
if (![heapUsed, external].every(bytes => Number.isFinite(bytes) && bytes >= 0)) return;
|
|
250
|
+
if (heapUsed + external <= (this.config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576) return;
|
|
251
|
+
let tracked = null;
|
|
252
|
+
|
|
253
|
+
try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
|
|
254
|
+
// Use the same cancellation/drain path as other failures, not an early
|
|
255
|
+
// finish that can return while a host command is still mutating files.
|
|
256
|
+
void this.complete(this.fail(formatMemoryAttribution({
|
|
257
|
+
limitMb: this.config.maxHeapMb ?? 512, heapBytes: heapUsed, externalBytes: external,
|
|
258
|
+
ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
|
|
259
|
+
})));
|
|
260
|
+
}
|
|
261
|
+
|
|
417
262
|
dispatchGuest(msg) {
|
|
418
263
|
if (msg.op === "log") this.onLog(msg);
|
|
419
264
|
else if (msg.op === "logTruncated") this.logTruncated = true;
|
|
265
|
+
else if (msg.op === "memory") this.onMemory(msg);
|
|
420
266
|
else if (msg.op === "rpc") this.onRpc(msg);
|
|
267
|
+
else if (msg.op === "rpc:ack") this.onReadAck(msg);
|
|
421
268
|
else if (msg.op === "done") this.onDone(msg);
|
|
422
269
|
else if (msg.op === "error") this.guestError(msg);
|
|
423
270
|
}
|
|
@@ -448,7 +295,7 @@ class GuestRun {
|
|
|
448
295
|
}
|
|
449
296
|
|
|
450
297
|
async attachWorker() {
|
|
451
|
-
if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
|
|
298
|
+
if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
|
|
452
299
|
this.handle = acquireWorker(this.config);
|
|
453
300
|
await this.handle.ready;
|
|
454
301
|
if (this.finished || this.signal?.aborted) { this.abort(); return false; }
|
|
@@ -459,7 +306,7 @@ class GuestRun {
|
|
|
459
306
|
this.handle.worker.on("message", this.onMessage);
|
|
460
307
|
this.handle.worker.on("error", this.onError);
|
|
461
308
|
this.handle.worker.on("exit", this.onExit);
|
|
462
|
-
if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
|
|
309
|
+
if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
|
|
463
310
|
this.available = available;
|
|
464
311
|
|
|
465
312
|
return true;
|
|
@@ -469,7 +316,8 @@ class GuestRun {
|
|
|
469
316
|
this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
|
|
470
317
|
batchRead: this.nova.batchRead !== false,
|
|
471
318
|
nativeArgv: this.nova.nativeArgv === true,
|
|
472
|
-
|
|
319
|
+
formatResult:true,
|
|
320
|
+
limits: { maxReturnChars:this.config.maxReturnChars ?? 32000, maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
|
|
473
321
|
}
|
|
474
322
|
|
|
475
323
|
async boot() {
|
|
@@ -488,20 +336,7 @@ class GuestRun {
|
|
|
488
336
|
start() {
|
|
489
337
|
return new Promise((resolve) => {
|
|
490
338
|
this.resolve = resolve;
|
|
491
|
-
this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
|
|
492
|
-
this.memTimer = setInterval(() => {
|
|
493
|
-
const now = rssBytes();
|
|
494
|
-
|
|
495
|
-
if (now <= this.rssLimit) return;
|
|
496
|
-
this.cancelHost();
|
|
497
|
-
let tracked = null;
|
|
498
|
-
|
|
499
|
-
try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
|
|
500
|
-
this.finish(this.fail(formatMemoryAttribution({
|
|
501
|
-
limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
|
|
502
|
-
ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
|
|
503
|
-
})));
|
|
504
|
-
}, MEMORY_POLL_MS);
|
|
339
|
+
this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
|
|
505
340
|
this.signal?.addEventListener("abort", this.signalAbort, { once: true });
|
|
506
341
|
void this.boot();
|
|
507
342
|
});
|
|
@@ -514,12 +349,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
514
349
|
|
|
515
350
|
if (admitted.error) return fail(admitted.error);
|
|
516
351
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
517
|
-
const rssStart = rssBytes();
|
|
518
352
|
|
|
519
353
|
return new GuestRun({
|
|
520
354
|
code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
|
|
521
355
|
runId: ++runSeq,
|
|
522
356
|
timeoutMs: admitted.timeoutMs,
|
|
523
|
-
rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
|
|
524
357
|
}).start();
|
|
525
358
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {Worker} from 'node:worker_threads';
|
|
2
|
+
|
|
3
|
+
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
4
|
+
|
|
5
|
+
let idleWorker = null;
|
|
6
|
+
|
|
7
|
+
function spawnWorker(config) {
|
|
8
|
+
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
9
|
+
// An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
|
|
10
|
+
// Keep Node's automatic flag inheritance: explicitly copying execArgv can
|
|
11
|
+
// reintroduce process-only V8 flags that Worker rejects under node --test.
|
|
12
|
+
const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
13
|
+
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
14
|
+
// This listener also owns errors between readiness and a run's listeners.
|
|
15
|
+
worker.on("error", () => { handle.dead = true; });
|
|
16
|
+
worker.on("exit", () => {
|
|
17
|
+
handle.dead = true;
|
|
18
|
+
|
|
19
|
+
if (idleWorker === handle) idleWorker = null;
|
|
20
|
+
});
|
|
21
|
+
handle.ready = new Promise((resolve, reject) => {
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
worker.off("message", onMessage);
|
|
24
|
+
worker.off("error", onFail);
|
|
25
|
+
worker.off("exit", onFail);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const onMessage = (msg) => {
|
|
29
|
+
if (msg?.op !== "ready") return;
|
|
30
|
+
cleanup();
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const onFail = (err) => {
|
|
35
|
+
cleanup();
|
|
36
|
+
reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
worker.on("message", onMessage);
|
|
40
|
+
worker.on("error", onFail);
|
|
41
|
+
worker.on("exit", onFail);
|
|
42
|
+
});
|
|
43
|
+
handle.ready.catch(() => {});
|
|
44
|
+
|
|
45
|
+
return handle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function killWorker(handle) {
|
|
49
|
+
if (!handle) return;
|
|
50
|
+
|
|
51
|
+
if (idleWorker === handle) idleWorker = null;
|
|
52
|
+
handle.dead = true;
|
|
53
|
+
|
|
54
|
+
return handle.worker.terminate();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function acquireWorker(config) {
|
|
58
|
+
const candidate = idleWorker;
|
|
59
|
+
idleWorker = null;
|
|
60
|
+
const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
|
|
61
|
+
|
|
62
|
+
if (candidate && !reusable) void killWorker(candidate);
|
|
63
|
+
const handle = reusable ? candidate : spawnWorker(config);
|
|
64
|
+
handle.worker.ref?.();
|
|
65
|
+
|
|
66
|
+
// Pipeline the successor while this run executes. A consumed worker's
|
|
67
|
+
// replacement starts at once; a cold start's replacement waits for ready,
|
|
68
|
+
// so the two constructions never overlap. The finish-time warm usually
|
|
69
|
+
// becomes a no-op, so steady-state spawn count is unchanged.
|
|
70
|
+
if (reusable) warmGuestWorker(config).catch(() => {});
|
|
71
|
+
else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
|
|
72
|
+
|
|
73
|
+
return handle;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Only pristine workers may be prewarmed. A used worker is never pooled. */
|
|
77
|
+
export function warmGuestWorker(config = {}) {
|
|
78
|
+
if (idleWorker && !idleWorker.dead) return idleWorker.ready;
|
|
79
|
+
const handle = spawnWorker(config);
|
|
80
|
+
idleWorker = handle;
|
|
81
|
+
handle.ready.then(() => {
|
|
82
|
+
if (idleWorker === handle) handle.worker.unref?.();
|
|
83
|
+
}, () => {});
|
|
84
|
+
|
|
85
|
+
return handle.ready;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stopWarmGuestWorker() {
|
|
89
|
+
return killWorker(idleWorker);
|
|
90
|
+
}
|
|
91
|
+
export { acquireWorker, killWorker };
|
package/src/shared/decode.js
CHANGED
|
@@ -26,10 +26,9 @@ export function mapChangedChildren(value, visit, context) {
|
|
|
26
26
|
if (!array && !isObject(value)) return value;
|
|
27
27
|
let out = value;
|
|
28
28
|
|
|
29
|
-
for (const key of
|
|
30
|
-
if (array && !(key in value)) continue;
|
|
29
|
+
for (const key of childKeys(value)) {
|
|
31
30
|
const before = value[key];
|
|
32
|
-
const after = visit(before, context);
|
|
31
|
+
const after = visit(before, context, key);
|
|
33
32
|
|
|
34
33
|
if (Object.is(before, after)) continue;
|
|
35
34
|
if (out === value) out = array ? value.slice() : { ...value };
|
|
@@ -49,6 +48,15 @@ export function assertModelImageMime(mimeType) {
|
|
|
49
48
|
}
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
/** Node's base64 decoder is permissive; model attachments must not be. */
|
|
52
|
+
export function decodeImageData(data) {
|
|
53
|
+
const bytes = Buffer.from(data,"base64");
|
|
54
|
+
if (!data || bytes.toString("base64") !== String(data)) {
|
|
55
|
+
throw new Error("invalid image base64; use canonical padded base64 without a data-URL prefix or extra characters; no image attached");
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
const MAX_DEPTH = 64;
|
|
53
61
|
|
|
54
62
|
const MAX_TYPED_ARRAY = 4096;
|
|
@@ -85,7 +93,7 @@ function plainFromCollection(value, seen, depth) {
|
|
|
85
93
|
if (value instanceof Map) return plainFromMap(value, seen, depth);
|
|
86
94
|
const out = Object.create(null);
|
|
87
95
|
|
|
88
|
-
for (const k of
|
|
96
|
+
for (const k of childKeys(value)) out[k] = toPlain(value[k], seen, depth + 1);
|
|
89
97
|
|
|
90
98
|
return out;
|
|
91
99
|
}
|
|
@@ -102,9 +110,7 @@ function functionLabel(value) {
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
function plainByTag(value, tag) {
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
if (tag === "[object Number]" || tag === "[object Boolean]") return { hit: true, out: value.valueOf() };
|
|
113
|
+
if (["[object String]", "[object Number]", "[object Boolean]"].includes(tag)) return { hit: true, out: value.valueOf() };
|
|
108
114
|
|
|
109
115
|
if (tag === "[object BigInt]") return { hit: true, out: value.toString() + "n" };
|
|
110
116
|
|
|
@@ -168,4 +174,12 @@ export function toPlain(value, seen = new Set(), depth = 0) {
|
|
|
168
174
|
return withSeen(value, seen, () => plainFromCollection(value, seen, depth));
|
|
169
175
|
}
|
|
170
176
|
|
|
171
|
-
|
|
177
|
+
export function errorMessage(error) {
|
|
178
|
+
return error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Arrays visit present indexes (including inherited ones), not extra properties.
|
|
182
|
+
function* childKeys(value) {
|
|
183
|
+
if (!Array.isArray(value)) { yield* Object.keys(value); return; }
|
|
184
|
+
for (const key of value.keys()) if (key in value) yield key;
|
|
185
|
+
}
|