beatrina 0.9.83 → 1.0.1

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.
Files changed (40) hide show
  1. package/app/Beatrina.app/Contents/Info.plist +86 -0
  2. package/app/Beatrina.app/Contents/MacOS/beatrina +0 -0
  3. package/app/Beatrina.app/Contents/Resources/icon.icns +0 -0
  4. package/app/beatrina.png +0 -0
  5. package/app/linux-arm64/beatrina +0 -0
  6. package/app/win-x64/Beatrina-setup.exe +0 -0
  7. package/beatrina_V1.0.1.html +1906 -0
  8. package/beatrina_V1.0.1.html.inputs.json +1 -0
  9. package/bin/beatrina.mjs +200 -12
  10. package/bin/cli.mjs +8 -3
  11. package/bin/finder.mjs +1 -1
  12. package/bin/shortcut.mjs +15 -15
  13. package/build-info.json +1 -1
  14. package/host/engine-node-job.mjs +35 -0
  15. package/host/engine-pool.mjs +153 -26
  16. package/host/engine-r.mjs +8 -1
  17. package/host/jobs/document-child.mjs +271 -0
  18. package/host/latex-compile.mjs +673 -0
  19. package/host/main.mjs +58 -18
  20. package/host/planes/analyze.mjs +18 -2
  21. package/host/planes/jobs.mjs +38 -5
  22. package/host/planes/sessions.mjs +5 -1
  23. package/host/pwa/icon-192.png +0 -0
  24. package/host/pwa/icon-512.png +0 -0
  25. package/host/quarto.mjs +118 -0
  26. package/host/server.mjs +83 -2
  27. package/host/worker-plane.mjs +7 -1
  28. package/kernel/check/acceptance.mjs +43 -12
  29. package/kernel/check/knit-file.mjs +55 -19
  30. package/kernel/check/session.mjs +19 -4
  31. package/kernel/job-run.R +4 -1
  32. package/kernel/kernel-version +1 -1
  33. package/kernel/worker-boot.R +2 -1
  34. package/kernel/worker.R +2 -1
  35. package/lib/engine-labels.js +7 -1
  36. package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
  37. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  38. package/package.json +4 -3
  39. package/beatrina_V0.9.83.html +0 -1903
  40. package/beatrina_V0.9.83.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 and the build stamps.
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 name of ["spike", "kernel"]) {
117
- if (fs.existsSync(path.join(base, name, "worker-boot.R"))) return path.join(base, name);
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: its page, its worker, and an R that runs. A protocol or build
198
- * stamp that cannot be read has already thrown by the time this is called (main's first lines),
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
- // R is spawned at boot: the page will not leave "connecting" until a `ready`
338
- // arrives. Every other engine starts the first time a chunk of its language
339
- // runs (docs/host-plan.md §1) — an ipykernel nobody asked for is 1.3 seconds
340
- // and tens of megabytes spent on a document that may hold no Python at all.
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: "eager", available: true,
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 = detectRscript(settings.rscriptPreference());
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
 
@@ -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({ name: "r", label: "R", makeEngine: () => new REngine({ workerPath: rWorkerPath, mode: "batch" }) }),
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: () => {
@@ -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
- export function createPlane({ spike, env, audit }) {
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
- engine = new REngine({ workerPath: jobRun, env: { ...spec.env, BEATRINA_JOB_ID: id } });
446
- // The child is a BATCH R: `Rscript job-run.R <sentinel>`. It never reads
447
- // stdin, has no session identity, and stamps its own frame ids.
448
- engine.mode = "batch";
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}` });
@@ -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 || "" }, "LISTEN");
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.
Binary file
Binary file
@@ -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
+ }
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
- ok: true, worker: plane.alive, pending_open: state.pendingOpen != null,
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()).
@@ -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;