beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// analyze.mjs — the analysis plane: children that READ source and never run
|
|
2
|
+
// it, one per language — spike/analyze.R and engines/python/analyze.py — each
|
|
3
|
+
// with its own queue, its own slot and its own routes. A request's `engine`
|
|
4
|
+
// picks the child; an ABSENT engine is R, so every page that predates Python
|
|
5
|
+
// analysis reaches analyze.R exactly as before.
|
|
6
|
+
//
|
|
7
|
+
// A port of spike/serve.R's analyzer half (`ANALYZE_FORWARDED`,
|
|
8
|
+
// `ensure_analyzer`, `route_analyze`, `enqueue_analyze_command`,
|
|
9
|
+
// `dispatch_analyze_queue`, `pump_analyzer`, `fail_analyze_routes`), driving
|
|
10
|
+
// the SAME spike/analyze.R, generalised to one lane per language
|
|
11
|
+
// (docs/wp/wp8a-python-analyzer.md).
|
|
12
|
+
//
|
|
13
|
+
// Why it is a separate process, and therefore a separate plane: worker.R reads
|
|
14
|
+
// its stdin only BETWEEN expressions, so a forty-second model fit takes syntax
|
|
15
|
+
// checking, completion and hover down with it. The split is the whole feature —
|
|
16
|
+
// "intelligence must not depend on the execution kernel being idle"
|
|
17
|
+
// (docs/stages/stage-2-intelligence-v1.md). The queue and the routes are
|
|
18
|
+
// separate for the mirror reason: a stuck analysis must never block a run, and
|
|
19
|
+
// a stuck run must never block an analysis. And the lanes are separate from
|
|
20
|
+
// EACH OTHER for the same reason one level down: a slow Python parse must not
|
|
21
|
+
// hold the slot an R diagnostic is waiting for.
|
|
22
|
+
//
|
|
23
|
+
// THE ONE RULE, inherited from spike/analyze.R and not weakened here: nothing
|
|
24
|
+
// in an analysis child may evaluate user source. Both are spawned in BATCH mode
|
|
25
|
+
// (`Rscript analyze.R <sentinel>`, `python -I analyze.py <sentinel>`) — no
|
|
26
|
+
// interactive console, no raw console channel, no debugger, no `exec`. The only
|
|
27
|
+
// commands this plane will ever write to them are the four below.
|
|
28
|
+
//
|
|
29
|
+
// Two more rules that only look like plumbing:
|
|
30
|
+
//
|
|
31
|
+
// · THE WIRE NAME IS THE ANALYZER'S, NOT THE BROWSER'S. The page says
|
|
32
|
+
// `analyze_ping` / `analyze_workspace` / `analyze_workspace_references`;
|
|
33
|
+
// the analyzers answer `ping` / `workspace` / `workspace_references`. The
|
|
34
|
+
// plane renames on the way DOWN and records the reply type it expects,
|
|
35
|
+
// because the browser waits on the type it asked for (`analyze_ping` →
|
|
36
|
+
// `pong`). Guessing the reply type from the frame would break the moment a
|
|
37
|
+
// reply carried an error instead.
|
|
38
|
+
// · FRAMES ARE RELAYED AS THE CHILD'S OWN BYTES. An analyze reply is full of
|
|
39
|
+
// deliberately-nullable fields — `failed` (null on success), `line`/`col`
|
|
40
|
+
// on a diagnostic the parser could not place — and a re-encode turns
|
|
41
|
+
// "nothing went wrong" into something else. `relayFrame` splices the
|
|
42
|
+
// routing id into the child's own bytes and changes nothing else.
|
|
43
|
+
//
|
|
44
|
+
// RELEASE THE SLOT BEFORE ASKING WHO WANTED THE ANSWER. These are two
|
|
45
|
+
// different questions and serve.R answered them in the wrong order once: a
|
|
46
|
+
// socket closing drops its routes including the one in flight, so the reply
|
|
47
|
+
// found no route, `next`-ed, and the dispatch slot was never freed — the
|
|
48
|
+
// analysis queue wedged for the life of the process while a healthy analyzer
|
|
49
|
+
// sat idle (docs/stability-plan.md S4). Kept in the right order here, per lane,
|
|
50
|
+
// with the same `===` guard so a LATE reply from a superseded request cannot
|
|
51
|
+
// free a slot that now belongs to another.
|
|
52
|
+
|
|
53
|
+
import fs from "node:fs";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
import { REngine } from "../engine-r.mjs";
|
|
56
|
+
import { StdioEngine } from "../engine-stdio.mjs";
|
|
57
|
+
import { relayFrame } from "../worker-plane.mjs";
|
|
58
|
+
|
|
59
|
+
/** The four ops the browser may send. An allow-list, deliberately tiny. */
|
|
60
|
+
export const ANALYZE_FORWARDED = Object.freeze(["analyze", "analyze_ping", "analyze_workspace",
|
|
61
|
+
"analyze_workspace_references"]);
|
|
62
|
+
/** Browser vocabulary → analyzer vocabulary, on the way down. */
|
|
63
|
+
export const ANALYZE_WIRE_NAME = Object.freeze({
|
|
64
|
+
analyze_ping: "ping", analyze_workspace: "workspace",
|
|
65
|
+
analyze_workspace_references: "workspace_references",
|
|
66
|
+
});
|
|
67
|
+
/** The frame type the browser is waiting on, recorded rather than guessed. */
|
|
68
|
+
export const ANALYZE_REPLY_TYPE = Object.freeze({
|
|
69
|
+
analyze: "analyze", analyze_ping: "pong", analyze_workspace: "workspace",
|
|
70
|
+
analyze_workspace_references: "workspace_references",
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* How long one analysis may hold its lane's slot before the plane gives up on it.
|
|
74
|
+
*
|
|
75
|
+
* serve.R has no such bound: analysis is milliseconds and the queue is nearly
|
|
76
|
+
* always empty, so a wedged child simply wedged the plane. 120 s is above the
|
|
77
|
+
* widest legitimate answer — `CARMAR_ANALYZE_BUDGET` clamps a workspace scan to
|
|
78
|
+
* 60 s — and far below "the editor has no diagnostics and nobody said why".
|
|
79
|
+
* Expiry fails the route AND kills the child, because a parse that did not
|
|
80
|
+
* return in two minutes will not return; the next request spawns a fresh one.
|
|
81
|
+
*/
|
|
82
|
+
export const ANALYZE_DEADLINE_S = 120;
|
|
83
|
+
|
|
84
|
+
const scalarChr = (x) => typeof x === "string" && x.length > 0;
|
|
85
|
+
/** What a Python child must not inherit (host/engine-python.mjs's list, restated). */
|
|
86
|
+
const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The Python analysis child: `python -I engines/python/analyze.py <sentinel>`.
|
|
90
|
+
*
|
|
91
|
+
* BATCH, exactly as analyze.R is spawned: plain JSON lines in, sentinel frames
|
|
92
|
+
* out, no console, no debugger, no `exec`. `-I` isolates it — no PYTHONPATH, no
|
|
93
|
+
* user site-packages, no script directory on sys.path — because the child
|
|
94
|
+
* imports the standard library only, and a path that lets a user's module be
|
|
95
|
+
* imported is a path that lets a user's module run.
|
|
96
|
+
*/
|
|
97
|
+
export class PythonAnalysisEngine extends StdioEngine {
|
|
98
|
+
/**
|
|
99
|
+
* @param {Object} opts
|
|
100
|
+
* @param {string} opts.workerPath engines/python/analyze.py
|
|
101
|
+
* @param {string} opts.python the interpreter the host's discovery chose
|
|
102
|
+
*/
|
|
103
|
+
constructor({ workerPath, python } = {}) {
|
|
104
|
+
super({ language: "python", workerPath });
|
|
105
|
+
if (!python) throw new Error("PythonAnalysisEngine: no Python interpreter");
|
|
106
|
+
this.python = python;
|
|
107
|
+
this.mode = "batch";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get label() { return "Python"; }
|
|
111
|
+
get stripEnv() { return STRIP_PY_ENV; }
|
|
112
|
+
get canInput() { return false; }
|
|
113
|
+
|
|
114
|
+
spawnPlan() {
|
|
115
|
+
return { bin: this.python, args: ["-I", path.resolve(this.workerPath), this.sentinel],
|
|
116
|
+
env: { CARMAR_WORKER_MODE: "batch" } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Plain JSON lines, as analyze.R's batch reader takes them: no console to tag into. */
|
|
120
|
+
send(cmd) {
|
|
121
|
+
if (!cmd || typeof cmd.type !== "string") throw new Error("PythonAnalysisEngine.send: a command needs a string type");
|
|
122
|
+
return this.write(`${JSON.stringify(cmd)}\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Which analyzer a request is for. An ABSENT engine is R — every page built
|
|
128
|
+
* before Python analysis existed sends none, and must reach analyze.R exactly
|
|
129
|
+
* as before. Lower-cased; "" (a fence that named no engine) stays "" so it is
|
|
130
|
+
* refused in words rather than folded into R.
|
|
131
|
+
*/
|
|
132
|
+
export function analyzeEngineOf(cmd) {
|
|
133
|
+
if (!cmd || cmd.engine == null) return "r";
|
|
134
|
+
return String(cmd.engine).trim().toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createPlane({ root, spike, env, audit }) {
|
|
138
|
+
const deadlineS = (() => {
|
|
139
|
+
const raw = Number(env("CARMAR_ANALYZE_DEADLINE", String(ANALYZE_DEADLINE_S)));
|
|
140
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : ANALYZE_DEADLINE_S;
|
|
141
|
+
})();
|
|
142
|
+
const hostRoot = root || path.resolve(spike, "..");
|
|
143
|
+
let seq = 0;
|
|
144
|
+
|
|
145
|
+
const enc = (obj) => JSON.stringify(obj);
|
|
146
|
+
const deliver = (rec, frame) => {
|
|
147
|
+
try { rec.ws.send(enc(frame)); } catch { /* the socket went away mid-send */ }
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One analyzer: its own child, queue, slot and deadline.
|
|
152
|
+
*
|
|
153
|
+
* @param {Object} o
|
|
154
|
+
* @param {string} o.name "r" | "python" (the audit's engine field)
|
|
155
|
+
* @param {string} o.label the word the deadline sentence uses
|
|
156
|
+
* @param {() => (StdioEngine|{error: string})} o.makeEngine
|
|
157
|
+
* an engine to start, or `{error}` — a sentence for why there is none
|
|
158
|
+
*/
|
|
159
|
+
function createLane({ name, label, makeEngine }) {
|
|
160
|
+
let engine = null;
|
|
161
|
+
let engineStarted = false; // an engine object that has ever been spawned
|
|
162
|
+
let analyzerPid = null;
|
|
163
|
+
const routes = new Map(); // wireId → {rec, clientId, responseType}
|
|
164
|
+
let queue = [];
|
|
165
|
+
let active = null; // wireId
|
|
166
|
+
let activeSince = null;
|
|
167
|
+
// R's audit events keep their exact shape; the Python lane names itself.
|
|
168
|
+
const laneAudit = (event, fields = {}) => audit(event, name === "r" ? fields : { engine: name, ...fields });
|
|
169
|
+
|
|
170
|
+
const dropRoute = (wireId) => { routes.delete(wireId); };
|
|
171
|
+
const releaseSlot = () => { active = null; activeSince = null; };
|
|
172
|
+
|
|
173
|
+
/** Every outstanding analysis is told, by name, that it will never arrive. */
|
|
174
|
+
const failRoutes = (reason) => {
|
|
175
|
+
for (const [wireId, route] of [...routes]) {
|
|
176
|
+
deliver(route.rec, { type: route.responseType, id: route.clientId, error: reason });
|
|
177
|
+
dropRoute(wireId);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const onEvent = (e) => {
|
|
182
|
+
// The analyzers write nothing to stdout but control frames, so anything
|
|
183
|
+
// without an id is a boot notice, not a user's output.
|
|
184
|
+
if (e.type === "analyze-ready") {
|
|
185
|
+
analyzerPid = e.pid ?? null;
|
|
186
|
+
laneAudit("analyzer-ready", { pid: analyzerPid });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!scalarChr(e.id)) return;
|
|
190
|
+
const wireId = e.id;
|
|
191
|
+
if (active === wireId) releaseSlot(); // BEFORE the route lookup: S4
|
|
192
|
+
const route = routes.get(wireId);
|
|
193
|
+
if (route) {
|
|
194
|
+
try {
|
|
195
|
+
route.rec.ws.send(relayFrame({ ...e, id: route.clientId }, wireId, route.clientId));
|
|
196
|
+
} catch { /* the asker's socket went away */ }
|
|
197
|
+
dropRoute(wireId);
|
|
198
|
+
}
|
|
199
|
+
dispatch();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const onExit = () => {
|
|
203
|
+
// Anything still in flight will never be answered by this child.
|
|
204
|
+
failRoutes("the analysis worker stopped");
|
|
205
|
+
releaseSlot();
|
|
206
|
+
queue = [];
|
|
207
|
+
laneAudit("analyzer-stopped", { pid: analyzerPid });
|
|
208
|
+
analyzerPid = null;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The child, spawned on FIRST USE rather than at startup: a notebook that
|
|
213
|
+
* never asks for a diagnostic should not pay for a second process, and a
|
|
214
|
+
* lazily started one also restarts itself for free after a crash.
|
|
215
|
+
* @returns {{engine: StdioEngine|null, error: string}}
|
|
216
|
+
*/
|
|
217
|
+
const ensureEngine = () => {
|
|
218
|
+
if (engine && engine.alive) return { engine, error: "" };
|
|
219
|
+
let made = null;
|
|
220
|
+
try { made = makeEngine(); } catch (err) {
|
|
221
|
+
laneAudit("analyzer-start-failed", { detail: err.message });
|
|
222
|
+
return { engine: null, error: "the analysis worker could not start" };
|
|
223
|
+
}
|
|
224
|
+
if (made && made.error) return { engine: null, error: made.error };
|
|
225
|
+
if (engineStarted) laneAudit("analyzer-restart", {});
|
|
226
|
+
const mine = made;
|
|
227
|
+
try {
|
|
228
|
+
mine.on("event", (e) => { if (engine === mine) onEvent(e); });
|
|
229
|
+
mine.on("exit", () => { if (engine === mine) onExit(); });
|
|
230
|
+
mine.on("spawn-error", (err) => laneAudit("analyzer-start-failed", { detail: err.message }));
|
|
231
|
+
mine.start();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
laneAudit("analyzer-start-failed", { detail: err.message });
|
|
234
|
+
return { engine: null, error: "the analysis worker could not start" };
|
|
235
|
+
}
|
|
236
|
+
engine = mine;
|
|
237
|
+
engineStarted = true;
|
|
238
|
+
return { engine, error: "" };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One in flight at a time, like the worker queue: the analyzer is a single
|
|
243
|
+
* process, so a second large source written while it is mid-parse could
|
|
244
|
+
* block the host inside a full stdin pipe.
|
|
245
|
+
*/
|
|
246
|
+
const dispatch = () => {
|
|
247
|
+
if (active !== null || !queue.length) return;
|
|
248
|
+
const { engine: a, error } = ensureEngine();
|
|
249
|
+
const item = queue.shift();
|
|
250
|
+
const route = routes.get(item.wireId);
|
|
251
|
+
if (!a) {
|
|
252
|
+
if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error });
|
|
253
|
+
dropRoute(item.wireId);
|
|
254
|
+
dispatch();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
active = item.wireId;
|
|
258
|
+
activeSince = Date.now();
|
|
259
|
+
let failed = null;
|
|
260
|
+
try { a.send(item.cmd); } catch (err) { failed = err.message; }
|
|
261
|
+
if (failed) {
|
|
262
|
+
if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error: failed });
|
|
263
|
+
dropRoute(item.wireId);
|
|
264
|
+
releaseSlot();
|
|
265
|
+
dispatch();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** A wedged child holds the slot; after the deadline it holds nothing. */
|
|
270
|
+
const deadlineCheck = () => {
|
|
271
|
+
if (!deadlineS || active === null || activeSince == null) return;
|
|
272
|
+
if (Date.now() - activeSince < deadlineS * 1000) return;
|
|
273
|
+
const wireId = active;
|
|
274
|
+
const route = routes.get(wireId);
|
|
275
|
+
const reason = `The analysis worker did not answer within ${Math.round(deadlineS)} seconds. `
|
|
276
|
+
+ `Your ${label} session and its variables are untouched; only this analysis was abandoned.`;
|
|
277
|
+
laneAudit("analyzer-deadline", { id: wireId, seconds: Math.round(deadlineS) });
|
|
278
|
+
if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error: reason });
|
|
279
|
+
dropRoute(wireId);
|
|
280
|
+
releaseSlot();
|
|
281
|
+
// The child is not merely slow, it is stuck; its exit clears the rest and
|
|
282
|
+
// the next request spawns a fresh one.
|
|
283
|
+
if (engine && engine.alive) engine.kill();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const kill = () => {
|
|
287
|
+
if (!engine || !engine.alive) return;
|
|
288
|
+
try { engine.proc.stdin.end(); } catch { /* already closed */ }
|
|
289
|
+
try { engine.kill(); } catch { /* already gone */ }
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
enqueue(down, route, wireId) {
|
|
294
|
+
routes.set(wireId, route);
|
|
295
|
+
queue.push({ cmd: down, wireId });
|
|
296
|
+
dispatch();
|
|
297
|
+
},
|
|
298
|
+
onClose(rec) {
|
|
299
|
+
const abandoned = new Set(queue.filter((item) => routes.get(item.wireId)?.rec === rec).map((i) => i.wireId));
|
|
300
|
+
if (abandoned.size) queue = queue.filter((item) => !abandoned.has(item.wireId));
|
|
301
|
+
for (const [wireId, route] of [...routes]) if (route.rec === rec) dropRoute(wireId);
|
|
302
|
+
// The in-flight route is dropped with the rest; its reply releases the
|
|
303
|
+
// slot on arrival (onEvent releases BEFORE it looks a route up).
|
|
304
|
+
},
|
|
305
|
+
deadlineCheck,
|
|
306
|
+
kill,
|
|
307
|
+
get pid() { return analyzerPid; },
|
|
308
|
+
get depth() { return queue.length; },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── the lanes ─────────────────────────────────────────────────────────────
|
|
313
|
+
const rWorkerPath = path.join(spike, "analyze.R");
|
|
314
|
+
const pyWorkerPath = path.join(hostRoot, "engines", "python", "analyze.py");
|
|
315
|
+
/** The engine pool, learned at init: which Python this host discovered. */
|
|
316
|
+
let pool = null;
|
|
317
|
+
|
|
318
|
+
const lanes = {
|
|
319
|
+
r: createLane({ name: "r", label: "R", makeEngine: () => new REngine({ workerPath: rWorkerPath, mode: "batch" }) }),
|
|
320
|
+
python: createLane({
|
|
321
|
+
name: "python", label: "Python",
|
|
322
|
+
makeEngine: () => {
|
|
323
|
+
// The SAME interpreter the Python engine runs on — the pool's own
|
|
324
|
+
// discovery, including CARMAR_PYTHON and CARMAR_NO_PYTHON — so "Python
|
|
325
|
+
// is available" means one thing on this host, and its reason is the
|
|
326
|
+
// one Doctor shows.
|
|
327
|
+
const spec = pool && Array.isArray(pool.specs) ? pool.specs.find((x) => x.name === "python") : null;
|
|
328
|
+
if (!spec || !spec.available || !spec.runtime || !spec.runtime.path) {
|
|
329
|
+
const why = spec && spec.detail ? spec.detail : "No Python interpreter was found.";
|
|
330
|
+
return { error: `Python analysis is unavailable: ${why}` };
|
|
331
|
+
}
|
|
332
|
+
if (!fs.existsSync(pyWorkerPath)) {
|
|
333
|
+
return { error: "Python analysis is unavailable: this host does not carry engines/python/analyze.py." };
|
|
334
|
+
}
|
|
335
|
+
return new PythonAnalysisEngine({ workerPath: pyWorkerPath, python: spec.runtime.path });
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const sweeper = setInterval(() => { Object.values(lanes).forEach((lane) => lane.deadlineCheck()); }, 500);
|
|
341
|
+
sweeper.unref();
|
|
342
|
+
const stop = () => Object.values(lanes).forEach((lane) => lane.kill());
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
name: "analyze",
|
|
346
|
+
ops: [...ANALYZE_FORWARDED],
|
|
347
|
+
agentRefused: [], // serve.R refuses none of these to an agent:
|
|
348
|
+
// reading source without running it is what an
|
|
349
|
+
// agent should be doing.
|
|
350
|
+
// NOT advertised in ready.commands, and that is deliberate parity: serve.R
|
|
351
|
+
// never merged the analyze ops there, so no page decides anything by their
|
|
352
|
+
// presence. The capability below is the one new fact a page may test for.
|
|
353
|
+
commands: [],
|
|
354
|
+
capabilities: ["analyze-python-v1"],
|
|
355
|
+
|
|
356
|
+
init(ctx) {
|
|
357
|
+
pool = ctx && ctx.plane ? ctx.plane : null;
|
|
358
|
+
// The analysis children are spawned lazily, so there is nothing to start
|
|
359
|
+
// here — only the promise that they do not outlive this process. Each is
|
|
360
|
+
// its own process group, and an orphan holding the user's files is
|
|
361
|
+
// exactly what the worker plane's exit handler exists to prevent.
|
|
362
|
+
process.on("exit", stop);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
handle(cmd, rec, ctx) {
|
|
366
|
+
if (!scalarChr(cmd.id)) return undefined;
|
|
367
|
+
if (!pool && ctx && ctx.plane) pool = ctx.plane;
|
|
368
|
+
const responseType = ANALYZE_REPLY_TYPE[cmd.type] || "analyze";
|
|
369
|
+
const engineName = analyzeEngineOf(cmd);
|
|
370
|
+
const lane = Object.hasOwn(lanes, engineName) ? lanes[engineName] : null;
|
|
371
|
+
if (!lane) {
|
|
372
|
+
// Answered, never dropped: a page waiting on an id must not hang
|
|
373
|
+
// because it asked about a language nothing here reads.
|
|
374
|
+
deliver(rec, { type: responseType, id: cmd.id,
|
|
375
|
+
error: engineName ? `No analyzer reads ${engineName} code.` : "A chunk that names no engine is not analysed." });
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
seq += 1;
|
|
379
|
+
const wireId = `awire-${seq}`;
|
|
380
|
+
// The routing key stays here: the children never see `engine`, so an R
|
|
381
|
+
// request reaches analyze.R byte-for-byte as it did.
|
|
382
|
+
const { engine: _routed, ...rest } = cmd;
|
|
383
|
+
const down = { ...rest, id: wireId, type: ANALYZE_WIRE_NAME[cmd.type] || cmd.type };
|
|
384
|
+
lane.enqueue(down, { rec, clientId: cmd.id, responseType }, wireId);
|
|
385
|
+
return undefined;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/** Spend no analyzer time on commands nobody will receive. */
|
|
389
|
+
onClose(rec) { Object.values(lanes).forEach((lane) => lane.onClose(rec)); },
|
|
390
|
+
|
|
391
|
+
// ── what the tests need to see, and nothing the wire can reach ────────
|
|
392
|
+
get analyzerPid() { return lanes.r.pid; },
|
|
393
|
+
get pythonAnalyzerPid() { return lanes.python.pid; },
|
|
394
|
+
get queueDepth() { return lanes.r.depth + lanes.python.depth; },
|
|
395
|
+
stop,
|
|
396
|
+
};
|
|
397
|
+
}
|