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,206 @@
|
|
|
1
|
+
// engine-r.mjs — the R engine: owns one `worker.R` process. Knows nothing
|
|
2
|
+
// about HTTP, sockets or pages.
|
|
3
|
+
//
|
|
4
|
+
// This is spike/kernel.R in JavaScript, and the shape it exposes is the seam
|
|
5
|
+
// the 2026-09-14 review asked for (docs/host-plan.md §9.5): the host talks to
|
|
6
|
+
// an ENGINE ADAPTER — start / send / interrupt / console / stop / kill and a
|
|
7
|
+
// stream of events — while the adapter owns the NATIVE transport underneath.
|
|
8
|
+
// For R that transport is not "a frame on stdin" for everything: Stop is a
|
|
9
|
+
// SIGINT to the worker's process group (a busy R does not read its stdin), and
|
|
10
|
+
// the debugger's step commands and a `readline()` answer are RAW console
|
|
11
|
+
// lines. Both survive here, behind the adapter, so "everything is a frame" is
|
|
12
|
+
// true of the host and false of the wire, which is the honest split.
|
|
13
|
+
//
|
|
14
|
+
// WP4 moved the transport — the two tokens, the sentinel framing, the cmdfile
|
|
15
|
+
// spill, the process group, the interrupt sequence — into host/engine-stdio.mjs,
|
|
16
|
+
// because the Python engine needed every line of it. What is R's, and stays
|
|
17
|
+
// here: finding an Rscript, the interactive `R` binary beside it, the batch
|
|
18
|
+
// fallback, the `sys.source` boot line, the R_* environment strip, and the two
|
|
19
|
+
// capabilities (R has a readline AND a debugger; Python has only the first).
|
|
20
|
+
// `LineFramer`, `workerEnvironment` and `MAX_CONSOLE_LINE` are re-exported so
|
|
21
|
+
// nothing that imported them from here has to move.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { LineFramer, MAX_CONSOLE_LINE, StdioEngine, engineEnvironment } from "./engine-stdio.mjs";
|
|
27
|
+
import { RTERM_INTERACTIVE_ARGS, detectRterm, detectWindowsRscript, findInterruptTool } from "./windows-runtime.mjs";
|
|
28
|
+
|
|
29
|
+
export { LineFramer, MAX_CONSOLE_LINE };
|
|
30
|
+
|
|
31
|
+
/** The R_* variables a user's shell may carry that would aim R elsewhere. */
|
|
32
|
+
const STRIP_R_ENV = /^R_(HOME|LIBS|LIBS_USER|LIBS_SITE|PROFILE|ENVIRON|DOC_DIR|INCLUDE_DIR|SHARE_DIR)$/;
|
|
33
|
+
|
|
34
|
+
/** Kept for callers that built a worker environment by hand. */
|
|
35
|
+
export function workerEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env }) {
|
|
36
|
+
return engineEnvironment({
|
|
37
|
+
sentinel, cmdtag, workerDir, base, strip: STRIP_R_ENV,
|
|
38
|
+
extra: { CARMAR_WORKER_MODE: "interactive", ...extra },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KNOWN_RSCRIPT = [
|
|
43
|
+
"/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
|
|
44
|
+
"/opt/homebrew/bin/Rscript",
|
|
45
|
+
"/usr/local/bin/Rscript",
|
|
46
|
+
"/opt/local/bin/Rscript",
|
|
47
|
+
"/usr/bin/Rscript",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which R should host the session? An explicit `CARMAR_RSCRIPT`, then the
|
|
52
|
+
* macOS framework R, then PATH — PATH last on purpose: a Finder-launched
|
|
53
|
+
* process has `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, rig,
|
|
54
|
+
* conda and Posit entirely.
|
|
55
|
+
* @param {string} [explicit]
|
|
56
|
+
* @returns {string} path to an Rscript binary, or ""
|
|
57
|
+
*/
|
|
58
|
+
export function detectRscript(explicit = process.env.CARMAR_RSCRIPT || "", platform = process.platform) {
|
|
59
|
+
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
60
|
+
// Windows R is not on PATH after a default install; its ladder is its own.
|
|
61
|
+
if (platform === "win32") return detectWindowsRscript(process.env);
|
|
62
|
+
for (const cand of KNOWN_RSCRIPT) if (fs.existsSync(cand)) return cand;
|
|
63
|
+
const versions = "/Library/Frameworks/R.framework/Versions";
|
|
64
|
+
if (fs.existsSync(versions)) {
|
|
65
|
+
const hits = fs.readdirSync(versions)
|
|
66
|
+
.map((v) => path.join(versions, v, "Resources/bin/Rscript"))
|
|
67
|
+
.filter((p) => fs.existsSync(p)).sort().reverse();
|
|
68
|
+
if (hits.length) return hits[0];
|
|
69
|
+
}
|
|
70
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
71
|
+
const cand = path.join(dir, process.platform === "win32" ? "Rscript.exe" : "Rscript");
|
|
72
|
+
if (dir && fs.existsSync(cand)) return cand;
|
|
73
|
+
}
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The full R binary beside an Rscript — the interactive worker needs it. */
|
|
78
|
+
export function detectRBinary(rscript) {
|
|
79
|
+
if (!rscript) return "";
|
|
80
|
+
const cand = path.join(path.dirname(rscript), process.platform === "win32" ? "R.exe" : "R");
|
|
81
|
+
return fs.existsSync(cand) ? cand : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One R worker process. */
|
|
85
|
+
export class REngine extends StdioEngine {
|
|
86
|
+
/**
|
|
87
|
+
* @param {Object} opts
|
|
88
|
+
* @param {string} opts.workerPath spike/worker.R (or the boot shim beside it)
|
|
89
|
+
* @param {string} [opts.rscript]
|
|
90
|
+
* @param {Record<string,string>} [opts.env] extra environment for the worker
|
|
91
|
+
* @param {string} [opts.cwd]
|
|
92
|
+
* @param {"batch"} [opts.mode] force BATCH: `Rscript <script> <sentinel>`,
|
|
93
|
+
* no interactive console and therefore no raw console channel at all. The
|
|
94
|
+
* analysis child (spike/analyze.R) is spawned this way — exactly as
|
|
95
|
+
* `kernel_start(..., interactive = FALSE)` spawns it in R — because it
|
|
96
|
+
* must never be able to evaluate anything. Omitted, the mode is decided by
|
|
97
|
+
* whether a full R binary sits beside Rscript, as before.
|
|
98
|
+
*/
|
|
99
|
+
constructor({ workerPath, rscript = detectRscript(), env = {}, cwd, mode, platform = process.platform,
|
|
100
|
+
io = null, processEnv = process.env, handoffScript = null } = {}) {
|
|
101
|
+
super({ language: "r", workerPath, env, cwd, platform });
|
|
102
|
+
// share()/use() for R. The host passes the path from ITS root; a packaged
|
|
103
|
+
// binary has no module path to derive one from (host/main.mjs, moduleDir).
|
|
104
|
+
this.handoffPath = handoffScript;
|
|
105
|
+
if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`REngine: no worker at ${workerPath}`);
|
|
106
|
+
if (!rscript) throw new Error("REngine: no Rscript found (set CARMAR_RSCRIPT)");
|
|
107
|
+
this.rscript = rscript;
|
|
108
|
+
// Windows: Rterm.exe --ess is the interactive worker (host/windows-runtime.mjs).
|
|
109
|
+
// CARMAR_WIN_BATCH=1 keeps the old batch worker, as an escape hatch should
|
|
110
|
+
// some R build refuse --ess with a piped stdin.
|
|
111
|
+
if (platform === "win32") {
|
|
112
|
+
const winIo = io || undefined;
|
|
113
|
+
this.rBinary = processEnv.CARMAR_WIN_BATCH === "1" ? "" : detectRterm(rscript, winIo);
|
|
114
|
+
this.interruptTool = findInterruptTool({ env: processEnv, rscript, io: winIo });
|
|
115
|
+
} else {
|
|
116
|
+
this.rBinary = detectRBinary(rscript);
|
|
117
|
+
}
|
|
118
|
+
// `mode: "batch"` is the analysis child (host/planes/analyze.mjs): it must
|
|
119
|
+
// never be interactive, because it never evaluates and has no debugger.
|
|
120
|
+
this.mode = mode === "batch" ? "batch" : (this.rBinary ? "interactive" : "batch");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get label() { return "R"; }
|
|
124
|
+
/** The analysis child never evaluates, so it never needs one. */
|
|
125
|
+
get usesStopFlag() { return !/analyze\.R$/.test(String(this.workerPath)); }
|
|
126
|
+
get stripEnv() { return STRIP_R_ENV; }
|
|
127
|
+
/** R's readline and R's browser() both ride the raw console line. */
|
|
128
|
+
get canInput() { return this.mode === "interactive"; }
|
|
129
|
+
get canDebug() { return this.mode === "interactive"; }
|
|
130
|
+
|
|
131
|
+
spawnPlan() {
|
|
132
|
+
if (this.mode === "interactive" && this.platform === "win32") {
|
|
133
|
+
return { bin: this.rBinary, args: [...RTERM_INTERACTIVE_ARGS], env: { CARMAR_WORKER_MODE: "interactive" } };
|
|
134
|
+
}
|
|
135
|
+
if (this.mode === "interactive") {
|
|
136
|
+
return {
|
|
137
|
+
bin: this.rBinary,
|
|
138
|
+
// --no-echo suppresses the "> " prompt; the echo it does not suppress
|
|
139
|
+
// is scrubbed by LineFramer through the cmdtag.
|
|
140
|
+
//
|
|
141
|
+
// --no-readline FIRST. With readline, R echoes every line it reads back
|
|
142
|
+
// on stdout — and on Linux (R 4.3.3, 2026-09-16) readline's horizontal
|
|
143
|
+
// scrolling redraws a long command line as "<" + its tail, which no
|
|
144
|
+
// longer carries the cmdtag, so the echo leaked into chunk output
|
|
145
|
+
// (`beatrina check` on .39 read it as "the line after Stop ran").
|
|
146
|
+
// R honours --no-readline only BEFORE --interactive: after it, the flag
|
|
147
|
+
// is ignored and capabilities("cledit") stays TRUE (measured on Linux
|
|
148
|
+
// R 4.3.3 and macOS R 4.5.2). Without readline R reads plain lines,
|
|
149
|
+
// echoes nothing, and readline()/browser() still read the console.
|
|
150
|
+
// test/r-echo.test.mjs pins both the order and the silence.
|
|
151
|
+
args: ["--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
|
|
152
|
+
env: { CARMAR_WORKER_MODE: "interactive" },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
bin: this.rscript,
|
|
157
|
+
args: ["--no-save", "--no-restore", "--no-site-file", this.workerPath, this.sentinel],
|
|
158
|
+
env: { CARMAR_WORKER_MODE: "batch" },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
afterSpawn() {
|
|
163
|
+
if (this.mode !== "interactive") return;
|
|
164
|
+
// The boot line replaces worker-boot.R: sys.source parses the ~3,000-line
|
|
165
|
+
// file in one pass instead of feeding it through the REPL reader.
|
|
166
|
+
const workerDir = path.dirname(path.resolve(this.workerPath));
|
|
167
|
+
const realWorker = path.join(workerDir, "worker.R");
|
|
168
|
+
const worker = fs.existsSync(realWorker) ? realWorker : path.resolve(this.workerPath);
|
|
169
|
+
// share()/use() (engines/r/handoff.R) ride the SAME line, attached before
|
|
170
|
+
// the worker starts, because sys.source(worker.R) never returns: its
|
|
171
|
+
// command loop is the rest of the process's life, so a second raw line
|
|
172
|
+
// would be read as a command. Absent file (an older package) → nothing.
|
|
173
|
+
const handoff = this.handoffScript();
|
|
174
|
+
const attach = handoff
|
|
175
|
+
? `local({ e <- attach(NULL, name = "beatrix:handoff", warn.conflicts = FALSE); sys.source(${JSON.stringify(handoff)}, envir = e, keep.source = FALSE) }); `
|
|
176
|
+
: "";
|
|
177
|
+
this.rawLine(`${attach}sys.source(${JSON.stringify(worker)}, envir = globalenv(), keep.source = FALSE)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** engines/r/handoff.R for this host, or "" when this build has none. */
|
|
181
|
+
handoffScript() {
|
|
182
|
+
let cand = this.handoffPath;
|
|
183
|
+
if (cand == null) {
|
|
184
|
+
const u = import.meta.url;
|
|
185
|
+
cand = typeof u === "string" && u.startsWith("file:") ? path.resolve(path.dirname(fileURLToPath(u)), "..", "engines", "r", "handoff.R") : "";
|
|
186
|
+
}
|
|
187
|
+
return cand && fs.existsSync(cand) ? cand : "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One RAW console line: the debugger's channel, and a readline() answer. */
|
|
191
|
+
console(line) { return this.rawLine(line); }
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Deliver one JSON command. The batch worker reads plain JSON lines off
|
|
195
|
+
* stdin and has no console to comment into, so the cmdtag and the cmdfile
|
|
196
|
+
* spill are the interactive path's only.
|
|
197
|
+
*/
|
|
198
|
+
send(cmd) {
|
|
199
|
+
if (!cmd || typeof cmd.type !== "string") throw new Error("REngine.send: a command needs a string type");
|
|
200
|
+
if (this.mode !== "interactive") {
|
|
201
|
+
if (cmd.type === "exec") this.interruptRefusalSaid = false;
|
|
202
|
+
return this.write(`${JSON.stringify(cmd)}\n`);
|
|
203
|
+
}
|
|
204
|
+
return super.send(cmd);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// engine-stdio.mjs — what every stdio engine shares, extracted from
|
|
2
|
+
// host/engine-r.mjs when the Python engine arrived (WP4).
|
|
3
|
+
//
|
|
4
|
+
// The host talks to an ENGINE ADAPTER — start / send / console / interrupt /
|
|
5
|
+
// stop / kill and a stream of events — and the adapter owns the NATIVE
|
|
6
|
+
// transport underneath (docs/worker-protocol.md §1). R and Python turned out
|
|
7
|
+
// to share the whole transport and to differ only in three answers:
|
|
8
|
+
//
|
|
9
|
+
// · which binary, with which arguments, and what to say to it first
|
|
10
|
+
// (R's interactive worker is booted with a `sys.source(...)` console line;
|
|
11
|
+
// Python's worker is an ordinary script argument);
|
|
12
|
+
// · what a raw "console line" MEANS (R: the debugger and a readline answer;
|
|
13
|
+
// Python: there is no debugger, and an answer is a command);
|
|
14
|
+
// · whether this engine has a debugger at all.
|
|
15
|
+
//
|
|
16
|
+
// Everything else — the two tokens, the sentinel framing, the cmdfile spill,
|
|
17
|
+
// the process group, the interrupt sequence, the stop ladder — is one body.
|
|
18
|
+
// Subclasses override `spawnPlan()`, `afterSpawn()`, `console()` and the two
|
|
19
|
+
// capability getters, and nothing else.
|
|
20
|
+
//
|
|
21
|
+
// Three rules carried over from spike/kernel.R, each with its measurement:
|
|
22
|
+
// · TWO tokens. The sentinel frames control lines FROM the worker; the
|
|
23
|
+
// cmdtag prefixes command lines TO it (`#<cmdtag> {json}` — a comment, so
|
|
24
|
+
// a line that ever reached a raw prompt is inert, and its echo is
|
|
25
|
+
// self-identifying). One token for both directions would let an echoed
|
|
26
|
+
// command parse as a frame from the worker.
|
|
27
|
+
// · The sentinel is found ANYWHERE in a line, not at its start: a cell
|
|
28
|
+
// ending in cat("done") with no newline leaves the cursor mid-line and the
|
|
29
|
+
// next frame lands glued to that text.
|
|
30
|
+
// · A command longer than 32,000 bytes travels through a 0600 temp file
|
|
31
|
+
// (`cmdfile`): R's interactive console reader wedges the whole process
|
|
32
|
+
// somewhere past 40,000 bytes, and the hang is a hang, not an error.
|
|
33
|
+
// Python has no such limit, but the spill costs nothing and one code path
|
|
34
|
+
// is worth more than a saved file write.
|
|
35
|
+
|
|
36
|
+
import { spawn } from "node:child_process";
|
|
37
|
+
import crypto from "node:crypto";
|
|
38
|
+
import { EventEmitter } from "node:events";
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { NO_INTERRUPT_TOOL, windowsInterruptPlan, windowsKillPlan } from "./windows-runtime.mjs";
|
|
43
|
+
|
|
44
|
+
export const MAX_CONSOLE_LINE = 32000;
|
|
45
|
+
/** The gap between the group SIGINT and the pid SIGINT: the measured lifetime of the spawned
|
|
46
|
+
* `kill` that used to separate them (median 3.2 ms, p90 5.3 ms, 2026-09-16). */
|
|
47
|
+
export const INTERRUPT_PAIR_GAP_MS = 5;
|
|
48
|
+
|
|
49
|
+
export const token = () => {
|
|
50
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
51
|
+
const bytes = crypto.randomBytes(24);
|
|
52
|
+
let out = "";
|
|
53
|
+
for (const b of bytes) out += alphabet[b % alphabet.length];
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The child's environment: a UTF-8 LC_CTYPE guaranteed, a dumb terminal, and
|
|
59
|
+
* the worker's two tokens. `strip` removes variables a user's shell may carry
|
|
60
|
+
* that would aim the child at a different install of its own runtime.
|
|
61
|
+
*/
|
|
62
|
+
export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env, strip = null }) {
|
|
63
|
+
const env = {};
|
|
64
|
+
for (const [k, v] of Object.entries(base)) {
|
|
65
|
+
if (strip && strip.test(k)) continue;
|
|
66
|
+
env[k] = v;
|
|
67
|
+
}
|
|
68
|
+
const utf8 = (x) => /utf-?8/i.test(String(x || ""));
|
|
69
|
+
const effective = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
|
|
70
|
+
if (!utf8(effective)) {
|
|
71
|
+
if (env.LC_ALL && !utf8(env.LC_ALL)) delete env.LC_ALL; // LC_ALL outranks LC_CTYPE
|
|
72
|
+
env.LC_CTYPE = "C.UTF-8";
|
|
73
|
+
}
|
|
74
|
+
Object.assign(env, extra);
|
|
75
|
+
// TERM=dumb: readline's terminal probe writes an escape sequence to stdout
|
|
76
|
+
// before anything else, and a dumb terminal keeps it out of the stream.
|
|
77
|
+
env.TERM = "dumb";
|
|
78
|
+
env.CARMAR_SENTINEL = sentinel;
|
|
79
|
+
env.CARMAR_CMD_TAG = cmdtag;
|
|
80
|
+
env.CARMAR_WORKER_DIR = workerDir;
|
|
81
|
+
return env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split a stream of worker bytes into events, exactly as kernel_poll does.
|
|
86
|
+
*
|
|
87
|
+
* Pure and incremental: feed it text, get back the events that are complete.
|
|
88
|
+
* Consecutive plain lines COALESCE into one event (a chunk printing 100k
|
|
89
|
+
* lines must not be 100k socket sends), a line carrying the sentinel is split
|
|
90
|
+
* into the user text before it and the frame, and a line carrying the cmdtag
|
|
91
|
+
* is our own command echoed back (kept only for the text before the tag).
|
|
92
|
+
*/
|
|
93
|
+
export class LineFramer {
|
|
94
|
+
constructor({ sentinel, cmdtag, channel }) {
|
|
95
|
+
this.sentinel = sentinel;
|
|
96
|
+
this.tag = cmdtag ? `#${cmdtag}` : null;
|
|
97
|
+
this.channel = channel; // "stdout" | "stderr"
|
|
98
|
+
this.tail = "";
|
|
99
|
+
}
|
|
100
|
+
/** @returns {Array<object>} events */
|
|
101
|
+
feed(text, { flush = false } = {}) {
|
|
102
|
+
const data = this.tail + text;
|
|
103
|
+
const nl = data.lastIndexOf("\n");
|
|
104
|
+
let complete;
|
|
105
|
+
if (nl < 0) {
|
|
106
|
+
if (flush && data) { this.tail = ""; complete = data; }
|
|
107
|
+
else { this.tail = data; return []; }
|
|
108
|
+
} else {
|
|
109
|
+
complete = data.slice(0, nl);
|
|
110
|
+
this.tail = data.slice(nl + 1);
|
|
111
|
+
if (flush && this.tail) { complete += `\n${this.tail}`; this.tail = ""; }
|
|
112
|
+
}
|
|
113
|
+
const lines = complete.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
114
|
+
const events = [];
|
|
115
|
+
let plain = [];
|
|
116
|
+
const flushPlain = () => {
|
|
117
|
+
if (plain.length) { events.push({ type: this.channel, text: plain.join("\n") }); plain = []; }
|
|
118
|
+
};
|
|
119
|
+
for (let line of lines) {
|
|
120
|
+
if (this.tag) {
|
|
121
|
+
const at = line.indexOf(this.tag);
|
|
122
|
+
if (at >= 0) { line = line.slice(0, at); if (!line) continue; }
|
|
123
|
+
}
|
|
124
|
+
const at = line.indexOf(this.sentinel);
|
|
125
|
+
if (at < 0) { plain.push(line); continue; }
|
|
126
|
+
const prefix = line.slice(0, at);
|
|
127
|
+
const payload = line.slice(at + this.sentinel.length);
|
|
128
|
+
let parsed = null;
|
|
129
|
+
try { parsed = JSON.parse(payload); } catch { parsed = null; }
|
|
130
|
+
// A control frame must be a JSON OBJECT: an array or a bare scalar parses
|
|
131
|
+
// cleanly and is not a frame. Same answer a parse failure gets.
|
|
132
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { plain.push(line); continue; }
|
|
133
|
+
if (prefix) plain.push(prefix);
|
|
134
|
+
flushPlain();
|
|
135
|
+
events.push({ ...parsed, __raw: payload });
|
|
136
|
+
}
|
|
137
|
+
flushPlain();
|
|
138
|
+
return events;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One engine process, behind stdio.
|
|
144
|
+
*
|
|
145
|
+
* Events: `event` (one worker event: a frame with `__raw`, or `{type:"stdout"|
|
|
146
|
+
* "stderr", text}`), `exit` ({code, signal}), `spawn-error` (Error).
|
|
147
|
+
*/
|
|
148
|
+
export class StdioEngine extends EventEmitter {
|
|
149
|
+
/**
|
|
150
|
+
* @param {Object} opts
|
|
151
|
+
* @param {string} opts.language the engine key ("r", "python")
|
|
152
|
+
* @param {string} opts.workerPath the file the runtime is pointed at
|
|
153
|
+
* @param {Record<string,string>} [opts.env] extra environment for the worker
|
|
154
|
+
* @param {string} [opts.cwd]
|
|
155
|
+
*/
|
|
156
|
+
constructor({ language, workerPath, env = {}, cwd, platform = process.platform } = {}) {
|
|
157
|
+
super();
|
|
158
|
+
// Both are instance state so a test can drive the Windows branches on any
|
|
159
|
+
// machine (test/windows-runtime.test.mjs); nothing else reassigns them.
|
|
160
|
+
this.platform = platform;
|
|
161
|
+
this.spawnImpl = spawn;
|
|
162
|
+
// Windows only: processx's interrupt.exe (host/windows-runtime.mjs). A
|
|
163
|
+
// subclass that knows where R's libraries are fills it in.
|
|
164
|
+
this.interruptTool = "";
|
|
165
|
+
this.interruptRefusalSaid = false;
|
|
166
|
+
this.language = language;
|
|
167
|
+
this.workerPath = workerPath;
|
|
168
|
+
this.extraEnv = env;
|
|
169
|
+
this.cwd = cwd;
|
|
170
|
+
this.mode = "interactive";
|
|
171
|
+
this.sentinel = token();
|
|
172
|
+
this.cmdtag = token();
|
|
173
|
+
this.proc = null;
|
|
174
|
+
this.alive = false;
|
|
175
|
+
this.pid = null;
|
|
176
|
+
this.spilled = new Set();
|
|
177
|
+
this.stopDir = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Does the worker read a stop flag between top-level expressions (CARMAR_STOP_DIR)? R's does
|
|
182
|
+
* (spike/worker.R `carmar_stop_requested`), because a signal cannot always reach R: libc's
|
|
183
|
+
* system() ignores SIGINT in R while its child runs, and whether the signal the host sends after
|
|
184
|
+
* the child dies lands before or after R walks on to the next line is a race — on Linux it lost
|
|
185
|
+
* every time (`beatrina check` on .39, 2026-09-16: 0/3 Stops of system("sleep 60")). The flag is
|
|
186
|
+
* read at the next expression boundary, so the line after the busy call never runs, on any OS.
|
|
187
|
+
*/
|
|
188
|
+
get usesStopFlag() { return false; }
|
|
189
|
+
|
|
190
|
+
/** Ask the worker to stop the run `wireId` at its next expression boundary. */
|
|
191
|
+
requestStop(wireId) {
|
|
192
|
+
if (!this.stopDir || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(wireId))) return false;
|
|
193
|
+
try { fs.writeFileSync(path.join(this.stopDir, `stop-${wireId}`), "", { mode: 0o600 }); return true; } catch { return false; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The run ended: its flag, if any, goes. */
|
|
197
|
+
clearStop(wireId) {
|
|
198
|
+
if (!this.stopDir || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(wireId))) return;
|
|
199
|
+
try { fs.unlinkSync(path.join(this.stopDir, `stop-${wireId}`)); } catch { /* none was written */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Can this engine answer a `readline()`-style question mid-run? */
|
|
203
|
+
get canInput() { return this.mode === "interactive"; }
|
|
204
|
+
/** Does it have a step debugger the page's `debug_cmd` can drive? */
|
|
205
|
+
get canDebug() { return false; }
|
|
206
|
+
|
|
207
|
+
/** Which environment variables the child must NOT inherit. */
|
|
208
|
+
get stripEnv() { return null; }
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @abstract
|
|
212
|
+
* @returns {{bin: string, args: string[], env?: Record<string,string>}}
|
|
213
|
+
*/
|
|
214
|
+
spawnPlan() { throw new Error("StdioEngine.spawnPlan: subclass must implement"); }
|
|
215
|
+
|
|
216
|
+
/** Anything to say to the child before the first command. */
|
|
217
|
+
afterSpawn() { /* most engines need nothing */ }
|
|
218
|
+
|
|
219
|
+
/** Spawn the worker. Resolves once the process exists (not once it is ready). */
|
|
220
|
+
start() {
|
|
221
|
+
const workerDir = path.dirname(path.resolve(this.workerPath));
|
|
222
|
+
const plan = this.spawnPlan();
|
|
223
|
+
// One private folder per worker for stop flags (0700; removed when the worker exits).
|
|
224
|
+
if (this.usesStopFlag && !this.stopDir) {
|
|
225
|
+
try { this.stopDir = fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-stop-")); fs.chmodSync(this.stopDir, 0o700); } catch { this.stopDir = null; }
|
|
226
|
+
}
|
|
227
|
+
const env = engineEnvironment({
|
|
228
|
+
sentinel: this.sentinel, cmdtag: this.cmdtag, workerDir,
|
|
229
|
+
extra: { ...this.extraEnv, ...(plan.env || {}), ...(this.stopDir ? { CARMAR_STOP_DIR: this.stopDir } : {}) }, strip: this.stripEnv,
|
|
230
|
+
});
|
|
231
|
+
// `detached`: the worker leads its OWN process group, so an interrupt can
|
|
232
|
+
// be sent to the group and reach a child under system() too — a terminal's
|
|
233
|
+
// ^C goes to the whole foreground group, and so does this.
|
|
234
|
+
// `windowsHide` (CREATE_NO_WINDOW) gives the worker a console of its OWN
|
|
235
|
+
// on Windows. That is what lets interrupt.exe attach to it and send Ctrl+C
|
|
236
|
+
// to the worker and its children without also reaching the host.
|
|
237
|
+
this.proc = this.spawnImpl(plan.bin, plan.args, {
|
|
238
|
+
cwd: this.cwd, env, stdio: ["pipe", "pipe", "pipe"],
|
|
239
|
+
detached: this.platform !== "win32",
|
|
240
|
+
windowsHide: true,
|
|
241
|
+
});
|
|
242
|
+
this.alive = true;
|
|
243
|
+
this.pid = this.proc.pid;
|
|
244
|
+
const out = new LineFramer({ sentinel: this.sentinel, cmdtag: this.cmdtag, channel: "stdout" });
|
|
245
|
+
const err = new LineFramer({ sentinel: this.sentinel, cmdtag: null, channel: "stderr" });
|
|
246
|
+
this.proc.stdout.setEncoding("utf8");
|
|
247
|
+
this.proc.stderr.setEncoding("utf8");
|
|
248
|
+
this.proc.stdout.on("data", (d) => out.feed(d).forEach((e) => this.emit("event", e)));
|
|
249
|
+
this.proc.stderr.on("data", (d) => err.feed(d).forEach((e) => this.emit("event", e)));
|
|
250
|
+
this.proc.stdin.on("error", () => { /* a dead worker's pipe: the exit event reports it */ });
|
|
251
|
+
this.proc.on("error", (e) => { this.alive = false; this.emit("spawn-error", e); });
|
|
252
|
+
this.proc.on("exit", (code, signal) => {
|
|
253
|
+
this.alive = false;
|
|
254
|
+
out.feed("", { flush: true }).forEach((e) => this.emit("event", e));
|
|
255
|
+
err.feed("", { flush: true }).forEach((e) => this.emit("event", e));
|
|
256
|
+
for (const f of this.spilled) { try { fs.unlinkSync(f); } catch { /* the worker took it */ } }
|
|
257
|
+
this.spilled.clear();
|
|
258
|
+
if (this.stopDir) { try { fs.rmSync(this.stopDir, { recursive: true, force: true }); } catch { /* gone */ } this.stopDir = null; }
|
|
259
|
+
this.emit("exit", { code, signal });
|
|
260
|
+
});
|
|
261
|
+
this.afterSpawn();
|
|
262
|
+
return Promise.resolve(this);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One RAW line on stdin, with no command framing. */
|
|
266
|
+
rawLine(line) {
|
|
267
|
+
if (/\n/.test(line)) throw new Error("StdioEngine.rawLine: one line, no newline");
|
|
268
|
+
return this.write(`${line}\n`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The channel a `readline()` answer and a debugger step travel on. R's is a
|
|
273
|
+
* raw console line; an engine with no raw prompt overrides this.
|
|
274
|
+
*/
|
|
275
|
+
console(line) { return this.rawLine(line); }
|
|
276
|
+
|
|
277
|
+
/** Deliver one JSON command, however it must travel. */
|
|
278
|
+
send(cmd) {
|
|
279
|
+
if (!cmd || typeof cmd.type !== "string") throw new Error("StdioEngine.send: a command needs a string type");
|
|
280
|
+
if (cmd.type === "exec") this.interruptRefusalSaid = false;
|
|
281
|
+
let json = JSON.stringify(cmd);
|
|
282
|
+
if (Buffer.byteLength(json) > MAX_CONSOLE_LINE) {
|
|
283
|
+
const spill = path.join(os.tmpdir(), `carmar-cmd-${crypto.randomBytes(8).toString("hex")}.json`);
|
|
284
|
+
fs.writeFileSync(spill, json, { mode: 0o600 });
|
|
285
|
+
this.spilled.add(spill);
|
|
286
|
+
json = JSON.stringify({ type: "cmdfile", path: spill });
|
|
287
|
+
}
|
|
288
|
+
return this.write(`#${this.cmdtag} ${json}\n`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
write(text) {
|
|
292
|
+
if (!this.alive || !this.proc || this.proc.stdin.destroyed) {
|
|
293
|
+
throw new Error(`${this.label} is not running — this command could not be sent.`);
|
|
294
|
+
}
|
|
295
|
+
this.proc.stdin.write(text);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The word this engine's sentences use. */
|
|
300
|
+
get label() { return this.language === "r" ? "R" : this.language; }
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Interrupt whatever the worker is doing: SIGINT to the worker's process
|
|
304
|
+
* GROUP, then SIGINT to the worker itself INTERRUPT_PAIR_GAP_MS later.
|
|
305
|
+
*
|
|
306
|
+
* Both, in that order, because each alone fails on `system("sleep 60")` in
|
|
307
|
+
* R. libc's system() ignores SIGINT in the caller while the child runs, so
|
|
308
|
+
* the pid signal alone never lands (measured: 0/5, and the sleep is
|
|
309
|
+
* orphaned). The group signal ends the child, but the SIGINT R got while
|
|
310
|
+
* waiting was thrown away, so R walks on to the next line (measured: 1/5)
|
|
311
|
+
* — which is what the stop FLAG (requestStop) now settles: R reads it at
|
|
312
|
+
* the next expression boundary, so the pair's timing no longer decides
|
|
313
|
+
* whether the line after a system() call runs.
|
|
314
|
+
*
|
|
315
|
+
* Both signals are sent from THIS process, and each is guarded by
|
|
316
|
+
* `stillWanted` at the instant it is sent. Until 0.8.6 the group signal was
|
|
317
|
+
* a spawned `kill -INT -- -pid` (whose 3–6 ms lifetime placed the pid signal
|
|
318
|
+
* inside system()'s return window, before the flag existed) and only the
|
|
319
|
+
* pid signal was guarded — the group signal had already left with the
|
|
320
|
+
* child. A signal in flight when the run ends reaches the NEXT command the
|
|
321
|
+
* plane writes: measured with the plane's ladder (tools/beatrina rigs,
|
|
322
|
+
* 2026-09-16), one such signal 3 ms after the next command was written
|
|
323
|
+
* escaped every handler in the worker and ended R, and the 8-hour soak had
|
|
324
|
+
* lost a session to the same window. A `process.kill` from here is
|
|
325
|
+
* synchronous with the guard: nothing can be dispatched between the check
|
|
326
|
+
* and the delivery. test/interrupt-guard.test.mjs pins the guard on both
|
|
327
|
+
* signals; test/stop-wedge.test.mjs pins the worker against a stray signal
|
|
328
|
+
* that still arrives (it is in-process, but the worker cannot know that).
|
|
329
|
+
*/
|
|
330
|
+
interrupt(stillWanted = () => true) {
|
|
331
|
+
if (!this.alive || !this.pid) return false;
|
|
332
|
+
const pid = this.pid;
|
|
333
|
+
if (this.platform === "win32") return this.interruptWindows(pid);
|
|
334
|
+
if (!stillWanted()) return false;
|
|
335
|
+
try { this.killImpl(-pid, "SIGINT"); } catch {
|
|
336
|
+
try { this.killImpl(pid, "SIGINT"); } catch { return false; } // not a group leader after all
|
|
337
|
+
}
|
|
338
|
+
const later = setTimeout(() => {
|
|
339
|
+
if (!this.alive || this.pid !== pid || !stillWanted()) return;
|
|
340
|
+
try { this.killImpl(pid, "SIGINT"); } catch { /* gone */ }
|
|
341
|
+
}, INTERRUPT_PAIR_GAP_MS);
|
|
342
|
+
if (typeof later.unref === "function") later.unref();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** process.kill, replaceable in tests (every signal this adapter sends goes through it). */
|
|
347
|
+
get killImpl() { return this._killImpl || process.kill.bind(process); }
|
|
348
|
+
set killImpl(fn) { this._killImpl = fn; }
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Windows: a console Ctrl+C through processx's helper — never
|
|
352
|
+
* `proc.kill("SIGINT")`, which on Windows ends the process. With no helper
|
|
353
|
+
* the chunk is left running and the page is TOLD, once per run, rather than
|
|
354
|
+
* Stop quietly becoming Force Stop.
|
|
355
|
+
*/
|
|
356
|
+
interruptWindows(pid) {
|
|
357
|
+
const plan = windowsInterruptPlan(pid, this.interruptTool);
|
|
358
|
+
if (!plan) {
|
|
359
|
+
if (!this.interruptRefusalSaid) {
|
|
360
|
+
this.interruptRefusalSaid = true;
|
|
361
|
+
this.emit("event", { type: "stderr", text: NO_INTERRUPT_TOOL });
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const child = this.spawnImpl(plan.bin, plan.args, { stdio: "ignore", windowsHide: true });
|
|
367
|
+
if (child && typeof child.on === "function") child.on("error", () => { /* the exit of the run reports it */ });
|
|
368
|
+
} catch { return false; }
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Ask the worker to exit, then make sure it did. */
|
|
373
|
+
async stop(graceMs = 2000) {
|
|
374
|
+
if (!this.alive) return;
|
|
375
|
+
try { this.send({ type: "shutdown" }); } catch { /* already gone */ }
|
|
376
|
+
const gone = await this.exited(graceMs);
|
|
377
|
+
if (!gone) this.kill();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** SIGKILL — Force Stop. The host's recovery loop starts a fresh session. */
|
|
381
|
+
kill() {
|
|
382
|
+
if (!this.alive || !this.pid) return;
|
|
383
|
+
try {
|
|
384
|
+
if (this.platform !== "win32") process.kill(-this.pid, "SIGKILL");
|
|
385
|
+
else {
|
|
386
|
+
// The tree, not the process: a system() child would outlive R.
|
|
387
|
+
const plan = windowsKillPlan(this.pid);
|
|
388
|
+
const child = this.spawnImpl(plan.bin, plan.args, { stdio: "ignore", windowsHide: true });
|
|
389
|
+
if (child && typeof child.on === "function") child.on("error", () => { try { this.proc.kill("SIGKILL"); } catch { /* gone */ } });
|
|
390
|
+
}
|
|
391
|
+
} catch { /* already gone */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
exited(ms) {
|
|
395
|
+
if (!this.alive) return Promise.resolve(true);
|
|
396
|
+
return new Promise((resolve) => {
|
|
397
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
398
|
+
this.once("exit", () => { clearTimeout(t); resolve(true); });
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|