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
@@ -23,11 +23,28 @@
23
23
  // §6.9 An absent engine is a STATED FACT: a chunk whose engine has no
24
24
  // runtime ends with a done frame that says so, never a hang.
25
25
  //
26
- // ON DEMAND, EXCEPT R. R starts at boot because the page will not leave
27
- // "connecting" until a `ready` arrives and because every existing suite
28
- // expects a session to be there. Every other engine starts the first time a
29
- // chunk of its language runs — an ipykernel that nobody asked for is 1.3
30
- // seconds and ~60 MB spent on a document that may hold no Python at all.
26
+ // THREE BOOT MODES (plans/getting_rid_of_R.md K2, K2a). The HOST is what a
27
+ // page waits for — `host-ready`, sent by host/server.mjs on every socket — so
28
+ // no engine is on the start-up path any more:
29
+ // "eager" started and AWAITED by start(). Nothing ships this way now;
30
+ // it stays for a test that wants a worker before the port.
31
+ // "background" started by start() WITHOUT waiting, and only when found. R:
32
+ // optional is not late — R users should not pay R's start-up
33
+ // on their first chunk — but a missing or broken R must never
34
+ // hold the host, the document or a Save hostage.
35
+ // "on-demand" started the first time a chunk of its language runs — an
36
+ // ipykernel nobody asked for is 1.3 s and ~60 MB spent on a
37
+ // document that may hold no Python at all.
38
+ // An engine that has been started but has not said `ready` HOLDS what is sent
39
+ // to it (whenEngineReady): a page may now be connected before R is, and a
40
+ // chunk written to R's stdin before its ready would race "Restart, keep
41
+ // variables" (the restore is queued on ready).
42
+ //
43
+ // AVAILABILITY IS READ WHEN AN ENGINE IS ABOUT TO START (K2b), not frozen at
44
+ // boot: a spec may carry `detect()`, re-run by `refresh()` before every start,
45
+ // so installing R or changing `rscript_path` takes effect without restarting
46
+ // the host. The "primary" below is the ROUTING DEFAULT for a request that names
47
+ // no engine (every existing page sends R's that way) — never readiness.
31
48
 
32
49
  import { EventEmitter } from "node:events";
33
50
  import { WorkerPlane } from "./worker-plane.mjs";
