beatrina 0.8.7 → 0.9.43
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/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
|
@@ -10,15 +10,16 @@ import net from "node:net";
|
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { mirrorEnv, setEnvName } from "../../host/env-names.mjs";
|
|
13
14
|
|
|
14
15
|
export const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
15
16
|
export const WIN = process.platform === "win32";
|
|
16
17
|
export const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
18
|
|
|
18
|
-
/** The installed
|
|
19
|
+
/** The installed R package's host/main.mjs (the package is still named `beatrina`), asked of R itself. */
|
|
19
20
|
export function installedHost() {
|
|
20
21
|
const r = spawnSync(WIN ? "Rscript.exe" : "Rscript",
|
|
21
|
-
["-e", "cat(system.file('app', 'host', 'main.mjs', package = '
|
|
22
|
+
["-e", "cat(system.file('app', 'host', 'main.mjs', package = 'beatrina'))"], { encoding: "utf8" });
|
|
22
23
|
return String(r.stdout || "").trim();
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -47,8 +48,11 @@ export function supervisorCommand({ supervisor = "beatrix", host = "", installed
|
|
|
47
48
|
fs.copyFileSync(path.join(repo, "kernel.protocol"), path.join(stage, "kernel-protocol"));
|
|
48
49
|
const version = String(spawnSync(process.execPath, ["-p", "require(process.argv[1]).version", path.join(repo, "notebook.version.cjs")], { encoding: "utf8" }).stdout).trim();
|
|
49
50
|
fs.writeFileSync(path.join(stage, "kernel-version"), `${version}\n`);
|
|
50
|
-
|
|
51
|
+
const checked = spawnSync(process.execPath, [path.join(repo, "tools/artifact-inputs.mjs")], {encoding:"utf8"});
|
|
52
|
+
if (checked.status !== 0) throw new Error(`Cannot stage stale notebook artifacts: ${checked.stderr || checked.error || "build verification failed"}`);
|
|
53
|
+
for (const f of [`beatrina_V${version}.html`, `beatrina_V${version}.beta.min.html`]) {
|
|
51
54
|
fs.copyFileSync(path.join(repo, "dist", f), path.join(stage, f));
|
|
55
|
+
fs.copyFileSync(path.join(repo, "dist", f + ".inputs.json"), path.join(stage, f + ".inputs.json"));
|
|
52
56
|
}
|
|
53
57
|
return { label: "R supervisor (serve.R, retired)", cmd: WIN ? "Rscript.exe" : "Rscript", args: [path.join(stage, "serve.R")] };
|
|
54
58
|
}
|
|
@@ -120,20 +124,57 @@ export function openSocket(port, origin) {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A port nothing is listening on, from the OS (listen on 0, read, close).
|
|
129
|
+
* `beatrina knit` without --port used to hand startSession an empty port, and
|
|
130
|
+
* the page socket then refused to connect to it — every knit that ran a chunk
|
|
131
|
+
* died before the first one (2026-09-19).
|
|
132
|
+
*/
|
|
133
|
+
export function freePort() {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const srv = net.createServer();
|
|
136
|
+
srv.unref();
|
|
137
|
+
srv.on("error", reject);
|
|
138
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
139
|
+
const { port } = srv.address();
|
|
140
|
+
srv.close(() => resolve(port));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
123
145
|
/**
|
|
124
146
|
* Start a supervisor on `port`, open a page socket, and wait for R's ready.
|
|
125
147
|
* @returns {Promise<object>} the session: frames, until, text, run, ask, ready, startupMs, log, shutdown
|
|
126
148
|
*/
|
|
127
149
|
export async function startSession({ command, port, cwd, env = {}, readyMs = 90000, logFile = "" }) {
|
|
128
150
|
const t0 = Date.now();
|
|
151
|
+
// A caller's override may be spelled BEATRINA_* or CARMAR_*; it is applied
|
|
152
|
+
// under BOTH names so it beats the defaults below whichever spelling it used.
|
|
153
|
+
const overrides = {};
|
|
154
|
+
for (const [k, v] of Object.entries(env)) setEnvName(overrides, k, v);
|
|
129
155
|
const child = spawn(command.cmd, command.args, {
|
|
130
156
|
cwd,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
157
|
+
// Canonical names, mirrored: a host older than the rename reads CARMAR_*.
|
|
158
|
+
env: mirrorEnv({
|
|
159
|
+
...process.env, BEATRINA_PORT: String(port), BEATRINA_NO_NATIVE_DIALOG: "1", BEATRINA_NO_RUNTIME_FILE: "1",
|
|
160
|
+
BEATRINA_HISTORY_DIR: path.join(cwd, ".beatrina-history"), BEATRINA_NO_UPDATE_CHECK: "1",
|
|
161
|
+
// A PRIVATE SESSION MUST BE ABLE TO REAP ITSELF.
|
|
162
|
+
//
|
|
163
|
+
// This pair — LINGER=0 (never idle-reap) with NO_RUNTIME_FILE=1 (write
|
|
164
|
+
// no discovery record) — makes a kernel that nothing can find and
|
|
165
|
+
// nothing will ever stop. If the parent dies between spawn and
|
|
166
|
+
// shutdown, it serves forever: the orphan measured on port 4748 (pid
|
|
167
|
+
// 64335, started 2026-09-19) is exactly this, and it is holding a port
|
|
168
|
+
// in the owner's own session band.
|
|
169
|
+
//
|
|
170
|
+
// A POSITIVE linger is the documented opt-in to the idle clock, and a
|
|
171
|
+
// RUNNING TASK BLOCKS IT (spike/settings.R), so a long knit or check is
|
|
172
|
+
// not at risk — only an abandoned one, which is the case we want
|
|
173
|
+
// collected. The caller's `overrides` still win, spelled either way.
|
|
174
|
+
BEATRINA_LINGER: "600",
|
|
134
175
|
BEATRIX_NO_OPEN: "1", // the `beatrix` command opens a browser unless told not to
|
|
135
|
-
...
|
|
136
|
-
},
|
|
176
|
+
...overrides,
|
|
177
|
+
}),
|
|
137
178
|
stdio: ["ignore", "pipe", "pipe"],
|
|
138
179
|
windowsHide: true,
|
|
139
180
|
});
|
package/kernel/deployment.R
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# deployment.R — where this kernel listens, and who is allowed to reach it.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
3
|
+
# Beatrina was written for exactly one deployment: a loopback socket serving one
|
|
4
4
|
# person on their own machine. Every trust rule in serve.R is an expression of
|
|
5
5
|
# that, and one of them INVERTS the moment the socket leaves loopback:
|
|
6
6
|
#
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
# The refusals are the point. Binding off loopback is a deployment decision
|
|
25
25
|
# with consequences the operator must state out loud:
|
|
26
26
|
#
|
|
27
|
-
# · no
|
|
27
|
+
# · no BEATRINA_HOSTS → refuse. An unset Host allow-list off
|
|
28
28
|
# loopback is a DNS-rebinding hole with the
|
|
29
29
|
# door held open.
|
|
30
|
-
# · no authentication in front → refuse, unless
|
|
30
|
+
# · no authentication in front → refuse, unless BEATRINA_ALLOW_UNAUTHENTICATED
|
|
31
31
|
# is set. The variable is deliberately long
|
|
32
32
|
# and unpleasant to type; it is a decision,
|
|
33
33
|
# not a default.
|
|
34
34
|
#
|
|
35
|
-
# The intended P0 shape is one
|
|
35
|
+
# The intended P0 shape is one Beatrina per person, each as their own OS user on
|
|
36
36
|
# its own port, with TLS and login in a reverse proxy (Caddy + OIDC
|
|
37
37
|
# forward-auth). serve.R never sees a password; it trusts an identity header,
|
|
38
38
|
# and only from the proxy address. See docs/server.md.
|
|
@@ -65,8 +65,8 @@ is_loopback_bind <- function(bind) tolower(bind %||% "") %in% LOOPBACK_HOSTS
|
|
|
65
65
|
#' `user_header`, `proxy_addrs`, and `errors` — a character vector that is
|
|
66
66
|
#' EMPTY when the posture is safe to serve. A non-empty `errors` must abort
|
|
67
67
|
#' startup; it is never a warning.
|
|
68
|
-
|
|
69
|
-
bind <- trimws(env("
|
|
68
|
+
beatrina_deployment <- function(port, env = Sys.getenv) {
|
|
69
|
+
bind <- trimws(env("BEATRINA_BIND", "127.0.0.1"))
|
|
70
70
|
if (!nzchar(bind)) bind <- "127.0.0.1"
|
|
71
71
|
loopback <- is_loopback_bind(bind)
|
|
72
72
|
|
|
@@ -77,9 +77,9 @@ carmar_deployment <- function(port, env = Sys.getenv) {
|
|
|
77
77
|
self_origins <- c(sprintf("http://%s:%d", bind, port),
|
|
78
78
|
sprintf("http://127.0.0.1:%d", port),
|
|
79
79
|
sprintf("http://localhost:%d", port))
|
|
80
|
-
origins <- unique(c(self_origins, split_list(env("
|
|
80
|
+
origins <- unique(c(self_origins, split_list(env("BEATRINA_ORIGINS", ""))))
|
|
81
81
|
|
|
82
|
-
hosts_env <- split_list(env("
|
|
82
|
+
hosts_env <- split_list(env("BEATRINA_HOSTS", ""))
|
|
83
83
|
default_hosts <- c(sprintf("127.0.0.1:%d", port), sprintf("localhost:%d", port),
|
|
84
84
|
sprintf("[::1]:%d", port))
|
|
85
85
|
# A configured host may or may not carry a port. Accept both spellings so an
|
|
@@ -97,33 +97,33 @@ carmar_deployment <- function(port, env = Sys.getenv) {
|
|
|
97
97
|
# loopback. So the posture is declarable in its own right and the bind
|
|
98
98
|
# address merely IMPLIES it; an operator hardening a shared machine does not
|
|
99
99
|
# have to move the socket to say so.
|
|
100
|
-
require_origin <- identical(env("
|
|
100
|
+
require_origin <- identical(env("BEATRINA_REQUIRE_ORIGIN", ""), "1") || !loopback
|
|
101
101
|
|
|
102
|
-
trust_proxy <- identical(env("
|
|
103
|
-
user_header <- trimws(env("
|
|
104
|
-
proxy_addrs <- split_list(env("
|
|
105
|
-
allow_open <- identical(env("
|
|
102
|
+
trust_proxy <- identical(env("BEATRINA_TRUST_PROXY", ""), "1")
|
|
103
|
+
user_header <- trimws(env("BEATRINA_USER_HEADER", "X-Forwarded-User"))
|
|
104
|
+
proxy_addrs <- split_list(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"))
|
|
105
|
+
allow_open <- identical(env("BEATRINA_ALLOW_UNAUTHENTICATED", ""), "1")
|
|
106
106
|
|
|
107
107
|
errors <- character(0)
|
|
108
108
|
if (!loopback) {
|
|
109
109
|
if (!length(hosts_env)) {
|
|
110
110
|
errors <- c(errors, paste0(
|
|
111
|
-
"
|
|
111
|
+
"BEATRINA_BIND is ", bind, " but BEATRINA_HOSTS is unset. Off loopback the ",
|
|
112
112
|
"Host allow-list is the only defence against DNS rebinding, so it must ",
|
|
113
|
-
"name the hostname readers will use (e.g.
|
|
113
|
+
"name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu)."))
|
|
114
114
|
}
|
|
115
115
|
if (!trust_proxy && !allow_open) {
|
|
116
116
|
errors <- c(errors, paste0(
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
117
|
+
"BEATRINA_BIND is ", bind, " with no authentication in front of it. Put ",
|
|
118
|
+
"Beatrina behind a reverse proxy that authenticates and set ",
|
|
119
|
+
"BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve R ",
|
|
120
120
|
"execution to everyone who can reach this port. See docs/server.md."))
|
|
121
121
|
}
|
|
122
122
|
if (trust_proxy && !nzchar(user_header)) {
|
|
123
|
-
errors <- c(errors, "
|
|
123
|
+
errors <- c(errors, "BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.")
|
|
124
124
|
}
|
|
125
125
|
if (trust_proxy && !length(proxy_addrs)) {
|
|
126
|
-
errors <- c(errors, "
|
|
126
|
+
errors <- c(errors, "BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.")
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
package/kernel/fileio.R
CHANGED
|
@@ -27,7 +27,7 @@ fs_abs <- function(p, wd = getwd()) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
# ── the confinement root ────────────────────────────────────────────────────
|
|
30
|
-
#
|
|
30
|
+
# BEATRINA_ROOT restricts every FILE command — browse, read, write, import, and
|
|
31
31
|
# changing the working directory — to one subtree. It exists for managed
|
|
32
32
|
# deployments where a notebook should see the project share and nothing else.
|
|
33
33
|
#
|
|
@@ -41,7 +41,7 @@ fs_abs <- function(p, wd = getwd()) {
|
|
|
41
41
|
# the operating system's job: run the kernel as a user who cannot read the
|
|
42
42
|
# rest, or in a container.
|
|
43
43
|
confine_root <- local({
|
|
44
|
-
raw <- Sys.getenv("
|
|
44
|
+
raw <- Sys.getenv("BEATRINA_ROOT", "")
|
|
45
45
|
if (!nzchar(raw)) NULL
|
|
46
46
|
else normalizePath(path.expand(raw), mustWork = FALSE)
|
|
47
47
|
})
|
|
@@ -414,7 +414,7 @@ fs_readfile <- function(id, path = NULL, wd = getwd()) {
|
|
|
414
414
|
#' power-loss durability guarantee. The mode of an existing file is kept.
|
|
415
415
|
#' Returns NULL on success or the sentence to report.
|
|
416
416
|
fs_replace_atomic <- function(p, body_bytes) {
|
|
417
|
-
temp <- tempfile(pattern = ".
|
|
417
|
+
temp <- tempfile(pattern = ".beatrina-save-", tmpdir = dirname(p))
|
|
418
418
|
existed <- file.exists(p)
|
|
419
419
|
old_mode <- if (existed) file.info(p)$mode else NULL
|
|
420
420
|
cleanup_save <- function() {
|
|
@@ -439,7 +439,7 @@ fs_replace_atomic <- function(p, body_bytes) {
|
|
|
439
439
|
if (is.character(ok)) return(fail_after_cleanup(ok))
|
|
440
440
|
# Keep the existing test knob as an alias for the pre-replacement boundary.
|
|
441
441
|
# The original is still at its path; a failure needs no restoration rename.
|
|
442
|
-
if (identical(Sys.getenv("
|
|
442
|
+
if (identical(Sys.getenv("BEATRINA_TEST_SAVE_FAILURE", ""), "after-backup")) {
|
|
443
443
|
return(fail_after_cleanup("simulated failure before replacement"))
|
|
444
444
|
}
|
|
445
445
|
replaced <- tryCatch(suppressWarnings(file.rename(temp, p)), error = function(e) FALSE)
|
|
@@ -557,8 +557,8 @@ fs_writefiles_atomic <- function(id, files = NULL, wd = getwd()) {
|
|
|
557
557
|
if (length(after_bytes) > MAX_TEXT_BYTES) return(fail(paste0(basename(p), " is too large")))
|
|
558
558
|
plans[[length(plans) + 1L]] <- list(path = p, before = before, after = after, bytes = after_bytes,
|
|
559
559
|
mode = file.info(p)$mode,
|
|
560
|
-
temp = tempfile(pattern = ".
|
|
561
|
-
backup = tempfile(pattern = ".
|
|
560
|
+
temp = tempfile(pattern = ".beatrina-new-", tmpdir = dirname(p)),
|
|
561
|
+
backup = tempfile(pattern = ".beatrina-old-", tmpdir = dirname(p)))
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
cleanup <- function() {
|
package/kernel/index.html
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<meta charset="utf-8">
|
|
3
|
-
<title>
|
|
3
|
+
<title>Beatrina kernel spike</title>
|
|
4
4
|
<style>
|
|
5
5
|
:root { color-scheme: light dark; }
|
|
6
6
|
body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 1.5rem;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
img { display: block; margin: .5rem 0; border-radius: 3px; }
|
|
26
26
|
</style>
|
|
27
27
|
|
|
28
|
-
<h1>
|
|
28
|
+
<h1>Beatrina kernel spike <span id="status">connecting…</span></h1>
|
|
29
29
|
|
|
30
30
|
<textarea id="src">for (i in 1:20) { cat("tick", i, "\n"); flush(stdout()); Sys.sleep(0.5) }</textarea>
|
|
31
31
|
|
package/kernel/job-run.R
CHANGED
|
@@ -38,6 +38,7 @@ here <- dirname(normalizePath(sub("^--file=", "", grep("^--file=", commandArgs(F
|
|
|
38
38
|
source(file.path(here, "kernel.R"))
|
|
39
39
|
source(file.path(here, "jobs.R"))
|
|
40
40
|
source(file.path(here, "latex.R"))
|
|
41
|
+
source(file.path(here, "typst.R"))
|
|
41
42
|
compile_identity <- NULL
|
|
42
43
|
|
|
43
44
|
#' Write one control frame: the session's random sentinel, then compact JSON.
|
|
@@ -59,12 +60,12 @@ emit <- function(obj) {
|
|
|
59
60
|
invisible(NULL)
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
job_id <- Sys.getenv("
|
|
63
|
-
task <- Sys.getenv("
|
|
64
|
-
root <- Sys.getenv("
|
|
65
|
-
target <- Sys.getenv("
|
|
66
|
-
filter <- Sys.getenv("
|
|
67
|
-
format <- Sys.getenv("
|
|
63
|
+
job_id <- Sys.getenv("BEATRINA_JOB_ID", "job")
|
|
64
|
+
task <- Sys.getenv("BEATRINA_JOB_TASK", "")
|
|
65
|
+
root <- Sys.getenv("BEATRINA_JOB_ROOT", "")
|
|
66
|
+
target <- Sys.getenv("BEATRINA_JOB_TARGET", "")
|
|
67
|
+
filter <- Sys.getenv("BEATRINA_JOB_FILTER", "")
|
|
68
|
+
format <- Sys.getenv("BEATRINA_JOB_FORMAT", "")
|
|
68
69
|
|
|
69
70
|
# The supervisor validated all of this in jobs.R before spawning. Re-checking
|
|
70
71
|
# here is not redundant: this file is also runnable by hand, and a task that
|
|
@@ -82,18 +83,18 @@ if (!nzchar(root) || !dir.exists(root)) {
|
|
|
82
83
|
}
|
|
83
84
|
# A render acts on a FILE. Checking only the folder would let a task start
|
|
84
85
|
# against a document that is not there and fail somewhere less legible.
|
|
85
|
-
if (task %in% c("render", "latex") && (!nzchar(target) || !file.exists(target))) {
|
|
86
|
+
if (task %in% c("render", "latex", "typst") && (!nzchar(target) || !file.exists(target))) {
|
|
86
87
|
emit(list(type = "job", event = "done", ok = FALSE,
|
|
87
88
|
error = sprintf("'%s' is not a file", target)))
|
|
88
89
|
quit(status = 2L, save = "no")
|
|
89
90
|
}
|
|
90
91
|
if (identical(task, "latex")) {
|
|
91
92
|
prepared <- tryCatch({
|
|
92
|
-
identity_text <- Sys.getenv("
|
|
93
|
+
identity_text <- Sys.getenv("BEATRINA_LATEX_IDENTITY", "")
|
|
93
94
|
identity <- if (nzchar(identity_text)) jsonlite::fromJSON(identity_text, simplifyVector = FALSE) else NULL
|
|
94
|
-
spec <- job_spec(task, target, list(engine = Sys.getenv("
|
|
95
|
-
timeout = suppressWarnings(as.numeric(Sys.getenv("
|
|
96
|
-
fetch = identical(Sys.getenv("
|
|
95
|
+
spec <- job_spec(task, target, list(engine = Sys.getenv("BEATRINA_LATEX_ENGINE", "auto"),
|
|
96
|
+
timeout = suppressWarnings(as.numeric(Sys.getenv("BEATRINA_LATEX_TIMEOUT", as.character(LATEX_TIMEOUT)))),
|
|
97
|
+
fetch = identical(Sys.getenv("BEATRINA_LATEX_FETCH", ""), "1"), identity = identity))
|
|
97
98
|
if (!isTRUE(spec$ok)) stop(spec$error, call. = FALSE)
|
|
98
99
|
compile_identity <- identity
|
|
99
100
|
spec
|
|
@@ -229,7 +230,7 @@ run_tests <- function() {
|
|
|
229
230
|
#' Locate a quarto binary.
|
|
230
231
|
#' @return Path to quarto, or "" when there is none.
|
|
231
232
|
find_quarto <- function() {
|
|
232
|
-
explicit <- Sys.getenv("
|
|
233
|
+
explicit <- Sys.getenv("BEATRINA_QUARTO", "")
|
|
233
234
|
if (nzchar(explicit) && file.exists(explicit)) return(explicit)
|
|
234
235
|
# RStudio and Positron both export this into the sessions they start.
|
|
235
236
|
from_env <- Sys.getenv("QUARTO_PATH", "")
|
|
@@ -261,9 +262,11 @@ find_quarto <- function() {
|
|
|
261
262
|
#'
|
|
262
263
|
#' @param path The document.
|
|
263
264
|
#' @return "quarto" or "rmarkdown".
|
|
264
|
-
pick_renderer <- function(path) {
|
|
265
|
+
pick_renderer <- function(path, format = "") {
|
|
265
266
|
ext <- tolower(sub("^.*\\.", "", basename(path)))
|
|
266
267
|
if (identical(ext, "qmd")) return("quarto")
|
|
268
|
+
# rmarkdown has no Typst writer; Quarto renders .Rmd and .md too.
|
|
269
|
+
if (identical(format, "typst")) return("quarto")
|
|
267
270
|
if (requireNamespace("rmarkdown", quietly = TRUE)) "rmarkdown" else "quarto"
|
|
268
271
|
}
|
|
269
272
|
|
|
@@ -287,7 +290,7 @@ run_streaming <- function(bin, args, wd = NULL, timeout = Inf, env = NULL) {
|
|
|
287
290
|
if (!nzchar(text)) return(invisible(NULL))
|
|
288
291
|
cat(text, sep = ""); flush(stdout())
|
|
289
292
|
kept <<- paste0(kept, text)
|
|
290
|
-
if (nchar(kept) > 1000000L) { kept <<- substring(kept, nchar(kept) - 999999L); truncated <<- TRUE }
|
|
293
|
+
if (nchar(kept) > 1000000L) { kept <<- substring(kept, nchar(kept) - 999999L, nchar(kept)); truncated <<- TRUE }
|
|
291
294
|
}
|
|
292
295
|
repeat {
|
|
293
296
|
proc$poll_io(100L)
|
|
@@ -316,26 +319,82 @@ run_streaming <- function(bin, args, wd = NULL, timeout = Inf, env = NULL) {
|
|
|
316
319
|
#' puts the output beside the source and still reports it relatively. Either
|
|
317
320
|
#' way the file is checked to exist before it is reported, for the same
|
|
318
321
|
#' reason a test failure only offers a link to a file that is really there.
|
|
322
|
+
#' The folder a document declares for R: the flat front-matter line
|
|
323
|
+
#' `beatrina-workdir: data/` — the same line lib/wd-menu.js reads for the
|
|
324
|
+
#' interactive session, so a chunk run in the window and the same chunk in
|
|
325
|
+
#' a render start from ONE folder. Relative to the document's own folder;
|
|
326
|
+
#' absolute and `~` paths as written. The document's folder when absent.
|
|
327
|
+
#' Returns list(path, declared): `declared` is "" when nothing was said.
|
|
328
|
+
document_workdir <- function(target) {
|
|
329
|
+
base <- dirname(target)
|
|
330
|
+
lines <- tryCatch(readLines(target, n = 200L, warn = FALSE, encoding = "UTF-8"),
|
|
331
|
+
error = function(e) character())
|
|
332
|
+
if (!length(lines) || !identical(trimws(lines[[1L]]), "---")) return(list(path = base, declared = ""))
|
|
333
|
+
close <- which(trimws(lines[-1L]) %in% c("---", "..."))
|
|
334
|
+
if (!length(close)) return(list(path = base, declared = ""))
|
|
335
|
+
front <- lines[seq_len(close[[1L]])]
|
|
336
|
+
hit <- grep("^beatrina-workdir:", front, value = TRUE)
|
|
337
|
+
if (!length(hit)) return(list(path = base, declared = ""))
|
|
338
|
+
value <- trimws(sub("^beatrina-workdir:", "", hit[[1L]]))
|
|
339
|
+
value <- trimws(sub("[ \t]+#.*$", "", value))
|
|
340
|
+
value <- gsub("^[\"']|[\"']$", "", value)
|
|
341
|
+
if (!nzchar(value)) return(list(path = base, declared = ""))
|
|
342
|
+
path <- if (grepl("^(/|~|[A-Za-z]:[\\\\/]|\\\\\\\\)", value)) path.expand(value)
|
|
343
|
+
else normalizePath(file.path(base, value), mustWork = FALSE)
|
|
344
|
+
list(path = path, declared = value)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#' The name rmarkdown knows a format by. The job's vocabulary is the short one
|
|
348
|
+
#' Quarto speaks (`html`, `pdf`, `docx`, `latex`); rmarkdown::render() evaluates
|
|
349
|
+
#' a bare `html` as an R object and fails with "object 'html' not found" — so
|
|
350
|
+
#' until 2026-09-19 choosing a format for an .Rmd could not work at all.
|
|
351
|
+
#' @param format A RENDER_FORMATS entry; "" lets the document's YAML decide.
|
|
352
|
+
#' @return A format name, or NULL for the document's own.
|
|
353
|
+
rmarkdown_format <- function(format) {
|
|
354
|
+
if (!nzchar(format)) return(NULL)
|
|
355
|
+
switch(format,
|
|
356
|
+
html = "html_document", pdf = "pdf_document", docx = "word_document",
|
|
357
|
+
latex = "latex_document",
|
|
358
|
+
stop(sprintf("rmarkdown cannot render to '%s'", format), call. = FALSE))
|
|
359
|
+
}
|
|
360
|
+
|
|
319
361
|
render_document <- function() {
|
|
320
|
-
renderer <- pick_renderer(target)
|
|
362
|
+
renderer <- pick_renderer(target, format)
|
|
321
363
|
produced <- NA_character_
|
|
364
|
+
# Where the code runs: the document's declared folder, else its own. Said
|
|
365
|
+
# when the declaration names a folder that is not there — a render that
|
|
366
|
+
# quietly ran somewhere else would make every relative path a mystery.
|
|
367
|
+
workdir <- document_workdir(target)
|
|
368
|
+
if (nzchar(workdir$declared) && !dir.exists(workdir$path)) {
|
|
369
|
+
stop(sprintf("the document declares beatrina-workdir: %s, but '%s' is not a folder",
|
|
370
|
+
workdir$declared, workdir$path), call. = FALSE)
|
|
371
|
+
}
|
|
322
372
|
|
|
323
373
|
if (identical(renderer, "rmarkdown")) {
|
|
324
374
|
need("rmarkdown")
|
|
325
375
|
out <- rmarkdown::render(
|
|
326
376
|
target,
|
|
327
|
-
output_format =
|
|
328
|
-
knit_root_dir =
|
|
377
|
+
output_format = rmarkdown_format(format),
|
|
378
|
+
knit_root_dir = workdir$path
|
|
329
379
|
)
|
|
330
380
|
if (length(out) >= 1L && nzchar(out[[1L]])) produced <- out[[1L]]
|
|
331
381
|
} else {
|
|
332
382
|
bin <- find_quarto()
|
|
333
383
|
if (!nzchar(bin)) {
|
|
334
|
-
stop("Quarto is not installed, or
|
|
335
|
-
"or set
|
|
384
|
+
stop("Quarto is not installed, or Beatrina could not find it. Install Quarto, ",
|
|
385
|
+
"or set BEATRINA_QUARTO to the binary.", call. = FALSE)
|
|
336
386
|
}
|
|
337
387
|
args <- c("render", target)
|
|
338
388
|
if (nzchar(format)) args <- c(args, "--to", format)
|
|
389
|
+
# Typst figures default to SVG, which knitr draws through cairo — and a
|
|
390
|
+
# Mac without XQuartz has no cairo, so the render failed at the first plot
|
|
391
|
+
# (measured, 2026-09-19). PNG needs nothing; keep-typ leaves the .typ
|
|
392
|
+
# source beside the PDF, which is what someone editing the paper wants.
|
|
393
|
+
if (identical(format, "typst")) args <- c(args, "-M", "fig-format:png", "-M", "keep-typ:true")
|
|
394
|
+
# Quarto's own execution directory, only when the document asked: its
|
|
395
|
+
# default (the document's folder, or the project's when the project says
|
|
396
|
+
# so) stays Quarto's decision for a document that said nothing.
|
|
397
|
+
if (nzchar(workdir$declared)) args <- c(args, "--execute-dir", workdir$path)
|
|
339
398
|
result <- run_streaming(bin, args)
|
|
340
399
|
if (!identical(as.integer(result$status), 0L)) {
|
|
341
400
|
stop(sprintf("quarto render exited %s", result$status), call. = FALSE)
|
|
@@ -374,9 +433,11 @@ outcome <- tryCatch({
|
|
|
374
433
|
build = { need("devtools"); devtools::build(root); NULL },
|
|
375
434
|
install = { need("devtools"); devtools::install(root, quick = TRUE, upgrade = "never"); NULL },
|
|
376
435
|
render = render_document(),
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
436
|
+
typst = typst_compile(target, suppressWarnings(as.numeric(Sys.getenv("BEATRINA_TYPST_TIMEOUT", as.character(TYPST_TIMEOUT)))),
|
|
437
|
+
run_streaming, emit, pages = identical(Sys.getenv("BEATRINA_TYPST_PAGES", ""), "svg")),
|
|
438
|
+
latex = latex_compile(target, prepared$env[["BEATRINA_LATEX_ENGINE"]],
|
|
439
|
+
as.numeric(prepared$env[["BEATRINA_LATEX_TIMEOUT"]]), job_id, run_streaming, emit,
|
|
440
|
+
fetch = identical(prepared$env[["BEATRINA_LATEX_FETCH"]], "1"))
|
|
380
441
|
)
|
|
381
442
|
list(ok = TRUE, error = "")
|
|
382
443
|
}, error = function(e) {
|
package/kernel/jobs.R
CHANGED
|
@@ -125,7 +125,10 @@ JOB_TASKS <- list(
|
|
|
125
125
|
build = list(label = "Build", running = "Building", target = "package", pkg = "devtools"),
|
|
126
126
|
install = list(label = "Install", running = "Installing", target = "package", pkg = "devtools"),
|
|
127
127
|
render = list(label = "Render", running = "Rendering", target = "document", pkg = NA_character_),
|
|
128
|
-
latex = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_)
|
|
128
|
+
latex = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_),
|
|
129
|
+
# Typst: one document in, one PDF out — the same shape as latex, a different
|
|
130
|
+
# compiler (Quarto bundles it; nothing to install).
|
|
131
|
+
typst = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_)
|
|
129
132
|
)
|
|
130
133
|
|
|
131
134
|
#' Documents this kernel will render. Lower-cased extension, no dot.
|
|
@@ -140,7 +143,7 @@ RENDER_EXTENSIONS <- c("qmd", "rmd", "md")
|
|
|
140
143
|
# decides. A document whose header says `format: revealjs` must render as
|
|
141
144
|
# revealjs when the reader presses Render without choosing anything —
|
|
142
145
|
# defaulting to html would silently overrule the file.
|
|
143
|
-
RENDER_FORMATS <- c("", "html", "pdf", "docx")
|
|
146
|
+
RENDER_FORMATS <- c("", "html", "pdf", "docx", "latex", "typst")
|
|
144
147
|
|
|
145
148
|
#' Is this a single, plain, non-empty string? Mirrors serve.R's scalar_chr —
|
|
146
149
|
#' R's coercion rules are exactly permissive enough to turn a JSON array into
|
|
@@ -167,7 +170,7 @@ path_extension <- function(path) {
|
|
|
167
170
|
#' Decide whether one task may run, and exactly what would run.
|
|
168
171
|
#'
|
|
169
172
|
#' The whole decision in one pure function, returning it whole — the same
|
|
170
|
-
#' shape as `
|
|
173
|
+
#' shape as `beatrina_deployment()`, and for the same reason: three callers
|
|
171
174
|
#' (the supervisor, the tests, the UI's error message) must not each
|
|
172
175
|
#' re-derive it and drift.
|
|
173
176
|
#'
|
|
@@ -178,7 +181,7 @@ path_extension <- function(path) {
|
|
|
178
181
|
#' @param task Task name from the wire.
|
|
179
182
|
#' @param target Directory or file from the wire.
|
|
180
183
|
#' @param opts Named list of extras from the wire: `filter` (test), `format`
|
|
181
|
-
#' (render). Unknown members are ignored rather than refused — a page one
|
|
184
|
+
#' (render), `pages` (typst). Unknown members are ignored rather than refused — a page one
|
|
182
185
|
#' version ahead must not be an error.
|
|
183
186
|
#' @return list(ok, error, task, target, root, label, package, env) — `env` is
|
|
184
187
|
#' the named character vector of environment variables for the child, and is
|
|
@@ -214,9 +217,9 @@ job_spec <- function(task, target, opts = list()) {
|
|
|
214
217
|
}
|
|
215
218
|
target <- tryCatch(normalizePath(target, mustWork = TRUE), error = function(e) target)
|
|
216
219
|
ext <- path_extension(target)
|
|
217
|
-
extensions <- if (identical(task, "latex")) "tex" else RENDER_EXTENSIONS
|
|
220
|
+
extensions <- if (identical(task, "latex")) "tex" else if (identical(task, "typst")) "typ" else RENDER_EXTENSIONS
|
|
218
221
|
if (!ext %in% extensions) {
|
|
219
|
-
return(bad(sprintf("
|
|
222
|
+
return(bad(sprintf("Beatrina renders %s files; '%s' is a .%s",
|
|
220
223
|
paste0(".", extensions, collapse = ", "), basename(target),
|
|
221
224
|
if (nzchar(ext)) ext else "(no extension)")))
|
|
222
225
|
}
|
|
@@ -234,6 +237,18 @@ job_spec <- function(task, target, opts = list()) {
|
|
|
234
237
|
if (nzchar(format) && !identical(task, "render")) {
|
|
235
238
|
return(bad("a format only applies to the render task"))
|
|
236
239
|
}
|
|
240
|
+
# `pages` asks Typst for one SVG per page instead of a PDF: what the live
|
|
241
|
+
# preview draws, so it can keep the reader's place across a rebuild
|
|
242
|
+
# (lib/doc-preview.js). Strict like `fetch` — exactly TRUE, never arrived at
|
|
243
|
+
# by coercion — and it means nothing to any other task.
|
|
244
|
+
pages <- isTRUE(opts$pages)
|
|
245
|
+
if (pages && !identical(task, "typst")) return(bad("pages only apply to the typst task"))
|
|
246
|
+
# …and only INSIDE the preview's own hidden folder: a pages compile deletes the
|
|
247
|
+
# last compile's `<stem>-N.svg` first, and that must never be aimed at a folder
|
|
248
|
+
# of somebody's own files. (The twin of host/planes/jobs.mjs.)
|
|
249
|
+
if (pages && !identical(basename(dirname(target)), ".beatrina-preview")) {
|
|
250
|
+
return(bad("pages are compiled only in a document's .beatrina-preview folder"))
|
|
251
|
+
}
|
|
237
252
|
if (!format %in% RENDER_FORMATS) {
|
|
238
253
|
return(bad(sprintf("'%s' is not a format this kernel renders (%s)",
|
|
239
254
|
format, paste(setdiff(RENDER_FORMATS, ""), collapse = ", "))))
|
|
@@ -245,9 +260,10 @@ job_spec <- function(task, target, opts = list()) {
|
|
|
245
260
|
label = spec$label, running = spec$running, targets = spec$target,
|
|
246
261
|
package = if (identical(spec$target, "package")) package_name(root) else "",
|
|
247
262
|
needs = spec$pkg,
|
|
248
|
-
env = c(
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
env = c(BEATRINA_JOB_TASK = task, BEATRINA_JOB_TARGET = target,
|
|
264
|
+
BEATRINA_JOB_ROOT = root, BEATRINA_JOB_FILTER = filter,
|
|
265
|
+
BEATRINA_JOB_FORMAT = format,
|
|
266
|
+
BEATRINA_TYPST_PAGES = if (pages) "svg" else "", latex_env)
|
|
251
267
|
)
|
|
252
268
|
}
|
|
253
269
|
|
|
@@ -260,14 +276,14 @@ job_spec <- function(task, target, opts = list()) {
|
|
|
260
276
|
#' @param spec A successful `job_spec()`.
|
|
261
277
|
#' @return One line of text.
|
|
262
278
|
job_title <- function(spec) {
|
|
263
|
-
if (identical(spec$task, "latex")) return(sprintf("Compiling %s", basename(spec$target)))
|
|
279
|
+
if (identical(spec$task, "latex") || identical(spec$task, "typst")) return(sprintf("Compiling %s", basename(spec$target)))
|
|
264
280
|
if (identical(spec$targets, "document")) {
|
|
265
|
-
format <- spec$env[["
|
|
281
|
+
format <- spec$env[["BEATRINA_JOB_FORMAT"]]
|
|
266
282
|
return(sprintf("Rendering %s%s", basename(spec$target),
|
|
267
283
|
if (nzchar(format)) paste0(" to ", format) else ""))
|
|
268
284
|
}
|
|
269
285
|
name <- if (nzchar(spec$package)) spec$package else basename(spec$root)
|
|
270
|
-
filter <- spec$env[["
|
|
286
|
+
filter <- spec$env[["BEATRINA_JOB_FILTER"]]
|
|
271
287
|
if (identical(spec$task, "test") && nzchar(filter)) {
|
|
272
288
|
sprintf("%s %s — tests matching '%s'", spec$running, name, filter)
|
|
273
289
|
} else {
|
package/kernel/kernel-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.9.43
|