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
package/host/main.mjs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// main.mjs — `beatrix`: start the host on a loopback port and print where it is.
|
|
3
|
+
//
|
|
4
|
+
// Prints ONE line of JSON on stdout — {"url": ..., "file": ...} — exactly as
|
|
5
|
+
// spike/serve.R does, so every launcher, fixture and agent that reads that
|
|
6
|
+
// line works unchanged. Everything else goes to stderr.
|
|
7
|
+
//
|
|
8
|
+
// Env (the CARMAR_* names are the WIRE the page and its launchers speak; they
|
|
9
|
+
// are kept on purpose — renaming them is WP7's job, with the launchers):
|
|
10
|
+
// CARMAR_PORT (4747), CARMAR_PORT_STRICT=1, CARMAR_BIND, CARMAR_HOSTS …
|
|
11
|
+
// (see deployment.mjs), CARMAR_RSCRIPT, CARMAR_KERNEL_BUILD,
|
|
12
|
+
// CARMAR_INSTALLED_BUILD, CARMAR_LOG, CARMAR_RUNTIME_DIR,
|
|
13
|
+
// CARMAR_NO_RUNTIME_FILE=1, CARMAR_LINGER, CARMAR_BOOT_GRACE,
|
|
14
|
+
// CARMAR_WORKER_DEADLINE_SCALE, CARMAR_WORKER_RESPAWN_MAX, CARMAR_OPEN_FILE,
|
|
15
|
+
// CARMAR_HOST_ROOT, CARMAR_KERNEL_DIR (WP7: where the files are when the
|
|
16
|
+
// host is not running from a source checkout — see hostRoot/kernelDir).
|
|
17
|
+
//
|
|
18
|
+
// PACKAGING (docs/packaging.md). Node's Single Executable Application runs its
|
|
19
|
+
// main script as CommonJS — `embedderRunCjs`, measured on Node 25.5.0, an ESM
|
|
20
|
+
// blob dies with "Cannot use import statement outside a module" — so this file
|
|
21
|
+
// must survive `esbuild --format=cjs`. That costs three rules, and they are why
|
|
22
|
+
// the body below is a function rather than a script:
|
|
23
|
+
// * no top-level `await` (esbuild refuses it outright in cjs output),
|
|
24
|
+
// * no bare `import.meta.url` on a path a packaged binary walks, because cjs
|
|
25
|
+
// output leaves it empty — `moduleDir()` is the one place that reads it, and
|
|
26
|
+
// * the planes are STATIC imports, not `await import(variable)`: a bundler
|
|
27
|
+
// cannot follow a dynamic specifier, so a packaged binary would boot with
|
|
28
|
+
// no planes at all and refuse every family by name.
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import net from "node:net";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import sea from "node:sea";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { carmarDeployment } from "./deployment.mjs";
|
|
38
|
+
import { REngine, detectRscript } from "./engine-r.mjs";
|
|
39
|
+
import { PythonEngine, detectPython } from "./engine-python.mjs";
|
|
40
|
+
import { JsEngine, detectNode } from "./engine-js.mjs";
|
|
41
|
+
import { EnginePool } from "./engine-pool.mjs";
|
|
42
|
+
import { createHostServer, secureToken } from "./server.mjs";
|
|
43
|
+
import { createSettings } from "./settings.mjs";
|
|
44
|
+
// ── planes (one line per family; see the seam in server.mjs) ───────────────
|
|
45
|
+
// Each work package registers its module here and nowhere else. Keep the
|
|
46
|
+
// list alphabetical by file so two packages landing at once merge cleanly.
|
|
47
|
+
// Static imports on purpose — see PACKAGING above.
|
|
48
|
+
import * as planeAiStore from "./planes/ai-store.mjs";
|
|
49
|
+
import * as planeAi from "./planes/ai.mjs";
|
|
50
|
+
import * as planeAnalyze from "./planes/analyze.mjs";
|
|
51
|
+
import * as planeCite from "./planes/cite.mjs";
|
|
52
|
+
import * as planeLatex from "./planes/latex.mjs";
|
|
53
|
+
import * as planeFiles from "./planes/files.mjs";
|
|
54
|
+
import * as planeJobs from "./planes/jobs.mjs";
|
|
55
|
+
import * as planeJournal from "./planes/journal.mjs";
|
|
56
|
+
import * as planeMcp from "./planes/mcp.mjs";
|
|
57
|
+
import * as planePair from "./planes/pair.mjs";
|
|
58
|
+
import * as planePlugins from "./planes/plugins.mjs";
|
|
59
|
+
import * as planeSessions from "./planes/sessions.mjs";
|
|
60
|
+
import * as planeSettings from "./planes/settings.mjs";
|
|
61
|
+
import * as planeTerminal from "./planes/terminal.mjs";
|
|
62
|
+
import * as planeTestFile from "./planes/test-file.mjs";
|
|
63
|
+
import * as planeUpdate from "./planes/update.mjs";
|
|
64
|
+
import { appId } from "./user-dirs.mjs";
|
|
65
|
+
import { updateAvailable, updateRecordFile } from "./update-record.mjs";
|
|
66
|
+
const PLANE_MODULES = [planeAiStore, planeAi, planeAnalyze, planeCite, planeFiles, planeJobs, planeJournal, planeLatex, planeMcp, planePair, planePlugins, planeSessions, planeSettings, planeTerminal, planeTestFile, planeUpdate];
|
|
67
|
+
|
|
68
|
+
const env = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
|
|
69
|
+
|
|
70
|
+
/** Whether this process is a packaged single executable, asked once. */
|
|
71
|
+
const packaged = (() => { try { return sea.isSea(); } catch { return false; } })();
|
|
72
|
+
|
|
73
|
+
/** This module's own directory — "" when the cjs bundler emptied import.meta. */
|
|
74
|
+
function moduleDir() {
|
|
75
|
+
const u = import.meta.url;
|
|
76
|
+
return typeof u === "string" && u.startsWith("file:") ? path.dirname(fileURLToPath(u)) : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where the host's own files live.
|
|
81
|
+
*
|
|
82
|
+
* A source checkout answers from this module's location (host/.. = the repo).
|
|
83
|
+
* A PACKAGED binary has no module path on disk, so it answers from the folder
|
|
84
|
+
* it sits in — which is also the rule the page pin needs: `carmar_V<build>.html`
|
|
85
|
+
* beside the binary, exactly as spike/notebook-page.R puts it beside kernel/.
|
|
86
|
+
*/
|
|
87
|
+
function hostRoot() {
|
|
88
|
+
const dictated = env("CARMAR_HOST_ROOT").trim();
|
|
89
|
+
if (dictated) return path.resolve(dictated);
|
|
90
|
+
if (packaged) return path.dirname(process.execPath);
|
|
91
|
+
const mine = moduleDir();
|
|
92
|
+
if (mine) return path.resolve(mine, "..");
|
|
93
|
+
return path.dirname(process.execPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The directory holding the R kernel scripts and the build stamps.
|
|
98
|
+
*
|
|
99
|
+
* `spike/` in the repo; `kernel/` in every distribution, because that is what
|
|
100
|
+
* tools/ship/build-r-pkg.sh and tools/app/build-app.sh have always called it.
|
|
101
|
+
* Named by CARMAR_KERNEL_DIR when a launcher knows better.
|
|
102
|
+
*/
|
|
103
|
+
function kernelDir(base) {
|
|
104
|
+
const dictated = env("CARMAR_KERNEL_DIR").trim();
|
|
105
|
+
if (dictated) return path.resolve(dictated);
|
|
106
|
+
for (const name of ["spike", "kernel"]) {
|
|
107
|
+
if (fs.existsSync(path.join(base, name, "worker-boot.R"))) return path.join(base, name);
|
|
108
|
+
}
|
|
109
|
+
return path.join(base, "spike");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const root = hostRoot();
|
|
113
|
+
const spike = kernelDir(root);
|
|
114
|
+
|
|
115
|
+
// ── identity: protocol and build, read the way serve.R reads them ──────────
|
|
116
|
+
function readProtocol() {
|
|
117
|
+
for (const p of [path.join(spike, "kernel-protocol"), path.join(root, "kernel.protocol")]) {
|
|
118
|
+
if (fs.existsSync(p)) {
|
|
119
|
+
const v = Number.parseInt(fs.readFileSync(p, "utf8").trim(), 10);
|
|
120
|
+
if (Number.isInteger(v) && v >= 1) return v;
|
|
121
|
+
throw new Error("Beatrina: the kernel protocol stamp is invalid.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error("Beatrina: the kernel protocol stamp is missing.");
|
|
125
|
+
}
|
|
126
|
+
function sourceVersion() {
|
|
127
|
+
const f = path.join(root, "notebook.version.cjs");
|
|
128
|
+
if (!fs.existsSync(f)) return "unknown";
|
|
129
|
+
const m = fs.readFileSync(f, "utf8").match(/version\s*:\s*"([^"]+)"/);
|
|
130
|
+
return m ? m[1] : "unknown";
|
|
131
|
+
}
|
|
132
|
+
function readKernelBuild() {
|
|
133
|
+
const dictated = env("CARMAR_KERNEL_BUILD").trim();
|
|
134
|
+
if (dictated) return dictated;
|
|
135
|
+
const stamp = path.join(spike, "kernel-version");
|
|
136
|
+
if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
|
|
137
|
+
return sourceVersion();
|
|
138
|
+
}
|
|
139
|
+
function installedBuild() {
|
|
140
|
+
const dictated = env("CARMAR_KERNEL_BUILD").trim();
|
|
141
|
+
if (dictated) return env("CARMAR_INSTALLED_BUILD").trim() || dictated;
|
|
142
|
+
const stamp = path.join(spike, "kernel-version");
|
|
143
|
+
if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
|
|
144
|
+
return sourceVersion();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The ONE page a kernel of a given build serves: a pin, never a search.
|
|
148
|
+
*
|
|
149
|
+
* `spike/index.html` is the fallback, exactly as `notebook_page()` in
|
|
150
|
+
* spike/serve.R has one: a source checkout with nothing built still ANNOUNCES
|
|
151
|
+
* a page, and the suites that wait for that announcement (anything through
|
|
152
|
+
* test/fixtures/ai-e2e.mjs, which resolves on the `file` field) work without a
|
|
153
|
+
* build on both supervisors. Returning "" here made them time out on the host
|
|
154
|
+
* alone, which is a difference in the fixture's eyes and not in the design's.
|
|
155
|
+
*/
|
|
156
|
+
function notebookPage(build, { fallback = true } = {}) {
|
|
157
|
+
const wanted = `carmar_V${build}.html`;
|
|
158
|
+
// serve.R's pin (spike/notebook-page.R): beside the KERNEL directory, not the
|
|
159
|
+
// host's root — the two differ exactly when the kernel files are a staged or
|
|
160
|
+
// installed copy, which is the shape every upgrade has.
|
|
161
|
+
for (const p of [path.join(spike, "..", "dist", wanted), path.join(spike, "..", wanted)]) if (fs.existsSync(p)) return path.resolve(p);
|
|
162
|
+
if (!fallback) return "";
|
|
163
|
+
const dev = path.join(spike, "index.html");
|
|
164
|
+
return fs.existsSync(dev) ? dev : "";
|
|
165
|
+
}
|
|
166
|
+
/** "4.5.2" out of "R version 4.5.2 (2025-10-31)" — the only kernel that sends
|
|
167
|
+
* a version without a `language_version` field is R, which sends this. */
|
|
168
|
+
function rVersionOf(frame) {
|
|
169
|
+
const m = typeof frame.r === "string" ? frame.r.match(/R version (\S+)/) : null;
|
|
170
|
+
return m ? m[1] : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function notebookFileUrl(p, port, cap) {
|
|
174
|
+
const slashed = p.replace(/\\/g, "/");
|
|
175
|
+
const prefix = slashed.startsWith("/") ? "file://" : "file:///";
|
|
176
|
+
return `${prefix}${encodeURI(slashed)}#kernel=${port}${cap ? `&pair=${cap}` : ""}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── the port: a preference by default, a promise when asked ────────────────
|
|
180
|
+
const portTaken = (p) => new Promise((resolve) => {
|
|
181
|
+
const s = net.connect({ port: p, host: "127.0.0.1" });
|
|
182
|
+
s.setTimeout(1000);
|
|
183
|
+
s.once("connect", () => { s.destroy(); resolve(true); });
|
|
184
|
+
s.once("error", () => resolve(false));
|
|
185
|
+
s.once("timeout", () => { s.destroy(); resolve(false); });
|
|
186
|
+
});
|
|
187
|
+
async function choosePort() {
|
|
188
|
+
let wanted = Number.parseInt(env("CARMAR_PORT", "4747"), 10);
|
|
189
|
+
if (!Number.isInteger(wanted) || wanted < 1024 || wanted > 65535) wanted = 4747;
|
|
190
|
+
if (!(await portTaken(wanted))) return wanted;
|
|
191
|
+
const waitS = Number(env("CARMAR_WAIT_PORT", "0"));
|
|
192
|
+
if (Number.isFinite(waitS) && waitS > 0) {
|
|
193
|
+
const deadline = Date.now() + waitS * 1000;
|
|
194
|
+
while (Date.now() < deadline) { // a wait is a loop
|
|
195
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
196
|
+
if (!(await portTaken(wanted))) return wanted;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (env("CARMAR_PORT_STRICT") === "1") {
|
|
200
|
+
console.error(`Beatrina refuses to start: CARMAR_PORT=${wanted} is already in use and CARMAR_PORT_STRICT=1 forbids moving to another port.`);
|
|
201
|
+
process.exit(2);
|
|
202
|
+
}
|
|
203
|
+
// Scan the session band, then let the OS pick.
|
|
204
|
+
for (let p = 4747; p <= 4947; p += 1) if (!(await portTaken(p))) return p;
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The whole start-up is a FUNCTION, not a script: Node SEA runs its main as
|
|
209
|
+
// CommonJS and esbuild refuses top-level await in cjs output (docs/packaging.md).
|
|
210
|
+
// Nothing below may move back out to the module body.
|
|
211
|
+
async function main() {
|
|
212
|
+
const protocol = readProtocol();
|
|
213
|
+
const kernelBuild = readKernelBuild();
|
|
214
|
+
// A newer Beatrina on npm, as the `beatrina` command last recorded it (host/update-record.mjs):
|
|
215
|
+
// the host reads that record and never the registry. Null for CarmaR's doors, which write none.
|
|
216
|
+
const newerOnNpm = () => updateAvailable({ file: updateRecordFile(appId()), current: kernelBuild });
|
|
217
|
+
// WP5a — the user's settings, and the administrator's managed policy.
|
|
218
|
+
// Created HERE, before the port and the deployment, because reading the managed
|
|
219
|
+
// file puts its values into the environment (serve.R does the same at its line
|
|
220
|
+
// 63): CARMAR_PORT, CARMAR_HOSTS and CARMAR_BIND may all come from it, and the
|
|
221
|
+
// env-wins-and-locks rule is only a single source of truth if the environment
|
|
222
|
+
// is already complete when the first subsystem reads it. Its errors refuse
|
|
223
|
+
// startup for the deployment's reason: a governance control that silently
|
|
224
|
+
// failed to apply is worse than one that does not come up.
|
|
225
|
+
// Which package's folders hold this host's data (host/user-dirs.mjs appId).
|
|
226
|
+
// Asked first: every store below keys on it, and a malformed value would
|
|
227
|
+
// otherwise surface as a crash inside whichever store touched it first.
|
|
228
|
+
try { appId(); } catch (e) {
|
|
229
|
+
console.error(`Beatrina refuses to start: ${e.message}`);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
const settings = createSettings({ env });
|
|
233
|
+
if (settings.errors.length) {
|
|
234
|
+
for (const m of settings.errors) console.error(`Beatrina refuses to start: ${m}`);
|
|
235
|
+
process.exit(2);
|
|
236
|
+
}
|
|
237
|
+
const port = await choosePort();
|
|
238
|
+
const deployment = carmarDeployment(port, env);
|
|
239
|
+
if (deployment.errors.length) {
|
|
240
|
+
for (const m of deployment.errors) console.error(`Beatrina refuses to start: ${m}`);
|
|
241
|
+
process.exit(2);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── the audit log ──────────────────────────────────────────────────────────
|
|
245
|
+
const logPath = env("CARMAR_LOG");
|
|
246
|
+
const audit = (event, fields = {}) => {
|
|
247
|
+
if (!logPath) return;
|
|
248
|
+
const rec = { ts: new Date().toISOString(), pid: process.pid, event, ...fields };
|
|
249
|
+
try { fs.appendFileSync(logPath, `${JSON.stringify(rec)}\n`); } catch { /* logging must never take the host down */ }
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// ── the engines and the pool ───────────────────────────────────────────────
|
|
253
|
+
// R is spawned at boot: the page will not leave "connecting" until a `ready`
|
|
254
|
+
// arrives. Every other engine starts the first time a chunk of its language
|
|
255
|
+
// runs (docs/host-plan.md §1) — an ipykernel nobody asked for is 1.3 seconds
|
|
256
|
+
// and tens of megabytes spent on a document that may hold no Python at all.
|
|
257
|
+
const workerPath = path.join(spike, "worker-boot.R");
|
|
258
|
+
const pythonWorker = path.join(root, "engines", "python", "worker.py");
|
|
259
|
+
const deadlineScale = Number(env("CARMAR_WORKER_DEADLINE_SCALE", "1")) > 0 ? Number(env("CARMAR_WORKER_DEADLINE_SCALE", "1")) : 1;
|
|
260
|
+
const respawnMax = Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) >= 1 ? Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) : 10;
|
|
261
|
+
const childEnv = {};
|
|
262
|
+
if (env("CARMAR_ROOT")) childEnv.CARMAR_ROOT = env("CARMAR_ROOT");
|
|
263
|
+
// share()/use(): the one explicit bridge between the session's languages.
|
|
264
|
+
// One 0700 directory per host, shared by every engine it starts, removed at
|
|
265
|
+
// exit. A value crosses only when a chunk names it (engines/js/worker.mjs,
|
|
266
|
+
// engines/r/handoff.R); nothing is ever copied implicitly.
|
|
267
|
+
const handoffDir = fs.mkdtempSync(path.join(os.tmpdir(), "beatrix-handoff-"));
|
|
268
|
+
fs.chmodSync(handoffDir, 0o700);
|
|
269
|
+
childEnv.CARMAR_HANDOFF_DIR = handoffDir;
|
|
270
|
+
process.on("exit", () => { try { fs.rmSync(handoffDir, { recursive: true, force: true }); } catch { /* best effort at exit */ } });
|
|
271
|
+
const planes = PLANE_MODULES.map((mod) => (mod.createPlane ? mod.createPlane({ root, spike, env, audit, settings }) : mod.default));
|
|
272
|
+
let servedCommands = [];
|
|
273
|
+
|
|
274
|
+
// Discovery runs BEFORE the first ready, not beside it: `engines` on that
|
|
275
|
+
// frame is what decides whether the page greys a {python} chunk, and a list
|
|
276
|
+
// that filled in half a second later would grey it and then quietly change
|
|
277
|
+
// its mind. Measured: 130–260 ms for the whole ladder.
|
|
278
|
+
const pythonFound = env("CARMAR_NO_PYTHON") === "1"
|
|
279
|
+
? { available: false, detail: "The Python engine is disabled (CARMAR_NO_PYTHON=1).", searched: [] }
|
|
280
|
+
: await detectPython(process.env);
|
|
281
|
+
audit("engine-discovery", { engine: "python", available: pythonFound.available, detail: pythonFound.detail });
|
|
282
|
+
const jsWorker = path.join(root, "engines", "js", "worker.mjs");
|
|
283
|
+
const nodeFound = env("CARMAR_NO_JS") === "1"
|
|
284
|
+
? { available: false, detail: "The JavaScript engine is disabled (CARMAR_NO_JS=1).", path: "", version: "" }
|
|
285
|
+
: !packaged && !fs.existsSync(jsWorker)
|
|
286
|
+
? { available: false, detail: `This Beatrina does not carry the JavaScript worker (${jsWorker}).`, path: "", version: "" }
|
|
287
|
+
: detectNode({ env: process.env, packaged });
|
|
288
|
+
audit("engine-discovery", { engine: "js", available: nodeFound.available, detail: nodeFound.detail });
|
|
289
|
+
|
|
290
|
+
const ENGINE_SPECS = [
|
|
291
|
+
// WP5a — which R, and what the child inherits, come from the resolved
|
|
292
|
+
// settings (`rscript_path`, the child env). Read per spawn, never
|
|
293
|
+
// captured, so Restart R picks up a changed binary.
|
|
294
|
+
{ name: "r", label: "R", boot: "eager", available: true,
|
|
295
|
+
factory: () => new REngine({
|
|
296
|
+
workerPath, handoffScript: path.join(root, "engines", "r", "handoff.R"),
|
|
297
|
+
rscript: detectRscript(settings.rscriptPreference()),
|
|
298
|
+
env: { ...childEnv, ...settings.childEnv() },
|
|
299
|
+
}) },
|
|
300
|
+
{ name: "python", label: "Python", boot: "on-demand",
|
|
301
|
+
available: pythonFound.available, detail: pythonFound.detail,
|
|
302
|
+
version: pythonFound.version || "",
|
|
303
|
+
runtime: { path: pythonFound.path || "", why: pythonFound.why || "", searched: pythonFound.searched },
|
|
304
|
+
factory: () => new PythonEngine({ workerPath: pythonWorker, python: pythonFound.path, env: { ...childEnv, ...settings.childEnv() } }) },
|
|
305
|
+
{ name: "js", label: "JavaScript", boot: "on-demand",
|
|
306
|
+
available: nodeFound.available, detail: nodeFound.detail, version: nodeFound.version || "",
|
|
307
|
+
runtime: { path: nodeFound.path || "", why: nodeFound.detail },
|
|
308
|
+
factory: () => new JsEngine({ workerPath: jsWorker, node: nodeFound.path, selfHosted: Boolean(nodeFound.selfHosted), env: { ...childEnv, ...settings.childEnv() } }) },
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
// `/health r` before the worker is ready: ask the chosen Rscript once, in
|
|
312
|
+
// the background, so a launcher polling /health the instant the port answers
|
|
313
|
+
// sees the R it is about to get rather than "".
|
|
314
|
+
let rVersionHint = "";
|
|
315
|
+
{
|
|
316
|
+
const { execFile } = await import("node:child_process");
|
|
317
|
+
const rscript = detectRscript(settings.rscriptPreference());
|
|
318
|
+
if (rscript) execFile(rscript, ["--version"], { timeout: 5000 }, (err, stdout, stderr) => {
|
|
319
|
+
const m = String(stdout || "").concat(String(stderr || "")).match(/version (\d+\.\d+(?:\.\d+)?)/);
|
|
320
|
+
if (!err && m) rVersionHint = m[1];
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const plane = new EnginePool({
|
|
325
|
+
specs: ENGINE_SPECS, audit, deadlineScale, respawnMax,
|
|
326
|
+
decorateReady: (frame, spec) => ({
|
|
327
|
+
...frame,
|
|
328
|
+
protocol,
|
|
329
|
+
kernel_build: kernelBuild,
|
|
330
|
+
installed_build: installedBuild(),
|
|
331
|
+
...(newerOnNpm() ? { update_available: newerOnNpm() } : {}),
|
|
332
|
+
// Per engine, never merged: `commands` is a contract about what THIS
|
|
333
|
+
// session answers, and lib/kernel-capability.js refuses an op by name on
|
|
334
|
+
// the strength of it. The host's own verbs are added to the primary's.
|
|
335
|
+
commands: [...new Set([
|
|
336
|
+
...(Array.isArray(frame.commands) ? frame.commands : [frame.commands]).filter(Boolean),
|
|
337
|
+
...(spec.name === plane.primaryName ? ["session_restart", ...servedCommands] : []),
|
|
338
|
+
])],
|
|
339
|
+
features: Array.isArray(frame.features) ? frame.features : (frame.features ? [frame.features] : []),
|
|
340
|
+
engine: spec.name,
|
|
341
|
+
language: frame.language || spec.name,
|
|
342
|
+
language_version: frame.language_version || rVersionOf(frame) || null,
|
|
343
|
+
// EVERY engine this host has a runtime for, on every engine's frame.
|
|
344
|
+
engines: plane.engineNames,
|
|
345
|
+
engine_status: plane.engineRows(),
|
|
346
|
+
host: "beatrix",
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
await plane.start();
|
|
350
|
+
|
|
351
|
+
// ── the server ─────────────────────────────────────────────────────────────
|
|
352
|
+
const bootAt = Date.now();
|
|
353
|
+
const fileLaunchCap = /^[A-Za-z0-9]{32}$/.test(env("CARMAR_FILE_LAUNCH_CAP")) ? env("CARMAR_FILE_LAUNCH_CAP") : secureToken(32);
|
|
354
|
+
delete process.env.CARMAR_FILE_LAUNCH_CAP;
|
|
355
|
+
let runtimeFile = "";
|
|
356
|
+
// `app` says whose session this is (BEATRIX_APP_ID): a double-clicked document
|
|
357
|
+
// is handed to a running session of the SAME app, never to CarmaR's.
|
|
358
|
+
const runtimeRecord = { url: "", file: "", port, pid: process.pid, started: new Date(bootAt).toISOString(), host: "beatrix", app: appId() };
|
|
359
|
+
// The document the session was started with, for `beatrina status`.
|
|
360
|
+
if (env("CARMAR_OPEN_FILE")) runtimeRecord.document = env("CARMAR_OPEN_FILE");
|
|
361
|
+
// A successor started by a handoff keeps the session's name.
|
|
362
|
+
const inheritedTitle = env("CARMAR_SESSION_TITLE").trim();
|
|
363
|
+
if (inheritedTitle) runtimeRecord.title = inheritedTitle.slice(0, 120);
|
|
364
|
+
const handoffFrom = env("CARMAR_HANDOFF_FROM").trim();
|
|
365
|
+
const writeRuntime = () => {
|
|
366
|
+
if (!runtimeFile) return false;
|
|
367
|
+
try { fs.writeFileSync(runtimeFile, JSON.stringify(runtimeRecord), { mode: 0o600 }); return true; } catch { return false; }
|
|
368
|
+
};
|
|
369
|
+
let stopping = false;
|
|
370
|
+
async function shutdown(reason) {
|
|
371
|
+
if (stopping) return;
|
|
372
|
+
stopping = true;
|
|
373
|
+
audit("stopping", { reason });
|
|
374
|
+
try { host.close(); } catch { /* closing */ }
|
|
375
|
+
// A plane may own children of its own (a job's process tree, a pty shell).
|
|
376
|
+
// They are spawned DETACHED so a group signal can reach what they started,
|
|
377
|
+
// which is exactly why nothing reaps them for us. serve.R kills the same
|
|
378
|
+
// trees in its exit handler; this is that handler.
|
|
379
|
+
for (const pl of planes) { try { await pl.shutdown?.(); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
|
|
380
|
+
await plane.stop();
|
|
381
|
+
if (runtimeFile) {
|
|
382
|
+
try {
|
|
383
|
+
const rec = JSON.parse(fs.readFileSync(runtimeFile, "utf8"));
|
|
384
|
+
if (rec.pid === process.pid) fs.unlinkSync(runtimeFile);
|
|
385
|
+
} catch { /* not ours, or already gone */ }
|
|
386
|
+
}
|
|
387
|
+
process.exit(0);
|
|
388
|
+
}
|
|
389
|
+
const host = createHostServer({
|
|
390
|
+
deployment, plane, audit, protocol, kernelBuild, installedBuild, updateAvailable: newerOnNpm, bootAt, app: appId(), fileLaunchCap, here: spike, planes,
|
|
391
|
+
rVersionHint: () => rVersionHint,
|
|
392
|
+
pagePath: () => notebookPage(kernelBuild),
|
|
393
|
+
// The handoff asks for the PIN alone (spike/notebook-page.R): a successor
|
|
394
|
+
// with nothing but the development index to serve is worse than a stale
|
|
395
|
+
// session, so no fallback may answer "the installed build has a page".
|
|
396
|
+
notebookPage: (build) => notebookPage(build, { fallback: false }), notebookFileUrl: (p, prt, cap) => notebookFileUrl(path.resolve(p), prt, cap),
|
|
397
|
+
runtimeRecord: () => runtimeRecord, handoffFrom,
|
|
398
|
+
pendingOpen: env("CARMAR_OPEN_FILE") || null,
|
|
399
|
+
onShutdown: (reason) => shutdown(reason || "explicit Quit request"),
|
|
400
|
+
onPageTitle: (title) => {
|
|
401
|
+
// eslint-disable-next-line no-control-regex
|
|
402
|
+
// `null` is a page-title that named no settable title: the listener mark
|
|
403
|
+
// still goes (a listener with a page is an attached session), the title stays.
|
|
404
|
+
if (title !== null) {
|
|
405
|
+
const clean = String(title).replace(/[\x00-\x1f\x7f]/g, " ").replace(/["\\]/g, "'").trim().slice(0, 120);
|
|
406
|
+
if (clean) runtimeRecord.title = clean; else delete runtimeRecord.title;
|
|
407
|
+
}
|
|
408
|
+
delete runtimeRecord.listen;
|
|
409
|
+
writeRuntime();
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
servedCommands = host.commands;
|
|
413
|
+
// A plane that refuses to start (a policy permitting nothing) stops the whole
|
|
414
|
+
// host — and takes the worker it already spawned with it, because the exit
|
|
415
|
+
// handler at the foot of this file has not been registered yet.
|
|
416
|
+
for (const pl of planes) {
|
|
417
|
+
try { await pl.init?.(host.ctx); } catch (e) {
|
|
418
|
+
console.error(`Beatrina: plane ${pl.name} failed to start: ${e.message}`);
|
|
419
|
+
await plane.stop().catch(() => {});
|
|
420
|
+
process.exit(2);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
await new Promise((resolve, reject) => {
|
|
424
|
+
host.server.once("error", reject);
|
|
425
|
+
host.server.listen(port, deployment.bind, resolve);
|
|
426
|
+
});
|
|
427
|
+
const boundPort = host.server.address().port;
|
|
428
|
+
const originOk = `http://${deployment.loopback ? deployment.bind : "127.0.0.1"}:${boundPort}`;
|
|
429
|
+
const url = `${originOk}/`;
|
|
430
|
+
const page = notebookPage(kernelBuild);
|
|
431
|
+
const fileUrl = page ? notebookFileUrl(path.resolve(page), boundPort, fileLaunchCap) : "";
|
|
432
|
+
runtimeRecord.url = url;
|
|
433
|
+
runtimeRecord.file = fileUrl;
|
|
434
|
+
if (env("CARMAR_LISTEN") === "1") runtimeRecord.listen = true;
|
|
435
|
+
if (env("CARMAR_NO_RUNTIME_FILE") !== "1") {
|
|
436
|
+
const dir = env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
|
|
437
|
+
try { fs.mkdirSync(dir, { recursive: true }); runtimeFile = path.join(dir, `kernel-${boundPort}.json`); } catch { runtimeFile = ""; }
|
|
438
|
+
if (runtimeFile && !writeRuntime()) runtimeFile = "";
|
|
439
|
+
}
|
|
440
|
+
audit("listening", { bind: deployment.bind, port: boundPort, loopback: deployment.loopback, require_origin: deployment.require_origin, host: "beatrix" });
|
|
441
|
+
audit("started", { port: boundPort, root: env("CARMAR_ROOT") });
|
|
442
|
+
process.stdout.write(`${JSON.stringify({ url, file: fileUrl })}\n`);
|
|
443
|
+
// --open: open the notebook FILE, as serve.R did for the double-click
|
|
444
|
+
// launchers. The kernel stays hidden plumbing. CARMAR_OPENER is the seam a
|
|
445
|
+
// test uses so nothing opens on a real desktop.
|
|
446
|
+
if (process.argv.slice(2).includes("--open")) {
|
|
447
|
+
const target = fileUrl || url;
|
|
448
|
+
const opener = String(process.env.CARMAR_OPENER || "");
|
|
449
|
+
try {
|
|
450
|
+
const child = path.isAbsolute(opener) ? spawn(opener, [target], { stdio: "ignore", detached: true })
|
|
451
|
+
: process.platform === "darwin" ? spawn("open", [target], { stdio: "ignore", detached: true })
|
|
452
|
+
: process.platform === "win32" ? spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true })
|
|
453
|
+
: spawn("xdg-open", [target], { stdio: "ignore", detached: true });
|
|
454
|
+
child.on("error", () => { /* no desktop opener: the URL is printed above */ });
|
|
455
|
+
child.unref();
|
|
456
|
+
} catch { /* no desktop opener */ }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── lifetime ───────────────────────────────────────────────────────────────
|
|
460
|
+
// Idle shutdown is OPT-IN. A kernel nobody has EVER connected to gets the boot
|
|
461
|
+
// grace instead, so an unopened launch does not leave an R behind — unless the
|
|
462
|
+
// setting is explicitly 0, which says "headless, keep".
|
|
463
|
+
//
|
|
464
|
+
// WP5a — the clock reads the RESOLVED setting rather than CARMAR_LINGER, so the
|
|
465
|
+
// user's settings.json can turn it on and Settings can change it live. "Still
|
|
466
|
+
// at its default" is the exact rule serve.R's resolve_linger() uses for "never
|
|
467
|
+
// reap": an explicit 0 from the env or the file is a CHOICE and is honoured as
|
|
468
|
+
// one, which is why it is `source === "default"` and not `value === 0`. Read
|
|
469
|
+
// per tick, never captured, so a settings_set takes effect without a restart.
|
|
470
|
+
const bootGraceS = Number.isFinite(Number(env("CARMAR_BOOT_GRACE", "180"))) ? Number(env("CARMAR_BOOT_GRACE", "180")) : 180;
|
|
471
|
+
let idleSince = null;
|
|
472
|
+
setInterval(() => {
|
|
473
|
+
const lingerS = settings.lingerSeconds();
|
|
474
|
+
const everSeen = host.state.everConnected || host.state.heldAt != null;
|
|
475
|
+
const unopenedDefault = !everSeen && settings.lingerIsDefault();
|
|
476
|
+
if (!(lingerS > 0) && !unopenedDefault) { idleSince = null; return; }
|
|
477
|
+
plane.sweepParked?.(lingerS);
|
|
478
|
+
if (!everSeen && Date.now() - bootAt < bootGraceS * 1000) return;
|
|
479
|
+
const held = host.state.heldAt != null && Date.now() - host.state.heldAt < 30000;
|
|
480
|
+
// A plane that says it is busy holds the clock open. The case this exists
|
|
481
|
+
// for: a detached job three minutes into a check nobody is watching — the
|
|
482
|
+
// one moment a long task matters is the one an idle timer would end it.
|
|
483
|
+
const planeBusy = planes.some((pl) => pl.busy?.() === true);
|
|
484
|
+
const idle = host.sockets.length === 0 && plane.running.size === 0 && !held && !planeBusy;
|
|
485
|
+
if (!idle) { idleSince = null; return; }
|
|
486
|
+
if (idleSince == null) { idleSince = Date.now(); return; }
|
|
487
|
+
const grace = unopenedDefault ? bootGraceS : lingerS;
|
|
488
|
+
if (Date.now() - idleSince >= grace * 1000) {
|
|
489
|
+
const reason = unopenedDefault ? "unopened-launch" : "configured-idle-timeout";
|
|
490
|
+
audit("linger-shutdown", { grace, reason });
|
|
491
|
+
console.error(`Beatrina stopping: ${reason} (grace ${grace} seconds).`);
|
|
492
|
+
shutdown(reason);
|
|
493
|
+
}
|
|
494
|
+
}, 1000).unref();
|
|
495
|
+
|
|
496
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => shutdown(sig));
|
|
497
|
+
process.on("exit", () => { try { if (typeof plane.killAll === "function") plane.killAll(); else if (plane.engine && plane.engine.alive) plane.engine.kill(); } catch { /* gone */ } });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
main().catch((e) => {
|
|
501
|
+
console.error(`Beatrina failed to start: ${e && e.stack ? e.stack : e}`);
|
|
502
|
+
process.exit(2);
|
|
503
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# host/planes — one supervisor-answered family per module
|
|
2
|
+
|
|
3
|
+
A plane is what `spike/serve.R` calls an allow-list family: the file ops, the
|
|
4
|
+
journal, the AI store, jobs, the terminal, citations, plugins, sessions, the
|
|
5
|
+
MCP plane. Each is one module here, registered by one line in `host/main.mjs`
|
|
6
|
+
(`PLANE_MODULES`), and consulted by `host/server.mjs` before its not-yet
|
|
7
|
+
refusals.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
export function createPlane({ root, spike, env, audit }) {
|
|
11
|
+
return {
|
|
12
|
+
name: "files",
|
|
13
|
+
ops: ["files", "readfile", …], // the frames this plane answers
|
|
14
|
+
agentRefused: ["writefile", …], // subset an mcp-role socket may not send
|
|
15
|
+
agentRefusalFor: (op) => "…", // that op's OWN sentence (serve.R gives each one)
|
|
16
|
+
commands: ["files", "readfile", …], // advertised in ready.commands — only what is served
|
|
17
|
+
capabilities: ["document-journal-v1"], // added to /health capabilities
|
|
18
|
+
async init(ctx) {}, // once, before listen
|
|
19
|
+
handle(cmd, rec, ctx, rawMessage) {}, // one frame; reply with rec.ws.send(ctx.enc({...}))
|
|
20
|
+
http(req, res, pathname, ctx) {}, // return true when handled
|
|
21
|
+
onOpen(rec, ctx) {}, onClose(rec, ctx) {},
|
|
22
|
+
busy() {}, // true holds the idle clock open
|
|
23
|
+
async shutdown() {}, // the kernel is going: reap your children
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`busy()` and `shutdown()` are read by `host/main.mjs`. A plane that spawns a
|
|
29
|
+
child spawns it DETACHED (so a group signal can reach what the child started),
|
|
30
|
+
which is exactly why nothing reaps it for us: `shutdown()` is where that is
|
|
31
|
+
done, and it is the counterpart of serve.R's exit handler.
|
|
32
|
+
|
|
33
|
+
`ctx` carries `deployment`, `plane` (the worker plane), `audit`, `sockets`,
|
|
34
|
+
`approvals`, `state`, `enc`, `pageRecs`, `socketClass`, `port()`, `respond`,
|
|
35
|
+
`reject`, `controlRejection`, `queryParam`, `broadcast`, `installedBuild`,
|
|
36
|
+
`kernelBuild`, `fileLaunchCap`, `sessionStamp`.
|
|
37
|
+
|
|
38
|
+
Rules: a page-only op checks `rec.class` against `PAGE_ONLY_CLASSES`
|
|
39
|
+
(served/file/local) — role is an honour system, class is what the upgrade gate
|
|
40
|
+
proved. Records that must not be re-encoded travel as strings. No dependency
|
|
41
|
+
without a stated reason (`node-pty` for the terminal is the first).
|