beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,704 @@
1
+ // jobs.mjs — development jobs: the THIRD kind of child process.
2
+ //
3
+ // A port of spike/jobs.R (the pure decision) and of everything named `job_*`,
4
+ // `jobs_running`, `pump_jobs`, `prune_jobs`, `job_broadcast`, `job_emit`,
5
+ // `job_log`, `job_snapshot`, `output_cap_*` and `/job-output` in spike/serve.R.
6
+ // The CHILD is spike/job-run.R, spawned UNCHANGED: it stamps its own ids and
7
+ // speaks the same sentinel-framed wire the worker does, so host/engine-r.mjs's
8
+ // REngine (in batch mode) and LineFramer are reused rather than reinvented.
9
+ //
10
+ // Why a job is its own process, kept from the R:
11
+ // · it never consumes the interactive worker — a four-minute check would
12
+ // take the user's variables with it and make Stop a lie for the duration;
13
+ // · it is DETACHED: frames are BROADCAST to every page (the page that
14
+ // pressed Test may be gone), the log is BUFFERED so a page that reloads
15
+ // mid-check catches up, and a running job holds the idle clock open;
16
+ // · it carries no session identity, which is what makes a third evaluating
17
+ // child safe at all (the header of spike/job-run.R).
18
+ //
19
+ // THE WIRE NEVER CARRIES CODE. The browser sends a task NAME out of a closed
20
+ // vocabulary plus a target; this file decides whether that pair is runnable
21
+ // and the host builds the argument vector. Every option reaches the child
22
+ // through its ENVIRONMENT, where `Sys.getenv()` cannot parse it.
23
+ //
24
+ // Two decisions that look like details and are not:
25
+ // · `job_open` names a JOB, never a path. The host opens the path IT
26
+ // recorded from the child's own `output` frame; a path from the wire
27
+ // would be "open any file on this machine" wearing a helpful label.
28
+ // · a child frame is BROADCAST AS BYTES (`__raw`), never re-encoded — the
29
+ // frame-fidelity rule, which is also why job-run.R stamps its own id.
30
+
31
+ import { spawn } from "node:child_process";
32
+ import crypto from "node:crypto";
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { REngine } from "../engine-r.mjs";
36
+ import { killProcessTree } from "./proc-tree.mjs";
37
+
38
+ // ── the vocabulary ──────────────────────────────────────────────────────────
39
+ //
40
+ // Every task declares WHAT IT ACTS ON, and that field is why this is a table
41
+ // and not a list of names: six tasks act on a package root, `render` and
42
+ // `latex` act on a single document, which has no DESCRIPTION and never will.
43
+ // `pkg` is what the task needs INSTALLED, carried so the child can say so in
44
+ // words; `render` depends on the document, so it carries null.
45
+ export const JOB_TASKS = Object.freeze({
46
+ load: { label: "Load", running: "Loading", target: "package", pkg: "pkgload" },
47
+ document: { label: "Document", running: "Documenting", target: "package", pkg: "devtools" },
48
+ test: { label: "Test", running: "Testing", target: "package", pkg: "testthat" },
49
+ check: { label: "Check", running: "Checking", target: "package", pkg: "devtools" },
50
+ build: { label: "Build", running: "Building", target: "package", pkg: "devtools" },
51
+ install: { label: "Install", running: "Installing", target: "package", pkg: "devtools" },
52
+ render: { label: "Render", running: "Rendering", target: "document", pkg: null },
53
+ latex: { label: "Compile", running: "Compiling", target: "document", pkg: null },
54
+ });
55
+ export const RENDER_EXTENSIONS = Object.freeze(["qmd", "rmd", "md"]);
56
+ // "" FIRST because it is the default and the default matters: no `--to` at
57
+ // all, so a document whose header says `format: revealjs` renders as revealjs.
58
+ export const RENDER_FORMATS = Object.freeze(["", "html", "pdf", "docx"]);
59
+ export const LATEX_ENGINES = Object.freeze(["tectonic", "latexmk", "pdflatex", "xelatex", "lualatex"]);
60
+ export const LATEX_TIMEOUT = 180;
61
+ const JOB_FILTER_MAX = 200;
62
+
63
+ const MAX_JOBS = 4; // a job is CPU- and disk-heavy; unbounded is a fork bomb
64
+ const JOB_HISTORY_MAX = 20;
65
+ const JOB_LOG_MAX = 5000;
66
+ const JOB_LOG_CHARS = 1000000;
67
+ const OUTPUT_CAP_MAX_BYTES = 128 * 1024 * 1024;
68
+
69
+ const scalarChr = (x) => typeof x === "string";
70
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
71
+ /** The classes the upgrade gate proved are the user's own page (server.mjs). */
72
+ const PAGE_ONLY = Object.freeze(["served", "file", "local"]);
73
+
74
+ /** `fs.realpathSync` where the path exists, the resolved path where it does not. */
75
+ function realish(p) {
76
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
77
+ }
78
+
79
+ /**
80
+ * Is this directory the root of an R package?
81
+ *
82
+ * A DESCRIPTION carrying a `Package:` field, not merely a file called
83
+ * DESCRIPTION — Bioconductor build dirs and a stray copied file both put that
84
+ * name somewhere it does not mean this. An unreadable file answers FALSE,
85
+ * which is a decision and not a swallowed error: "this is not a package root"
86
+ * is the right answer for a malformed file.
87
+ */
88
+ export function isPackageRoot(dir) {
89
+ const desc = path.join(dir, "DESCRIPTION");
90
+ let stat;
91
+ try { stat = fs.statSync(desc); } catch { return false; }
92
+ if (!stat.isFile()) return false;
93
+ let text;
94
+ try { text = fs.readFileSync(desc, "utf8"); } catch { return false; }
95
+ return Boolean(packageFieldOf(text));
96
+ }
97
+
98
+ /** The first record's `Package:` field of a DCF text, or "". */
99
+ function packageFieldOf(text) {
100
+ const m = String(text).match(/^Package:[ \t]*(.*)$/m);
101
+ return m ? m[1].trim() : "";
102
+ }
103
+
104
+ /**
105
+ * Every directory from `p` up to the filesystem root, nearest first.
106
+ *
107
+ * A parent chain cannot be vectorised — each step is computed from the last —
108
+ * so this is a BOUNDED loop, and the bound is the point: a symlink loop must
109
+ * not spin the host's event loop.
110
+ */
111
+ export function pathAncestors(p, maxUp = 40) {
112
+ const out = [];
113
+ let current = p;
114
+ while (out.length < maxUp) {
115
+ out.push(current);
116
+ const parent = path.dirname(current);
117
+ if (parent === current) break; // "/" is its own dirname
118
+ current = parent;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** The package root at or above a path, or "". */
124
+ export function findPackageRoot(p) {
125
+ if (!scalarChr(p) || !p) return "";
126
+ let start = p;
127
+ try { start = fs.statSync(p).isDirectory() ? p : path.dirname(p); } catch { start = path.dirname(p); }
128
+ start = realish(start);
129
+ return pathAncestors(start).find(isPackageRoot) || "";
130
+ }
131
+
132
+ /** The package's name, for a label a person recognises. */
133
+ export function packageName(root) {
134
+ if (!root || !isPackageRoot(root)) return "";
135
+ try { return packageFieldOf(fs.readFileSync(path.join(root, "DESCRIPTION"), "utf8")); } catch { return ""; }
136
+ }
137
+
138
+ /** The lower-cased extension of a path, with no dot. */
139
+ export function pathExtension(p) {
140
+ const base = path.basename(p);
141
+ if (!base.includes(".")) return "";
142
+ return base.replace(/^.*\./, "").toLowerCase();
143
+ }
144
+
145
+ /**
146
+ * The data-only options a compile job carries, validated (spike/latex.R's
147
+ * `latex_options`). Throws a readable message, which becomes the refusal.
148
+ *
149
+ * `fetch` is strict where the others are lenient — exactly `true` turns it
150
+ * on, anything else is off. An option that means "reach the network" must
151
+ * never be arrived at by coercion.
152
+ */
153
+ export function latexOptions(opts = {}) {
154
+ const engine = opts.engine == null ? "auto" : opts.engine;
155
+ const timeout = opts.timeout == null ? LATEX_TIMEOUT : opts.timeout;
156
+ if (!scalarChr(engine) || !["auto", ...LATEX_ENGINES].includes(engine)) {
157
+ throw new Error("Choose auto, tectonic, latexmk, pdflatex, xelatex or lualatex.");
158
+ }
159
+ if (!isNum(timeout) || timeout !== Math.floor(timeout) || timeout < 1 || timeout > 600) {
160
+ throw new Error("Compile timeout must be a whole number from 1 to 600 seconds.");
161
+ }
162
+ let identity = opts.identity;
163
+ if (identity != null) {
164
+ const number = (v) => isNum(v) && v >= 0 && v <= 9007199254740991 && v === Math.floor(v);
165
+ if (typeof identity !== "object" || Array.isArray(identity) || !scalarChr(identity.uri) || !identity.uri
166
+ || identity.uri.length > 4096 || !number(identity.version) || !number(identity.generation)) {
167
+ throw new Error("Invalid compile source identity.");
168
+ }
169
+ identity = { uri: identity.uri, version: identity.version, generation: identity.generation };
170
+ }
171
+ const fetch = opts.fetch === true;
172
+ return {
173
+ CARMAR_LATEX_ENGINE: engine,
174
+ CARMAR_LATEX_TIMEOUT: String(timeout),
175
+ CARMAR_LATEX_FETCH: fetch ? "1" : "",
176
+ CARMAR_LATEX_IDENTITY: identity == null ? "" : JSON.stringify(identity),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Decide whether one task may run, and exactly what would run.
182
+ *
183
+ * The whole decision in one pure function, returned whole — the same shape as
184
+ * `carmarDeployment()`, for the same reason: three callers (the host, the
185
+ * tests, the UI's error message) must not each re-derive it and drift.
186
+ *
187
+ * @returns {{ok: boolean, error?: string, task?: string, target?: string,
188
+ * root?: string, label?: string, running?: string, targets?: string,
189
+ * package?: string, needs?: string|null, env?: Record<string,string>}}
190
+ */
191
+ export function jobSpec(task, target, opts = {}) {
192
+ const bad = (error) => ({ ok: false, error });
193
+ if (!scalarChr(task) || !task) return bad("no task was named");
194
+ if (!Object.prototype.hasOwnProperty.call(JOB_TASKS, task)) {
195
+ return bad(`'${task}' is not a task this kernel runs (${Object.keys(JOB_TASKS).join(", ")})`);
196
+ }
197
+ const spec = JOB_TASKS[task];
198
+ if (!scalarChr(target) || !target) {
199
+ return bad(spec.target === "document" ? "no document was named" : "no folder was named");
200
+ }
201
+ let filter = scalarChr(opts.filter) ? opts.filter : "";
202
+ const format = scalarChr(opts.format) ? opts.format : "";
203
+ let latexEnv = {};
204
+ if (task === "latex") {
205
+ try { latexEnv = latexOptions(opts); } catch (e) { return bad(e.message); }
206
+ }
207
+
208
+ let root;
209
+ let resolved = target;
210
+ if (spec.target === "package") {
211
+ let isDir = false;
212
+ try { isDir = fs.statSync(target).isDirectory(); } catch { isDir = false; }
213
+ if (!isDir) return bad(`'${target}' is not a folder`);
214
+ resolved = realish(target);
215
+ if (!isPackageRoot(resolved)) {
216
+ return bad(`'${resolved}' is not an R package — no DESCRIPTION with a Package field. `
217
+ + "Open a file inside the package and try again.");
218
+ }
219
+ root = resolved;
220
+ } else {
221
+ let isFile = false;
222
+ try { isFile = fs.statSync(target).isFile(); } catch { isFile = false; }
223
+ if (!isFile) return bad(`'${target}' is not a file`);
224
+ resolved = realish(target);
225
+ const ext = pathExtension(resolved);
226
+ const extensions = task === "latex" ? ["tex"] : RENDER_EXTENSIONS;
227
+ if (!extensions.includes(ext)) {
228
+ return bad(`CarmaR renders ${extensions.map((e) => `.${e}`).join(", ")} files; `
229
+ + `'${path.basename(resolved)}' is a .${ext || "(no extension)"}`);
230
+ }
231
+ // The document's own folder, so a job still has one place it belongs.
232
+ root = path.dirname(resolved);
233
+ }
234
+
235
+ if (filter && task !== "test") return bad("a filter only applies to the test task");
236
+ // Data, never code — but still bounded and stripped of control characters,
237
+ // because it is echoed into a label and into the audit stream, where a
238
+ // newline would forge a record.
239
+ // eslint-disable-next-line no-control-regex
240
+ filter = filter.replace(/[\x00-\x1f\x7f]/g, "");
241
+ if (filter.length > JOB_FILTER_MAX) return bad("the test filter is too long");
242
+
243
+ if (format && task !== "render") return bad("a format only applies to the render task");
244
+ if (!RENDER_FORMATS.includes(format)) {
245
+ return bad(`'${format}' is not a format this kernel renders (${RENDER_FORMATS.filter(Boolean).join(", ")})`);
246
+ }
247
+
248
+ return {
249
+ ok: true, error: "",
250
+ task, target: resolved, root,
251
+ label: spec.label, running: spec.running, targets: spec.target,
252
+ package: spec.target === "package" ? packageName(root) : "",
253
+ needs: spec.pkg,
254
+ env: {
255
+ CARMAR_JOB_TASK: task, CARMAR_JOB_TARGET: resolved,
256
+ CARMAR_JOB_ROOT: root, CARMAR_JOB_FILTER: filter,
257
+ CARMAR_JOB_FORMAT: format, ...latexEnv,
258
+ },
259
+ };
260
+ }
261
+
262
+ /** A sentence naming what is about to happen, before it happens. */
263
+ export function jobTitle(spec) {
264
+ if (spec.task === "latex") return `Compiling ${path.basename(spec.target)}`;
265
+ if (spec.targets === "document") {
266
+ const format = spec.env.CARMAR_JOB_FORMAT;
267
+ return `Rendering ${path.basename(spec.target)}${format ? ` to ${format}` : ""}`;
268
+ }
269
+ const name = spec.package || path.basename(spec.root);
270
+ const filter = spec.env.CARMAR_JOB_FILTER;
271
+ if (spec.task === "test" && filter) return `${spec.running} ${name} — tests matching '${filter}'`;
272
+ return `${spec.running} ${name}`;
273
+ }
274
+
275
+ const token = (n = 32) => {
276
+ const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
277
+ let out = "";
278
+ for (const b of crypto.randomBytes(n)) out += alphabet[b % alphabet.length];
279
+ return out;
280
+ };
281
+
282
+ // What a declared agent may never do here, in the op's own words. `job_start`
283
+ // is on this list DELIBERATELY and is the entry most open to argument: a job
284
+ // IS visible, so the chunk_run rule is satisfied — but a second evaluation
285
+ // door is a policy call, not a consequence of building the mechanism.
286
+ // Reading job state is not refused: an agent that can see the suite is red is
287
+ // more useful and evaluates nothing.
288
+ const AGENT_REFUSED = Object.freeze({
289
+ job_start: "Agents cannot start development jobs; ask the user to run one.",
290
+ job_stop: "Agents cannot stop development jobs.",
291
+ job_open: "Agents cannot open files on the user's desktop.",
292
+ job_output_cap: "Agents run code through notebook chunks (chunk_run), not raw exec.",
293
+ });
294
+
295
+ /**
296
+ * @param {{root: string, spike: string, env: (n: string, d?: string) => string,
297
+ * audit: (event: string, fields?: object) => void}} deps
298
+ */
299
+ export function createPlane({ spike, env, audit }) {
300
+ const jobRun = path.join(spike, "job-run.R");
301
+ // Sixty seconds is long enough for a fetch that starts the moment the page
302
+ // is handed the token, short enough that a token in a stray log is dead by
303
+ // the time anyone reads it. Tests shorten it.
304
+ const ttlGiven = Number(env("CARMAR_OUTPUT_CAP_TTL", ""));
305
+ const OUTPUT_CAP_TTL = Number.isFinite(ttlGiven) && ttlGiven >= 1 && ttlGiven <= 3600 ? ttlGiven : 60;
306
+
307
+ /** @type {Map<string, object>} */
308
+ const jobs = new Map();
309
+ /** @type {Map<string, {job: string, path: string, expires: number}>} */
310
+ const outputCaps = new Map();
311
+ let seq = 0;
312
+ let ctxRef = null;
313
+
314
+ const enc = (o) => JSON.stringify(o);
315
+ /** Broadcast, not addressed: a job is detached, and the page that pressed
316
+ * Test may be gone while the page that is here now needs to see it. */
317
+ const broadcast = (text) => { for (const r of ctxRef.pageRecs()) { try { r.ws.send(text); } catch { /* gone */ } } };
318
+ const jobEmit = (id, fields) => {
319
+ const job = id ? jobs.get(id) : null;
320
+ broadcast(enc({ type: "job", id, ...fields, ...(job && job.identity ? job.identity : {}) }));
321
+ };
322
+
323
+ /** Append output to a job's buffer, bounded — and REPORT what was dropped. */
324
+ const jobLog = (job, text) => {
325
+ // `strsplit("", "\n")[[1]]` is character(0) in R and `"".split("\n")` is
326
+ // `[""]` here, so the empty case is refused explicitly rather than
327
+ // appending a blank line the R never would.
328
+ if (!text) return;
329
+ const lines = String(text).split("\n");
330
+ job.log.push(...lines);
331
+ if (job.log.length > JOB_LOG_MAX) {
332
+ job.dropped += job.log.length - JOB_LOG_MAX;
333
+ job.log = job.log.slice(job.log.length - JOB_LOG_MAX);
334
+ }
335
+ let joined = job.log.join("\n");
336
+ if (joined.length > JOB_LOG_CHARS) {
337
+ const before = job.log.length;
338
+ joined = joined.slice(joined.length - JOB_LOG_CHARS);
339
+ job.log = joined.split("\n");
340
+ job.dropped += Math.max(1, before - job.log.length);
341
+ }
342
+ };
343
+
344
+ /** What a page needs to render one job in a list. */
345
+ const snapshot = (job, withLog = false) => {
346
+ const out = {
347
+ id: job.id, task: job.task, root: job.root, target: job.target,
348
+ output: job.output, title: job.title, state: job.state, ok: job.ok,
349
+ error: job.error, started: job.started, elapsed: job.elapsed,
350
+ counts: job.counts, rows: job.rows, diagnostics: job.diagnostics,
351
+ truncated: job.truncated, dropped: job.dropped,
352
+ ...(job.identity || {}),
353
+ };
354
+ if (withLog) out.log = job.log.slice();
355
+ return out;
356
+ };
357
+
358
+ /**
359
+ * Kill the whole tree. This is spike/kernel.R's `kernel_kill_tree()` with
360
+ * the half processx cannot reach added: `killProcessTree` does the three
361
+ * things that are only correct together — descendants, the process group,
362
+ * then the leader — and host/planes/proc-tree.mjs records the two measured
363
+ * failures that make each of them necessary.
364
+ */
365
+ const killTree = (job) => {
366
+ const pid = job.engine && job.engine.pid;
367
+ if (!pid) return;
368
+ if (process.platform === "win32") {
369
+ try { spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" }); } catch { /* gone */ }
370
+ return;
371
+ }
372
+ killProcessTree(pid);
373
+ };
374
+
375
+ const pruneJobs = () => {
376
+ const finished = [...jobs.values()].filter((j) => j.state !== "running");
377
+ if (finished.length <= JOB_HISTORY_MAX) return;
378
+ finished.sort((a, b) => a.started - b.started);
379
+ for (const j of finished.slice(0, finished.length - JOB_HISTORY_MAX)) jobs.delete(j.id);
380
+ };
381
+
382
+ const finish = (job, fields) => {
383
+ job.state = fields.state || "done";
384
+ job.ok = fields.ok;
385
+ if (!job.ok) job.output = null;
386
+ job.error = fields.error ?? "";
387
+ job.elapsed = fields.elapsed ?? Math.round((Date.now() / 1000 - job.started) * 100) / 100;
388
+ };
389
+
390
+ const sweepCaps = () => {
391
+ const now = Date.now() / 1000;
392
+ for (const [t, g] of outputCaps) if (g.expires < now) outputCaps.delete(t);
393
+ };
394
+
395
+ // ── starting one ─────────────────────────────────────────────────────────
396
+ function jobStart(cmd, rec) {
397
+ const request = scalarChr(cmd.id) && cmd.id.length <= 200 ? cmd.id : "";
398
+ const running = [...jobs.values()].filter((j) => j.state === "running");
399
+ if (running.length >= MAX_JOBS) {
400
+ jobEmit("", { event: "refused", request, error: `${running.length} jobs are already running. Wait for one to finish, or stop it.` });
401
+ return;
402
+ }
403
+ // `fetch` is the ONE option that changes what the child may TOUCH — a
404
+ // compile that may download what it is missing. Read as an exact true,
405
+ // never remembered, and AUDITED separately, because "the kernel reached
406
+ // the network" is a fact a person should be able to find afterwards.
407
+ const fetch = cmd.fetch === true;
408
+ const spec = jobSpec(cmd.task ?? "", cmd.target ?? "", {
409
+ filter: cmd.filter ?? "", format: cmd.format ?? "",
410
+ engine: cmd.engine, timeout: cmd.timeout, fetch, identity: cmd.identity,
411
+ });
412
+ if (!spec.ok) {
413
+ audit("job-refused", { detail: spec.error });
414
+ jobEmit("", { event: "refused", request, error: spec.error });
415
+ return;
416
+ }
417
+ if (fetch) audit("latex-fetch", { target: path.basename(cmd.target ?? "") });
418
+ seq += 1;
419
+ const id = `job-${seq}`;
420
+ let engine;
421
+ try {
422
+ engine = new REngine({ workerPath: jobRun, env: { ...spec.env, CARMAR_JOB_ID: id } });
423
+ // The child is a BATCH R: `Rscript job-run.R <sentinel>`. It never reads
424
+ // stdin, has no session identity, and stamps its own frame ids.
425
+ engine.mode = "batch";
426
+ } catch (e) {
427
+ audit("job-start-failed", { detail: e.message });
428
+ jobEmit(id, { event: "refused", request, error: `the job process could not start: ${e.message}` });
429
+ return;
430
+ }
431
+ const job = {
432
+ id, engine, task: spec.task, root: spec.root, target: spec.target,
433
+ identity: spec.env.CARMAR_LATEX_IDENTITY ? JSON.parse(spec.env.CARMAR_LATEX_IDENTITY) : null,
434
+ output: null, title: jobTitle(spec), state: "running", ok: null, error: "",
435
+ started: Date.now() / 1000, elapsed: null, log: [], dropped: 0,
436
+ rows: [], diagnostics: [], renderer: "", counts: null, truncated: 0, pid: null,
437
+ };
438
+ jobs.set(id, job);
439
+
440
+ engine.on("event", (e) => {
441
+ if (job.state !== "running") return;
442
+ if (e.type === "stdout" || e.type === "stderr") {
443
+ const text = e.text ?? "";
444
+ jobLog(job, text);
445
+ jobEmit(id, { event: "log", stream: e.type, text });
446
+ return;
447
+ }
448
+ if (e.type !== "job") return;
449
+ const event = e.event ?? "";
450
+ if (event === "started") job.pid = e.pid;
451
+ if (event === "output") {
452
+ // Kept so a page that connects after the render still learns where
453
+ // the file went — and so `job_open` has a path of its OWN to trust.
454
+ job.output = scalarChr(e.path) ? e.path : null;
455
+ job.renderer = e.renderer ?? "";
456
+ }
457
+ if (event === "tests") {
458
+ job.rows = Array.isArray(e.rows) ? e.rows : [];
459
+ job.counts = e.counts ?? null;
460
+ job.truncated = e.truncated ?? 0;
461
+ }
462
+ if (event === "diagnostics") job.diagnostics = Array.isArray(e.rows) ? e.rows : [];
463
+ if (event === "done") finish(job, { ok: e.ok === true, error: e.error ?? "", elapsed: e.elapsed ?? null });
464
+ // The child stamps its own id, so this is byte-faithful: no re-encode,
465
+ // no four-digit rounding, no `{}` where the child wrote null.
466
+ broadcast(e.__raw ?? enc(e));
467
+ if (event === "done") pruneJobs();
468
+ });
469
+ engine.on("spawn-error", (err) => {
470
+ if (job.state !== "running") return;
471
+ audit("job-start-failed", { detail: err.message });
472
+ finish(job, { ok: false, error: `the job process could not start: ${err.message}` });
473
+ jobEmit(id, { event: "done", ok: false, error: job.error, elapsed: job.elapsed });
474
+ pruneJobs();
475
+ });
476
+ engine.on("exit", ({ code }) => {
477
+ if (job.state !== "running") return;
478
+ // Died without a `done`. Never silent: an empty log and a crashed job
479
+ // look identical on screen and mean opposite things.
480
+ finish(job, { ok: false, error: `the job process stopped unexpectedly (exit ${code == null ? "unknown" : code})` });
481
+ audit("job-died", { id, detail: String(code) });
482
+ jobEmit(id, { event: "done", ok: false, error: job.error, elapsed: job.elapsed });
483
+ pruneJobs();
484
+ });
485
+
486
+ try {
487
+ engine.start();
488
+ } catch (e) {
489
+ jobs.delete(id);
490
+ audit("job-start-failed", { detail: e.message });
491
+ jobEmit(id, { event: "refused", request, error: `the job process could not start: ${e.message}` });
492
+ return;
493
+ }
494
+ audit("job-start", { id, detail: spec.task });
495
+ // Announced before it runs, in the words of the thing that will happen.
496
+ jobEmit(id, { event: "accepted", request, task: spec.task, root: spec.root,
497
+ target: spec.target, title: job.title, started: job.started });
498
+ }
499
+
500
+ /** Any page may stop any job: a job is the kernel's, not a socket's. */
501
+ function jobStop(id) {
502
+ const job = jobs.get(id);
503
+ if (!job || job.state !== "running") return;
504
+ // No graceful protocol: job-run.R never reads stdin, so a shutdown frame
505
+ // would go unread and only the kill would land. Kill the tree directly.
506
+ killTree(job);
507
+ finish(job, { state: "stopped", ok: false, error: "stopped" });
508
+ audit("job-stop", { id });
509
+ jobEmit(id, { event: "done", ok: false, stopped: true, error: "stopped", elapsed: job.elapsed });
510
+ pruneJobs();
511
+ }
512
+
513
+ const plane = {
514
+ name: "jobs",
515
+ ops: ["job_start", "job_stop", "job_list", "job_context", "job_root", "job_open", "job_output_cap"],
516
+ // Not declared through the seam's `agentRefused`: the R answers each of
517
+ // these in the OP'S OWN WORDS, and a suite pins those words. The refusal
518
+ // is made here, first thing in handle(), with the same messages.
519
+ agentRefused: [],
520
+ // serve.R advertises no job verb in ready.commands, so neither does this.
521
+ commands: [],
522
+ capabilities: [],
523
+
524
+ /** Is any job still running? The idle clock must not stop a kernel that
525
+ * is three minutes into a check nobody is watching — precisely the case
526
+ * a detached job exists for. */
527
+ busy: () => [...jobs.values()].some((j) => j.state === "running"),
528
+
529
+ init(ctx) { ctxRef = ctx; },
530
+
531
+ handle(cmd, rec, ctx) {
532
+ ctxRef = ctx;
533
+ const type = cmd.type;
534
+ if (rec.role === "mcp" && AGENT_REFUSED[type]) {
535
+ audit("mcp-refused", { reason: `agent asked for ${type}` });
536
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: AGENT_REFUSED[type] }));
537
+ return;
538
+ }
539
+ switch (type) {
540
+ case "job_start":
541
+ if (rec.role !== "page") return;
542
+ return jobStart(cmd, rec);
543
+ case "job_stop":
544
+ if (rec.role !== "page" || !scalarChr(cmd.job)) return;
545
+ return jobStop(cmd.job);
546
+ case "job_list": {
547
+ // Reattachment: everything a page needs to render the pane it just
548
+ // opened, including the buffered log of a job that started before
549
+ // this page existed. This is what makes a job detached rather than
550
+ // merely asynchronous.
551
+ if (!scalarChr(cmd.id)) return;
552
+ const wantLog = scalarChr(cmd.job);
553
+ const ordered = [...jobs.values()].sort((a, b) => a.started - b.started);
554
+ rec.ws.send(enc({ type: "job_list", id: cmd.id,
555
+ jobs: ordered.map((j) => snapshot(j, wantLog && j.id === cmd.job)) }));
556
+ return;
557
+ }
558
+ case "job_context":
559
+ case "job_root": {
560
+ // "What can I do with what I am looking at?" — ONE question with two
561
+ // halves: the file may be inside a package, and may itself be
562
+ // renderable. It was `job_root` while it could only answer the first;
563
+ // both spellings are answered, each typed as the op that was asked,
564
+ // so an older page is never handed a frame type it does not know.
565
+ if (!scalarChr(cmd.id)) return;
566
+ let p = cmd.path ?? "";
567
+ const root = findPackageRoot(p);
568
+ const isFile = (() => { try { return fs.statSync(p).isFile(); } catch { return false; } })();
569
+ const renderable = scalarChr(p) && Boolean(p) && isFile && RENDER_EXTENSIONS.includes(pathExtension(p));
570
+ const compilable = scalarChr(p) && Boolean(p) && isFile && pathExtension(p) === "tex";
571
+ // Report the path the JOB will act on, not the one the page sent:
572
+ // jobSpec normalizes, and on macOS those differ by a /private prefix
573
+ // on everything under /var.
574
+ if (renderable || compilable) p = realish(p);
575
+ rec.ws.send(enc({ type, id: cmd.id, root, package: packageName(root),
576
+ document: renderable ? p : "", latex: compilable ? p : "",
577
+ engines: ["auto", ...LATEX_ENGINES], formats: [...RENDER_FORMATS],
578
+ tasks: Object.keys(JOB_TASKS) }));
579
+ return;
580
+ }
581
+ case "job_output_cap": {
582
+ // Page-only in BOTH senses: an agent may not ask (above), and only a
583
+ // socket the upgrade gate classed as the user's own page may ask.
584
+ if (!scalarChr(cmd.id)) return;
585
+ const reply = (fields) => rec.ws.send(enc({ type, id: cmd.id, ...fields }));
586
+ if (rec.role !== "page" || !PAGE_ONLY.includes(rec.class)) {
587
+ audit("job-output-cap-refused", { class: rec.class ?? "unknown" });
588
+ reply({ error: "Only the local notebook page may ask this." });
589
+ return;
590
+ }
591
+ if (!scalarChr(cmd.job)) { reply({ error: "which job?" }); return; }
592
+ const job = jobs.get(cmd.job);
593
+ if (!job || job.task !== "latex" || job.state !== "done" || job.ok !== true
594
+ || !scalarChr(job.output) || !fs.existsSync(job.output)) {
595
+ reply({ job: cmd.job, error: "That job has no PDF to show." });
596
+ return;
597
+ }
598
+ sweepCaps();
599
+ const t = token(32);
600
+ outputCaps.set(t, { job: cmd.job, path: job.output, expires: Date.now() / 1000 + OUTPUT_CAP_TTL });
601
+ audit("job-output-cap", { id: cmd.job });
602
+ reply({ job: cmd.job, token: t, bytes: fs.statSync(job.output).size,
603
+ name: path.basename(job.output), ttl: OUTPUT_CAP_TTL });
604
+ return;
605
+ }
606
+ case "job_open": {
607
+ // The wire names a JOB, never a path, and the host opens the path IT
608
+ // recorded from the child's own `output` frame.
609
+ if (rec.role !== "page" || !scalarChr(cmd.job)) return;
610
+ const job = jobs.get(cmd.job);
611
+ if (!job || job.state !== "done" || job.ok !== true
612
+ || !scalarChr(job.output) || !fs.existsSync(job.output)) return;
613
+ audit("job-open", { id: cmd.job });
614
+ openOnDesktop(job.output);
615
+ return;
616
+ }
617
+ default:
618
+ }
619
+ },
620
+
621
+ /**
622
+ * The file a finished job produced, by ONE-TIME CAPABILITY. The page asked
623
+ * `job_output_cap` over its gated socket and was handed a token; the token
624
+ * names a job, is spent on first use, dies after the TTL, and appears in no
625
+ * listing. A drive-by that merely found the port has nothing to present.
626
+ */
627
+ http(req, res, pathname, ctx) {
628
+ if (pathname !== "/job-output") return false;
629
+ const blocked = ctx.controlRejection(req, { allowFile: true, allowApproved: true });
630
+ if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
631
+ const origin = req.headers.origin || "";
632
+ // A page that sent an Origin needs CORS to READ the body it was granted;
633
+ // a same-origin page needs nothing. Never "*". REFUSALS carry it too, or
634
+ // a 404 reaches a file page as the browser's own "Failed to fetch".
635
+ const cors = origin ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" } : {};
636
+ const cap = ctx.queryParam(req, "cap");
637
+ sweepCaps();
638
+ let grant = null;
639
+ if (/^[A-Za-z0-9]{32}$/.test(cap)) {
640
+ grant = outputCaps.get(cap) || null;
641
+ outputCaps.delete(cap); // spent on first use
642
+ if (grant && grant.expires < Date.now() / 1000) grant = null;
643
+ }
644
+ if (!grant) {
645
+ audit("job-output-refused", {});
646
+ ctx.respond(res, 404, "text/plain", "no such output", cors);
647
+ return true;
648
+ }
649
+ const job = jobs.get(grant.job);
650
+ let stat = null;
651
+ try { stat = fs.statSync(grant.path); } catch { stat = null; }
652
+ if (!job || job.state !== "done" || job.ok !== true || job.output !== grant.path
653
+ || !stat || !stat.isFile()) {
654
+ audit("job-output-refused", { id: grant.job });
655
+ ctx.respond(res, 404, "text/plain", "no such output", cors);
656
+ return true;
657
+ }
658
+ if (!Number.isFinite(stat.size) || stat.size > OUTPUT_CAP_MAX_BYTES) {
659
+ audit("job-output-refused", { id: grant.job, detail: "too large" });
660
+ ctx.respond(res, 413, "text/plain", "output too large to serve", cors);
661
+ return true;
662
+ }
663
+ let bytes;
664
+ try { bytes = fs.readFileSync(grant.path); } catch { bytes = null; }
665
+ if (!bytes) { ctx.respond(res, 500, "text/plain", "could not read the output", cors); return true; }
666
+ audit("job-output", { id: grant.job });
667
+ ctx.respond(res, 200, "application/pdf", bytes, {
668
+ // serve.R writes the name's UTF-8 BYTES into the header. Node writes a
669
+ // header string as Latin-1 and THROWS on anything above U+00FF, which
670
+ // dropped the connection for `paper 🧬.pdf` — so the same bytes are
671
+ // handed over spelled as Latin-1, and the wire matches R's exactly.
672
+ "Content-Disposition": Buffer.from(`inline; filename="${path.basename(grant.path).replace(/["\\\r\n]/g, "_")}"`, "utf8").toString("latin1"),
673
+ "Content-Length": String(bytes.length),
674
+ ...cors,
675
+ });
676
+ return true;
677
+ },
678
+
679
+ /** A job outlives the socket that started it, so nothing happens here. */
680
+ onClose() {},
681
+
682
+ /** The kernel is going away; no job may outlive it. */
683
+ shutdown() { for (const job of jobs.values()) if (job.state === "running") killTree(job); },
684
+ };
685
+ return plane;
686
+ }
687
+
688
+ /** `utils::browseURL` for the platform — an argument VECTOR, never a shell string. */
689
+ function openOnDesktop(p) {
690
+ try {
691
+ // CARMAR_OPENER: an executable handed the path as its ONE argument. R's
692
+ // browseURL reads `options(browser)` for the same purpose, and a test rig
693
+ // that swaps that option is invisible to a supervisor that is not R — so
694
+ // without this the host opened every synthetic PDF a suite produced on the
695
+ // user's real desktop. Absolute paths only; never a shell string.
696
+ const opener = String(process.env.CARMAR_OPENER || "");
697
+ const child = path.isAbsolute(opener) ? spawn(opener, [p], { stdio: "ignore", detached: true })
698
+ : process.platform === "darwin" ? spawn("open", [p], { stdio: "ignore", detached: true })
699
+ : process.platform === "win32" ? spawn("cmd", ["/c", "start", "", p], { stdio: "ignore", detached: true })
700
+ : spawn("xdg-open", [p], { stdio: "ignore", detached: true });
701
+ child.on("error", () => { /* no desktop opener: nothing opens, nothing breaks */ });
702
+ child.unref();
703
+ } catch { /* no desktop opener */ }
704
+ }