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/server.mjs
CHANGED
|
@@ -82,6 +82,13 @@ export const SAFE_HEADERS = Object.freeze({
|
|
|
82
82
|
const scalarChr = (x) => typeof x === "string" && x.length > 0;
|
|
83
83
|
const enc = (obj) => JSON.stringify(obj);
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The version of the `host-ready` handshake (plans/getting_rid_of_R.md K2). A
|
|
87
|
+
* page reads `host_protocol` to know which fields it may rely on; it never
|
|
88
|
+
* replaces `protocol`, which is the worker wire's and gates compatibility.
|
|
89
|
+
*/
|
|
90
|
+
export const HOST_READY_PROTOCOL = 1;
|
|
91
|
+
|
|
85
92
|
/** Bytes no caller can predict — the OS CSPRNG, never Math.random. */
|
|
86
93
|
export function secureToken(n = 32) {
|
|
87
94
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
@@ -91,6 +98,38 @@ export function secureToken(n = 32) {
|
|
|
91
98
|
return out;
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* INSTALL AS AN APP (a Progressive Web App), for whoever wants it: the browser
|
|
103
|
+
* offers "Install Beatrina Note" (Chrome, Edge) or File ▸ Add to Dock (Safari) and
|
|
104
|
+
* the page opens in a window of its own — the same page, from the same host,
|
|
105
|
+
* locally or from a server. Added to the SERVED page only, like the session
|
|
106
|
+
* stamp: the file on disk is unchanged, so a notebook double-clicked from Finder
|
|
107
|
+
* behaves exactly as before. The page's CSP admits `manifest-src 'self'` and
|
|
108
|
+
* nothing more for it — no service worker; Chromium reports the page
|
|
109
|
+
* installable without one (test/pwa.browser.mjs).
|
|
110
|
+
*/
|
|
111
|
+
export const PWA_HEAD = '<link rel="manifest" href="/manifest.webmanifest">'
|
|
112
|
+
+ '<link rel="icon" type="image/png" sizes="192x192" href="/pwa/icon-192.png">'
|
|
113
|
+
+ '<link rel="apple-touch-icon" href="/pwa/icon-192.png">'
|
|
114
|
+
+ '<meta name="theme-color" content="#1f2937">';
|
|
115
|
+
|
|
116
|
+
/** The manifest the installed app is made from; `start_url` is the page this host serves. */
|
|
117
|
+
export function pwaManifest(app = "beatrina") {
|
|
118
|
+
// "Beatrina Note": the page installed from the browser, told apart from Beatrina, the desktop
|
|
119
|
+
// window (`beatrina app`), in the Dock, the Start menu and the app list.
|
|
120
|
+
const name = app && app !== "beatrina" ? `${app} Note` : "Beatrina Note";
|
|
121
|
+
return JSON.stringify({
|
|
122
|
+
name, short_name: name, description: "A Quarto writer that also runs R, Python and JavaScript.",
|
|
123
|
+
id: "/", start_url: "/", scope: "/", display: "standalone",
|
|
124
|
+
background_color: "#ffffff", theme_color: "#1f2937",
|
|
125
|
+
icons: [
|
|
126
|
+
{ src: "/pwa/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
127
|
+
{ src: "/pwa/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const PWA_ICONS = Object.freeze({ "/pwa/icon-192.png": "icon-192.png", "/pwa/icon-512.png": "icon-512.png" });
|
|
132
|
+
|
|
94
133
|
/**
|
|
95
134
|
* Stamp the served HTML with this session's identity, once per load — and, for a product other
|
|
96
135
|
* than Beatrina (`app`, BEATRIX_APP_ID: "beatrina"), with which product served it, so the page's
|
|
@@ -100,7 +139,7 @@ export function secureToken(n = 32) {
|
|
|
100
139
|
*/
|
|
101
140
|
export function stampSession(html, stamp, app = "") {
|
|
102
141
|
const product = String(app || "").replace(/[^A-Za-z0-9.]/g, "");
|
|
103
|
-
const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}`;
|
|
142
|
+
const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}${PWA_HEAD}`;
|
|
104
143
|
let m = html.match(/<head\b[^>]*>/);
|
|
105
144
|
if (!m) m = html.match(/<html\b[^>]*>/);
|
|
106
145
|
if (!m) return meta + html;
|
|
@@ -153,6 +192,33 @@ export function createHostServer(opts) {
|
|
|
153
192
|
} catch { return payload; }
|
|
154
193
|
};
|
|
155
194
|
plane.on("broadcast", (payload) => { const out = strandedNotice(payload); sockets.forEach((r) => r.ws.send(out)); });
|
|
195
|
+
|
|
196
|
+
// ── host-ready: the HOST's readiness, independent of every engine (K2) ────
|
|
197
|
+
// Sent to every socket on open, BEFORE any engine's frame, and again when an
|
|
198
|
+
// engine's row changes. It carries the host's own verbs — the ones planes
|
|
199
|
+
// answer with no engine at all (files, journal, settings, …) — so a page
|
|
200
|
+
// with no R still learns that it may save and recover. It is DISCLOSURE:
|
|
201
|
+
// who may call a verb is still decided per frame below (gatewayCommands,
|
|
202
|
+
// AGENT_REFUSED, PAGE_ONLY_CLASSES), never by what this frame lists (K2c).
|
|
203
|
+
// R's legacy `ready` keeps carrying the same verbs while R runs, so a page
|
|
204
|
+
// older than this frame still connects to a host that has R (K2).
|
|
205
|
+
const hostReadyFrame = () => {
|
|
206
|
+
const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
|
|
207
|
+
return enc({
|
|
208
|
+
type: "host-ready", host_protocol: HOST_READY_PROTOCOL, host: "beatrix", app: opts.app || "beatrina",
|
|
209
|
+
protocol: opts.protocol, kernel_build: opts.kernelBuild,
|
|
210
|
+
installed_build: typeof opts.installedBuild === "function" ? opts.installedBuild() : null,
|
|
211
|
+
...(newerOnNpm ? { update_available: newerOnNpm } : {}),
|
|
212
|
+
commands: gatewayCommands(["session_restart", ...commands], gatewayMode),
|
|
213
|
+
capabilities,
|
|
214
|
+
// The routing default for a request that names no engine — NOT a
|
|
215
|
+
// primary: nothing waits for it (K2).
|
|
216
|
+
default_engine: plane.primaryName || null,
|
|
217
|
+
engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : [],
|
|
218
|
+
engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : [],
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
plane.on("engines-changed", () => { const out = hostReadyFrame(); sockets.forEach((r) => r.ws.send(out)); });
|
|
156
222
|
// A restore's report is about every object in the session: pages only, never an agent or a native client.
|
|
157
223
|
plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
|
|
158
224
|
|
|
@@ -267,7 +333,9 @@ export function createHostServer(opts) {
|
|
|
267
333
|
// CORS is granted HERE AND ONLY HERE, so a file page probing for its
|
|
268
334
|
// kernel can read the answer. Booleans and versions only.
|
|
269
335
|
return respond(res, 200, "application/json", enc({
|
|
270
|
-
|
|
336
|
+
// `host_ready`: this host speaks the host-ready handshake, so a probing page need not
|
|
337
|
+
// wait for `worker` — which is only "R is running", and false forever with no R.
|
|
338
|
+
ok: true, host_ready: HOST_READY_PROTOCOL, worker: plane.alive, pending_open: state.pendingOpen != null,
|
|
271
339
|
r: rVersion(), started: startedSeconds, protocol: opts.protocol,
|
|
272
340
|
kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(),
|
|
273
341
|
pages: pageRecs().length,
|
|
@@ -300,6 +368,17 @@ export function createHostServer(opts) {
|
|
|
300
368
|
for (const pl of planes) {
|
|
301
369
|
if (typeof pl.http === "function" && pl.http(req, res, pathname, ctx) === true) return undefined;
|
|
302
370
|
}
|
|
371
|
+
if (pathname === "/manifest.webmanifest") {
|
|
372
|
+
return respond(res, 200, "application/manifest+json", pwaManifest(opts.app || "beatrina"));
|
|
373
|
+
}
|
|
374
|
+
if (Object.prototype.hasOwnProperty.call(PWA_ICONS, pathname)) {
|
|
375
|
+
// host/pwa beside the kernel directory's parent — the host's own root, in a
|
|
376
|
+
// checkout and in the npm package alike (never import.meta: host/main.mjs PACKAGING).
|
|
377
|
+
const icon = path.join(opts.here, "..", "host", "pwa", PWA_ICONS[pathname]);
|
|
378
|
+
let bytes;
|
|
379
|
+
try { bytes = fs.readFileSync(icon); } catch { return respond(res, 404, "text/plain", "not found"); }
|
|
380
|
+
return respond(res, 200, "image/png", bytes);
|
|
381
|
+
}
|
|
303
382
|
if (pathname === "/" || pathname === "/spike") {
|
|
304
383
|
const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
|
|
305
384
|
let html;
|
|
@@ -333,6 +412,8 @@ export function createHostServer(opts) {
|
|
|
333
412
|
ws.onPong(() => { rec.lastSeen = Date.now(); });
|
|
334
413
|
state.everConnected = true;
|
|
335
414
|
audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
|
|
415
|
+
// The host first: the page may save, recover and edit on this alone.
|
|
416
|
+
ws.send(hostReadyFrame());
|
|
336
417
|
// The engine announced itself long before this page connected; replay
|
|
337
418
|
// it — and every other engine that has since started, each under its own
|
|
338
419
|
// `engine-ready` (host/engine-pool.mjs replay()).
|
package/host/worker-plane.mjs
CHANGED
|
@@ -324,9 +324,15 @@ export class WorkerPlane extends EventEmitter {
|
|
|
324
324
|
this.audit("restart", { keep: keepTokenOk(resume) });
|
|
325
325
|
this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
|
|
326
326
|
this.expectedExit = true;
|
|
327
|
+
// Not ready from THIS moment, not after the old process is gone: a command
|
|
328
|
+
// that arrives while the old engine is being stopped would otherwise be
|
|
329
|
+
// queued on it and wiped by resetSession() below with no reply at all. With
|
|
330
|
+
// `hello` cleared the pool holds it until the fresh engine's ready
|
|
331
|
+
// (EnginePool.whenEngineReady) — measured by test/stop.test.mjs, whose probe
|
|
332
|
+
// landed in exactly that window once host-ready moved R's ready off frame 0.
|
|
333
|
+
this.hello = null;
|
|
327
334
|
if (this.engine) await this.engine.stop(1000);
|
|
328
335
|
this.resetSession();
|
|
329
|
-
this.hello = null;
|
|
330
336
|
this.recovering = false;
|
|
331
337
|
this.restartAttempts = 0;
|
|
332
338
|
this.gaveUp = false;
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// kept · readline() · file ops and completion while R is busy · Force Stop ·
|
|
13
13
|
// the terminal · Python and JavaScript chunks when those engines exist.
|
|
14
14
|
//
|
|
15
|
+
// WITH NO R (plans/getting_rid_of_R.md Stage 2) the host is still judged —
|
|
16
|
+
// startup, the document (save, open, list), the terminal, the other engines —
|
|
17
|
+
// and every R area is reported SKIP with the reason, never counted as a pass.
|
|
18
|
+
//
|
|
15
19
|
// Usage
|
|
16
20
|
// node tools/beatrina/acceptance.mjs # this checkout's host
|
|
17
21
|
// node tools/beatrina/acceptance.mjs --installed # the installed beatrina package's host
|
|
@@ -61,17 +65,44 @@ if (!session.announced) {
|
|
|
61
65
|
record("startup", "the supervisor announces a URL", "fail", session.log().split("\n").slice(-6).join(" | "));
|
|
62
66
|
await shutdown(1);
|
|
63
67
|
}
|
|
64
|
-
const { frames, until, ready, page } = session;
|
|
65
|
-
if (!ready) { record("startup", "R announces ready", "fail", session.log().slice(-400)); await shutdown(1); }
|
|
66
|
-
|
|
68
|
+
const { frames, until, ready, page, hostReady, noR } = session;
|
|
69
|
+
if (!ready && !noR) { record("startup", "R announces ready", "fail", session.log().slice(-400)); await shutdown(1); }
|
|
70
|
+
if (noR) {
|
|
71
|
+
record("startup", "the host is ready with no R (host-ready)", hostReady ? "pass" : "fail", "", session.startupMs);
|
|
72
|
+
record("startup", "R", "skip", `R is not available: ${noR}. Every R area below is skipped, not passed.`);
|
|
73
|
+
} else {
|
|
74
|
+
record("startup", `R ready (${ready.r || "?"}, worker mode ${ready.mode || "?"})`, "pass", "", session.startupMs, { r: ready.r, mode: ready.mode });
|
|
75
|
+
}
|
|
76
|
+
/** An R area runs only when R does; otherwise it is ONE skip line with the reason. */
|
|
77
|
+
const wantR = (area) => {
|
|
78
|
+
if (!want(area)) return false;
|
|
79
|
+
if (noR) { record(area, `${area} (needs R)`, "skip", noR); return false; }
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
67
82
|
|
|
68
83
|
let seq = 0;
|
|
69
84
|
const text = (id) => session.text(id);
|
|
70
85
|
const run = (source, opts = {}) => session.run(source, opts);
|
|
71
|
-
const
|
|
86
|
+
const announced = ready || hostReady;
|
|
87
|
+
const engines = Array.isArray(announced.engines) ? announced.engines : ["r"];
|
|
88
|
+
|
|
89
|
+
// ── the document: what a person does with no engine at all ──────────────────
|
|
90
|
+
if (want("document")) {
|
|
91
|
+
const doc = path.join(scratch, "chapter.qmd");
|
|
92
|
+
const body = "---\ntitle: Acceptance\n---\n\n# Method\n\nWords, saved by the host [@key].\n";
|
|
93
|
+
const w = await session.ask("writefile", { path: doc, text: body });
|
|
94
|
+
record("document", "Save writes a .qmd", w.reply && !w.reply.error && fs.readFileSync(doc, "utf8") === body ? "pass" : "fail",
|
|
95
|
+
w.reply ? (w.reply.error || "") : "no reply", w.ms);
|
|
96
|
+
const rd = await session.ask("readfile", { path: doc });
|
|
97
|
+
record("document", "Open reads it back, byte for byte", rd.reply && rd.reply.text === body ? "pass" : "fail",
|
|
98
|
+
rd.reply ? (rd.reply.error || "") : "no reply", rd.ms);
|
|
99
|
+
const ls = await session.ask("files", { path: scratch });
|
|
100
|
+
record("document", "the file browser lists it", ls.reply && !ls.reply.error && JSON.stringify(ls.reply).includes("chapter.qmd") ? "pass" : "fail",
|
|
101
|
+
ls.reply ? (ls.reply.error || "") : "no reply", ls.ms);
|
|
102
|
+
}
|
|
72
103
|
|
|
73
104
|
// ── run ──────────────────────────────────────────────────────────────────────
|
|
74
|
-
if (
|
|
105
|
+
if (wantR("run")) {
|
|
75
106
|
const r = await run('keep <- 42; cat("keep=", keep, "\\n")');
|
|
76
107
|
record("run", "a chunk runs and prints", r.done?.status === "ok" && /keep= 42/.test(r.out()) ? "pass" : "fail", r.out().trim(), r.ms);
|
|
77
108
|
const warm = [];
|
|
@@ -109,7 +140,7 @@ async function stopCase(label, source, { engine, probe, expect, after = 900, kee
|
|
|
109
140
|
record("stop", `${label}: interrupted with the session kept, ${REPEAT - misses.length}/${REPEAT}`, misses.length ? "fail" : "pass",
|
|
110
141
|
summary, lat.length ? median(lat) : null, { series: lat, misses: misses.length, repeat: REPEAT });
|
|
111
142
|
}
|
|
112
|
-
if (
|
|
143
|
+
if (wantR("stop")) {
|
|
113
144
|
const probe = 'cat("keep=", exists("keep") && keep == 42, "\\n")';
|
|
114
145
|
await run("keep <- 42");
|
|
115
146
|
const keep = "keep <- 42";
|
|
@@ -119,7 +150,7 @@ if (want("stop")) {
|
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
// ── readline ─────────────────────────────────────────────────────────────────
|
|
122
|
-
if (
|
|
153
|
+
if (wantR("readline")) {
|
|
123
154
|
const id = `r${++seq}`;
|
|
124
155
|
const from = frames.length;
|
|
125
156
|
page.send({ type: "exec", id, source: 'ans <- readline("Your name: "); cat("hello", ans, "\\n")' });
|
|
@@ -135,7 +166,7 @@ if (want("readline")) {
|
|
|
135
166
|
|
|
136
167
|
// ── while busy ───────────────────────────────────────────────────────────────
|
|
137
168
|
const ask = (type, payload, ms) => session.ask(type, payload, ms);
|
|
138
|
-
if (
|
|
169
|
+
if (wantR("busy")) {
|
|
139
170
|
fs.writeFileSync(path.join(scratch, "note.txt"), "hello");
|
|
140
171
|
const id = `b${++seq}`;
|
|
141
172
|
const from = frames.length;
|
|
@@ -160,7 +191,7 @@ if (want("busy")) {
|
|
|
160
191
|
}
|
|
161
192
|
|
|
162
193
|
// ── force stop ───────────────────────────────────────────────────────────────
|
|
163
|
-
if (
|
|
194
|
+
if (wantR("force")) {
|
|
164
195
|
await run("keep <- 42");
|
|
165
196
|
const id = `f${++seq}`;
|
|
166
197
|
const from = frames.length;
|
|
@@ -179,7 +210,7 @@ if (want("force")) {
|
|
|
179
210
|
// R dies under a running chunk (kill -9, as a segfault or the OOM killer would):
|
|
180
211
|
// the chunk must END with a sentence, the page must be told, and a fresh R must
|
|
181
212
|
// answer — never a spinner that waits for a process that is gone.
|
|
182
|
-
if (
|
|
213
|
+
if (wantR("crash")) {
|
|
183
214
|
const probe = await run('cat("pid=", Sys.getpid(), "\\n")');
|
|
184
215
|
const pid = Number((probe.out().match(/pid= (\d+)/) || [])[1]);
|
|
185
216
|
const id = `c${++seq}`;
|
|
@@ -211,7 +242,7 @@ function rssKb(pid) {
|
|
|
211
242
|
const v = Number(String(r.stdout).trim());
|
|
212
243
|
return Number.isFinite(v) && v > 0 ? v : null;
|
|
213
244
|
}
|
|
214
|
-
if (
|
|
245
|
+
if (wantR("memory")) {
|
|
215
246
|
const rounds = Math.max(4, Number(argv.memoryRounds || 8));
|
|
216
247
|
const samples = [];
|
|
217
248
|
await run("df <- data.frame(id = seq_len(5000), x = rnorm(5000), g = sample(letters, 5000, TRUE))");
|
|
@@ -251,7 +282,7 @@ for (const [engine, setup, probe, sleeper] of [
|
|
|
251
282
|
["js", "globalThis.keepJs = 42; console.log('js keep', keepJs)", "console.log('js keep', keepJs)", "for (let i = 0; i < 1e13; i++) {}\nconsole.log('after-stop')"],
|
|
252
283
|
]) {
|
|
253
284
|
if (!want(engine)) continue;
|
|
254
|
-
const row = Array.isArray(
|
|
285
|
+
const row = Array.isArray(announced.engine_status) ? announced.engine_status.find((e) => e.engine === engine) : null;
|
|
255
286
|
if (!engines.includes(engine)) { record(engine, `${engine} chunks`, "skip", row ? row.detail || "engine not available" : "this host has no such engine"); continue; }
|
|
256
287
|
const first = await run(setup, { engine, ms: 120000 });
|
|
257
288
|
record(engine, `a ${engine} chunk runs (first run starts the engine)`, first.done?.status === "ok" && /keep 42/.test(first.out()) ? "pass" : "fail", first.out().trim() || first.done?.message || "", first.ms);
|