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/engine-pool.mjs
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
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
|
|
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.
|
|
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)
|
|
337
|
-
|
|
338
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)));
|
package/host/journal-store.mjs
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
// the page's checkpoint codec wrote)
|
|
23
23
|
// checkpoint.rev the revision of that envelope
|
|
24
24
|
// pins/<rev>.json a named version, with its content
|
|
25
|
-
// meta.json display name, file binding, gaps, updated
|
|
25
|
+
// meta.json display name, file binding, gaps, updated,
|
|
26
|
+
// and `saved` — what the last Save wrote (N3)
|
|
26
27
|
// journal.corrupt-<time> bytes cut from a journal that did not end at
|
|
27
28
|
// a line boundary — kept as evidence
|
|
28
29
|
//
|
|
@@ -295,6 +296,10 @@ export function createJournalStore() {
|
|
|
295
296
|
|
|
296
297
|
// ── the ops ───────────────────────────────────────────────────────────────
|
|
297
298
|
|
|
299
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
300
|
+
const validSaved = (v) => v != null && typeof v === "object" && !Array.isArray(v)
|
|
301
|
+
&& typeof v.model === "string" && HEX64.test(v.model) && typeof v.file === "string" && HEX64.test(v.file);
|
|
302
|
+
|
|
298
303
|
const open = (id, key, meta, takeover) => {
|
|
299
304
|
if (!enabled) return err("journal_open", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1).");
|
|
300
305
|
if (!validKey(key)) return err("journal_open", id, "invalid_key", "A journal needs a durable document key.");
|
|
@@ -428,7 +433,7 @@ export function createJournalStore() {
|
|
|
428
433
|
return before - st.bytes;
|
|
429
434
|
};
|
|
430
435
|
|
|
431
|
-
const checkpoint = (id, key, revision, generation, envelope, meta, rebase, epoch) => {
|
|
436
|
+
const checkpoint = (id, key, revision, generation, envelope, meta, rebase, epoch, saved) => {
|
|
432
437
|
if (!enabled) return err("journal_checkpoint", id, "unavailable", "The document journal is disabled.");
|
|
433
438
|
if (!validKey(key)) return err("journal_checkpoint", id, "invalid_key", "A journal needs a durable document key.");
|
|
434
439
|
const refused = leaseRefusal("journal_checkpoint", id, dirOf(key), epoch);
|
|
@@ -437,6 +442,9 @@ export function createJournalStore() {
|
|
|
437
442
|
|| typeof envelope !== "string" || !envelope.length) {
|
|
438
443
|
return err("journal_checkpoint", id, "invalid_checkpoint", "A checkpoint needs a revision and its envelope.");
|
|
439
444
|
}
|
|
445
|
+
// Refused before anything is written, so a bad marker never leaves a
|
|
446
|
+
// checkpoint on disk that the store's state does not know about.
|
|
447
|
+
if (saved != null && !validSaved(saved)) return err("journal_checkpoint", id, "invalid_saved", "A save marker needs two SHA-256 digests.");
|
|
440
448
|
let st;
|
|
441
449
|
try { st = stateOf(key); } catch (e) { return err("journal_checkpoint", id, "io", e.message); }
|
|
442
450
|
const rev = Math.trunc(revision);
|
|
@@ -464,6 +472,13 @@ export function createJournalStore() {
|
|
|
464
472
|
stored.gaps = [...(stored.gaps ?? []), gap];
|
|
465
473
|
}
|
|
466
474
|
if (meta && typeof meta === "object" && Object.keys(meta).length) stored.display = meta;
|
|
475
|
+
// N3: a Save rides on a checkpoint and says what it wrote — two digests,
|
|
476
|
+
// the document's content and the file's bytes. It is kept apart from
|
|
477
|
+
// `display` because the next opening's journal_open replaces `display`
|
|
478
|
+
// before it loads, and this is exactly what that opening needs to read.
|
|
479
|
+
// Content-addressed, so it is never cleared: a later edit simply stops it
|
|
480
|
+
// matching. Anything malformed is refused rather than stored half-read.
|
|
481
|
+
if (saved != null) stored.saved = { model: saved.model, file: saved.file, at: Date.now() };
|
|
467
482
|
try { writeMeta(st.dir, stored); } catch { /* the checkpoint still stands */ }
|
|
468
483
|
st.checkpoint = rev;
|
|
469
484
|
st.last = rev;
|
|
@@ -501,6 +516,7 @@ export function createJournalStore() {
|
|
|
501
516
|
return { type: "journal_load", id, key, aliases: meta.aliases ?? [],
|
|
502
517
|
checkpointRevision: st.checkpoint, durableRevision: st.last,
|
|
503
518
|
more, repaired: st.repaired === true, gaps: meta.gaps ?? [], pins: pinsOf(st.dir),
|
|
519
|
+
saved: validSaved(meta.saved) ? meta.saved : null,
|
|
504
520
|
__raw: { records, checkpoint: envelope } };
|
|
505
521
|
};
|
|
506
522
|
|
|
@@ -691,7 +707,7 @@ export function createJournalStore() {
|
|
|
691
707
|
case "journal_probe": return probe(cmd.id, cmd.key);
|
|
692
708
|
case "journal_append": return append(cmd.id, cmd.key, cmd.records, cmd.epoch);
|
|
693
709
|
case "journal_checkpoint": return checkpoint(cmd.id, cmd.key, cmd.revision, cmd.generation, cmd.envelope,
|
|
694
|
-
cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.rebase === true, cmd.epoch);
|
|
710
|
+
cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.rebase === true, cmd.epoch, cmd.saved ?? null);
|
|
695
711
|
case "journal_archive": return archive(cmd.id, cmd.key);
|
|
696
712
|
case "journal_rename": return rename(cmd.id, cmd.from, cmd.to);
|
|
697
713
|
case "journal_load": return load(cmd.id, cmd.key, cmd.afterRevision);
|