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.
Files changed (44) 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.0.html +1906 -0
  8. package/beatrina_V1.0.0.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/journal-store.mjs +19 -3
  19. package/host/latex-compile.mjs +673 -0
  20. package/host/main.mjs +58 -18
  21. package/host/planes/analyze.mjs +18 -2
  22. package/host/planes/choose.mjs +13 -2
  23. package/host/planes/expect-term.mjs +102 -0
  24. package/host/planes/jobs.mjs +38 -5
  25. package/host/planes/sessions.mjs +5 -1
  26. package/host/planes/terminal.mjs +23 -9
  27. package/host/pwa/icon-192.png +0 -0
  28. package/host/pwa/icon-512.png +0 -0
  29. package/host/quarto.mjs +118 -0
  30. package/host/server.mjs +83 -2
  31. package/host/worker-plane.mjs +7 -1
  32. package/kernel/check/acceptance.mjs +43 -12
  33. package/kernel/check/knit-file.mjs +300 -52
  34. package/kernel/check/session.mjs +19 -4
  35. package/kernel/kernel-version +1 -1
  36. package/kernel/knitr-run.R +8 -1
  37. package/kernel/mcp/beatrina-mcp.mjs +6 -5
  38. package/kernel/worker.R +21 -1
  39. package/lib/engine-labels.js +7 -1
  40. package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
  41. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  42. package/package.json +4 -3
  43. package/beatrina_V0.9.43.html +0 -1885
  44. 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
- 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;
@@ -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
- record("startup", `R ready (${ready.r || "?"}, worker mode ${ready.mode || "?"})`, "pass", "", session.startupMs, { r: ready.r, mode: ready.mode });
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 engines = Array.isArray(ready.engines) ? ready.engines : ["r"];
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 (want("run")) {
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 (want("stop")) {
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 (want("readline")) {
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 (want("busy")) {
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 (want("force")) {
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 (want("crash")) {
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 (want("memory")) {
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(ready.engine_status) ? ready.engine_status.find((e) => e.engine === engine) : null;
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);