pi-supernova 0.3.2 → 0.4.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 +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- 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 +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- 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 +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -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,25 @@
|
|
|
1
|
+
// Complete model-facing API reference; kept in every request, not moved into history.
|
|
2
|
+
export const REFERENCE = `Run a JavaScript async body or arrow with read, write, edit and bash. Strings stay raw. Single program: exactly one of code or file; file rereads a workspace program with the same limits, bindings, calling-workspace cwd and fresh guest. Code and data character caps use UTF-16 units. Split large writes using append.
|
|
3
|
+
|
|
4
|
+
Native commands (async):
|
|
5
|
+
read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
|
|
6
|
+
read(imagePath) → image attachment when returned (PNG/JPEG/GIF/WebP/BMP); no browser needed
|
|
7
|
+
read({path,json:".field"}) → parsed JSON field; supports .items[0:3], quoted keys, selector arrays, or true for the whole JSON value
|
|
8
|
+
read("agent://id?q=.answer") → JSON field from a calling-session resource (when host artifacts are available)
|
|
9
|
+
read("symbol or question") → locate and open source in one call, without an index; selected file text stays raw
|
|
10
|
+
read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
|
|
11
|
+
read(path, {about: question}) → relevant file bodies, or source selection inside a directory
|
|
12
|
+
read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
|
|
13
|
+
write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
|
|
14
|
+
edit(path, oldText, newText) → post-edit lines, checks, and references
|
|
15
|
+
edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
|
|
16
|
+
bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
|
|
17
|
+
bash({command, args:[...]}) → literal argv without shell expansion of arguments
|
|
18
|
+
|
|
19
|
+
Start with a source question or scoped about read; do not redundantly reopen selected source. Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true to check status before editing its path; narrow the directory with path+about when uncertain.
|
|
20
|
+
For read-modify-write, use read({path,complete:true}); it rejects partial output. JSON selectors parse the full document (16 MiB cap); selections must fit the read budget. No full jq; do not JSON.parse line windows. For large text audits use about or offset/limit, not complete:true. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
|
|
21
|
+
Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
|
|
22
|
+
File changes stage until program success; later errors roll them back. Shell calls commit preceding writes and cannot be rolled back. Outcomes report committed/rolledBack file versions and external-call attempts.
|
|
23
|
+
Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.
|
|
24
|
+
|
|
25
|
+
For known continuations use programs:[{code|file,data?},...]. Entries run sequentially in fresh guests with separate commits. The batch stops on failure and returns all attempted results, including a typed stop report; earlier commits remain. Deadlines, host calls, logs and output are shared across the batch. Use separate calls when the next action needs model reasoning.`;
|
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,6 +151,7 @@ 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
157
|
search: (nova, args) => nova.search(args[0], args[1]),
|
|
@@ -132,14 +161,30 @@ const RPC_METHODS = {
|
|
|
132
161
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
133
162
|
};
|
|
134
163
|
|
|
135
|
-
export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
|
|
164
|
+
export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
|
|
136
165
|
const started = performance.now();
|
|
137
166
|
const wall = () => Math.round(performance.now() - started);
|
|
138
167
|
const logs = [];
|
|
139
168
|
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
140
169
|
let logTruncated = false;
|
|
141
|
-
|
|
142
|
-
if (code
|
|
170
|
+
|
|
171
|
+
if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
|
|
172
|
+
|
|
173
|
+
if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
|
|
174
|
+
|
|
175
|
+
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");
|
|
176
|
+
|
|
177
|
+
if (data !== undefined) {
|
|
178
|
+
try {
|
|
179
|
+
const encoded = JSON.stringify(data);
|
|
180
|
+
|
|
181
|
+
if (encoded === undefined) return fail("data must be JSON-serializable");
|
|
182
|
+
|
|
183
|
+
if (encoded.length > (config.maxCodeChars ?? 48000)) return fail("data exceeds " + (config.maxCodeChars ?? 48000) + " characters; split literal inputs across invocations");
|
|
184
|
+
data = JSON.parse(encoded);
|
|
185
|
+
} catch { return fail("data must be JSON-serializable"); }
|
|
186
|
+
}
|
|
187
|
+
|
|
143
188
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
144
189
|
const runId = ++runSeq;
|
|
145
190
|
const timeoutMs = config.timeoutMs ?? 60000;
|
|
@@ -153,7 +198,10 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
153
198
|
let hostError;
|
|
154
199
|
let notifyingHost = false;
|
|
155
200
|
const pending = new Set();
|
|
201
|
+
const inputController = new AbortController();
|
|
202
|
+
|
|
156
203
|
const cleanup = () => {
|
|
204
|
+
inputController.abort();
|
|
157
205
|
clearTimeout(timer);
|
|
158
206
|
clearInterval(memTimer);
|
|
159
207
|
signal?.removeEventListener("abort", signalAbort);
|
|
@@ -161,6 +209,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
161
209
|
handle?.worker.off("error", onError);
|
|
162
210
|
handle?.worker.off("exit", onExit);
|
|
163
211
|
};
|
|
212
|
+
|
|
164
213
|
const finish = (outcome) => {
|
|
165
214
|
if (finished) return;
|
|
166
215
|
finished = true;
|
|
@@ -169,27 +218,37 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
169
218
|
void killWorker(handle);
|
|
170
219
|
resolve({ ...outcome, wallMs: wall() });
|
|
171
220
|
};
|
|
221
|
+
|
|
172
222
|
const cancelHost = () => {
|
|
173
223
|
notifyingHost = true;
|
|
224
|
+
|
|
174
225
|
try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
|
|
175
226
|
};
|
|
227
|
+
|
|
176
228
|
const abort = () => {
|
|
177
229
|
if (finished) return;
|
|
178
230
|
cancelHost();
|
|
231
|
+
|
|
179
232
|
try { onTimeout?.(); } catch {}
|
|
233
|
+
|
|
180
234
|
finish(fail(ABORT_MESSAGE));
|
|
181
235
|
};
|
|
236
|
+
|
|
182
237
|
const signalAbort = () => { if (!notifyingHost) abort(); };
|
|
238
|
+
|
|
183
239
|
const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
|
|
240
|
+
|
|
184
241
|
const memTimer = setInterval(() => {
|
|
185
242
|
if (rssBytes() <= rssLimit) return;
|
|
186
243
|
cancelHost();
|
|
187
244
|
finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
|
|
188
245
|
}, MEMORY_POLL_MS);
|
|
246
|
+
|
|
189
247
|
signal?.addEventListener("abort", signalAbort, { once: true });
|
|
190
248
|
|
|
191
249
|
const postResult = (message) => {
|
|
192
250
|
if (!accepting || finished) return;
|
|
251
|
+
|
|
193
252
|
try {
|
|
194
253
|
handle.worker.postMessage({ ...message, runId });
|
|
195
254
|
} catch (err) {
|
|
@@ -200,6 +259,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
200
259
|
}
|
|
201
260
|
}
|
|
202
261
|
};
|
|
262
|
+
|
|
203
263
|
const complete = async (outcome) => {
|
|
204
264
|
if (finished || completing) return;
|
|
205
265
|
completing = true;
|
|
@@ -208,15 +268,21 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
208
268
|
handle?.worker.off("error", onError);
|
|
209
269
|
handle?.worker.off("exit", onExit);
|
|
210
270
|
void killWorker(handle);
|
|
271
|
+
|
|
211
272
|
if (!outcome.ok) cancelHost();
|
|
212
273
|
await Promise.allSettled(pending);
|
|
274
|
+
|
|
213
275
|
if (finished) return;
|
|
214
276
|
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
215
277
|
};
|
|
278
|
+
|
|
216
279
|
const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
|
|
280
|
+
|
|
217
281
|
const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
|
|
282
|
+
|
|
218
283
|
const onMessage = (msg) => {
|
|
219
284
|
if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
|
|
285
|
+
|
|
220
286
|
if (msg.op === "log") {
|
|
221
287
|
if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
|
|
222
288
|
else logTruncated = true;
|
|
@@ -225,10 +291,13 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
225
291
|
logTruncated = true;
|
|
226
292
|
} else if (msg.op === "rpc") {
|
|
227
293
|
const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
|
|
294
|
+
|
|
228
295
|
const work = Promise.resolve().then(() => {
|
|
229
296
|
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
297
|
+
|
|
230
298
|
return method(nova, msg.args);
|
|
231
299
|
});
|
|
300
|
+
|
|
232
301
|
pending.add(work);
|
|
233
302
|
work.then(
|
|
234
303
|
value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
@@ -256,23 +325,38 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
256
325
|
void (async () => {
|
|
257
326
|
try {
|
|
258
327
|
if (signal?.aborted) return abort();
|
|
328
|
+
|
|
329
|
+
if (file !== undefined) code = await readProgramFile(file, cwd, config.maxCodeChars ?? 48000, inputController.signal);
|
|
330
|
+
|
|
331
|
+
if (finished) return;
|
|
332
|
+
|
|
333
|
+
if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
|
|
334
|
+
let prepared;
|
|
335
|
+
|
|
336
|
+
try { prepared = prepareProgram(code); }
|
|
337
|
+
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}).")); }
|
|
338
|
+
|
|
339
|
+
if (wall() >= timeoutMs) return abort();
|
|
259
340
|
handle = acquireWorker(config);
|
|
260
341
|
await handle.ready;
|
|
342
|
+
|
|
261
343
|
if (finished || signal?.aborted) return abort();
|
|
262
344
|
const available = isFunction(nova.names) ? await nova.names() : [];
|
|
345
|
+
|
|
263
346
|
if (finished || signal?.aborted) return abort();
|
|
264
347
|
handle.worker.on("message", onMessage);
|
|
265
348
|
handle.worker.on("error", onError);
|
|
266
349
|
handle.worker.on("exit", onExit);
|
|
267
|
-
|
|
350
|
+
|
|
268
351
|
if (wall() >= timeoutMs) return abort();
|
|
269
|
-
handle.worker.postMessage({ op: "run", runId, prepared, available,
|
|
352
|
+
handle.worker.postMessage({ op: "run", runId, prepared, data, available,
|
|
270
353
|
batchRead: nova.batchRead !== false,
|
|
271
354
|
nativeArgv: nova.nativeArgv === true,
|
|
272
355
|
limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
|
|
273
356
|
} catch (err) {
|
|
357
|
+
if (finished) return;
|
|
274
358
|
cancelHost();
|
|
275
|
-
finish(fail("
|
|
359
|
+
finish(fail("program failed to start: " + err.message + "; no commands ran"));
|
|
276
360
|
}
|
|
277
361
|
})();
|
|
278
362
|
});
|