pi-supernova 0.8.2 → 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 +188 -51
- package/docs/CHANGELOG.md +86 -1
- package/docs/TOKEN_COSTS.md +38 -0
- package/index.js +10 -175
- package/package.json +2 -1
- package/src/adapters/bash.js +14 -30
- 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 +6 -1
- 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 -227
- package/src/fs/vfs.js +5 -239
- package/src/fs/workspace.js +2 -1
- 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 +21 -75
- package/src/runtime/program-file.js +3 -11
- package/src/runtime/program.js +141 -0
- package/src/runtime/reference.js +6 -5
- package/src/runtime/runtime.js +77 -253
- 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,211 +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
|
+
import { errorContext } from "../shared/syntax-context.js";
|
|
13
|
+
import {validateReturnedImages} from "../shared/image.js";
|
|
12
14
|
|
|
13
15
|
const ABORT_MESSAGE = "supernova aborted";
|
|
14
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";
|
|
15
17
|
|
|
16
|
-
const MEMORY_POLL_MS = 50;
|
|
17
|
-
|
|
18
18
|
const MEMORY_SLACK = 1.5;
|
|
19
19
|
|
|
20
|
-
const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
|
|
21
|
-
|
|
22
20
|
const toMb = bytes => Math.max(0, bytes / 1048576);
|
|
23
21
|
|
|
24
|
-
/**
|
|
25
|
-
export function formatMemoryAttribution({ limitMb,
|
|
26
|
-
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 }) {
|
|
27
24
|
const parts = tracked
|
|
28
25
|
? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
|
|
29
26
|
: [];
|
|
30
|
-
const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
|
|
31
|
-
const untrackedMb = Math.max(0, deltaMb - trackedMb);
|
|
32
27
|
const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
|
|
33
28
|
const seen = parts.length
|
|
34
|
-
? `; 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`
|
|
35
30
|
: "";
|
|
36
31
|
|
|
37
|
-
return `guest exceeded memory limit (maxHeapMb=${limitMb}):
|
|
38
|
-
+ `~${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}`;
|
|
39
33
|
}
|
|
40
34
|
|
|
41
|
-
let idleWorker = null;
|
|
42
|
-
|
|
43
35
|
let runSeq = 0;
|
|
44
36
|
|
|
45
|
-
const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
|
|
46
|
-
|
|
47
|
-
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
48
|
-
|
|
49
|
-
function hasReturn(node) {
|
|
50
|
-
if (!isObject(node)) return false;
|
|
51
|
-
|
|
52
|
-
if (node.type === "ReturnStatement") return true;
|
|
53
|
-
|
|
54
|
-
if (FUNCTION_TYPES.has(node.type)) return false;
|
|
55
|
-
|
|
56
|
-
return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function parseExpressionFunction(code) {
|
|
60
|
-
try {
|
|
61
|
-
const program = parse(code, PARSE_OPTIONS);
|
|
62
|
-
const statements = program.body.filter(node => node.type !== "EmptyStatement");
|
|
63
|
-
const statement = statements.length === 1 ? statements[0] : undefined;
|
|
64
|
-
const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
|
|
65
|
-
|
|
66
|
-
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
67
|
-
return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return { program };
|
|
71
|
-
} catch (bodyError) {
|
|
72
|
-
const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
|
|
76
|
-
const expression = wrapped.body[0]?.expression;
|
|
77
|
-
|
|
78
|
-
if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
|
|
79
|
-
|
|
80
|
-
return { expression, expressionSource };
|
|
81
|
-
} catch { throw bodyError; }
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function deniedSpecifier(node) {
|
|
86
|
-
if (node?.type === "Literal" && isString(node.value)) return node.value;
|
|
87
|
-
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function assertGuestImports(ast) {
|
|
91
|
-
function walk(node) {
|
|
92
|
-
if (Array.isArray(node)) { node.forEach(walk); return; }
|
|
93
|
-
if (!isObject(node)) return;
|
|
94
|
-
if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
|
|
95
|
-
const spec = deniedSpecifier(node.source);
|
|
96
|
-
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
97
|
-
}
|
|
98
|
-
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
|
|
99
|
-
const spec = deniedSpecifier(node.arguments?.[0]);
|
|
100
|
-
throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
|
|
101
|
-
}
|
|
102
|
-
for (const key of Object.keys(node)) {
|
|
103
|
-
if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
104
|
-
walk(node[key]);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
walk(ast);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function prepareProgram(code) {
|
|
112
|
-
const parsed = parseExpressionFunction(code);
|
|
113
|
-
assertGuestImports(parsed.program ?? parsed.expression);
|
|
114
|
-
const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
|
|
115
|
-
const returns = parsed.expression
|
|
116
|
-
? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
|
|
117
|
-
: hasReturn(parsed.program);
|
|
118
|
-
|
|
119
|
-
return { body, hasReturn: returns };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function spawnWorker(config) {
|
|
123
|
-
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
124
|
-
// An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
|
|
125
|
-
// Keep Node's automatic flag inheritance: explicitly copying execArgv can
|
|
126
|
-
// reintroduce process-only V8 flags that Worker rejects under node --test.
|
|
127
|
-
const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
128
|
-
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
129
|
-
// This listener also owns errors between readiness and a run's listeners.
|
|
130
|
-
worker.on("error", () => { handle.dead = true; });
|
|
131
|
-
worker.on("exit", () => {
|
|
132
|
-
handle.dead = true;
|
|
133
|
-
|
|
134
|
-
if (idleWorker === handle) idleWorker = null;
|
|
135
|
-
});
|
|
136
|
-
handle.ready = new Promise((resolve, reject) => {
|
|
137
|
-
const cleanup = () => {
|
|
138
|
-
worker.off("message", onMessage);
|
|
139
|
-
worker.off("error", onFail);
|
|
140
|
-
worker.off("exit", onFail);
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const onMessage = (msg) => {
|
|
144
|
-
if (msg?.op !== "ready") return;
|
|
145
|
-
cleanup();
|
|
146
|
-
resolve();
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const onFail = (err) => {
|
|
150
|
-
cleanup();
|
|
151
|
-
reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
worker.on("message", onMessage);
|
|
155
|
-
worker.on("error", onFail);
|
|
156
|
-
worker.on("exit", onFail);
|
|
157
|
-
});
|
|
158
|
-
handle.ready.catch(() => {});
|
|
159
|
-
|
|
160
|
-
return handle;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function killWorker(handle) {
|
|
164
|
-
if (!handle) return;
|
|
165
|
-
|
|
166
|
-
if (idleWorker === handle) idleWorker = null;
|
|
167
|
-
handle.dead = true;
|
|
168
|
-
|
|
169
|
-
return handle.worker.terminate();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function acquireWorker(config) {
|
|
173
|
-
const candidate = idleWorker;
|
|
174
|
-
idleWorker = null;
|
|
175
|
-
const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
|
|
176
|
-
|
|
177
|
-
if (candidate && !reusable) void killWorker(candidate);
|
|
178
|
-
const handle = reusable ? candidate : spawnWorker(config);
|
|
179
|
-
handle.worker.ref?.();
|
|
180
|
-
|
|
181
|
-
// Pipeline the successor while this run executes. A consumed worker's
|
|
182
|
-
// replacement starts at once; a cold start's replacement waits for ready,
|
|
183
|
-
// so the two constructions never overlap. The finish-time warm usually
|
|
184
|
-
// becomes a no-op, so steady-state spawn count is unchanged.
|
|
185
|
-
if (reusable) warmGuestWorker(config).catch(() => {});
|
|
186
|
-
else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
|
|
187
|
-
|
|
188
|
-
return handle;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Only pristine workers may be prewarmed. A used worker is never pooled. */
|
|
192
|
-
export function warmGuestWorker(config = {}) {
|
|
193
|
-
if (idleWorker && !idleWorker.dead) return idleWorker.ready;
|
|
194
|
-
const handle = spawnWorker(config);
|
|
195
|
-
idleWorker = handle;
|
|
196
|
-
handle.ready.then(() => {
|
|
197
|
-
if (idleWorker === handle) handle.worker.unref?.();
|
|
198
|
-
}, () => {});
|
|
199
|
-
|
|
200
|
-
return handle.ready;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function stopWarmGuestWorker() {
|
|
204
|
-
return killWorker(idleWorker);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
37
|
const RPC_METHODS = {
|
|
208
|
-
call: (nova, args) => nova.call(args[0], args[1]),
|
|
38
|
+
call: (nova, args, onItem) => nova.call(args[0], args[1], onItem),
|
|
209
39
|
callMany: async (nova, args) => {
|
|
210
40
|
const wave = await nova.callMany(args[0]);
|
|
211
41
|
|
|
@@ -216,50 +46,8 @@ const RPC_METHODS = {
|
|
|
216
46
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
217
47
|
};
|
|
218
48
|
|
|
219
|
-
function admitData(data, cap) {
|
|
220
|
-
if (data === undefined) return { data };
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const encoded = JSON.stringify(data);
|
|
224
|
-
|
|
225
|
-
if (encoded === undefined) return { error: "data must be JSON-serializable" };
|
|
226
|
-
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" };
|
|
227
|
-
|
|
228
|
-
return { data: JSON.parse(encoded) };
|
|
229
|
-
} catch { return { error: "data must be JSON-serializable" }; }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function admitCode({ code, file, cap }) {
|
|
233
|
-
if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
|
|
234
|
-
if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
|
|
235
|
-
if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function admitTimeout(config) {
|
|
239
|
-
const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
|
|
240
|
-
|
|
241
|
-
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
|
|
242
|
-
|
|
243
|
-
return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function admitGuest({ code, file, data, config }) {
|
|
247
|
-
const cap = config.maxCodeChars ?? 48000;
|
|
248
|
-
const codeError = admitCode({ code, file, cap });
|
|
249
|
-
|
|
250
|
-
if (codeError) return codeError;
|
|
251
|
-
const admitted = admitData(data, cap);
|
|
252
|
-
|
|
253
|
-
if (admitted.error) return admitted;
|
|
254
|
-
const timeout = admitTimeout(config);
|
|
255
|
-
|
|
256
|
-
if (timeout.error) return timeout;
|
|
257
|
-
|
|
258
|
-
return { data: admitted.data, timeoutMs: timeout.timeoutMs };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
49
|
class GuestRun {
|
|
262
|
-
constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs
|
|
50
|
+
constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs }) {
|
|
263
51
|
this.code = code;
|
|
264
52
|
this.file = file;
|
|
265
53
|
this.cwd = cwd;
|
|
@@ -270,8 +58,6 @@ class GuestRun {
|
|
|
270
58
|
this.onTimeout = onTimeout;
|
|
271
59
|
this.runId = runId;
|
|
272
60
|
this.timeoutMs = timeoutMs;
|
|
273
|
-
this.rssLimit = rssLimit;
|
|
274
|
-
this.rssStart = rssStart;
|
|
275
61
|
this.started = performance.now();
|
|
276
62
|
this.logs = [];
|
|
277
63
|
this.logTruncated = false;
|
|
@@ -285,6 +71,11 @@ class GuestRun {
|
|
|
285
71
|
this.abortOutcome = undefined;
|
|
286
72
|
this.pending = new Set();
|
|
287
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});
|
|
288
79
|
this.rpcCount = 0;
|
|
289
80
|
this.lastRpcMethod = null;
|
|
290
81
|
}
|
|
@@ -300,7 +91,6 @@ class GuestRun {
|
|
|
300
91
|
cleanup() {
|
|
301
92
|
this.inputController.abort();
|
|
302
93
|
clearTimeout(this.timer);
|
|
303
|
-
clearInterval(this.memTimer);
|
|
304
94
|
this.signal?.removeEventListener("abort", this.signalAbort);
|
|
305
95
|
this.handle?.worker.off("message", this.onMessage);
|
|
306
96
|
this.handle?.worker.off("error", this.onError);
|
|
@@ -337,19 +127,42 @@ class GuestRun {
|
|
|
337
127
|
}
|
|
338
128
|
|
|
339
129
|
postResult(message) {
|
|
340
|
-
if (!this.accepting || this.finished) return;
|
|
130
|
+
if (!this.accepting || this.finished) return false;
|
|
341
131
|
|
|
342
132
|
try {
|
|
343
133
|
this.handle.worker.postMessage({ ...message, runId: this.runId });
|
|
134
|
+
return true;
|
|
344
135
|
} catch (err) {
|
|
345
136
|
try {
|
|
346
137
|
this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
|
|
347
138
|
} catch (error) {
|
|
348
139
|
this.onError(error);
|
|
349
140
|
}
|
|
141
|
+
return false;
|
|
350
142
|
}
|
|
351
143
|
}
|
|
352
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
|
+
|
|
353
166
|
async drainPending(outcome) {
|
|
354
167
|
if (this.pending.size || !outcome.ok) this.cancelHost();
|
|
355
168
|
if (!this.pending.size) return;
|
|
@@ -365,6 +178,7 @@ class GuestRun {
|
|
|
365
178
|
if (this.finished || this.completing) return;
|
|
366
179
|
this.completing = true;
|
|
367
180
|
this.accepting = false;
|
|
181
|
+
this.inputController.abort();
|
|
368
182
|
this.handle?.worker.off("error", this.onError);
|
|
369
183
|
this.handle?.worker.off("exit", this.onExit);
|
|
370
184
|
void killWorker(this.handle);
|
|
@@ -394,39 +208,63 @@ class GuestRun {
|
|
|
394
208
|
const work = Promise.resolve().then(() => {
|
|
395
209
|
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
396
210
|
|
|
397
|
-
|
|
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);
|
|
398
214
|
});
|
|
399
215
|
|
|
400
216
|
this.pending.add(work);
|
|
401
217
|
work.then(
|
|
402
218
|
value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
403
219
|
err => {
|
|
404
|
-
if (!this.accepting) this.hostError ??=
|
|
405
|
-
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) });
|
|
406
222
|
},
|
|
407
223
|
).finally(() => this.pending.delete(work));
|
|
408
224
|
}
|
|
409
225
|
|
|
410
|
-
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;
|
|
411
230
|
try {
|
|
412
|
-
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);
|
|
413
233
|
void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
414
234
|
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
415
|
-
logs:
|
|
235
|
+
logs: this.logs, logTruncated: this.logTruncated || packed.logTruncated });
|
|
416
236
|
} catch (err) {
|
|
417
237
|
void this.complete(this.fail(err.message));
|
|
418
238
|
}
|
|
419
239
|
}
|
|
420
240
|
|
|
421
241
|
guestError(msg) {
|
|
422
|
-
const
|
|
242
|
+
const location = normalizeGuestLocation(this.prepared.body, msg.location);
|
|
243
|
+
const where = location ? " (line " + location.line + ":" + location.col + ")" : "";
|
|
423
244
|
void this.complete(this.fail(msg.message + where));
|
|
424
245
|
}
|
|
425
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
|
+
|
|
426
262
|
dispatchGuest(msg) {
|
|
427
263
|
if (msg.op === "log") this.onLog(msg);
|
|
428
264
|
else if (msg.op === "logTruncated") this.logTruncated = true;
|
|
265
|
+
else if (msg.op === "memory") this.onMemory(msg);
|
|
429
266
|
else if (msg.op === "rpc") this.onRpc(msg);
|
|
267
|
+
else if (msg.op === "rpc:ack") this.onReadAck(msg);
|
|
430
268
|
else if (msg.op === "done") this.onDone(msg);
|
|
431
269
|
else if (msg.op === "error") this.guestError(msg);
|
|
432
270
|
}
|
|
@@ -478,7 +316,8 @@ class GuestRun {
|
|
|
478
316
|
this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
|
|
479
317
|
batchRead: this.nova.batchRead !== false,
|
|
480
318
|
nativeArgv: this.nova.nativeArgv === true,
|
|
481
|
-
|
|
319
|
+
formatResult:true,
|
|
320
|
+
limits: { maxReturnChars:this.config.maxReturnChars ?? 32000, maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
|
|
482
321
|
}
|
|
483
322
|
|
|
484
323
|
async boot() {
|
|
@@ -498,19 +337,6 @@ class GuestRun {
|
|
|
498
337
|
return new Promise((resolve) => {
|
|
499
338
|
this.resolve = resolve;
|
|
500
339
|
this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
|
|
501
|
-
this.memTimer = setInterval(() => {
|
|
502
|
-
const now = rssBytes();
|
|
503
|
-
|
|
504
|
-
if (now <= this.rssLimit) return;
|
|
505
|
-
this.cancelHost();
|
|
506
|
-
let tracked = null;
|
|
507
|
-
|
|
508
|
-
try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
|
|
509
|
-
this.finish(this.fail(formatMemoryAttribution({
|
|
510
|
-
limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
|
|
511
|
-
ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
|
|
512
|
-
})));
|
|
513
|
-
}, MEMORY_POLL_MS);
|
|
514
340
|
this.signal?.addEventListener("abort", this.signalAbort, { once: true });
|
|
515
341
|
void this.boot();
|
|
516
342
|
});
|
|
@@ -523,12 +349,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
523
349
|
|
|
524
350
|
if (admitted.error) return fail(admitted.error);
|
|
525
351
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
526
|
-
const rssStart = rssBytes();
|
|
527
352
|
|
|
528
353
|
return new GuestRun({
|
|
529
354
|
code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
|
|
530
355
|
runId: ++runSeq,
|
|
531
356
|
timeoutMs: admitted.timeoutMs,
|
|
532
|
-
rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
|
|
533
357
|
}).start();
|
|
534
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Isolated decoder: no guest code or user image paths/contents in argv.
|
|
2
|
+
// The parent enforces a wall-clock kill and serializes raster allocations.
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
const MAX_PIXELS = 32_000_000;
|
|
6
|
+
async function inputBytes() {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
let size = 0;
|
|
9
|
+
for await (const chunk of process.stdin) {
|
|
10
|
+
size += chunk.length;
|
|
11
|
+
if (size > 20 * 1024 * 1024) throw new Error("encoded image exceeds 20 MiB");
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
}
|
|
14
|
+
return Buffer.concat(chunks,size);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function decode() {
|
|
18
|
+
const image = sharp(await inputBytes(),{failOn:"warning",limitInputPixels:MAX_PIXELS,pages:-1}).timeout({seconds:5});
|
|
19
|
+
try {
|
|
20
|
+
const meta = await image.metadata();
|
|
21
|
+
if ("image/" + meta.format !== process.argv[2]) throw new Error("encoded format does not match declared MIME");
|
|
22
|
+
if (meta.width * meta.height > MAX_PIXELS) throw new Error("decoded pixel limit exceeded");
|
|
23
|
+
// Full decode, without resizing or shrinking that could conceal corruption.
|
|
24
|
+
// No decoded pixels cross back into the agent process.
|
|
25
|
+
await image.raw().toBuffer();
|
|
26
|
+
} finally { image.destroy(); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try { await decode(); }
|
|
30
|
+
catch (error) { process.stderr.write(String(error.message).slice(0,2048)); process.exitCode = 1; }
|