pi-supernova 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -29
- package/docs/CHANGELOG.md +52 -0
- package/docs/TOKEN_COSTS.md +173 -0
- package/index.js +155 -41
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +411 -50
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +102 -4
- package/src/context/search.js +45 -0
- package/src/context/snap.js +118 -6
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +33 -6
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +91 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +102 -5
- package/src/fs/workspace.js +62 -3
- package/src/output/bottleneck.js +63 -4
- package/src/output/format.js +136 -17
- package/src/runtime/guest-worker.js +169 -31
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +18 -0
- package/src/runtime/runtime.js +90 -8
- package/src/shared/decode.js +40 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/runtime/parallel.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { isFunction } from "../shared/decode.js";
|
|
2
2
|
|
|
3
3
|
const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
|
|
4
|
+
|
|
4
5
|
const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
|
|
5
6
|
|
|
6
7
|
export function isMutatingTool(name, config = {}, args = {}, definition) {
|
|
7
8
|
if ((config.mutatingTools ?? []).includes(name)) return true;
|
|
9
|
+
|
|
8
10
|
if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
|
|
11
|
+
|
|
9
12
|
if (definition?.annotations?.readOnlyHint === true) return false;
|
|
13
|
+
|
|
10
14
|
if (name === "lsp") {
|
|
11
15
|
const action = args.action ?? args.operation;
|
|
16
|
+
|
|
12
17
|
if (READ_ONLY_LSP.has(action)) return false;
|
|
18
|
+
|
|
13
19
|
if (["rename", "rename_file"].includes(action)) return args.apply !== false;
|
|
20
|
+
|
|
14
21
|
if (action === "code_actions") return args.apply === true;
|
|
22
|
+
|
|
15
23
|
return true;
|
|
16
24
|
}
|
|
25
|
+
|
|
17
26
|
if (name === "todo") return args.op !== "view";
|
|
27
|
+
|
|
18
28
|
if (name === "hub") return !["list", "ps", "logs", "describe"].includes(args.op);
|
|
29
|
+
|
|
19
30
|
return !READ_ONLY_TOOLS.has(name);
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -30,22 +41,28 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
|
|
|
30
41
|
try {
|
|
31
42
|
while (queue.length) {
|
|
32
43
|
const first = queue.shift();
|
|
44
|
+
|
|
33
45
|
if (first.cancelled) continue;
|
|
34
46
|
const wave = [first];
|
|
47
|
+
|
|
35
48
|
if (first.name === "read") {
|
|
36
49
|
while (wave.length < maxParallelReads && queue[0]?.name === "read") {
|
|
37
50
|
const next = queue.shift();
|
|
51
|
+
|
|
38
52
|
if (!next.cancelled) wave.push(next);
|
|
39
53
|
}
|
|
54
|
+
|
|
40
55
|
stats.readWaves++;
|
|
41
56
|
stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
|
|
42
57
|
}
|
|
58
|
+
|
|
43
59
|
// Settle each promise separately: one failed read must not discard its
|
|
44
60
|
// siblings or release a write barrier while other reads are still active.
|
|
45
61
|
await Promise.all(wave.map(async job => {
|
|
46
62
|
if (job.cancelled) return;
|
|
47
63
|
job.started = true;
|
|
48
64
|
job.signal?.removeEventListener("abort", job.abort);
|
|
65
|
+
|
|
49
66
|
try {
|
|
50
67
|
job.signal?.throwIfAborted();
|
|
51
68
|
const result = await job.run();
|
|
@@ -63,8 +80,10 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
|
|
|
63
80
|
if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
|
|
64
81
|
return Promise.reject(new Error("scheduler requires a native tool and an executor"));
|
|
65
82
|
}
|
|
83
|
+
|
|
66
84
|
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
67
85
|
stats.calls++;
|
|
86
|
+
|
|
68
87
|
return new Promise((resolve, reject) => {
|
|
69
88
|
const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
|
|
70
89
|
job.abort = () => {
|
|
@@ -73,8 +92,10 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
|
|
|
73
92
|
signal.removeEventListener("abort", job.abort);
|
|
74
93
|
reject(signal.reason ?? new Error("aborted"));
|
|
75
94
|
};
|
|
95
|
+
|
|
76
96
|
signal?.addEventListener("abort", job.abort, { once: true });
|
|
77
97
|
queue.push(job);
|
|
98
|
+
|
|
78
99
|
if (!draining) {
|
|
79
100
|
draining = true;
|
|
80
101
|
// Pi submits sibling tools in the same turn without model-side code.
|
|
@@ -88,25 +109,34 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
|
|
|
88
109
|
|
|
89
110
|
function requireArray(value, name) {
|
|
90
111
|
if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
|
|
112
|
+
|
|
91
113
|
return value;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
export async function runParallelWave(thunks, meta, options = {}) {
|
|
95
117
|
const list = requireArray(thunks, "parallel wave");
|
|
118
|
+
|
|
96
119
|
if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
|
|
120
|
+
|
|
97
121
|
if (!list.length) return { results: [], mode: "serial", reason: "empty" };
|
|
98
122
|
const { mode = "auto", config = {} } = options;
|
|
99
123
|
const names = meta?.names ?? [];
|
|
100
124
|
const mutating = names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
|
|
125
|
+
|
|
101
126
|
if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
|
|
102
127
|
// Do not finish a wave while its already-started host calls are still running.
|
|
103
128
|
const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
|
|
104
129
|
const failure = settled.find(item => item.status === "rejected");
|
|
130
|
+
|
|
105
131
|
if (failure) throw failure.reason;
|
|
132
|
+
|
|
106
133
|
return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
|
|
107
134
|
}
|
|
135
|
+
|
|
108
136
|
const results = [];
|
|
137
|
+
|
|
109
138
|
for (const thunk of list) results.push(await thunk());
|
|
139
|
+
|
|
110
140
|
return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -116,7 +146,10 @@ export async function parallel(items) {
|
|
|
116
146
|
|
|
117
147
|
export async function pipeline(items, ...stages) {
|
|
118
148
|
let current = requireArray(items, "pipeline");
|
|
149
|
+
|
|
119
150
|
if (stages.some(stage => !isFunction(stage))) throw new TypeError("pipeline stages must be functions");
|
|
151
|
+
|
|
120
152
|
for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
|
|
153
|
+
|
|
121
154
|
return current;
|
|
122
155
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { isObject, isString } from "../shared/decode.js";
|
|
2
|
+
import { truncateChars } from "../output/format.js";
|
|
3
|
+
|
|
4
|
+
const textOf = result => result.content.filter(block => block.type === "text").map(block => block.text).join("\n");
|
|
5
|
+
|
|
6
|
+
const mutationTotals = results => results.reduce((total, result) => {
|
|
7
|
+
const m = result.details?.mutations;
|
|
8
|
+
|
|
9
|
+
for (const key of ["committed","rolledBack","external","pendingCommits"]) total[key] += m?.[key] ?? 0;
|
|
10
|
+
total.recoveryFailed ||= !m || m.recoveryFailed === true;
|
|
11
|
+
|
|
12
|
+
return total;
|
|
13
|
+
}, {committed:0,rolledBack:0,external:0,pendingCommits:0,recoveryFailed:false});
|
|
14
|
+
|
|
15
|
+
export function programBatchText(results, total, stopped = "") {
|
|
16
|
+
const m = mutationTotals(results);
|
|
17
|
+
|
|
18
|
+
return (stopped ? "error: programs stopped: " + stopped : "ok: programs") + " " + results.length + "/" + total +
|
|
19
|
+
(m.recoveryFailed || m.pendingCommits ? "; filesystem outcome uncertain: inspect disk" : "") + "\nresults (UTF-16 lengths):\n" + results.map((result,i) => {
|
|
20
|
+
const text = textOf(result);
|
|
21
|
+
|
|
22
|
+
return "[" + i + "] " + text.length + "\n" + text + "\n";
|
|
23
|
+
}).join("");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function batchInputs(params, config) {
|
|
27
|
+
if (["code","file","data"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code, file or data; no programs ran");
|
|
28
|
+
|
|
29
|
+
if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
|
|
30
|
+
|
|
31
|
+
for (const p of params.programs) {
|
|
32
|
+
if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
|
|
33
|
+
((p.code === undefined) === (p.file === undefined)) || !isString(p.code ?? p.file) || !(p.code ?? p.file).trim()) {
|
|
34
|
+
throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let encoded;
|
|
39
|
+
|
|
40
|
+
try { encoded = JSON.stringify(params.programs); } catch { throw new Error("programs must be JSON-serializable; no programs ran"); }
|
|
41
|
+
|
|
42
|
+
if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
|
|
43
|
+
|
|
44
|
+
return JSON.parse(encoded);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Explicit known continuations, not inferred plans, retries, or a shared heap. */
|
|
48
|
+
export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
|
|
49
|
+
const programs = batchInputs(params,config);
|
|
50
|
+
const started = performance.now();
|
|
51
|
+
const timeout = Number.isInteger(params.timeoutMs) ? params.timeoutMs : config.timeoutMs;
|
|
52
|
+
const deadline = started + timeout;
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
|
|
55
|
+
const timer = setTimeout(() => controller.abort(),Math.min(timeout,2147483647));
|
|
56
|
+
const budget = {calls:0,logLines:0};
|
|
57
|
+
const results = [], images = [], imageLabels = [], trace = [];
|
|
58
|
+
const imageTextChars = () => imageLabels.reduce((chars,label)=>chars+label.length+1,0);
|
|
59
|
+
let imageBytes = 0, stopped = "";
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
for (const [i, program] of programs.entries()) {
|
|
63
|
+
if (combined.aborted || performance.now() >= deadline) { stopped = "deadline or cancellation; remaining programs did not run"; break; }
|
|
64
|
+
|
|
65
|
+
let result;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
result = await execute(id + ":" + i,{...program,timeoutMs:Math.max(1,Math.ceil(deadline-performance.now()))},combined,update => {
|
|
69
|
+
try { onUpdate?.({...update,details:{...update.details,trace:[...trace,...(update.details?.trace ?? [])]}}); } catch {}
|
|
70
|
+
},ctx,budget);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
result = error.supernovaResult ?? {content:[{type:"text",text:String(error.message ?? error)}],details:{ok:false,error:String(error.message ?? error)}};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
results.push(result);
|
|
76
|
+
trace.push(...(result.details?.trace ?? []));
|
|
77
|
+
let image = 0;
|
|
78
|
+
|
|
79
|
+
for (const block of result.content) if (block.type === "image") {
|
|
80
|
+
imageBytes += Buffer.byteLength(block.data,"base64");
|
|
81
|
+
|
|
82
|
+
if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
|
|
83
|
+
|
|
84
|
+
images.push(block);
|
|
85
|
+
imageLabels.push("program " + (i+1) + " image " + (++image));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
|
|
89
|
+
const text = programBatchText(results,programs.length,stopped);
|
|
90
|
+
|
|
91
|
+
if (text.length + imageTextChars() > config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
|
|
92
|
+
|
|
93
|
+
if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
|
|
94
|
+
|
|
95
|
+
if (combined.aborted || performance.now() >= deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
|
|
96
|
+
|
|
97
|
+
if (stopped) break;
|
|
98
|
+
}
|
|
99
|
+
} finally { clearTimeout(timer); controller.abort(); }
|
|
100
|
+
|
|
101
|
+
const bounded = truncateChars(programBatchText(results,programs.length,stopped),Math.max(0,config.maxReturnChars-imageTextChars()),"batch output");
|
|
102
|
+
const content = [{type:"text",text:bounded.text}];
|
|
103
|
+
images.forEach((image,i) => content.push({type:"text",text:imageLabels[i]},image));
|
|
104
|
+
|
|
105
|
+
// Return a typed stop report instead of throwing away earlier results/images.
|
|
106
|
+
// Single-program errors retain their existing throwing behavior.
|
|
107
|
+
return {content,isError:!!stopped,details:{ok:!stopped,error:stopped || undefined,wallMs:Math.round(performance.now()-started),
|
|
108
|
+
programs:results,attempted:results.length,total:programs.length,stopped,
|
|
109
|
+
result:bounded.truncated ? bounded.text : results.map(result=>result.details?.result),
|
|
110
|
+
returnTruncated:bounded.truncated || results.some(result=>result.details?.returnTruncated),
|
|
111
|
+
logTruncated:results.some(result=>result.details?.logTruncated),logs:results.flatMap(result=>result.details?.logs ?? []),trace,mutations:mutationTotals(results)}};
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { resolveWorkspacePath } from "../fs/workspace.js";
|
|
3
|
+
|
|
4
|
+
/** Explicit source reuse, never a cached program or a persistent guest heap. */
|
|
5
|
+
export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const target = await resolveWorkspacePath(cwd, file, "program file", false, true);
|
|
8
|
+
// A FIFO must fail without waiting for a writer or occupying an I/O worker.
|
|
9
|
+
const handle = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const stat = await handle.stat();
|
|
14
|
+
|
|
15
|
+
if (!stat.isFile()) throw new Error("program file requires a regular file");
|
|
16
|
+
// Every UTF-16 unit needs at most three UTF-8 bytes. Also check streamed size:
|
|
17
|
+
// another editor can grow the file after stat. No prefix-only execution.
|
|
18
|
+
const maxBytes = maxChars * 3;
|
|
19
|
+
const tooLarge = () => new Error("code exceeds " + maxChars + " characters; split the program");
|
|
20
|
+
|
|
21
|
+
if (stat.size > maxBytes) throw tooLarge();
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
|
|
25
|
+
for await (const chunk of handle.createReadStream({ end: maxBytes, autoClose: false, signal })) {
|
|
26
|
+
bytes += chunk.length;
|
|
27
|
+
|
|
28
|
+
if (bytes > maxBytes) throw tooLarge();
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
// Do not silently replace invalid bytes in executable source. Preserve BOMs.
|
|
34
|
+
const code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
|
|
35
|
+
|
|
36
|
+
if (code.length > maxChars) throw tooLarge();
|
|
37
|
+
|
|
38
|
+
return code;
|
|
39
|
+
} finally { await handle.close(); }
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Complete model-facing API reference; kept in every request, not moved into history.
|
|
2
|
+
export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. Strings stay raw. Exactly one of code or file; file rereads that program (same limits, cwd, fresh guest). Caps are UTF-16. Put Markdown/scripts/argv in data.
|
|
3
|
+
|
|
4
|
+
read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries
|
|
5
|
+
read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP)
|
|
6
|
+
read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, selector arrays, or true; 16 MiB cap; no jq
|
|
7
|
+
read("agent://id?q=.answer") → JSON field from session artifacts
|
|
8
|
+
read("symbol or question") → same view as resolve:true
|
|
9
|
+
read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
|
|
10
|
+
read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
|
|
11
|
+
write(path, text) → replace; write({path,content,append:true}) → append without a prior read
|
|
12
|
+
edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
|
|
13
|
+
edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
|
|
14
|
+
bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
|
|
15
|
+
bash({command,args}) → literal argv, no shell expansion of args
|
|
16
|
+
|
|
17
|
+
edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file; uncertain returns ambiguous, not_found, or incomplete. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files; use about or offset/limit for large audits. Array reads reject failures; Promise.allSettled for per-path outcomes. Edits stage until success; bash commits preceding writes.
|
|
18
|
+
programs:[{code|file,data?},...] sequential fresh guests, separate commits; stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { readProgramFile } from "./program-file.js";
|
|
2
3
|
import { parse } from "acorn";
|
|
3
4
|
import { performance } from "node:perf_hooks";
|
|
4
5
|
import { packageFinalReturn } from "../output/bottleneck.js";
|
|
@@ -6,20 +7,30 @@ import { truncateChars } from "../output/format.js";
|
|
|
6
7
|
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
7
8
|
|
|
8
9
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
10
|
+
|
|
9
11
|
const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
|
|
12
|
+
|
|
10
13
|
const MEMORY_POLL_MS = 50;
|
|
14
|
+
|
|
11
15
|
const MEMORY_SLACK = 1.5;
|
|
16
|
+
|
|
12
17
|
const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
|
|
18
|
+
|
|
13
19
|
let idleWorker = null;
|
|
20
|
+
|
|
14
21
|
let runSeq = 0;
|
|
15
22
|
|
|
16
23
|
const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
|
|
24
|
+
|
|
17
25
|
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
18
26
|
|
|
19
27
|
function hasReturn(node) {
|
|
20
28
|
if (!isObject(node)) return false;
|
|
29
|
+
|
|
21
30
|
if (node.type === "ReturnStatement") return true;
|
|
31
|
+
|
|
22
32
|
if (FUNCTION_TYPES.has(node.type)) return false;
|
|
33
|
+
|
|
23
34
|
return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -27,27 +38,34 @@ function prepareProgram(code) {
|
|
|
27
38
|
let program;
|
|
28
39
|
let expression;
|
|
29
40
|
let expressionSource;
|
|
41
|
+
|
|
30
42
|
try {
|
|
31
43
|
program = parse(code, PARSE_OPTIONS);
|
|
32
44
|
const statements = program.body.filter(node => node.type !== "EmptyStatement");
|
|
33
45
|
const statement = statements.length === 1 ? statements[0] : undefined;
|
|
34
46
|
const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
|
|
47
|
+
|
|
35
48
|
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
36
49
|
expression = candidate;
|
|
37
50
|
expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
|
|
38
51
|
}
|
|
39
52
|
} catch (bodyError) {
|
|
40
53
|
expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
54
|
+
|
|
41
55
|
try {
|
|
42
56
|
const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
|
|
43
57
|
expression = wrapped.body[0]?.expression;
|
|
58
|
+
|
|
44
59
|
if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
|
|
45
60
|
} catch { throw bodyError; }
|
|
46
61
|
}
|
|
62
|
+
|
|
47
63
|
const body = expression ? "return await (" + expressionSource + "\n)();" : code;
|
|
64
|
+
|
|
48
65
|
const returns = expression
|
|
49
66
|
? expression.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement" || hasReturn(expression.body)
|
|
50
67
|
: hasReturn(program);
|
|
68
|
+
|
|
51
69
|
return { body, hasReturn: returns };
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -62,6 +80,7 @@ function spawnWorker(config) {
|
|
|
62
80
|
worker.on("error", () => { handle.dead = true; });
|
|
63
81
|
worker.on("exit", () => {
|
|
64
82
|
handle.dead = true;
|
|
83
|
+
|
|
65
84
|
if (idleWorker === handle) idleWorker = null;
|
|
66
85
|
});
|
|
67
86
|
handle.ready = new Promise((resolve, reject) => {
|
|
@@ -70,27 +89,33 @@ function spawnWorker(config) {
|
|
|
70
89
|
worker.off("error", onFail);
|
|
71
90
|
worker.off("exit", onFail);
|
|
72
91
|
};
|
|
92
|
+
|
|
73
93
|
const onMessage = (msg) => {
|
|
74
94
|
if (msg?.op !== "ready") return;
|
|
75
95
|
cleanup();
|
|
76
96
|
resolve();
|
|
77
97
|
};
|
|
98
|
+
|
|
78
99
|
const onFail = (err) => {
|
|
79
100
|
cleanup();
|
|
80
101
|
reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
|
|
81
102
|
};
|
|
103
|
+
|
|
82
104
|
worker.on("message", onMessage);
|
|
83
105
|
worker.on("error", onFail);
|
|
84
106
|
worker.on("exit", onFail);
|
|
85
107
|
});
|
|
86
108
|
handle.ready.catch(() => {});
|
|
109
|
+
|
|
87
110
|
return handle;
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
function killWorker(handle) {
|
|
91
114
|
if (!handle) return;
|
|
115
|
+
|
|
92
116
|
if (idleWorker === handle) idleWorker = null;
|
|
93
117
|
handle.dead = true;
|
|
118
|
+
|
|
94
119
|
return handle.worker.terminate();
|
|
95
120
|
}
|
|
96
121
|
|
|
@@ -98,9 +123,11 @@ function acquireWorker(config) {
|
|
|
98
123
|
const candidate = idleWorker;
|
|
99
124
|
idleWorker = null;
|
|
100
125
|
const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
|
|
126
|
+
|
|
101
127
|
if (candidate && !reusable) void killWorker(candidate);
|
|
102
128
|
const handle = reusable ? candidate : spawnWorker(config);
|
|
103
129
|
handle.worker.ref?.();
|
|
130
|
+
|
|
104
131
|
return handle;
|
|
105
132
|
}
|
|
106
133
|
|
|
@@ -112,6 +139,7 @@ export function warmGuestWorker(config = {}) {
|
|
|
112
139
|
handle.ready.then(() => {
|
|
113
140
|
if (idleWorker === handle) handle.worker.unref?.();
|
|
114
141
|
}, () => {});
|
|
142
|
+
|
|
115
143
|
return handle.ready;
|
|
116
144
|
}
|
|
117
145
|
|
|
@@ -123,23 +151,38 @@ const RPC_METHODS = {
|
|
|
123
151
|
call: (nova, args) => nova.call(args[0], args[1]),
|
|
124
152
|
callMany: async (nova, args) => {
|
|
125
153
|
const wave = await nova.callMany(args[0]);
|
|
154
|
+
|
|
126
155
|
return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
|
|
127
156
|
},
|
|
128
|
-
search: (nova, args) => nova.search(args[0], args[1]),
|
|
129
|
-
describe: (nova, args) => nova.describe(args[0]),
|
|
130
157
|
speculateBegin: (nova) => nova.speculateBegin(),
|
|
131
158
|
speculateCommit: (nova) => nova.speculateCommit(),
|
|
132
159
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
133
160
|
};
|
|
134
161
|
|
|
135
|
-
export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
|
|
162
|
+
export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
|
|
136
163
|
const started = performance.now();
|
|
137
164
|
const wall = () => Math.round(performance.now() - started);
|
|
138
165
|
const logs = [];
|
|
139
166
|
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
140
167
|
let logTruncated = false;
|
|
141
|
-
|
|
142
|
-
if (code
|
|
168
|
+
|
|
169
|
+
if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
|
|
170
|
+
|
|
171
|
+
if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
|
|
172
|
+
|
|
173
|
+
if (file === undefined && code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters; split large writes into write({path,content,append:true}) chunks");
|
|
174
|
+
|
|
175
|
+
if (data !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
const encoded = JSON.stringify(data);
|
|
178
|
+
|
|
179
|
+
if (encoded === undefined) return fail("data must be JSON-serializable");
|
|
180
|
+
|
|
181
|
+
if (encoded.length > (config.maxCodeChars ?? 48000)) return fail("data exceeds " + (config.maxCodeChars ?? 48000) + " characters; split literal inputs across invocations");
|
|
182
|
+
data = JSON.parse(encoded);
|
|
183
|
+
} catch { return fail("data must be JSON-serializable"); }
|
|
184
|
+
}
|
|
185
|
+
|
|
143
186
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
144
187
|
const runId = ++runSeq;
|
|
145
188
|
const timeoutMs = config.timeoutMs ?? 60000;
|
|
@@ -153,7 +196,10 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
153
196
|
let hostError;
|
|
154
197
|
let notifyingHost = false;
|
|
155
198
|
const pending = new Set();
|
|
199
|
+
const inputController = new AbortController();
|
|
200
|
+
|
|
156
201
|
const cleanup = () => {
|
|
202
|
+
inputController.abort();
|
|
157
203
|
clearTimeout(timer);
|
|
158
204
|
clearInterval(memTimer);
|
|
159
205
|
signal?.removeEventListener("abort", signalAbort);
|
|
@@ -161,6 +207,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
161
207
|
handle?.worker.off("error", onError);
|
|
162
208
|
handle?.worker.off("exit", onExit);
|
|
163
209
|
};
|
|
210
|
+
|
|
164
211
|
const finish = (outcome) => {
|
|
165
212
|
if (finished) return;
|
|
166
213
|
finished = true;
|
|
@@ -169,27 +216,37 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
169
216
|
void killWorker(handle);
|
|
170
217
|
resolve({ ...outcome, wallMs: wall() });
|
|
171
218
|
};
|
|
219
|
+
|
|
172
220
|
const cancelHost = () => {
|
|
173
221
|
notifyingHost = true;
|
|
222
|
+
|
|
174
223
|
try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
|
|
175
224
|
};
|
|
225
|
+
|
|
176
226
|
const abort = () => {
|
|
177
227
|
if (finished) return;
|
|
178
228
|
cancelHost();
|
|
229
|
+
|
|
179
230
|
try { onTimeout?.(); } catch {}
|
|
231
|
+
|
|
180
232
|
finish(fail(ABORT_MESSAGE));
|
|
181
233
|
};
|
|
234
|
+
|
|
182
235
|
const signalAbort = () => { if (!notifyingHost) abort(); };
|
|
236
|
+
|
|
183
237
|
const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
|
|
238
|
+
|
|
184
239
|
const memTimer = setInterval(() => {
|
|
185
240
|
if (rssBytes() <= rssLimit) return;
|
|
186
241
|
cancelHost();
|
|
187
242
|
finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
|
|
188
243
|
}, MEMORY_POLL_MS);
|
|
244
|
+
|
|
189
245
|
signal?.addEventListener("abort", signalAbort, { once: true });
|
|
190
246
|
|
|
191
247
|
const postResult = (message) => {
|
|
192
248
|
if (!accepting || finished) return;
|
|
249
|
+
|
|
193
250
|
try {
|
|
194
251
|
handle.worker.postMessage({ ...message, runId });
|
|
195
252
|
} catch (err) {
|
|
@@ -200,6 +257,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
200
257
|
}
|
|
201
258
|
}
|
|
202
259
|
};
|
|
260
|
+
|
|
203
261
|
const complete = async (outcome) => {
|
|
204
262
|
if (finished || completing) return;
|
|
205
263
|
completing = true;
|
|
@@ -208,15 +266,21 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
208
266
|
handle?.worker.off("error", onError);
|
|
209
267
|
handle?.worker.off("exit", onExit);
|
|
210
268
|
void killWorker(handle);
|
|
269
|
+
|
|
211
270
|
if (!outcome.ok) cancelHost();
|
|
212
271
|
await Promise.allSettled(pending);
|
|
272
|
+
|
|
213
273
|
if (finished) return;
|
|
214
274
|
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
215
275
|
};
|
|
276
|
+
|
|
216
277
|
const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
|
|
278
|
+
|
|
217
279
|
const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
|
|
280
|
+
|
|
218
281
|
const onMessage = (msg) => {
|
|
219
282
|
if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
|
|
283
|
+
|
|
220
284
|
if (msg.op === "log") {
|
|
221
285
|
if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
|
|
222
286
|
else logTruncated = true;
|
|
@@ -225,10 +289,13 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
225
289
|
logTruncated = true;
|
|
226
290
|
} else if (msg.op === "rpc") {
|
|
227
291
|
const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
|
|
292
|
+
|
|
228
293
|
const work = Promise.resolve().then(() => {
|
|
229
294
|
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
295
|
+
|
|
230
296
|
return method(nova, msg.args);
|
|
231
297
|
});
|
|
298
|
+
|
|
232
299
|
pending.add(work);
|
|
233
300
|
work.then(
|
|
234
301
|
value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
@@ -256,23 +323,38 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
256
323
|
void (async () => {
|
|
257
324
|
try {
|
|
258
325
|
if (signal?.aborted) return abort();
|
|
326
|
+
|
|
327
|
+
if (file !== undefined) code = await readProgramFile(file, cwd, config.maxCodeChars ?? 48000, inputController.signal);
|
|
328
|
+
|
|
329
|
+
if (finished) return;
|
|
330
|
+
|
|
331
|
+
if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
|
|
332
|
+
let prepared;
|
|
333
|
+
|
|
334
|
+
try { prepared = prepareProgram(code); }
|
|
335
|
+
catch (error) { return finish(fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); }
|
|
336
|
+
|
|
337
|
+
if (wall() >= timeoutMs) return abort();
|
|
259
338
|
handle = acquireWorker(config);
|
|
260
339
|
await handle.ready;
|
|
340
|
+
|
|
261
341
|
if (finished || signal?.aborted) return abort();
|
|
262
342
|
const available = isFunction(nova.names) ? await nova.names() : [];
|
|
343
|
+
|
|
263
344
|
if (finished || signal?.aborted) return abort();
|
|
264
345
|
handle.worker.on("message", onMessage);
|
|
265
346
|
handle.worker.on("error", onError);
|
|
266
347
|
handle.worker.on("exit", onExit);
|
|
267
|
-
|
|
348
|
+
|
|
268
349
|
if (wall() >= timeoutMs) return abort();
|
|
269
|
-
handle.worker.postMessage({ op: "run", runId, prepared, available,
|
|
350
|
+
handle.worker.postMessage({ op: "run", runId, prepared, data, available,
|
|
270
351
|
batchRead: nova.batchRead !== false,
|
|
271
352
|
nativeArgv: nova.nativeArgv === true,
|
|
272
353
|
limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
|
|
273
354
|
} catch (err) {
|
|
355
|
+
if (finished) return;
|
|
274
356
|
cancelHost();
|
|
275
|
-
finish(fail("
|
|
357
|
+
finish(fail("program failed to start: " + err.message + "; no commands ran"));
|
|
276
358
|
}
|
|
277
359
|
})();
|
|
278
360
|
});
|