pi-supernova 0.0.11 → 0.1.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/CHANGELOG.md +74 -3
- package/README.md +72 -21
- package/bottleneck.js +98 -97
- package/catalog.js +12 -36
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -2
- package/decode.js +57 -6
- package/evidence.js +27 -35
- package/format.js +16 -17
- package/fuzzy.js +182 -0
- package/guest-worker.js +38 -157
- package/host-bridge.js +246 -124
- package/index.js +95 -123
- package/ledger.js +150 -0
- package/omp-frame.js +7 -23
- package/outline.js +80 -0
- package/package.json +11 -2
- package/parallel.js +42 -34
- package/patch.js +62 -72
- package/render-measure.js +46 -144
- package/render.js +43 -158
- package/repo-index.js +106 -11
- package/runtime.js +204 -227
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +114 -75
- package/workspace.js +51 -33
package/runtime.js
CHANGED
|
@@ -1,293 +1,270 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { parse } from "acorn";
|
|
2
3
|
import { performance } from "node:perf_hooks";
|
|
3
4
|
import { packageFinalReturn } from "./bottleneck.js";
|
|
4
|
-
import { isFunction, isObject } from "./decode.js";
|
|
5
|
-
|
|
6
|
-
// Guest code runs in a worker thread (see guest-worker.js). The host thread
|
|
7
|
-
// owns the bridge and answers nova.* RPCs; a hard timeout or abort terminates
|
|
8
|
-
// the worker, which is the only way to stop a synchronous loop.
|
|
5
|
+
import { isFunction, isObject, isString } from "./decode.js";
|
|
9
6
|
|
|
10
7
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
11
8
|
const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
|
|
12
|
-
// Bun ignores worker resourceLimits, so a process-RSS watchdog backs up the V8 heap cap.
|
|
13
9
|
const MEMORY_POLL_MS = 50;
|
|
14
10
|
const MEMORY_SLACK = 1.5;
|
|
15
|
-
|
|
16
11
|
const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
|
|
17
|
-
|
|
18
12
|
let idleWorker = null;
|
|
19
13
|
let runSeq = 0;
|
|
20
14
|
|
|
15
|
+
const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
|
|
16
|
+
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
17
|
+
|
|
18
|
+
function hasReturn(node) {
|
|
19
|
+
if (!isObject(node)) return false;
|
|
20
|
+
if (node.type === "ReturnStatement") return true;
|
|
21
|
+
if (FUNCTION_TYPES.has(node.type)) return false;
|
|
22
|
+
return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function prepareProgram(code) {
|
|
26
|
+
let program;
|
|
27
|
+
let expression;
|
|
28
|
+
let expressionSource;
|
|
29
|
+
try {
|
|
30
|
+
program = parse(code, PARSE_OPTIONS);
|
|
31
|
+
const statements = program.body.filter(node => node.type !== "EmptyStatement");
|
|
32
|
+
const statement = statements.length === 1 ? statements[0] : undefined;
|
|
33
|
+
const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
|
|
34
|
+
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
35
|
+
expression = candidate;
|
|
36
|
+
expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
|
|
37
|
+
}
|
|
38
|
+
} catch (bodyError) {
|
|
39
|
+
expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
40
|
+
try {
|
|
41
|
+
const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
|
|
42
|
+
expression = wrapped.body[0]?.expression;
|
|
43
|
+
if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
|
|
44
|
+
} catch { throw bodyError; }
|
|
45
|
+
}
|
|
46
|
+
const body = expression ? "return await (" + expressionSource + "\n)();" : code;
|
|
47
|
+
const returns = expression
|
|
48
|
+
? expression.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement" || hasReturn(expression.body)
|
|
49
|
+
: hasReturn(program);
|
|
50
|
+
return { body, hasReturn: returns };
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
function spawnWorker(config) {
|
|
22
|
-
const
|
|
23
|
-
const worker = new Worker(WORKER_URL, {
|
|
24
|
-
|
|
54
|
+
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
55
|
+
const worker = new Worker(WORKER_URL, { resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
56
|
+
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
57
|
+
// This listener also owns errors between readiness and a run's listeners.
|
|
58
|
+
worker.on("error", () => { handle.dead = true; });
|
|
59
|
+
worker.on("exit", () => {
|
|
60
|
+
handle.dead = true;
|
|
61
|
+
if (idleWorker === handle) idleWorker = null;
|
|
25
62
|
});
|
|
26
|
-
const handle = { worker, dead: false, ready: null };
|
|
27
63
|
handle.ready = new Promise((resolve, reject) => {
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
worker.off("message", onMessage);
|
|
66
|
+
worker.off("error", onFail);
|
|
67
|
+
worker.off("exit", onFail);
|
|
68
|
+
};
|
|
28
69
|
const onMessage = (msg) => {
|
|
29
|
-
if (msg?.op
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
70
|
+
if (msg?.op !== "ready") return;
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve();
|
|
33
73
|
};
|
|
34
74
|
const onFail = (err) => {
|
|
35
75
|
cleanup();
|
|
36
|
-
handle.dead = true;
|
|
37
76
|
reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
|
|
38
77
|
};
|
|
39
|
-
const cleanup = () => {
|
|
40
|
-
worker.off("message", onMessage);
|
|
41
|
-
worker.off("error", onFail);
|
|
42
|
-
worker.off("exit", onFail);
|
|
43
|
-
};
|
|
44
78
|
worker.on("message", onMessage);
|
|
45
79
|
worker.on("error", onFail);
|
|
46
80
|
worker.on("exit", onFail);
|
|
47
81
|
});
|
|
48
82
|
handle.ready.catch(() => {});
|
|
49
|
-
worker.on("exit", () => {
|
|
50
|
-
handle.dead = true;
|
|
51
|
-
if (idleWorker === handle) idleWorker = null;
|
|
52
|
-
});
|
|
53
83
|
return handle;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
if (
|
|
86
|
+
function killWorker(handle) {
|
|
87
|
+
if (!handle) return;
|
|
88
|
+
if (idleWorker === handle) idleWorker = null;
|
|
89
|
+
handle.dead = true;
|
|
90
|
+
return handle.worker.terminate();
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
function acquireWorker(config) {
|
|
62
|
-
const
|
|
94
|
+
const candidate = idleWorker;
|
|
63
95
|
idleWorker = null;
|
|
64
|
-
|
|
96
|
+
const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
|
|
97
|
+
if (candidate && !reusable) void killWorker(candidate);
|
|
98
|
+
const handle = reusable ? candidate : spawnWorker(config);
|
|
99
|
+
handle.worker.ref?.();
|
|
65
100
|
return handle;
|
|
66
101
|
}
|
|
67
102
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (idleWorker && idleWorker !== handle) {
|
|
71
|
-
void handle.worker.terminate();
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
idleWorker = handle;
|
|
75
|
-
setIdleRef(handle, true);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function killWorker(handle) {
|
|
79
|
-
handle.dead = true;
|
|
80
|
-
if (idleWorker === handle) idleWorker = null;
|
|
81
|
-
void handle.worker.terminate();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Pre-spawn the guest worker so the first program does not pay startup cost. */
|
|
85
|
-
export function warmGuestWorker(config) {
|
|
103
|
+
/** Only pristine workers may be prewarmed. A used worker is never pooled. */
|
|
104
|
+
export function warmGuestWorker(config = {}) {
|
|
86
105
|
if (idleWorker && !idleWorker.dead) return idleWorker.ready;
|
|
87
|
-
const handle = spawnWorker(config
|
|
106
|
+
const handle = spawnWorker(config);
|
|
88
107
|
idleWorker = handle;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
() => {},
|
|
93
|
-
);
|
|
108
|
+
handle.ready.then(() => {
|
|
109
|
+
if (idleWorker === handle) handle.worker.unref?.();
|
|
110
|
+
}, () => {});
|
|
94
111
|
return handle.ready;
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
/** Terminate every guest worker (tests, shutdown). */
|
|
98
|
-
export async function shutdownGuestWorkers() {
|
|
99
|
-
if (!idleWorker) return;
|
|
100
|
-
const handle = idleWorker;
|
|
101
|
-
idleWorker = null;
|
|
102
|
-
handle.dead = true;
|
|
103
|
-
await handle.worker.terminate();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
114
|
const RPC_METHODS = {
|
|
107
|
-
call: (nova, args) =>
|
|
108
|
-
if (!isFunction(nova?.call)) throw new Error("nova.call unavailable");
|
|
109
|
-
return nova.call(args[0], args[1]);
|
|
110
|
-
},
|
|
115
|
+
call: (nova, args) => nova.call(args[0], args[1]),
|
|
111
116
|
callMany: async (nova, args) => {
|
|
112
|
-
if (!isFunction(nova?.callMany)) throw new Error("nova.callMany unavailable");
|
|
113
117
|
const wave = await nova.callMany(args[0]);
|
|
114
|
-
|
|
115
|
-
return wave;
|
|
116
|
-
},
|
|
117
|
-
search: (nova, args) => {
|
|
118
|
-
if (!isFunction(nova?.search)) throw new Error("nova.search unavailable");
|
|
119
|
-
return nova.search(args[0], args[1]);
|
|
120
|
-
},
|
|
121
|
-
describe: (nova, args) => {
|
|
122
|
-
if (!isFunction(nova?.describe)) throw new Error("nova.describe unavailable");
|
|
123
|
-
return nova.describe(args[0]);
|
|
124
|
-
},
|
|
125
|
-
surface: (nova, args) => {
|
|
126
|
-
if (isFunction(nova?.surface)) return nova.surface(args[0]);
|
|
127
|
-
return nova.call("surface", { path: args[0] });
|
|
128
|
-
},
|
|
129
|
-
snap: (nova, args) => {
|
|
130
|
-
if (isFunction(nova?.snap)) return nova.snap(args[0], args[1]);
|
|
131
|
-
return nova.call("snap", { query: args[0], path: args[1] });
|
|
132
|
-
},
|
|
133
|
-
speculateBegin: (nova) => (isFunction(nova?.speculateBegin) ? nova.speculateBegin() : undefined),
|
|
134
|
-
speculateCommit: (nova) => (isFunction(nova?.speculateCommit) ? nova.speculateCommit() : undefined),
|
|
135
|
-
speculateRollback: (nova) => (isFunction(nova?.speculateRollback) ? nova.speculateRollback() : undefined),
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
async function dispatchRpc(nova, method, args) {
|
|
139
|
-
const fn = RPC_METHODS[method];
|
|
140
|
-
if (!fn) throw new Error("unknown nova method: " + method);
|
|
141
|
-
return fn(nova, args);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function loadAvailable(nova) {
|
|
145
|
-
if (!isFunction(nova?.names)) return [];
|
|
146
|
-
try {
|
|
147
|
-
return await nova.names();
|
|
148
|
-
} catch {
|
|
149
|
-
return [];
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function prepareRun(options, fail) {
|
|
154
|
-
const { code, config, signal, nova } = options;
|
|
155
|
-
if (!String(code || "").trim()) return { failed: fail("code must be a non-empty string") };
|
|
156
|
-
const maxCode = config.maxCodeChars ?? 48000;
|
|
157
|
-
if (code.length > maxCode) return { failed: fail("code exceeds " + maxCode + " characters") };
|
|
158
|
-
if (signal?.aborted) return { failed: fail(ABORT_MESSAGE) };
|
|
159
|
-
const handle = acquireWorker(config);
|
|
160
|
-
try {
|
|
161
|
-
await handle.ready;
|
|
162
|
-
} catch (err) {
|
|
163
|
-
return { failed: fail("guest worker failed to start: " + err?.message) };
|
|
164
|
-
}
|
|
165
|
-
const available = await loadAvailable(nova);
|
|
166
|
-
return { handle, worker: handle.worker, available };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function startWatchdogs({ timeoutMs, rssLimit, signal, onAbort, onMemoryExceeded }) {
|
|
170
|
-
const timer = setTimeout(onAbort, timeoutMs);
|
|
171
|
-
if (timer.unref) timer.unref();
|
|
172
|
-
const memTimer = setInterval(() => {
|
|
173
|
-
if (rssBytes() <= rssLimit) return;
|
|
174
|
-
onMemoryExceeded(rssBytes());
|
|
175
|
-
}, MEMORY_POLL_MS);
|
|
176
|
-
if (memTimer.unref) memTimer.unref();
|
|
177
|
-
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
178
|
-
return () => {
|
|
179
|
-
clearTimeout(timer);
|
|
180
|
-
clearInterval(memTimer);
|
|
181
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const MESSAGE_HANDLERS = {
|
|
186
|
-
log: (msg, ctx) => {
|
|
187
|
-
ctx.logs.push(msg.line);
|
|
188
|
-
},
|
|
189
|
-
rpc: (msg, ctx) => {
|
|
190
|
-
dispatchRpc(ctx.nova, msg.method, msg.args).then(
|
|
191
|
-
(value) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
192
|
-
(err) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) }),
|
|
193
|
-
);
|
|
194
|
-
},
|
|
195
|
-
done: (msg, ctx) => {
|
|
196
|
-
const packaged = packageFinalReturn(msg.value, ctx.logs, ctx.config);
|
|
197
|
-
ctx.finish(
|
|
198
|
-
{
|
|
199
|
-
ok: true,
|
|
200
|
-
result: packaged.returnValue,
|
|
201
|
-
resultText: packaged.returnText,
|
|
202
|
-
returnTruncated: packaged.returnTruncated,
|
|
203
|
-
undefinedReturn: msg.undefinedReturn === true && msg.hasReturn === false,
|
|
204
|
-
logs: packaged.logs,
|
|
205
|
-
logTruncated: packaged.logTruncated,
|
|
206
|
-
wallMs: ctx.wall(),
|
|
207
|
-
},
|
|
208
|
-
true,
|
|
209
|
-
);
|
|
210
|
-
},
|
|
211
|
-
error: (msg, ctx) => {
|
|
212
|
-
const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
|
|
213
|
-
ctx.finish(ctx.fail(msg.message + where, ctx.logs), true);
|
|
118
|
+
return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
|
|
214
119
|
},
|
|
120
|
+
search: (nova, args) => nova.search(args[0], args[1]),
|
|
121
|
+
describe: (nova, args) => nova.describe(args[0]),
|
|
122
|
+
speculateBegin: (nova) => nova.speculateBegin(),
|
|
123
|
+
speculateCommit: (nova) => nova.speculateCommit(),
|
|
124
|
+
speculateRollback: (nova) => nova.speculateRollback(),
|
|
215
125
|
};
|
|
216
126
|
|
|
217
|
-
export async function runGuestProgram(
|
|
218
|
-
const { code, nova, config, signal, onTimeout } = options;
|
|
127
|
+
export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
|
|
219
128
|
const started = performance.now();
|
|
220
129
|
const wall = () => Math.round(performance.now() - started);
|
|
221
|
-
const fail = (error, logs = []) => ({ ok: false, error, logs, wallMs: wall() });
|
|
222
|
-
const prepared = await prepareRun(options, fail);
|
|
223
|
-
if (prepared.failed) return prepared.failed;
|
|
224
|
-
const { handle, worker, available } = prepared;
|
|
225
130
|
const logs = [];
|
|
131
|
+
const fail = (error) => ({ ok: false, error, logs, logTruncated, wallMs: wall() });
|
|
132
|
+
let logTruncated = false;
|
|
133
|
+
if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
|
|
134
|
+
if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
|
|
135
|
+
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
226
136
|
const runId = ++runSeq;
|
|
227
|
-
const
|
|
228
|
-
const rssLimit = rssBytes() + maxHeapMb * MEMORY_SLACK * 1048576;
|
|
229
|
-
|
|
137
|
+
const timeoutMs = config.timeoutMs ?? 60000;
|
|
138
|
+
const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let handle;
|
|
230
142
|
let finished = false;
|
|
231
|
-
let
|
|
232
|
-
|
|
143
|
+
let accepting = true;
|
|
144
|
+
let completing = false;
|
|
145
|
+
let hostError;
|
|
146
|
+
let notifyingHost = false;
|
|
147
|
+
const pending = new Set();
|
|
148
|
+
const cleanup = () => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
clearInterval(memTimer);
|
|
151
|
+
signal?.removeEventListener("abort", signalAbort);
|
|
152
|
+
handle?.worker.off("message", onMessage);
|
|
153
|
+
handle?.worker.off("error", onError);
|
|
154
|
+
handle?.worker.off("exit", onExit);
|
|
155
|
+
};
|
|
156
|
+
const finish = (outcome) => {
|
|
233
157
|
if (finished) return;
|
|
234
158
|
finished = true;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (keepWorker) releaseWorker(handle);
|
|
240
|
-
else killWorker(handle);
|
|
241
|
-
resolve(outcome);
|
|
159
|
+
accepting = false;
|
|
160
|
+
cleanup();
|
|
161
|
+
void killWorker(handle);
|
|
162
|
+
resolve({ ...outcome, wallMs: wall() });
|
|
242
163
|
};
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
} catch {}
|
|
247
|
-
finish(fail(ABORT_MESSAGE, logs), false);
|
|
164
|
+
const cancelHost = () => {
|
|
165
|
+
notifyingHost = true;
|
|
166
|
+
try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
|
|
248
167
|
};
|
|
249
|
-
const
|
|
168
|
+
const abort = () => {
|
|
250
169
|
if (finished) return;
|
|
170
|
+
cancelHost();
|
|
171
|
+
try { onTimeout?.(); } catch {}
|
|
172
|
+
finish(fail(ABORT_MESSAGE));
|
|
173
|
+
};
|
|
174
|
+
const signalAbort = () => { if (!notifyingHost) abort(); };
|
|
175
|
+
const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
|
|
176
|
+
const memTimer = setInterval(() => {
|
|
177
|
+
if (rssBytes() <= rssLimit) return;
|
|
178
|
+
cancelHost();
|
|
179
|
+
finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
|
|
180
|
+
}, MEMORY_POLL_MS);
|
|
181
|
+
signal?.addEventListener("abort", signalAbort, { once: true });
|
|
182
|
+
|
|
183
|
+
const postResult = (message) => {
|
|
184
|
+
if (!accepting || finished) return;
|
|
251
185
|
try {
|
|
252
|
-
worker.postMessage(
|
|
186
|
+
handle.worker.postMessage({ ...message, runId });
|
|
253
187
|
} catch (err) {
|
|
254
|
-
|
|
188
|
+
try {
|
|
189
|
+
handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
onError(error);
|
|
192
|
+
}
|
|
255
193
|
}
|
|
256
194
|
};
|
|
257
|
-
const
|
|
195
|
+
const complete = async (outcome) => {
|
|
196
|
+
if (finished || completing) return;
|
|
197
|
+
completing = true;
|
|
198
|
+
accepting = false;
|
|
199
|
+
// Stop timers and detached guest continuations before settling host work.
|
|
200
|
+
handle?.worker.off("error", onError);
|
|
201
|
+
handle?.worker.off("exit", onExit);
|
|
202
|
+
void killWorker(handle);
|
|
203
|
+
if (!outcome.ok) cancelHost();
|
|
204
|
+
await Promise.allSettled(pending);
|
|
205
|
+
if (finished) return;
|
|
206
|
+
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
207
|
+
};
|
|
208
|
+
const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
|
|
209
|
+
const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
|
|
258
210
|
const onMessage = (msg) => {
|
|
259
|
-
if (!isObject(msg)) return;
|
|
260
|
-
if (msg.
|
|
261
|
-
if (
|
|
262
|
-
|
|
211
|
+
if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
|
|
212
|
+
if (msg.op === "log") {
|
|
213
|
+
if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
|
|
214
|
+
else logTruncated = true;
|
|
215
|
+
logTruncated ||= msg.truncated === true;
|
|
216
|
+
} else if (msg.op === "logTruncated") {
|
|
217
|
+
logTruncated = true;
|
|
218
|
+
} else if (msg.op === "rpc") {
|
|
219
|
+
const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
|
|
220
|
+
const work = Promise.resolve().then(() => {
|
|
221
|
+
if (!method) throw new Error("unknown nova method: " + msg.method);
|
|
222
|
+
return method(nova, msg.args);
|
|
223
|
+
});
|
|
224
|
+
pending.add(work);
|
|
225
|
+
work.then(
|
|
226
|
+
value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
|
|
227
|
+
err => {
|
|
228
|
+
// An awaited, handled host error must not poison the whole program.
|
|
229
|
+
if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
|
|
230
|
+
postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
231
|
+
},
|
|
232
|
+
).finally(() => pending.delete(work));
|
|
233
|
+
} else if (msg.op === "done") {
|
|
234
|
+
try {
|
|
235
|
+
const packed = packageFinalReturn(msg.value, logs, config);
|
|
236
|
+
void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
237
|
+
returnTruncated: packed.returnTruncated, undefinedReturn: msg.undefinedReturn === true,
|
|
238
|
+
logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
void complete(fail(err.message));
|
|
241
|
+
}
|
|
242
|
+
} else if (msg.op === "error") {
|
|
243
|
+
const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
|
|
244
|
+
void complete(fail(msg.message + where));
|
|
263
245
|
}
|
|
264
|
-
const handler = MESSAGE_HANDLERS[msg.op];
|
|
265
|
-
if (handler) handler(msg, ctx);
|
|
266
246
|
};
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
limits: { maxLogLines, maxLogLineChars },
|
|
290
|
-
available,
|
|
291
|
-
});
|
|
247
|
+
|
|
248
|
+
void (async () => {
|
|
249
|
+
try {
|
|
250
|
+
if (signal?.aborted) return abort();
|
|
251
|
+
handle = acquireWorker(config);
|
|
252
|
+
await handle.ready;
|
|
253
|
+
if (finished || signal?.aborted) return abort();
|
|
254
|
+
const available = isFunction(nova.names) ? await nova.names() : [];
|
|
255
|
+
if (finished || signal?.aborted) return abort();
|
|
256
|
+
handle.worker.on("message", onMessage);
|
|
257
|
+
handle.worker.on("error", onError);
|
|
258
|
+
handle.worker.on("exit", onExit);
|
|
259
|
+
const prepared = prepareProgram(code);
|
|
260
|
+
if (wall() >= timeoutMs) return abort();
|
|
261
|
+
handle.worker.postMessage({ op: "run", runId, prepared, available,
|
|
262
|
+
batchRead: nova.batchRead !== false,
|
|
263
|
+
limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
cancelHost();
|
|
266
|
+
finish(fail("guest worker failed to start: " + err.message));
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
292
269
|
});
|
|
293
270
|
}
|
package/search.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
3
|
+
import { rankPaths, smartCase, fuzzyMatch } from "./fuzzy.js";
|
|
4
|
+
import { runCommand, relativeSlash } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
// Search served from the in-process index: fuzzy path find (fff port), smart-case grep with
|
|
7
|
+
// definition-first rows and fuzzy fallback, glob listing. rg is spawned only for trees too
|
|
8
|
+
// large to scan in-process.
|
|
9
|
+
|
|
10
|
+
function textResult(text, details) {
|
|
11
|
+
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function rgGrepArgs(pattern, params, searchPath) {
|
|
15
|
+
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
16
|
+
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
17
|
+
if (params?.glob) args.push("--glob", String(params.glob));
|
|
18
|
+
args.push("--", pattern, searchPath);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
23
|
+
export async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
24
|
+
const args = ["--files"];
|
|
25
|
+
if (pattern) args.push("-g", pattern);
|
|
26
|
+
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
27
|
+
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
28
|
+
const findArgs = [searchDir];
|
|
29
|
+
if (pattern) findArgs.push("-name", pattern);
|
|
30
|
+
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
31
|
+
return textResult(findRes.stdout, { via: "find" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
|
|
38
|
+
* Returns "path" rows (best first) or null when the pattern is a real glob.
|
|
39
|
+
*/
|
|
40
|
+
export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
41
|
+
if (!pattern || GLOB_CHARS.test(pattern)) return null;
|
|
42
|
+
const files = await index.files(root);
|
|
43
|
+
if (!index.canScan(files)) return null;
|
|
44
|
+
const rel = files.map((f) => relativeSlash(cwd, f));
|
|
45
|
+
const absolute = new Map(rel.map((r, i) => [r, files[i]]));
|
|
46
|
+
// mtime is only consulted for paths that matched; never stat the whole tree.
|
|
47
|
+
const mtimeOf = (r) => index.mtimeSeconds(absolute.get(r));
|
|
48
|
+
const ranked = rankPaths(pattern, rel, { frecency: index.frecency, mtimeOf, modified: await index.modifiedFiles(cwd), currentFile: index.lastTouched });
|
|
49
|
+
// fff weak-match detector: when nothing matches exactly and the best is mostly typos, say so instead of flooding.
|
|
50
|
+
const rows = ranked.slice(0, limit);
|
|
51
|
+
if (rows.length === 0) return "";
|
|
52
|
+
return rows.map((r) => r.path).join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
|
|
56
|
+
export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
57
|
+
const compiled = grepRegex(pattern, params);
|
|
58
|
+
if (!compiled) return null;
|
|
59
|
+
const { regex, caseSensitive } = compiled;
|
|
60
|
+
let files = await index.files(searchPath);
|
|
61
|
+
if (!index.canScan(files)) return null;
|
|
62
|
+
if (params?.glob) {
|
|
63
|
+
const matcher = globToRegExp(String(params.glob));
|
|
64
|
+
files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
|
|
65
|
+
}
|
|
66
|
+
const rows = index.grepRows(files, regex, cwd);
|
|
67
|
+
const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
|
|
68
|
+
return formatGrepRows(fallback, grepLimit(params));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function grepLimit(params) {
|
|
72
|
+
return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function grepRegex(pattern, params) {
|
|
76
|
+
const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
|
|
77
|
+
try {
|
|
78
|
+
return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
|
|
85
|
+
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
86
|
+
const maxTypos = pattern.length >= 8 ? 2 : 1;
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const filePath of files) {
|
|
89
|
+
const e = index.entry(filePath);
|
|
90
|
+
if (!e) continue;
|
|
91
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
92
|
+
const rel = relativeSlash(cwd, filePath);
|
|
93
|
+
for (let i = 0; i < raw.length && rows.length <= 400; i++) {
|
|
94
|
+
const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
|
|
95
|
+
if (!m || m.end - m.start > pattern.length + 2) continue;
|
|
96
|
+
rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** fff definition-first hinting: files that declare the name come first, declarations first within a file; one header per file. */
|
|
103
|
+
function formatGrepRows(rows, limit) {
|
|
104
|
+
if (rows.length === 0) return "";
|
|
105
|
+
const groups = new Map();
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
if (!groups.has(r.rel)) groups.set(r.rel, []);
|
|
108
|
+
groups.get(r.rel).push(r);
|
|
109
|
+
}
|
|
110
|
+
const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
|
|
111
|
+
let out = "";
|
|
112
|
+
let shown = 0;
|
|
113
|
+
for (const group of files) {
|
|
114
|
+
if (shown >= limit) break;
|
|
115
|
+
out += group[0].rel + "\n";
|
|
116
|
+
group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
|
|
117
|
+
for (const r of group) {
|
|
118
|
+
if (shown++ >= limit) break;
|
|
119
|
+
out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
127
|
+
export async function listIndexed(index, root, cwd, pattern) {
|
|
128
|
+
const files = await index.files(root);
|
|
129
|
+
if (!index.canScan(files)) return null;
|
|
130
|
+
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
|
131
|
+
if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
|
|
132
|
+
let matcher;
|
|
133
|
+
try {
|
|
134
|
+
matcher = globToRegExp(pattern);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const hits = rel.filter((f) => matcher.test(f));
|
|
139
|
+
return hits.length ? hits.join("\n") + "\n" : "";
|
|
140
|
+
}
|
|
141
|
+
|
package/snap.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isString } from "./decode.js";
|
|
4
4
|
import { WorkspaceIndex } from "./repo-index.js";
|
|
5
|
+
import { isTestPath } from "./workspace.js";
|
|
5
6
|
|
|
6
7
|
const STOP_WORDS = new Set([
|
|
7
8
|
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
@@ -137,12 +138,6 @@ function mergeGrepHits(candidates, grepHits) {
|
|
|
137
138
|
return candidates;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
function isTestPath(filePath) {
|
|
141
|
-
const segments = filePath.split(path.sep);
|
|
142
|
-
const base = segments[segments.length - 1];
|
|
143
|
-
return segments.includes("test") || segments.includes("tests") || base.includes(".test.") || base.includes(".spec.");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
141
|
function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
|
|
147
142
|
if (candidates.length >= 5) return candidates;
|
|
148
143
|
const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
|