@@ -66,20 +83,58 @@ export class EnginePool extends EventEmitter {
66
83
  this.planes = new Map();
67
84
  this.starting = new Map(); // engine → Promise, so two chunks start one engine
68
85
  this.sockets = () => [];
86
+ this.deadlineScale = deadlineScale;
87
+ this.respawnMax = respawnMax;
69
88
  this.primaryName = specs[0] ? specs[0].name : "r";
70
- for (const spec of specs) {
71
- if (!spec.available) continue;
72
- const plane = new WorkerPlane({
73
- engineFactory: spec.factory, name: spec.name, label: spec.label,
74
- audit: (event, fields = {}) => audit(event, { engine: spec.name, ...fields }),
75
- deadlineScale, respawnMax,
76
- decorateReady: (frame) => this.onReady(spec, frame),
77
- });
78
- plane.sockets = () => this.sockets();
79
- plane.on("broadcast", (payload) => this.emit("broadcast", payload));
80
- plane.on("broadcast-pages", (payload) => this.emit("broadcast-pages", payload));
81
- this.planes.set(spec.name, plane);
89
+ for (const spec of specs) if (spec.available) this.makePlane(spec);
90
+ }
91
+
92
+ /** The WorkerPlane for one spec, created once — at construction, or when a
93
+ * refresh() finds a runtime that was absent at boot. */
94
+ makePlane(spec) {
95
+ if (this.planes.has(spec.name)) return this.planes.get(spec.name);
96
+ const plane = new WorkerPlane({
97
+ engineFactory: spec.factory, name: spec.name, label: spec.label,
98
+ audit: (event, fields = {}) => this.audit(event, { engine: spec.name, ...fields }),
99
+ deadlineScale: this.deadlineScale, respawnMax: this.respawnMax,
100
+ decorateReady: (frame) => this.onReady(spec, frame),
101
+ });
102
+ plane.sockets = () => this.sockets();
103
+ plane.on("broadcast", (payload) => {
104
+ this.emit("broadcast", payload);
105
+ // A death, a restart or a refusal changes this engine's row; the host
106
+ // re-announces the table so a page's greyed chunks follow the fact.
107
+ if (typeof payload === "string" && /"type":"worker-(died|restarting)"/.test(payload)) this.emit("engines-changed");
108
+ });
109
+ plane.on("broadcast-pages", (payload) => this.emit("broadcast-pages", payload));
110
+ plane.on("ready", () => { spec.startError = ""; this.emit("engines-changed"); });
111
+ this.planes.set(spec.name, plane);
112
+ return plane;
113
+ }
114
+
115
+ /**
116
+ * Re-read one engine's availability (K2b). Only a spec that carries
117
+ * `detect()` changes; the others are what boot discovered. A runtime that
118
+ * disappears under a RUNNING engine is not acted on here — the process is
119
+ * already running and keeps its session until it is restarted.
120
+ * @returns {object|null} the spec
121
+ */
122
+ refresh(name) {
123
+ const spec = this.specs.find((s) => s.name === engineKey(name, this.primaryName));
124
+ if (!spec || typeof spec.detect !== "function") return spec || null;
125
+ let found;
126
+ try { found = spec.detect() || {}; } catch (e) { found = { available: false, detail: e.message }; }
127
+ const was = Boolean(spec.available);
128
+ spec.available = Boolean(found.available);
129
+ spec.detail = found.detail || "";
130
+ if (found.version != null) spec.version = found.version;
131
+ if (found.runtime != null) spec.runtime = found.runtime;
132
+ if (spec.available) this.makePlane(spec);
133
+ if (was !== spec.available) {
134
+ this.audit("engine-discovery", { engine: spec.name, available: spec.available, detail: spec.detail });
135
+ this.emit("engines-changed");
82
136
  }
137
+ return spec;
83
138
  }
84
139
 
85
140
  /** The plane for an engine, or null when this host has no such engine. */
@@ -104,6 +159,11 @@ export class EnginePool extends EventEmitter {
104
159
  engine: spec.name, label: spec.label, available: Boolean(spec.available),
105
160
  detail: spec.detail || "", runtime: spec.runtime || null,
106
161
  started: Boolean(plane && plane.engine), alive: Boolean(plane && plane.alive),
162
+ // Has it said `ready`? `alive` is only "the process exists".
163
+ ready: Boolean(plane && plane.engine && plane.hello),
164
+ boot: spec.boot || "eager",
165
+ // Why a background start failed, until the engine next says ready.
166
+ error: spec.startError || (plane && plane.gaveUp ? "stopped" : ""),
107
167
  pid: plane && plane.engine ? plane.pid : null,
108
168
  version: spec.version || "",
109
169
  };
@@ -143,9 +203,60 @@ export class EnginePool extends EventEmitter {
143
203
  for (const p of this.planes.values()) { if (p.engine && p.alive) { try { p.engine.kill(); } catch { /* gone */ } } }
144
204
  }
145
205
 
206
+ /**
207
+ * Start what boots with the host. "eager" is awaited (and its failure is the
208
+ * caller's); "background" is kicked and NOT awaited — the returned promise
209
+ * of each is kept on `this.background` for a test to wait on. A background
210
+ * engine that will not start is recorded on its row and announced as a
211
+ * terminal worker-died, never thrown: the host serves without it.
212
+ */
146
213
  async start() {
147
- const eager = this.specs.filter((s) => s.available && s.boot !== "on-demand");
214
+ const eager = this.specs.filter((s) => s.available && (s.boot || "eager") === "eager");
148
215
  for (const spec of eager) await this.planes.get(spec.name).start();
216
+ this.background = this.specs.filter((s) => s.boot === "background").map((spec) => this.startBackground(spec));
217
+ }
218
+
219
+ async startBackground(spec) {
220
+ this.refresh(spec.name);
221
+ if (!spec.available) return { ok: false, message: spec.detail || "" };
222
+ const r = await this.ensure(spec.name);
223
+ if (!r.ok) {
224
+ // The bare cause: the page words the sentence (lib/host-words.js).
225
+ const cause = r.message.replace(/^The \S+ engine would not start: /, "");
226
+ spec.startError = cause;
227
+ const plane = this.planes.get(spec.name);
228
+ // The page is told the way a failed Restart tells it (WorkerPlane.refuseStart).
229
+ if (plane && !plane.notice) plane.refuseStart(new Error(cause));
230
+ this.emit("engines-changed");
231
+ }
232
+ return r;
233
+ }
234
+
235
+ /**
236
+ * Resolve once an engine that has been STARTED has said `ready`, or has
237
+ * given up (a terminal worker-died), whichever is first. An engine that
238
+ * already said ready resolves at once. Commands for it wait on this, so no
239
+ * write can overtake its ready — and the restore that ready queues.
240
+ */
241
+ whenEngineReady(key) {
242
+ const plane = this.planes.get(key);
243
+ if (!plane || !plane.engine || plane.hello || plane.gaveUp) return Promise.resolve();
244
+ if (!this.readyWaits) this.readyWaits = new Map();
245
+ if (!this.readyWaits.has(key)) {
246
+ this.readyWaits.set(key, new Promise((resolve) => {
247
+ const done = () => {
248
+ clearInterval(poll);
249
+ plane.off("ready", done);
250
+ this.readyWaits.delete(key);
251
+ resolve();
252
+ };
253
+ plane.on("ready", done);
254
+ // A give-up is a flag, not an event; the plane's own sweep is 100 ms.
255
+ const poll = setInterval(() => { if (plane.gaveUp || !plane.engine) done(); }, 100);
256
+ poll.unref?.();
257
+ }));
258
+ }
259
+ return this.readyWaits.get(key);
149
260
  }
150
261
 
151
262
  /**
@@ -154,6 +265,8 @@ export class EnginePool extends EventEmitter {
154
265
  */
155
266
  async ensure(name) {
156
267
  const key = engineKey(name, this.primaryName);
268
+ // Availability is read NOW (K2b), before anything is started.
269
+ if (!this.planes.get(key)?.engine) this.refresh(key);
157
270
  const spec = this.specs.find((s) => s.name === key);
158
271
  if (!spec) {
159
272
  return { ok: false, message: `Beatrina has no ${key} engine; this chunk was not run.` };
@@ -163,7 +276,7 @@ export class EnginePool extends EventEmitter {
163
276
  return { ok: false, message: `${spec.label} is not installed or was not found; this chunk was not run. `
164
277
  + `${spec.detail || ""} Doctor lists what was searched.`.replace(/\s+/g, " ").trim() };
165
278
  }
166
- const plane = this.planes.get(key);
279
+ const plane = this.makePlane(spec);
167
280
  if (plane.engine) return { ok: true, plane };
168
281
  if (!this.starting.has(key)) {
169
282
  this.audit("engine-start", { engine: key });
@@ -250,14 +363,15 @@ export class EnginePool extends EventEmitter {
250
363
  if (!scalarChr(cmd.id) || typeof cmd.source !== "string") return undefined;
251
364
  const key = engineKey(cmd.engine, this.primaryName);
252
365
  const plane = this.planes.get(key);
253
- if (plane && plane.engine) return plane.exec(cmd, rec);
366
+ if (plane && plane.engine && plane.hello) return plane.exec(cmd, rec);
254
367
  // An engine that is not up yet: start it, THEN dispatch. The page sees a
255
368
  // slower first chunk and nothing else — no queue of its own, no state here.
256
- this.ensure(key).then((r) => {
369
+ this.ensure(key).then(async (r) => {
257
370
  if (!r.ok) {
258
371
  this.audit("engine-absent", { engine: key });
259
372
  return rec.ws.send(enc({ type: "done", id: cmd.id, status: "error", message: r.message }));
260
373
  }
374
+ await this.whenEngineReady(key);
261
375
  return r.plane.exec(cmd, rec);
262
376
  }).catch((e) => {
263
377
  rec.ws.send(enc({ type: "done", id: cmd.id, status: "error", message: e.message }));
@@ -279,7 +393,16 @@ export class EnginePool extends EventEmitter {
279
393
  if (cmd.type === "debug_breaks") this.noteBreaks(cmd);
280
394
  const key = engineKey(cmd.engine, this.primaryName);
281
395
  const plane = this.planes.get(key);
282
- if (plane && plane.engine) return plane.request(cmd, rec);
396
+ if (plane && plane.engine && plane.hello) return plane.request(cmd, rec);
397
+ // Started, not yet ready (a background R still booting): held, not refused.
398
+ if (plane && plane.engine && !plane.gaveUp) {
399
+ this.whenEngineReady(key).then(() => {
400
+ if (plane.engine && plane.hello) return plane.request(cmd, rec);
401
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type: cmd.type, id: cmd.id, error: `${plane.label} did not start.`, code: "no-engine" }));
402
+ return undefined;
403
+ });
404
+ return undefined;
405
+ }
283
406
  if (!scalarChr(cmd.id)) return undefined;
284
407
  const spec = this.specs.find((s) => s.name === key);
285
408
  const why = !spec ? `Beatrina has no ${key} engine.`
@@ -333,11 +456,15 @@ export class EnginePool extends EventEmitter {
333
456
  async restart(engine, opts = {}) {
334
457
  const key = engineKey(engine, this.primaryName);
335
458
  const plane = this.planes.get(key);
336
- if (!plane) return undefined;
337
- if (!plane.engine) {
338
- // Restarting an engine that never started is "start it", not an error.
459
+ if (!plane || !plane.engine) {
460
+ // Restarting an engine that never started is "start it", not an error —
461
+ // and ensure() re-reads availability, so "Restart R" after installing R
462
+ // is how a running host picks it up (K2b).
339
463
  const r = await this.ensure(key);
340
- return r.ok ? undefined : undefined;
464
+ // A refusal is re-announced as the engines table, which is what a page
465
+ // reads to say why (host-ready's engine_status row for this engine).
466
+ if (!r.ok) this.emit("engines-changed");
467
+ return undefined;
341
468
  }
342
469
  return plane.restart(key === this.primaryName ? opts : {});
343
470
  }
package/host/engine-r.mjs CHANGED
@@ -60,7 +60,14 @@ export function detectRscript(explicit = readEnv(process.env, "RSCRIPT"), platfo
60
60
  for (const cand of KNOWN_RSCRIPT) if (fs.existsSync(cand)) return cand;
61
61
  const versions = "/Library/Frameworks/R.framework/Versions";
62
62
  if (fs.existsSync(versions)) {
63
- const hits = fs.readdirSync(versions)
63
+ // A framework folder that EXISTS but cannot be listed (permissions, a
64
+ // half-removed install, a sandbox) is an R this process cannot run — "not
65
+ // found", said by the caller. It threw here, and at start that is a host
66
+ // that will not come up over an R it did not need (found 2026-09-23 by
67
+ // running the no-R suites with R's files unreadable).
68
+ let names = [];
69
+ try { names = fs.readdirSync(versions); } catch { names = []; }
70
+ const hits = names
64
71
  .map((v) => path.join(versions, v, "Resources/bin/Rscript"))
65
72
  .filter((p) => fs.existsSync(p)).sort().reverse();
66
73
  if (hits.length) return hits[0];
@@ -0,0 +1,271 @@
1
+ // document-child.mjs — the `render`, `typst` and `latex` jobs with no R
2
+ // (plans/getting_rid_of_R.md Stage 3, K7).
3
+ //
4
+ // The R child (spike/job-run.R) renders through rmarkdown or Quarto and needs R
5
+ // to start at all, so a prose-only document could not be rendered on a machine
6
+ // without R. This child speaks the SAME framed wire — a sentinel, then one
7
+ // JSON object with the job's own id stamped (the frame-fidelity rule: the plane
8
+ // forwards these bytes, never re-encodes them) — and the same events:
9
+ // `started`, `output`, `done`. Everything Quarto prints goes to stdout as
10
+ // ordinary output, which the plane streams into the job's log.
11
+ //
12
+ // It renders with QUARTO only: rmarkdown is an R package. Before rendering it
13
+ // asks Quarto which engine the document binds to, and a document whose R
14
+ // chunks need knitr is refused in words rather than failed half-way.
15
+ //
16
+ // `typst` is spike/typst.R's typst_compile(), ported rule for rule: typst from
17
+ // BEATRINA_TYPST, PATH, or Quarto's bundled copy (`quarto typst`); `--root`
18
+ // one level up for a file inside `.beatrina-preview`; a PDF beside the source,
19
+ // or one SVG per page (`<stem>-N.svg`, the old ones deleted first) for the
20
+ // live preview; the first `error:` and its `file.typ:L:C` said as one sentence.
21
+ //
22
+ // `latex` is spike/latex.R's latex_compile(), ported to host/latex-compile.mjs
23
+ // and driven from here exactly as job-run.R drives the R one: the options are
24
+ // re-validated by the plane's own jobSpec() before anything starts (this file
25
+ // is runnable by hand, and a task that trusted its environment would be a very
26
+ // quiet way to run the wrong thing), and the compile's source identity
27
+ // (BEATRINA_LATEX_IDENTITY: uri, version, generation) is stamped on EVERY
28
+ // frame from `started` to `done`, after the job id — job-run.R's
29
+ // `compile_identity`, which is how the page drops a reply for an older edit.
30
+ //
31
+ // Inputs arrive in the ENVIRONMENT, never argv (jobs.mjs: the wire never
32
+ // carries code): BEATRINA_JOB_ID / _TASK / _TARGET / _FORMAT, BEATRINA_SENTINEL,
33
+ // BEATRINA_JOB_R_AVAILABLE ("1" when this session has R), and for `latex`
34
+ // BEATRINA_LATEX_ENGINE / _TIMEOUT / _FETCH / _IDENTITY (jobs.mjs latexOptions).
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { spawn } from "node:child_process";
39
+ import { readEnv } from "../env-names.mjs";
40
+ import { documentWorkdir, findQuarto, missingForRender, quartoEngines } from "../quarto.mjs";
41
+ import { latexCompile, LATEX_TIMEOUT } from "../latex-compile.mjs";
42
+ import { jobSpec } from "../planes/jobs.mjs";
43
+ import { descendantsOf } from "../planes/proc-tree.mjs";
44
+
45
+ const SENTINEL = readEnv(process.env, "SENTINEL");
46
+ const id = readEnv(process.env, "JOB_ID") || "job";
47
+ const task = readEnv(process.env, "JOB_TASK");
48
+ const target = readEnv(process.env, "JOB_TARGET");
49
+ const format = readEnv(process.env, "JOB_FORMAT");
50
+ const haveR = readEnv(process.env, "JOB_R_AVAILABLE") === "1";
51
+ const started = Date.now();
52
+ /** A LaTeX compile's source identity, stamped on every frame once validated. */
53
+ let compileIdentity = null;
54
+
55
+ /** One control frame: the sentinel, then compact JSON with this job's id first. */
56
+ const emit = (obj) => {
57
+ const { type, ...rest } = obj;
58
+ process.stdout.write(`${SENTINEL}${JSON.stringify({ type, id, ...(compileIdentity || {}), ...rest })}\n`);
59
+ };
60
+ const done = (ok, error = "") => {
61
+ emit({ type: "job", event: "done", ok, error, elapsed: Math.round((Date.now() - started) / 10) / 100 });
62
+ process.exitCode = ok ? 0 : 1;
63
+ };
64
+
65
+ async function main() {
66
+ // As job-run.R: a LaTeX job's options are checked BEFORE `started`, and a
67
+ // refusal is a lone `done` (exit 2), so no frame carries an unvalidated identity.
68
+ const latex = task === "latex" ? prepareLatex() : null;
69
+ if (task === "latex" && !latex) return;
70
+ emit({ type: "job", event: "started", task, root: path.dirname(target), pid: process.pid });
71
+ if (!target || !fs.existsSync(target)) return done(false, `'${target}' is not a file`);
72
+ if (task === "typst") return typst();
73
+ if (task === "latex") return compileLatex(latex);
74
+ if (task !== "render") return done(false, `this child renders and typesets documents; it was asked for '${task}'`);
75
+
76
+ const bin = findQuarto();
77
+ if (!bin) {
78
+ return done(false, "Quarto is not installed, or Beatrina could not find it. Install Quarto from https://quarto.org, "
79
+ + "or set BEATRINA_QUARTO to the binary. Your document is unchanged.");
80
+ }
81
+ const { engines, detail } = quartoEngines(bin, target);
82
+ const missing = missingForRender(engines, { r: haveR });
83
+ if (missing) return done(false, missing);
84
+ if (!engines && detail) process.stdout.write(`(quarto inspect could not say which engine this document uses: ${detail})\n`);
85
+
86
+ let text = "";
87
+ try { text = fs.readFileSync(target, "utf8"); } catch { text = ""; }
88
+ const workdir = documentWorkdir(target, text);
89
+ if (workdir.declared && !fs.existsSync(workdir.path)) {
90
+ return done(false, `the document declares beatrina-workdir: ${workdir.declared}, but '${workdir.path}' is not a folder`);
91
+ }
92
+ const args = ["render", target];
93
+ if (format) args.push("--to", format);
94
+ // As job-run.R: PNG figures (no cairo needed) and the .typ kept for Typst.
95
+ if (format === "typst") args.push("-M", "fig-format:png", "-M", "keep-typ:true");
96
+ if (workdir.declared) args.push("--execute-dir", workdir.path);
97
+
98
+ const status = await runStreaming(bin, args, { cwd: path.dirname(target) });
99
+ const { lines } = status;
100
+ if (status.error) return done(false, `quarto could not be started: ${status.error}`);
101
+ if (status.code !== 0) return done(false, `quarto render exited ${status.signal || status.code}`);
102
+
103
+ // The output path is REPORTED by Quarto ("Output created: <path>", relative
104
+ // to the document's folder) and checked to exist — never guessed.
105
+ const hits = lines.map((l) => l.trim()).filter((l) => l.startsWith("Output created:"));
106
+ let produced = null;
107
+ if (hits.length) {
108
+ const reported = hits[hits.length - 1].replace(/^Output created:/, "").trim();
109
+ const candidate = path.isAbsolute(reported) ? reported : path.join(path.dirname(target), reported);
110
+ if (fs.existsSync(candidate)) produced = path.resolve(candidate);
111
+ }
112
+ emit({ type: "job", event: "output", renderer: "quarto", path: produced, format: format || null });
113
+ return done(true);
114
+ }
115
+
116
+ /** At most this many characters of a run's output are KEPT (all are streamed). */
117
+ const KEPT_CHARS = 1000000;
118
+
119
+ /**
120
+ * Run a program, streaming its output into the job log and keeping the lines.
121
+ *
122
+ * What is kept is bounded twice — 20,000 lines and, as job-run.R's
123
+ * run_streaming, 1,000,000 characters — and `truncated` says when either bound
124
+ * dropped something, because a list that silently stops looks exactly like a
125
+ * transcript that ended. (R cuts mid-line at the character bound; this drops
126
+ * whole lines from the head, so the first kept line is never a fragment.)
127
+ * A timeout kills the child's DESCENDANTS first, then the child — latexmk runs
128
+ * pdflatex and bibtex beneath it, and SIGTERM to latexmk alone left them
129
+ * running (job-run.R does the same through kernel_kill_tree).
130
+ *
131
+ * `merge` puts stderr on stdout's pipe, IN ORDER, as processx's `2>&1` does
132
+ * for job-run.R. Two pipes read separately interleave however the reads land,
133
+ * and for a compile the order is data: a `!` line and the `l.N` after it are
134
+ * one diagnostic only when they arrive adjacent. On a POSIX system the program
135
+ * is exec'd by `/bin/sh -c 'exec "$0" "$@" 2>&1'` with the binary and every
136
+ * argument as POSITIONAL parameters — the script is a constant, nothing from
137
+ * the job is ever parsed by the shell, and `exec` keeps the child's pid the
138
+ * engine's own. Windows has no such sh; there the two pipes are read as before.
139
+ */
140
+ function runStreaming(bin, args, { cwd, timeoutMs = 0, merge = false } = {}) {
141
+ return new Promise((resolve) => {
142
+ const lines = [];
143
+ let tail = "";
144
+ let timedOut = false;
145
+ let truncated = false;
146
+ let keptChars = 0;
147
+ const keep = (chunk) => {
148
+ const text = String(chunk);
149
+ process.stdout.write(text);
150
+ const parts = (tail + text).split("\n");
151
+ tail = parts.pop();
152
+ lines.push(...parts);
153
+ keptChars += parts.reduce((n, p) => n + p.length + 1, 0);
154
+ if (lines.length > 20000) {
155
+ lines.splice(0, lines.length - 20000).forEach((p) => { keptChars -= p.length + 1; });
156
+ truncated = true;
157
+ }
158
+ while (keptChars > KEPT_CHARS && lines.length) { keptChars -= lines.shift().length + 1; truncated = true; }
159
+ };
160
+ const [cmd, argv] = merge && process.platform !== "win32"
161
+ ? ["/bin/sh", ["-c", 'exec "$0" "$@" 2>&1', bin, ...args]]
162
+ : [bin, args];
163
+ const child = spawn(cmd, argv, { cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
164
+ const timer = timeoutMs > 0 ? setTimeout(() => {
165
+ timedOut = true;
166
+ for (const pid of descendantsOf(child.pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
167
+ try { child.kill("SIGTERM"); } catch { /* gone */ }
168
+ }, timeoutMs) : null;
169
+ child.stdout.on("data", keep);
170
+ child.stderr.on("data", keep);
171
+ for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => { try { child.kill(sig); } catch { /* gone */ } });
172
+ child.on("error", (e) => { if (timer) clearTimeout(timer); resolve({ code: null, error: e.message, lines, timedOut, truncated }); });
173
+ child.on("close", (code, signal) => { if (timer) clearTimeout(timer); if (tail) lines.push(tail); resolve({ code, signal, lines, timedOut, truncated }); });
174
+ });
175
+ }
176
+
177
+ /** The typst to run: [binary, prefix-args], or null. */
178
+ function typstCommand() {
179
+ const explicit = readEnv(process.env, "TYPST");
180
+ if (explicit && fs.existsSync(explicit)) return [explicit, []];
181
+ const exe = process.platform === "win32" ? "typst.exe" : "typst";
182
+ for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
183
+ if (dir && fs.existsSync(path.join(dir, exe))) return [path.join(dir, exe), []];
184
+ }
185
+ const quarto = findQuarto();
186
+ return quarto ? [quarto, ["typst"]] : null;
187
+ }
188
+
189
+ async function typst() {
190
+ const found = typstCommand();
191
+ if (!found) return done(false, "Typst was not found. It comes with Quarto — install Quarto, or set BEATRINA_TYPST to a typst binary.");
192
+ const [bin, prefix] = found;
193
+ const timeoutS = Number(readEnv(process.env, "TYPST_TIMEOUT")) > 0 ? Number(readEnv(process.env, "TYPST_TIMEOUT")) : 120;
194
+ const pages = readEnv(process.env, "TYPST_PAGES") === "svg";
195
+ const stem = path.basename(target).replace(/\.[^.]+$/, "");
196
+ const here = path.dirname(target);
197
+ const root = path.basename(here) === ".beatrina-preview" ? path.dirname(here) : here;
198
+ const pdf = path.join(here, `${stem}.pdf`);
199
+ const pageFiles = () => fs.readdirSync(here).filter((n) => n.startsWith(`${stem}-`) && /^[0-9]+\.svg$/.test(n.slice(stem.length + 1)))
200
+ .sort().map((n) => path.join(here, n));
201
+ if (pages) pageFiles().forEach((f) => { try { fs.unlinkSync(f); } catch { /* gone */ } });
202
+ const args = pages
203
+ ? [...prefix, "compile", "--root", root, "--format", "svg", target, path.join(here, `${stem}-{p}.svg`)]
204
+ : [...prefix, "compile", "--root", root, target, pdf];
205
+ const r = await runStreaming(bin, args, { cwd: here, timeoutMs: timeoutS * 1000 });
206
+ if (r.timedOut) return done(false, `The Typst compile did not finish within ${timeoutS} seconds.`);
207
+ if (r.error) return done(false, `typst could not be started: ${r.error}`);
208
+ if (r.code !== 0) {
209
+ const at = r.lines.findIndex((l) => /^error:/.test(l));
210
+ let detail = "";
211
+ if (at >= 0) {
212
+ const span = r.lines.slice(at, at + 6);
213
+ // eslint-disable-next-line no-control-regex
214
+ const where = span.find((l) => /\.typ:[0-9]+:[0-9]+/.test(l));
215
+ detail = [span[0].replace(/^error:\s*/, ""), where ? where.replace(/[\u2500-\u257F]|[\x00-\x1f]/g, "").trim() : ""].filter(Boolean).join(" — ");
216
+ }
217
+ return done(false, detail ? `Typst: ${detail.slice(0, 400)}` : "Typst reported an error; see the log in Jobs.");
218
+ }
219
+ if (pages) {
220
+ const made = pageFiles();
221
+ if (!made.length) return done(false, "Typst compiled the document but wrote no pages.");
222
+ emit({ type: "job", event: "output", renderer: "typst", path: null, format: "svg", pages: made.length });
223
+ return done(true);
224
+ }
225
+ emit({ type: "job", event: "output", renderer: "typst", path: fs.existsSync(pdf) ? path.resolve(pdf) : null });
226
+ return done(true);
227
+ }
228
+
229
+ /**
230
+ * Validate a LaTeX job the way job-run.R does — the plane's own jobSpec(), so
231
+ * the two cannot disagree about what is runnable — and adopt its identity.
232
+ * @returns {{engine: string, timeout: number, fetch: boolean}|null} null after a refusal
233
+ */
234
+ function prepareLatex() {
235
+ try {
236
+ const identityText = readEnv(process.env, "LATEX_IDENTITY");
237
+ const identity = identityText ? JSON.parse(identityText) : null;
238
+ const timeoutText = readEnv(process.env, "LATEX_TIMEOUT", String(LATEX_TIMEOUT));
239
+ const spec = jobSpec("latex", target, {
240
+ engine: readEnv(process.env, "LATEX_ENGINE", "auto"),
241
+ // as.numeric(): "" and junk are NA, which the validator refuses.
242
+ timeout: timeoutText.trim() === "" ? NaN : Number(timeoutText),
243
+ fetch: readEnv(process.env, "LATEX_FETCH") === "1",
244
+ identity,
245
+ });
246
+ if (!spec.ok) throw new Error(spec.error);
247
+ compileIdentity = identity ? { uri: identity.uri, version: identity.version, generation: identity.generation } : null;
248
+ return {
249
+ engine: spec.env.BEATRINA_LATEX_ENGINE,
250
+ timeout: Number(spec.env.BEATRINA_LATEX_TIMEOUT),
251
+ fetch: spec.env.BEATRINA_LATEX_FETCH === "1",
252
+ };
253
+ } catch (e) {
254
+ done(false, e && e.message ? e.message : String(e));
255
+ process.exitCode = 2;
256
+ return null;
257
+ }
258
+ }
259
+
260
+ async function compileLatex({ engine, timeout, fetch }) {
261
+ try {
262
+ await latexCompile({ target, engine, timeout, fetch, emit,
263
+ runStreaming: (bin, args, opts) => runStreaming(bin, args, { ...opts, merge: true }) });
264
+ } catch (e) {
265
+ // Reported, never swallowed: the message is the job's result.
266
+ return done(false, e && e.message ? e.message : String(e));
267
+ }
268
+ return done(true);
269
+ }
270
+
271
+ main().catch((e) => done(false, e && e.message ? e.message : String(e)));