beatrina 0.9.43 → 1.0.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/app/Beatrina.app/Contents/Info.plist +86 -0
- package/app/Beatrina.app/Contents/MacOS/beatrina +0 -0
- package/app/Beatrina.app/Contents/Resources/icon.icns +0 -0
- package/app/beatrina.png +0 -0
- package/app/linux-arm64/beatrina +0 -0
- package/app/win-x64/Beatrina-setup.exe +0 -0
- package/beatrina_V1.0.0.html +1906 -0
- package/beatrina_V1.0.0.html.inputs.json +1 -0
- package/bin/beatrina.mjs +200 -12
- package/bin/cli.mjs +8 -3
- package/bin/finder.mjs +1 -1
- package/bin/shortcut.mjs +15 -15
- package/build-info.json +1 -1
- package/host/engine-node-job.mjs +35 -0
- package/host/engine-pool.mjs +153 -26
- package/host/engine-r.mjs +8 -1
- package/host/jobs/document-child.mjs +271 -0
- package/host/journal-store.mjs +19 -3
- package/host/latex-compile.mjs +673 -0
- package/host/main.mjs +58 -18
- package/host/planes/analyze.mjs +18 -2
- package/host/planes/choose.mjs +13 -2
- package/host/planes/expect-term.mjs +102 -0
- package/host/planes/jobs.mjs +38 -5
- package/host/planes/sessions.mjs +5 -1
- package/host/planes/terminal.mjs +23 -9
- package/host/pwa/icon-192.png +0 -0
- package/host/pwa/icon-512.png +0 -0
- package/host/quarto.mjs +118 -0
- package/host/server.mjs +83 -2
- package/host/worker-plane.mjs +7 -1
- package/kernel/check/acceptance.mjs +43 -12
- package/kernel/check/knit-file.mjs +300 -52
- package/kernel/check/session.mjs +19 -4
- package/kernel/kernel-version +1 -1
- package/kernel/knitr-run.R +8 -1
- package/kernel/mcp/beatrina-mcp.mjs +6 -5
- package/kernel/worker.R +21 -1
- package/lib/engine-labels.js +7 -1
- package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/package.json +4 -3
- package/beatrina_V0.9.43.html +0 -1885
- package/beatrina_V0.9.43.html.inputs.json +0 -1
package/host/main.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// BEATRINA_RUNTIME_DIR (default ~/.beatrina/run; host/runtime-dir.mjs),
|
|
15
15
|
// BEATRINA_NO_RUNTIME_FILE=1, BEATRINA_LINGER, BEATRINA_BOOT_GRACE,
|
|
16
16
|
// BEATRINA_WORKER_DEADLINE_SCALE, BEATRINA_WORKER_RESPAWN_MAX,
|
|
17
|
+
// BEATRINA_NO_R=1 (the R engine is off — tests and administrators; the host
|
|
18
|
+
// serves without it, plans/getting_rid_of_R.md K2b),
|
|
17
19
|
// BEATRINA_OPEN_FILE, BEATRINA_HOST_ROOT, BEATRINA_KERNEL_DIR (WP7: where
|
|
18
20
|
// the files are when the host is not running from a source checkout — see
|
|
19
21
|
// hostRoot/kernelDir). Children are handed both spellings (mirrorEnv).
|
|
@@ -104,17 +106,22 @@ function hostRoot() {
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
|
-
* The directory holding the R kernel scripts
|
|
109
|
+
* The directory holding the build stamps (and the R kernel scripts, when this
|
|
110
|
+
* Beatrina carries R's worker).
|
|
108
111
|
*
|
|
109
112
|
* `spike/` in the repo; `kernel/` in every distribution, because that is what
|
|
110
113
|
* tools/ship/build-r-pkg.sh and tools/app/build-app.sh have always called it.
|
|
111
|
-
* Named by BEATRINA_KERNEL_DIR when a launcher knows better.
|
|
114
|
+
* Named by BEATRINA_KERNEL_DIR when a launcher knows better. Recognised by its
|
|
115
|
+
* protocol STAMP, which the host needs whatever engines exist, and only then
|
|
116
|
+
* by R's worker — a host must be findable without R (plans/getting_rid_of_R.md A5).
|
|
112
117
|
*/
|
|
113
118
|
function kernelDir(base) {
|
|
114
119
|
const dictated = env("BEATRINA_KERNEL_DIR").trim();
|
|
115
120
|
if (dictated) return path.resolve(dictated);
|
|
116
|
-
for (const
|
|
117
|
-
|
|
121
|
+
for (const marker of ["kernel-protocol", "worker-boot.R"]) {
|
|
122
|
+
for (const name of ["spike", "kernel"]) {
|
|
123
|
+
if (fs.existsSync(path.join(base, name, marker))) return path.join(base, name);
|
|
124
|
+
}
|
|
118
125
|
}
|
|
119
126
|
return path.join(base, "spike");
|
|
120
127
|
}
|
|
@@ -194,16 +201,21 @@ function notebookFileUrl(p, port, cap) {
|
|
|
194
201
|
// ── the handoff successor's preflight ───────────────────────────────────────
|
|
195
202
|
/**
|
|
196
203
|
* Everything a successor needs to serve the session it is taking over, checked while the
|
|
197
|
-
* predecessor is still serving it
|
|
198
|
-
* stamp that cannot be read has already thrown by the time this is called (main's first
|
|
199
|
-
* which the predecessor sees as an exit before `ready`.
|
|
204
|
+
* predecessor is still serving it. The HOST's floor is the page — the document. A protocol or
|
|
205
|
+
* build stamp that cannot be read has already thrown by the time this is called (main's first
|
|
206
|
+
* lines), which the predecessor sees as an exit before `ready`.
|
|
207
|
+
*
|
|
208
|
+
* R is part of the floor only when the predecessor's R is RUNNING (`needsR`, set by
|
|
209
|
+
* planes/sessions.mjs): handing a live R session to a successor that cannot run R would drop it.
|
|
210
|
+
* A session that never started R hands off with or without one (plans/getting_rid_of_R.md A4).
|
|
200
211
|
* @returns {Promise<{ok:boolean, error?:string, build:string, pid:number}>}
|
|
201
212
|
*/
|
|
202
|
-
async function handoffPreflight(kernelBuild, settings) {
|
|
213
|
+
async function handoffPreflight(kernelBuild, settings, { needsR = false } = {}) {
|
|
203
214
|
const fail = (error) => ({ ok: false, error, build: kernelBuild, pid: process.pid });
|
|
204
215
|
if (!notebookPage(kernelBuild, { fallback: false })) return fail(`it has no notebook page for Beatrina ${kernelBuild}`);
|
|
216
|
+
if (!needsR) return { ok: true, build: kernelBuild, pid: process.pid };
|
|
205
217
|
if (!fs.existsSync(path.join(spike, "worker-boot.R"))) return fail(`its R worker is missing (${path.join(spike, "worker-boot.R")})`);
|
|
206
|
-
const rscript = detectRscript(settings.rscriptPreference());
|
|
218
|
+
const rscript = env("BEATRINA_NO_R") === "1" ? "" : detectRscript(settings.rscriptPreference());
|
|
207
219
|
if (!rscript) return fail("it cannot find R");
|
|
208
220
|
const { execFile } = await import("node:child_process");
|
|
209
221
|
const ran = await new Promise((resolve) => {
|
|
@@ -309,9 +321,11 @@ async function main() {
|
|
|
309
321
|
// A handoff successor PROVES it can run before it waits for the port its predecessor still
|
|
310
322
|
// holds (planes/sessions.mjs handoffBegin, phase 1). The predecessor retires only on `ok`.
|
|
311
323
|
const handoffReady = env("BEATRINA_HANDOFF_READY").trim();
|
|
324
|
+
const handoffNeedsR = env("BEATRINA_HANDOFF_NEEDS_R") === "1";
|
|
312
325
|
unsetEnv(process.env, "HANDOFF_READY");
|
|
326
|
+
unsetEnv(process.env, "HANDOFF_NEEDS_R");
|
|
313
327
|
if (handoffReady) {
|
|
314
|
-
const verdict = await handoffPreflight(kernelBuild, settings);
|
|
328
|
+
const verdict = await handoffPreflight(kernelBuild, settings, { needsR: handoffNeedsR });
|
|
315
329
|
writeHandoffReady(handoffReady, verdict);
|
|
316
330
|
if (!verdict.ok) {
|
|
317
331
|
console.error(`Beatrina will not take over the session: ${verdict.error}`);
|
|
@@ -334,10 +348,11 @@ async function main() {
|
|
|
334
348
|
};
|
|
335
349
|
|
|
336
350
|
// ── the engines and the pool ───────────────────────────────────────────────
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
351
|
+
// No engine is on the start-up path: a page waits for the HOST (`host-ready`,
|
|
352
|
+
// host/server.mjs). R boots in the BACKGROUND when it is found — optional is
|
|
353
|
+
// not late — and every other engine starts the first time a chunk of its
|
|
354
|
+
// language runs (docs/host-plan.md §1): an ipykernel nobody asked for is 1.3
|
|
355
|
+
// seconds and tens of megabytes spent on a document that may hold no Python.
|
|
341
356
|
const workerPath = path.join(spike, "worker-boot.R");
|
|
342
357
|
const pythonWorker = path.join(root, "engines", "python", "worker.py");
|
|
343
358
|
const deadlineScale = Number(env("BEATRINA_WORKER_DEADLINE_SCALE", "1")) > 0 ? Number(env("BEATRINA_WORKER_DEADLINE_SCALE", "1")) : 1;
|
|
@@ -372,13 +387,30 @@ async function main() {
|
|
|
372
387
|
? { available: false, detail: `This Beatrina does not carry the JavaScript worker (${jsWorker}).`, path: "", version: "" }
|
|
373
388
|
: detectNode({ env: process.env });
|
|
374
389
|
audit("engine-discovery", { engine: "js", available: nodeFound.available, detail: nodeFound.detail });
|
|
390
|
+
let rVersionHint = "";
|
|
375
391
|
const pinned = (engine, make) => guardFactory(make, { enabled: payloadGuard, boot: bootPayload, bootBuild: kernelBuild, measure: measurePayload, audit, engine });
|
|
376
392
|
|
|
393
|
+
// R's availability is a QUESTION asked before every start (K2b), from the
|
|
394
|
+
// same resolved settings the spawn reads, so installing R or pointing
|
|
395
|
+
// Settings at another one needs no host restart. Absent R is a stated fact
|
|
396
|
+
// on R's engine row, never a host that will not come up (A1).
|
|
397
|
+
const rAbsent = (detail) => ({ available: false, detail, runtime: { path: "", why: detail } });
|
|
398
|
+
const detectR = () => {
|
|
399
|
+
if (env("BEATRINA_NO_R") === "1") return rAbsent(`The R engine is disabled (${envSetName(process.env, "NO_R")}=1).`);
|
|
400
|
+
if (!fs.existsSync(workerPath)) return rAbsent(`This Beatrina does not carry the R worker (${workerPath}).`);
|
|
401
|
+
const rscript = detectRscript(settings.rscriptPreference());
|
|
402
|
+
if (!rscript) return rAbsent("No R was found. Install R from https://cran.r-project.org, or set the path to Rscript in Settings.");
|
|
403
|
+
return { available: true, detail: "", version: rVersionHint, runtime: { path: rscript, why: "" } };
|
|
404
|
+
};
|
|
405
|
+
const rFound = detectR();
|
|
406
|
+
audit("engine-discovery", { engine: "r", available: rFound.available, detail: rFound.detail });
|
|
407
|
+
|
|
377
408
|
const ENGINE_SPECS = [
|
|
378
409
|
// WP5a — which R, and what the child inherits, come from the resolved
|
|
379
410
|
// settings (`rscript_path`, the child env). Read per spawn, never
|
|
380
411
|
// captured, so Restart R picks up a changed binary.
|
|
381
|
-
{ name: "r", label: "R", boot: "
|
|
412
|
+
{ name: "r", label: "R", boot: env("BEATRINA_R_BOOT") === "on-demand" ? "on-demand" : "background",
|
|
413
|
+
...rFound, detect: detectR,
|
|
382
414
|
factory: pinned("r", () => new REngine({
|
|
383
415
|
workerPath, handoffScript: path.join(root, "engines", "r", "handoff.R"),
|
|
384
416
|
rscript: detectRscript(settings.rscriptPreference()),
|
|
@@ -398,13 +430,12 @@ async function main() {
|
|
|
398
430
|
// `/health r` before the worker is ready: ask the chosen Rscript once, in
|
|
399
431
|
// the background, so a launcher polling /health the instant the port answers
|
|
400
432
|
// sees the R it is about to get rather than "".
|
|
401
|
-
let rVersionHint = "";
|
|
402
433
|
{
|
|
403
434
|
const { execFile } = await import("node:child_process");
|
|
404
|
-
const rscript =
|
|
435
|
+
const rscript = rFound.available ? rFound.runtime.path : "";
|
|
405
436
|
if (rscript) execFile(rscript, ["--version"], { timeout: 5000 }, (err, stdout, stderr) => {
|
|
406
437
|
const m = String(stdout || "").concat(String(stderr || "")).match(/version (\d+\.\d+(?:\.\d+)?)/);
|
|
407
|
-
if (!err && m) rVersionHint = m[1];
|
|
438
|
+
if (!err && m) { rVersionHint = m[1]; const spec = ENGINE_SPECS.find((x) => x.name === "r"); if (spec && !spec.version) spec.version = m[1]; }
|
|
408
439
|
});
|
|
409
440
|
}
|
|
410
441
|
|
|
@@ -634,6 +665,15 @@ async function main() {
|
|
|
634
665
|
}, 1000).unref();
|
|
635
666
|
|
|
636
667
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => shutdown(sig));
|
|
668
|
+
// A window that started this host (Beatrina) holds its stdin open for as long as it lives. It
|
|
669
|
+
// stops the host itself on a clean quit; this covers the unclean one — a crash or a Force Quit
|
|
670
|
+
// closes the pipe, and without it the host would outlive its window forever (idle shutdown is off
|
|
671
|
+
// by default). BEATRINA_EXIT_WITH_STDIN=1 opts in; nothing else reads the host's stdin.
|
|
672
|
+
if (readEnv(process.env, "EXIT_WITH_STDIN") === "1") {
|
|
673
|
+
process.stdin.on("end", () => shutdown("the window that started this session is gone"));
|
|
674
|
+
process.stdin.on("error", () => shutdown("the window that started this session is gone"));
|
|
675
|
+
process.stdin.resume();
|
|
676
|
+
}
|
|
637
677
|
process.on("exit", () => { try { if (typeof plane.killAll === "function") plane.killAll(); else if (plane.engine && plane.engine.alive) plane.engine.kill(); } catch { /* gone */ } });
|
|
638
678
|
}
|
|
639
679
|
|
package/host/planes/analyze.mjs
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// it, one per language — spike/analyze.R and engines/python/analyze.py — each
|
|
3
3
|
// with its own queue, its own slot and its own routes. A request's `engine`
|
|
4
4
|
// picks the child; an ABSENT engine is R, so every page that predates Python
|
|
5
|
-
// analysis reaches analyze.R exactly as before.
|
|
5
|
+
// analysis reaches analyze.R exactly as before — when this host HAS R. With no
|
|
6
|
+
// R the request is answered with the reason, never a spawn that cannot work
|
|
7
|
+
// (plans/getting_rid_of_R.md A6).
|
|
6
8
|
//
|
|
7
9
|
// A port of spike/serve.R's analyzer half (`ANALYZE_FORWARDED`,
|
|
8
10
|
// `ensure_analyzer`, `route_analyze`, `enqueue_analyze_command`,
|
|
@@ -317,7 +319,21 @@ export function createPlane({ root, spike, env, audit }) {
|
|
|
317
319
|
let pool = null;
|
|
318
320
|
|
|
319
321
|
const lanes = {
|
|
320
|
-
r: createLane({
|
|
322
|
+
r: createLane({
|
|
323
|
+
name: "r", label: "R",
|
|
324
|
+
makeEngine: () => {
|
|
325
|
+
// The SAME R the R engine runs on — the pool's own spec, re-read now
|
|
326
|
+
// (K2b), including BEATRINA_NO_R and the Rscript chosen in Settings —
|
|
327
|
+
// so "R is available" means one thing on this host.
|
|
328
|
+
const spec = pool && typeof pool.refresh === "function" ? pool.refresh("r")
|
|
329
|
+
: pool && Array.isArray(pool.specs) ? pool.specs.find((x) => x.name === "r") : null;
|
|
330
|
+
if (spec && !spec.available) {
|
|
331
|
+
return { error: `R analysis is unavailable: ${spec.detail || "No R was found."}` };
|
|
332
|
+
}
|
|
333
|
+
const rscript = spec && spec.runtime && spec.runtime.path ? spec.runtime.path : undefined;
|
|
334
|
+
return new REngine({ workerPath: rWorkerPath, mode: "batch", ...(rscript ? { rscript } : {}) });
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
321
337
|
python: createLane({
|
|
322
338
|
name: "python", label: "Python",
|
|
323
339
|
makeEngine: () => {
|
package/host/planes/choose.mjs
CHANGED
|
@@ -50,7 +50,12 @@ import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
|
50
50
|
import { canonicalPath, fsAbs, makeRoot } from "./files.mjs";
|
|
51
51
|
|
|
52
52
|
export const CHOOSE_MODES = Object.freeze(["file", "files", "dir", "save"]);
|
|
53
|
-
|
|
53
|
+
// `choose_cancel` closes the panel the SAME page opened, from the page: the
|
|
54
|
+
// panel can open behind the window or on another Space, and a page that says
|
|
55
|
+
// "a panel is open on your desktop" must also offer a way out of it
|
|
56
|
+
// (FINDINGS 2026-09-22 B6). Answered as a plain cancel, not an error.
|
|
57
|
+
const OPS = ["choose", "choose_cancel"];
|
|
58
|
+
const PAGE_CANCEL = "cancelled from the page";
|
|
54
59
|
/** The env suffix that overrides each tool's binary (BEATRINA_<suffix>). */
|
|
55
60
|
const TOOL_ENV = Object.freeze({ osascript: "OSASCRIPT", powershell: "POWERSHELL", zenity: "ZENITY", kdialog: "KDIALOG" });
|
|
56
61
|
/** Output kept per stream: a path list is small; a runaway tool is not. */
|
|
@@ -342,6 +347,11 @@ export function createPlane({ env, audit }) {
|
|
|
342
347
|
audit("choose-refused", { reason: "class", class: rec.class || "unknown" });
|
|
343
348
|
return reply({ error: "Only the local notebook page may open the file panel." });
|
|
344
349
|
}
|
|
350
|
+
if (cmd.type === "choose_cancel") {
|
|
351
|
+
const had = !!(open && open.rec === rec && alive(open.child));
|
|
352
|
+
if (had) kill(PAGE_CANCEL);
|
|
353
|
+
return reply({ ok: true, closed: had });
|
|
354
|
+
}
|
|
345
355
|
const req = readChooseRequest(cmd);
|
|
346
356
|
if (req.error) return reply({ error: req.error });
|
|
347
357
|
if (flagged) return reply({ unsupported: true, reason: offReason });
|
|
@@ -382,7 +392,8 @@ export function createPlane({ env, audit }) {
|
|
|
382
392
|
};
|
|
383
393
|
child.on("error", (e) => finish({ error: `The file panel could not start: ${e.message}` }));
|
|
384
394
|
child.on("close", (status, signal) => {
|
|
385
|
-
if (mine.killed) return finish(
|
|
395
|
+
if (mine.killed) return finish(mine.killed === PAGE_CANCEL ? { cancelled: true }
|
|
396
|
+
: { error: `The file panel was closed (${mine.killed}).` });
|
|
386
397
|
const read = readChooserResult(spec.tool, req.mode, { status, signal, stdout, stderr });
|
|
387
398
|
if (read.cancelled || read.error) return finish(read);
|
|
388
399
|
finish(validateChosen(req.mode, read, { root }));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// expect-term.mjs — a REAL pty for the terminal, without a native module.
|
|
2
|
+
//
|
|
3
|
+
// A packaged Beatrina executable can load no native module, so node-pty is
|
|
4
|
+
// never there and the terminal fell to plain pipes: "This terminal has no pty:
|
|
5
|
+
// line programs work; full-screen programs and password prompts do not"
|
|
6
|
+
// (FINDINGS 2026-09-22 B8, the installed app). But the machine usually has a
|
|
7
|
+
// pty provider of its own: `expect` (Tcl) ships with macOS and is one package
|
|
8
|
+
// away on Linux, and its whole job is to run a program on a pseudo-terminal
|
|
9
|
+
// while reading and writing plain pipes. Measured on macOS 15: `tty` answers
|
|
10
|
+
// /dev/ttys001, `stty size` the size asked for, and `read -s` hides a
|
|
11
|
+
// password as a terminal should.
|
|
12
|
+
//
|
|
13
|
+
// The shape is node-pty's IPty (pid · pty · onData · onExit · write · resize ·
|
|
14
|
+
// kill), so host/planes/terminal.mjs keeps one code path:
|
|
15
|
+
// · write: raw bytes into the pty — Enter, ^C, ^D, arrows mean what they
|
|
16
|
+
// mean at a terminal, because the line discipline is real;
|
|
17
|
+
// · resize: a private escape sequence the expect script intercepts and turns
|
|
18
|
+
// into `stty rows … columns …` on the pty, which is what makes the kernel
|
|
19
|
+
// deliver SIGWINCH to the foreground program;
|
|
20
|
+
// · kill: the expect process group; the shell, in its own session on the
|
|
21
|
+
// pty, gets the hangup a closed terminal gives.
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/** The escape a resize travels as; nothing a person types can produce it. */
|
|
26
|
+
export const RESIZE_PREFIX = "\x1b]beatrina-resize;";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The expect program. It reads its parameters from the environment rather
|
|
30
|
+
* than from a command line, so a shell path with a space or a quote is never
|
|
31
|
+
* pasted into Tcl source: BEATRINA_TERM_ARGV is the argument vector joined by
|
|
32
|
+
* TAB (a TAB cannot occur in a path we would run), and the size is two numbers.
|
|
33
|
+
*/
|
|
34
|
+
export const EXPECT_SCRIPT = [
|
|
35
|
+
"log_user 1",
|
|
36
|
+
"set stty_init \"rows $env(BEATRINA_TERM_ROWS) columns $env(BEATRINA_TERM_COLS)\"",
|
|
37
|
+
"set argl [split $env(BEATRINA_TERM_ARGV) \"\\t\"]",
|
|
38
|
+
"spawn -noecho {*}$argl",
|
|
39
|
+
"set slave $spawn_out(slave,name)",
|
|
40
|
+
"interact {",
|
|
41
|
+
" -re \"\\033\\\\\\]beatrina-resize;(\\[0-9\\]+);(\\[0-9\\]+)\\007\" {",
|
|
42
|
+
" catch {stty rows $interact_out(2,string) columns $interact_out(1,string) < $slave}",
|
|
43
|
+
" }",
|
|
44
|
+
"}",
|
|
45
|
+
"catch {wait} result",
|
|
46
|
+
"exit [lindex $result 3]",
|
|
47
|
+
].join("\n");
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start `file args…` on a pty through expect.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} expectBin the expect executable (whichSync("expect"))
|
|
53
|
+
* @param {string} file
|
|
54
|
+
* @param {string[]} args
|
|
55
|
+
* @param {{cwd?: string, env?: object, cols?: number, rows?: number, spawnImpl?: Function, signal?: Function}} opts
|
|
56
|
+
* @returns {{pid:number, pty:true, onData:Function, onExit:Function, write:Function, resize:Function, kill:Function}}
|
|
57
|
+
*/
|
|
58
|
+
export function expectSpawn(expectBin, file, args, opts = {}) {
|
|
59
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
60
|
+
const signal = opts.signal || ((pid, sig) => process.kill(pid, sig));
|
|
61
|
+
const cols = Math.round(Number(opts.cols) || 100);
|
|
62
|
+
const rows = Math.round(Number(opts.rows) || 30);
|
|
63
|
+
const argv = [file, ...(args || [])];
|
|
64
|
+
if (argv.some((a) => String(a).includes("\t"))) throw new Error("a terminal argument contains a TAB");
|
|
65
|
+
const child = spawnImpl(expectBin, ["-c", EXPECT_SCRIPT], {
|
|
66
|
+
cwd: opts.cwd,
|
|
67
|
+
env: { ...(opts.env || process.env), BEATRINA_TERM_ARGV: argv.join("\t"),
|
|
68
|
+
BEATRINA_TERM_COLS: String(cols), BEATRINA_TERM_ROWS: String(rows) },
|
|
69
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
70
|
+
detached: true,
|
|
71
|
+
});
|
|
72
|
+
const dataFns = [];
|
|
73
|
+
const exitFns = [];
|
|
74
|
+
const emit = (d) => dataFns.forEach((fn) => { try { fn(d); } catch { /* a listener must not break the stream */ } });
|
|
75
|
+
child.stdout.setEncoding("utf8");
|
|
76
|
+
child.stderr.setEncoding("utf8");
|
|
77
|
+
child.stdout.on("data", emit);
|
|
78
|
+
child.stderr.on("data", emit);
|
|
79
|
+
child.stdin.on("error", () => { /* the terminal has gone; exit says so */ });
|
|
80
|
+
let exited = false;
|
|
81
|
+
const done = (exitCode) => { if (exited) return; exited = true; exitFns.forEach((fn) => fn({ exitCode })); };
|
|
82
|
+
child.on("exit", (code) => done(code));
|
|
83
|
+
child.on("error", (e) => { emit(`[the terminal could not start: ${e.message}]\r\n`); done(null); });
|
|
84
|
+
return {
|
|
85
|
+
pid: child.pid,
|
|
86
|
+
pty: true,
|
|
87
|
+
onData: (fn) => { dataFns.push(fn); },
|
|
88
|
+
onExit: (fn) => { exitFns.push(fn); },
|
|
89
|
+
write(text) { try { child.stdin.write(String(text)); } catch { /* gone */ } },
|
|
90
|
+
resize(c, r) {
|
|
91
|
+
const cc = Math.round(Number(c)), rr = Math.round(Number(r));
|
|
92
|
+
if (!(cc > 0) || !(rr > 0)) return;
|
|
93
|
+
try { child.stdin.write(`${RESIZE_PREFIX}${cc};${rr}\x07`); } catch { /* gone */ }
|
|
94
|
+
},
|
|
95
|
+
kill() {
|
|
96
|
+
try { signal(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
|
|
97
|
+
try { signal(child.pid, "SIGKILL"); } catch { /* gone */ }
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default expectSpawn;
|
package/host/planes/jobs.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import crypto from "node:crypto";
|
|
|
33
33
|
import fs from "node:fs";
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { REngine } from "../engine-r.mjs";
|
|
36
|
+
import { NodeJobEngine } from "../engine-node-job.mjs";
|
|
36
37
|
import { killProcessTree } from "./proc-tree.mjs";
|
|
37
38
|
import { readEnv } from "../env-names.mjs";
|
|
38
39
|
|
|
@@ -318,8 +319,27 @@ const AGENT_REFUSED = Object.freeze({
|
|
|
318
319
|
* @param {{root: string, spike: string, env: (n: string, d?: string) => string,
|
|
319
320
|
* audit: (event: string, fields?: object) => void}} deps
|
|
320
321
|
*/
|
|
321
|
-
|
|
322
|
+
/**
|
|
323
|
+
* The tasks the Node child runs when this session has no R: every task that
|
|
324
|
+
* acts on a DOCUMENT. `latex` joined when spike/latex.R's compile was ported to
|
|
325
|
+
* host/latex-compile.mjs (K7); only the package tasks still need R.
|
|
326
|
+
*/
|
|
327
|
+
const NODE_TASKS = new Set(["render", "typst", "latex"]);
|
|
328
|
+
|
|
329
|
+
export function createPlane({ root, spike, env, audit }) {
|
|
322
330
|
const jobRun = path.join(spike, "job-run.R");
|
|
331
|
+
// A render with no R in this session runs Quarto from Node instead
|
|
332
|
+
// (plans/getting_rid_of_R.md Stage 3): the R child cannot start at all
|
|
333
|
+
// without R, and a prose document needs none. With R the R child renders,
|
|
334
|
+
// exactly as before — rmarkdown for an .Rmd is an R package.
|
|
335
|
+
const documentChild = path.join(root || path.resolve(spike, ".."), "host", "jobs", "document-child.mjs");
|
|
336
|
+
/** Does this session have R right now? Read at the start of each job (K2b). */
|
|
337
|
+
const rAvailable = () => {
|
|
338
|
+
const pool = ctxRef && ctxRef.plane;
|
|
339
|
+
if (!pool || typeof pool.refresh !== "function") return true; // an older host: R, as always
|
|
340
|
+
const spec = pool.refresh("r");
|
|
341
|
+
return !spec || Boolean(spec.available);
|
|
342
|
+
};
|
|
323
343
|
// Sixty seconds is long enough for a fetch that starts the moment the page
|
|
324
344
|
// is handed the token, short enough that a token in a stray log is dead by
|
|
325
345
|
// the time anyone reads it. Tests shorten it.
|
|
@@ -442,10 +462,23 @@ export function createPlane({ spike, env, audit }) {
|
|
|
442
462
|
const id = `job-${seq}`;
|
|
443
463
|
let engine;
|
|
444
464
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
465
|
+
const haveR = rAvailable();
|
|
466
|
+
if (!haveR && NODE_TASKS.has(spec.task)) {
|
|
467
|
+
engine = new NodeJobEngine({ workerPath: documentChild, env: { ...spec.env, BEATRINA_JOB_ID: id, BEATRINA_JOB_R_AVAILABLE: "0" } });
|
|
468
|
+
} else if (!haveR) {
|
|
469
|
+
// Said before anything is spawned, in the task's own terms — not
|
|
470
|
+
// "the job process could not start: REngine: no Rscript found". Only a
|
|
471
|
+
// package task reaches here: every document task is in NODE_TASKS.
|
|
472
|
+
const why = `${JOB_TASKS[spec.task].label} is an R package task, and R is not available in this session.`;
|
|
473
|
+
audit("job-refused", { detail: `no R: ${spec.task}` });
|
|
474
|
+
jobEmit("", { event: "refused", request, error: why });
|
|
475
|
+
return;
|
|
476
|
+
} else {
|
|
477
|
+
engine = new REngine({ workerPath: jobRun, env: { ...spec.env, BEATRINA_JOB_ID: id } });
|
|
478
|
+
// The child is a BATCH R: `Rscript job-run.R <sentinel>`. It never reads
|
|
479
|
+
// stdin, has no session identity, and stamps its own frame ids.
|
|
480
|
+
engine.mode = "batch";
|
|
481
|
+
}
|
|
449
482
|
} catch (e) {
|
|
450
483
|
audit("job-start-failed", { detail: e.message });
|
|
451
484
|
jobEmit(id, { event: "refused", request, error: `the job process could not start: ${e.message}` });
|
package/host/planes/sessions.mjs
CHANGED
|
@@ -409,7 +409,11 @@ export function createPlane({ root, env, audit }) {
|
|
|
409
409
|
BEATRINA_SESSION_TITLE: record.title || "",
|
|
410
410
|
BEATRINA_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
|
|
411
411
|
// Only a token travels, never a path; the successor re-checks it.
|
|
412
|
-
BEATRINA_RESUME_TOKEN: h.resume || ""
|
|
412
|
+
BEATRINA_RESUME_TOKEN: h.resume || "",
|
|
413
|
+
// A session whose R is RUNNING must not be handed to a successor with no working R — that
|
|
414
|
+
// would drop the R session it was asked to continue. A session that never started R has
|
|
415
|
+
// nothing of R's to lose and hands off with or without one (plans/getting_rid_of_R.md A4).
|
|
416
|
+
BEATRINA_HANDOFF_NEEDS_R: ctx.plane && ctx.plane.primary && ctx.plane.primary.engine ? "1" : "" }, "LISTEN");
|
|
413
417
|
if (record.listen) childEnv.BEATRINA_LISTEN = "1";
|
|
414
418
|
const logPath = path.join(dir, `kernel-handoff-${port}.log`);
|
|
415
419
|
// The log is appended across attempts: only what THIS successor wrote is quoted.
|
package/host/planes/terminal.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import path from "node:path";
|
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { killProcessTree } from "./proc-tree.mjs";
|
|
32
32
|
import { PIPE_NOTICE, pipeShell, pipeSpawn } from "./pipe-term.mjs";
|
|
33
|
+
import { expectSpawn } from "./expect-term.mjs";
|
|
34
|
+
import { whichSync } from "../which.mjs";
|
|
33
35
|
import { envSetName, mirrorEnv } from "../env-names.mjs";
|
|
34
36
|
|
|
35
37
|
const MAX_TERMS = 4;
|
|
@@ -123,6 +125,7 @@ export function createPlane({ env, audit }) {
|
|
|
123
125
|
const terms = new Map();
|
|
124
126
|
let seq = 0;
|
|
125
127
|
let loaded = null; // lazy: a kernel that never opens one pays nothing
|
|
128
|
+
let expectBin; // undefined: not yet looked for; "" when there is none
|
|
126
129
|
let ctxRef = null;
|
|
127
130
|
const ctxPort = () => (ctxRef ? ctxRef.port() : 0);
|
|
128
131
|
|
|
@@ -181,7 +184,17 @@ export function createPlane({ env, audit }) {
|
|
|
181
184
|
if (scalarChr(cmd.cwd) && cmd.cwd) {
|
|
182
185
|
try { if (fs.statSync(cmd.cwd).isDirectory()) wd = cmd.cwd; } catch { /* keep the kernel's cwd */ }
|
|
183
186
|
}
|
|
184
|
-
|
|
187
|
+
// THREE rungs: node-pty; else `expect`, which ships with macOS and runs a
|
|
188
|
+
// program on a real pty over plain pipes (host/planes/expect-term.mjs) —
|
|
189
|
+
// the packaged app, which can load no native module, gets a real terminal
|
|
190
|
+
// from it (FINDINGS 2026-09-22 B8); else the pipe. BEATRINA_TERMINAL_EXPECT=0
|
|
191
|
+
// skips the middle rung, which is how a machine with expect tests the pipe.
|
|
192
|
+
if (expectBin === undefined) {
|
|
193
|
+
expectBin = loaded.pty || process.platform === "win32" || env("BEATRINA_TERMINAL_EXPECT") === "0"
|
|
194
|
+
? "" : whichSync("expect");
|
|
195
|
+
}
|
|
196
|
+
const useExpect = !loaded.pty && !!expectBin;
|
|
197
|
+
const usePipe = !loaded.pty && !useExpect;
|
|
185
198
|
const sh = usePipe ? pipeShell(process.env) : termShell(process.env);
|
|
186
199
|
const argv = usePipe ? { file: sh.cmd, args: sh.args } : termArgv(sh);
|
|
187
200
|
// The shell runs in the PHYSICAL directory (processx resolves the wd for
|
|
@@ -191,16 +204,16 @@ export function createPlane({ env, audit }) {
|
|
|
191
204
|
let real = wd;
|
|
192
205
|
try { real = fs.realpathSync(wd); } catch { real = wd; }
|
|
193
206
|
let term;
|
|
207
|
+
const ptyEnv = () => mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
208
|
+
COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) });
|
|
194
209
|
try {
|
|
195
|
-
term =
|
|
210
|
+
term = useExpect
|
|
211
|
+
? expectSpawn(expectBin, argv.file, argv.args, { cols, rows, cwd: real, env: ptyEnv() })
|
|
212
|
+
: usePipe
|
|
196
213
|
? pipeSpawn(argv.file, argv.args, { cwd: real,
|
|
197
214
|
env: mirrorEnv({ ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }) })
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
|
|
201
|
-
env: mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
202
|
-
COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }),
|
|
203
|
-
});
|
|
215
|
+
// The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
|
|
216
|
+
: loaded.pty.spawn(argv.file, argv.args, { name: "xterm-256color", cols, rows, cwd: real, env: ptyEnv() });
|
|
204
217
|
} catch (e) { return refuse(`the shell could not start: ${e.message}`); }
|
|
205
218
|
|
|
206
219
|
seq += 1;
|
|
@@ -218,7 +231,8 @@ export function createPlane({ env, audit }) {
|
|
|
218
231
|
audit("term-exit", { id, detail: String(exitCode) });
|
|
219
232
|
send(rec, id, { event: "exit", code: exitCode ?? null, reason: "exited" });
|
|
220
233
|
});
|
|
221
|
-
audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe
|
|
234
|
+
audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe,
|
|
235
|
+
via: loaded.pty ? "node-pty" : useExpect ? "expect" : "pipe" });
|
|
222
236
|
send(rec, id, { event: "open", shell: sh.cmd, pid: term.pid, cols, rows, cwd: wd, pty: !usePipe });
|
|
223
237
|
if (usePipe) {
|
|
224
238
|
if (loaded.error) audit("term-pipe", { id, detail: loaded.error });
|
|
Binary file
|
|
Binary file
|
package/host/quarto.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// quarto.mjs — finding Quarto, and asking it how it will run a document, from
|
|
2
|
+
// Node (plans/getting_rid_of_R.md Stage 3, K4).
|
|
3
|
+
//
|
|
4
|
+
// Two questions, both answered by QUARTO rather than by a table kept here:
|
|
5
|
+
//
|
|
6
|
+
// · WHERE IS IT? A ladder, not `which`: measured on a normal Mac with RStudio,
|
|
7
|
+
// `quarto` is not on PATH and a working 1.9.37 sits inside RStudio.app
|
|
8
|
+
// (the same finding spike/job-run.R's find_quarto() is built on, same order).
|
|
9
|
+
// · WHICH ENGINE WILL IT USE? `quarto inspect <doc>` names it — `markdown`
|
|
10
|
+
// for prose only, `knitr` for R chunks (which needs R), `jupyter` for
|
|
11
|
+
// Python. Copying Quarto's binding rules here would drift from Quarto's;
|
|
12
|
+
// asking it cannot.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { readEnv } from "./env-names.mjs";
|
|
19
|
+
|
|
20
|
+
/** Where Quarto lives when nothing put it on PATH, most specific first. */
|
|
21
|
+
export const QUARTO_LOCATIONS = Object.freeze([
|
|
22
|
+
"/Applications/RStudio.app/Contents/Resources/app/quarto/bin/quarto",
|
|
23
|
+
"/Applications/Positron.app/Contents/Resources/app/quarto/bin/quarto",
|
|
24
|
+
"/Applications/quarto/bin/quarto",
|
|
25
|
+
"/usr/local/bin/quarto",
|
|
26
|
+
"/opt/homebrew/bin/quarto",
|
|
27
|
+
"/opt/quarto/bin/quarto",
|
|
28
|
+
"/usr/lib/rstudio/resources/app/bin/quarto/bin/quarto",
|
|
29
|
+
"C:/Program Files/Quarto/bin/quarto.exe",
|
|
30
|
+
"C:/Program Files/RStudio/resources/app/bin/quarto/bin/quarto.exe",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Quarto binary to use, or "" when there is none.
|
|
35
|
+
*
|
|
36
|
+
* Order: BEATRINA_QUARTO (the person's choice), QUARTO_PATH (Quarto's own
|
|
37
|
+
* variable), PATH, ~/.local/bin, then the known install locations.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} [o]
|
|
40
|
+
* @param {Record<string,string|undefined>} [o.env]
|
|
41
|
+
* @param {(p: string) => boolean} [o.exists] injected for tests
|
|
42
|
+
* @param {string} [o.platform]
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function findQuarto({ env = process.env, exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }, platform = process.platform } = {}) {
|
|
46
|
+
const exe = platform === "win32" ? "quarto.exe" : "quarto";
|
|
47
|
+
const chosen = readEnv(env, "QUARTO");
|
|
48
|
+
if (chosen && exists(chosen)) return chosen;
|
|
49
|
+
if (env.QUARTO_PATH && exists(env.QUARTO_PATH)) return env.QUARTO_PATH;
|
|
50
|
+
for (const dir of String(env.PATH || "").split(path.delimiter)) {
|
|
51
|
+
if (dir && exists(path.join(dir, exe))) return path.join(dir, exe);
|
|
52
|
+
}
|
|
53
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
54
|
+
const local = home ? path.join(home, ".local", "bin", exe) : "";
|
|
55
|
+
if (local && exists(local)) return local;
|
|
56
|
+
return QUARTO_LOCATIONS.find((p) => exists(p)) || "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The engines Quarto will use for one document, as `quarto inspect` reports
|
|
61
|
+
* them: ["markdown"], ["knitr"], ["jupyter"], … — or null when it could not
|
|
62
|
+
* say (the document does not parse, Quarto is too old, the call timed out).
|
|
63
|
+
* Null is "unknown", never "none": the caller renders and lets Quarto speak.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} bin the Quarto binary
|
|
66
|
+
* @param {string} target the document
|
|
67
|
+
* @param {{timeoutMs?: number, run?: Function}} [o]
|
|
68
|
+
* @returns {{engines: string[]|null, detail: string}}
|
|
69
|
+
*/
|
|
70
|
+
export function quartoEngines(bin, target, { timeoutMs = 30000, run = spawnSync } = {}) {
|
|
71
|
+
const r = run(bin, ["inspect", target], { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
|
|
72
|
+
if (r.error) return { engines: null, detail: r.error.message };
|
|
73
|
+
if (r.status !== 0) return { engines: null, detail: String(r.stderr || r.stdout || "").trim().split("\n").pop() || `exit ${r.status}` };
|
|
74
|
+
try {
|
|
75
|
+
const out = JSON.parse(String(r.stdout));
|
|
76
|
+
return { engines: Array.isArray(out.engines) ? out.engines.map(String) : null, detail: "" };
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { engines: null, detail: `quarto inspect did not answer in JSON (${e.message})` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a document needs that this session does not have, as a sentence — or ""
|
|
84
|
+
* when nothing is missing. Only the engines Beatrina knows the runtime of are
|
|
85
|
+
* judged; anything else is Quarto's to report when it renders.
|
|
86
|
+
*
|
|
87
|
+
* @param {string[]|null} engines from quartoEngines
|
|
88
|
+
* @param {{r: boolean}} have
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function missingForRender(engines, { r }) {
|
|
92
|
+
if (!Array.isArray(engines)) return "";
|
|
93
|
+
if (engines.includes("knitr") && !r) {
|
|
94
|
+
return "This document has R chunks, which Quarto runs with knitr — that needs R, and R is not available in this session. "
|
|
95
|
+
+ "Install R, or set the R chunks to `eval: false`, and render again.";
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The folder a document declares with `beatrina-workdir:` in its front matter —
|
|
102
|
+
* the rule spike/job-run.R's document_workdir() follows, so a render from here
|
|
103
|
+
* and from R start in one folder. {path, declared}; declared "" when unsaid.
|
|
104
|
+
*/
|
|
105
|
+
export function documentWorkdir(file, text, { home = process.env.HOME || "" } = {}) {
|
|
106
|
+
const base = path.dirname(file);
|
|
107
|
+
const lines = String(text || "").split(/\r?\n/).slice(0, 200);
|
|
108
|
+
if ((lines[0] || "").trim() !== "---") return { path: base, declared: "" };
|
|
109
|
+
const close = lines.slice(1).findIndex((l) => ["---", "..."].includes(l.trim()));
|
|
110
|
+
if (close < 0) return { path: base, declared: "" };
|
|
111
|
+
const hit = lines.slice(1, close + 1).find((l) => /^beatrina-workdir:/.test(l));
|
|
112
|
+
if (!hit) return { path: base, declared: "" };
|
|
113
|
+
const value = hit.replace(/^beatrina-workdir:/, "").replace(/[ \t]+#.*$/, "").trim().replace(/^["']|["']$/g, "");
|
|
114
|
+
if (!value) return { path: base, declared: "" };
|
|
115
|
+
const absolute = /^(\/|~|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
116
|
+
const expanded = value.startsWith("~") ? path.join(home, value.slice(1)) : value;
|
|
117
|
+
return { path: absolute ? expanded : path.resolve(base, value), declared: value };
|
|
118
|
+
}
|