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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// acceptance.mjs — does Beatrina do, on THIS machine, what a person needs?
|
|
3
|
+
//
|
|
4
|
+
// One file, no dependencies, so it can be copied to a Windows laptop and run
|
|
5
|
+
// with the Node Beatrina already needs. It starts a supervisor on a scratch
|
|
6
|
+
// port, opens a socket as the page does (Origin = the page the host served,
|
|
7
|
+
// so page-only ops such as the terminal and a readline() answer are allowed),
|
|
8
|
+
// and measures the things docs/beatrix-alternative-plan.md Stages 0, 1 and 4
|
|
9
|
+
// are judged by:
|
|
10
|
+
//
|
|
11
|
+
// startup · run · Stop (sleep, tight loop, a system() child) with the session
|
|
12
|
+
// kept · readline() · file ops and completion while R is busy · Force Stop ·
|
|
13
|
+
// the terminal · Python and JavaScript chunks when those engines exist.
|
|
14
|
+
//
|
|
15
|
+
// Usage
|
|
16
|
+
// node tools/beatrina/acceptance.mjs # this checkout's host
|
|
17
|
+
// node tools/beatrina/acceptance.mjs --installed # the installed carmar package's host
|
|
18
|
+
// node tools/beatrina/acceptance.mjs --host=C:\path\to\host\main.mjs
|
|
19
|
+
// node tools/beatrina/acceptance.mjs --supervisor=serve-r # the retired R supervisor, for comparison
|
|
20
|
+
// options: --port=28900 --repeat=5 --json=out.json --junit=out.xml --only=stop,readline --log=host.log
|
|
21
|
+
//
|
|
22
|
+
// Exit status: 0 when nothing FAILED (a SKIP is reported, never counted as a pass).
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { WIN, pause, startSession, supervisorCommand } from "./session.mjs";
|
|
29
|
+
|
|
30
|
+
const argv = Object.fromEntries(process.argv.slice(2).map((a) => {
|
|
31
|
+
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
|
|
32
|
+
return m ? [m[1], m[2] ?? "1"] : [a, "1"];
|
|
33
|
+
}));
|
|
34
|
+
const PORT = Number(argv.port || 28900 + Math.floor(Math.random() * 400));
|
|
35
|
+
const REPEAT = Math.max(1, Number(argv.repeat || 3));
|
|
36
|
+
const ONLY = argv.only ? new Set(argv.only.split(",")) : null;
|
|
37
|
+
const SUPERVISOR = argv.supervisor || "beatrix";
|
|
38
|
+
|
|
39
|
+
const results = [];
|
|
40
|
+
const record = (area, name, status, detail = "", ms = null, extra = {}) => {
|
|
41
|
+
results.push({ area, name, status, detail: String(detail), ms, ...extra });
|
|
42
|
+
const tag = { pass: "PASS", fail: "FAIL", skip: "SKIP", note: "NOTE" }[status];
|
|
43
|
+
console.log(` ${tag} [${area}] ${name}${ms != null ? ` (${Math.round(ms)} ms)` : ""}${detail ? ` — ${String(detail).slice(0, 220)}` : ""}`);
|
|
44
|
+
};
|
|
45
|
+
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.floor((s.length - 1) / 2)] : null; };
|
|
46
|
+
const want = (area) => !ONLY || ONLY.has(area);
|
|
47
|
+
|
|
48
|
+
// ── the session under test ───────────────────────────────────────────────────
|
|
49
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-accept-"));
|
|
50
|
+
const sup = supervisorCommand({ supervisor: SUPERVISOR, host: argv.host || "", installed: Boolean(argv.installed), binary: argv.binary || "", scratch });
|
|
51
|
+
console.log(`\nBeatrina acceptance — ${sup.label}\n ${os.type()} ${os.release()} ${process.arch} · node ${process.version} · port ${PORT}\n`);
|
|
52
|
+
const session = await startSession({ command: sup, port: PORT, cwd: scratch, logFile: argv.log ? path.resolve(argv.log) : "" });
|
|
53
|
+
|
|
54
|
+
async function shutdown(code) {
|
|
55
|
+
await session.shutdown();
|
|
56
|
+
writeReports();
|
|
57
|
+
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* Windows may hold a file a moment longer */ }
|
|
58
|
+
process.exit(code);
|
|
59
|
+
}
|
|
60
|
+
if (!session.announced) {
|
|
61
|
+
record("startup", "the supervisor announces a URL", "fail", session.log().split("\n").slice(-6).join(" | "));
|
|
62
|
+
await shutdown(1);
|
|
63
|
+
}
|
|
64
|
+
const { frames, until, ready, page } = session;
|
|
65
|
+
if (!ready) { record("startup", "R announces ready", "fail", session.log().slice(-400)); await shutdown(1); }
|
|
66
|
+
record("startup", `R ready (${ready.r || "?"}, worker mode ${ready.mode || "?"})`, "pass", "", session.startupMs, { r: ready.r, mode: ready.mode });
|
|
67
|
+
|
|
68
|
+
let seq = 0;
|
|
69
|
+
const text = (id) => session.text(id);
|
|
70
|
+
const run = (source, opts = {}) => session.run(source, opts);
|
|
71
|
+
const engines = Array.isArray(ready.engines) ? ready.engines : ["r"];
|
|
72
|
+
|
|
73
|
+
// ── run ──────────────────────────────────────────────────────────────────────
|
|
74
|
+
if (want("run")) {
|
|
75
|
+
const r = await run('keep <- 42; cat("keep=", keep, "\\n")');
|
|
76
|
+
record("run", "a chunk runs and prints", r.done?.status === "ok" && /keep= 42/.test(r.out()) ? "pass" : "fail", r.out().trim(), r.ms);
|
|
77
|
+
const warm = [];
|
|
78
|
+
for (let i = 0; i < REPEAT * 3; i += 1) warm.push((await run("invisible(1)")).ms);
|
|
79
|
+
record("run", `round trip of a trivial chunk, median of ${warm.length}`, "pass", `max ${Math.max(...warm)} ms`, median(warm), { series: warm });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── stop ─────────────────────────────────────────────────────────────────────
|
|
83
|
+
async function stopCase(label, source, { engine, probe, expect, after = 900, keep }) {
|
|
84
|
+
// Every repetition is counted, not stopped at the first miss: a race shows
|
|
85
|
+
// up as a RATE, and one bad run in twenty is the finding.
|
|
86
|
+
const lat = [];
|
|
87
|
+
const misses = [];
|
|
88
|
+
for (let i = 0; i < REPEAT; i += 1) {
|
|
89
|
+
const id = `s${++seq}`;
|
|
90
|
+
const from = frames.length;
|
|
91
|
+
page.send({ type: "exec", id, source, ...(engine ? { engine } : {}) });
|
|
92
|
+
await pause(after);
|
|
93
|
+
const early = frames.slice(from).find((f) => f.type === "done" && f.id === id);
|
|
94
|
+
if (early) { misses.push(`finished before Stop: ${early.status}`); continue; }
|
|
95
|
+
const t = Date.now();
|
|
96
|
+
page.send({ type: "interrupt", id });
|
|
97
|
+
const done = await until((f) => f.type === "done" && f.id === id, 15000, from);
|
|
98
|
+
if (!done) { misses.push("no done frame 15 s after Stop"); break; }
|
|
99
|
+
// The harm is code AFTER the busy line running although Stop was pressed.
|
|
100
|
+
if (/after-stop/.test(text(id))) misses.push("the line after the busy call ran");
|
|
101
|
+
else lat.push(Date.now() - t);
|
|
102
|
+
const p = await run(probe, { engine, ms: 30000 });
|
|
103
|
+
if (!expect.test(p.out())) {
|
|
104
|
+
misses.push(`next chunk ${p.done?.status || "hung"}: ${JSON.stringify(p.out().trim().slice(0, 60))}`);
|
|
105
|
+
if (keep) await run(keep, { engine });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const summary = misses.length ? `${misses.length}/${REPEAT} missed — ${[...new Set(misses)].join("; ")}` : `max ${lat.length ? Math.max(...lat) : "—"} ms`;
|
|
109
|
+
record("stop", `${label}: interrupted with the session kept, ${REPEAT - misses.length}/${REPEAT}`, misses.length ? "fail" : "pass",
|
|
110
|
+
summary, lat.length ? median(lat) : null, { series: lat, misses: misses.length, repeat: REPEAT });
|
|
111
|
+
}
|
|
112
|
+
if (want("stop")) {
|
|
113
|
+
const probe = 'cat("keep=", exists("keep") && keep == 42, "\\n")';
|
|
114
|
+
await run("keep <- 42");
|
|
115
|
+
const keep = "keep <- 42";
|
|
116
|
+
await stopCase("Sys.sleep(60)", "Sys.sleep(60); cat('after-stop\\n')", { probe, expect: /keep= TRUE/, keep });
|
|
117
|
+
await stopCase("a tight loop", "i <- 0; while (i < 1e12) i <- i + 1; cat('after-stop\\n')", { probe, expect: /keep= TRUE/, keep });
|
|
118
|
+
await stopCase("a system() child", WIN ? 'system("ping -n 60 127.0.0.1", ignore.stdout = TRUE); cat("after-stop\\n")' : 'system("sleep 60"); cat("after-stop\\n")', { probe, expect: /keep= TRUE/, after: 1500, keep });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── readline ─────────────────────────────────────────────────────────────────
|
|
122
|
+
if (want("readline")) {
|
|
123
|
+
const id = `r${++seq}`;
|
|
124
|
+
const from = frames.length;
|
|
125
|
+
page.send({ type: "exec", id, source: 'ans <- readline("Your name: "); cat("hello", ans, "\\n")' });
|
|
126
|
+
const asked = await until((f) => (f.type === "input_request") || (f.type === "done" && f.id === id), 10000, from);
|
|
127
|
+
if (!asked) record("readline", "readline() asks the page", "fail", "neither a question nor a done frame in 10 s");
|
|
128
|
+
else if (asked.type === "done") record("readline", "readline() asks the page", "fail", `returned without asking (worker mode ${ready.mode}): ${JSON.stringify(text(id))}`);
|
|
129
|
+
else {
|
|
130
|
+
page.send({ type: "input_reply", id: `rr${seq}`, value: "Beyza" });
|
|
131
|
+
const done = await until((f) => f.type === "done" && f.id === id, 10000, from);
|
|
132
|
+
record("readline", "readline() asks the page and receives the answer", done?.status === "ok" && /hello Beyza/.test(text(id)) ? "pass" : "fail", text(id).trim());
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── while busy ───────────────────────────────────────────────────────────────
|
|
137
|
+
const ask = (type, payload, ms) => session.ask(type, payload, ms);
|
|
138
|
+
if (want("busy")) {
|
|
139
|
+
fs.writeFileSync(path.join(scratch, "note.txt"), "hello");
|
|
140
|
+
const id = `b${++seq}`;
|
|
141
|
+
const from = frames.length;
|
|
142
|
+
page.send({ type: "exec", id, source: "Sys.sleep(30)" });
|
|
143
|
+
await pause(800);
|
|
144
|
+
const w = await ask("writefile", { path: path.join(scratch, "saved.txt"), text: "saved while R was busy" });
|
|
145
|
+
record("busy", "Save (writefile) while a chunk runs", w.reply && !w.reply.error && fs.existsSync(path.join(scratch, "saved.txt")) ? "pass" : "fail", w.reply ? (w.reply.error || "") : "no reply", w.ms);
|
|
146
|
+
const rd = await ask("readfile", { path: path.join(scratch, "note.txt") });
|
|
147
|
+
record("busy", "Open (readfile) while a chunk runs", rd.reply && rd.reply.text === "hello" ? "pass" : "fail", rd.reply ? (rd.reply.error || "") : "no reply", rd.ms);
|
|
148
|
+
// Diagnostics come from the analysis process, which never waits for R.
|
|
149
|
+
const a = await ask("analyze", { uri: "chunk:accept", version: 1, source: "x <- 1\ny z" }, 15000);
|
|
150
|
+
const diag = JSON.stringify(a.reply || {});
|
|
151
|
+
record("busy", "diagnostics (analyze) while a chunk runs", a.reply && a.reply.type === "analyze" && /unexpected|error|syntax/i.test(diag) ? "pass" : "fail",
|
|
152
|
+
a.reply ? diag.slice(0, 120) : "no reply in 15 s", a.ms);
|
|
153
|
+
// Live completion reads R's workspace, so it is a WORKER op and waits by
|
|
154
|
+
// design; the page stops waiting after 1.5 s and keeps the analyzer's names.
|
|
155
|
+
const c = await ask("complete", { line: "rowSum", cursor: 6 }, 2500);
|
|
156
|
+
record("busy", "live completion while a chunk runs (a worker op: waits for R by design)", "note",
|
|
157
|
+
c.reply ? "answered" : "no answer within the page's 1.5 s budget, as designed", c.ms);
|
|
158
|
+
page.send({ type: "interrupt", id });
|
|
159
|
+
await until((f) => f.type === "done" && f.id === id, 15000, from);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── force stop ───────────────────────────────────────────────────────────────
|
|
163
|
+
if (want("force")) {
|
|
164
|
+
await run("keep <- 42");
|
|
165
|
+
const id = `f${++seq}`;
|
|
166
|
+
const from = frames.length;
|
|
167
|
+
page.send({ type: "exec", id, source: "repeat tryCatch(Sys.sleep(1), interrupt = function(e) NULL)" });
|
|
168
|
+
await pause(900);
|
|
169
|
+
const t = Date.now();
|
|
170
|
+
page.send({ type: "force_stop", id });
|
|
171
|
+
const done = await until((f) => f.type === "done" && f.id === id, 20000, from);
|
|
172
|
+
const again = await until((f) => f.type === "ready", 60000, from);
|
|
173
|
+
const p = again ? await run('cat("fresh=", !exists("keep"), "\\n")') : null;
|
|
174
|
+
record("force", "Force Stop ends an uninterruptible chunk and a fresh R answers", done && again && p && /fresh= TRUE/.test(p.out()) ? "pass" : "fail",
|
|
175
|
+
`done=${done ? done.status : "none"} ready=${Boolean(again)} probe=${p ? p.out().trim() : "—"}`, again ? Date.now() - t : null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── crash recovery ───────────────────────────────────────────────────────────
|
|
179
|
+
// R dies under a running chunk (kill -9, as a segfault or the OOM killer would):
|
|
180
|
+
// the chunk must END with a sentence, the page must be told, and a fresh R must
|
|
181
|
+
// answer — never a spinner that waits for a process that is gone.
|
|
182
|
+
if (want("crash")) {
|
|
183
|
+
const probe = await run('cat("pid=", Sys.getpid(), "\\n")');
|
|
184
|
+
const pid = Number((probe.out().match(/pid= (\d+)/) || [])[1]);
|
|
185
|
+
const id = `c${++seq}`;
|
|
186
|
+
const from = frames.length;
|
|
187
|
+
page.send({ type: "exec", id, source: "Sys.sleep(60)" });
|
|
188
|
+
await pause(800);
|
|
189
|
+
const t = Date.now();
|
|
190
|
+
let killed = false;
|
|
191
|
+
try { process.kill(pid, WIN ? undefined : "SIGKILL"); killed = true; } catch { killed = false; }
|
|
192
|
+
const done = await until((f) => f.type === "done" && f.id === id, 20000, from);
|
|
193
|
+
const told = await until((f) => f.type === "worker-died", 20000, from);
|
|
194
|
+
const again = await until((f) => f.type === "ready" && Number(f.pid) !== pid, 60000, from);
|
|
195
|
+
const after = again ? await run('cat("alive after crash\\n")') : null;
|
|
196
|
+
record("crash", "R killed under a running chunk: the chunk ends, the page is told, a fresh R answers",
|
|
197
|
+
killed && done && told && again && /alive after crash/.test(after ? after.out() : "") ? "pass" : "fail",
|
|
198
|
+
`killed=${killed} done=${done ? done.status : "none"} worker-died=${Boolean(told)} ready=${Boolean(again)}`, again ? Date.now() - t : null);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── memory over a working session ────────────────────────────────────────────
|
|
202
|
+
// The SUPERVISOR's resident memory while a session runs many chunks, some with
|
|
203
|
+
// tables. Growth that does not level off is the leak an 8-hour day would find.
|
|
204
|
+
function rssKb(pid) {
|
|
205
|
+
if (WIN) {
|
|
206
|
+
const r = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8" });
|
|
207
|
+
const m = String(r.stdout).match(/"([\d,.\s]+) K"/);
|
|
208
|
+
return m ? Number(m[1].replace(/[^\d]/g, "")) : null;
|
|
209
|
+
}
|
|
210
|
+
const r = spawnSync("ps", ["-o", "rss=", "-p", String(pid)], { encoding: "utf8" });
|
|
211
|
+
const v = Number(String(r.stdout).trim());
|
|
212
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
213
|
+
}
|
|
214
|
+
if (want("memory")) {
|
|
215
|
+
const rounds = Math.max(4, Number(argv.memoryRounds || 8));
|
|
216
|
+
const samples = [];
|
|
217
|
+
await run("df <- data.frame(id = seq_len(5000), x = rnorm(5000), g = sample(letters, 5000, TRUE))");
|
|
218
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
219
|
+
for (let i = 0; i < 50; i += 1) await run(i % 10 === 0 ? "df" : `invisible(sum(df$x) + ${i})`);
|
|
220
|
+
samples.push(rssKb(session.child.pid));
|
|
221
|
+
}
|
|
222
|
+
const ok = samples.every((v) => v != null);
|
|
223
|
+
const firstHalf = samples.slice(0, Math.floor(samples.length / 2));
|
|
224
|
+
const secondHalf = samples.slice(Math.floor(samples.length / 2));
|
|
225
|
+
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
226
|
+
const growthMb = ok ? (mean(secondHalf) - mean(firstHalf)) / 1024 : null;
|
|
227
|
+
record("memory", `supervisor memory over ${rounds * 50} chunks (every tenth prints a 5,000-row table)`,
|
|
228
|
+
ok ? "note" : "skip", ok ? `RSS ${Math.round(samples[0] / 1024)} → ${Math.round(samples[samples.length - 1] / 1024)} MB; second half vs first half ${growthMb >= 0 ? "+" : ""}${growthMb.toFixed(1)} MB` : "memory could not be read on this platform",
|
|
229
|
+
null, { rssKb: samples, growthMb });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── terminal ─────────────────────────────────────────────────────────────────
|
|
233
|
+
if (want("terminal")) {
|
|
234
|
+
const from = frames.length;
|
|
235
|
+
page.send({ type: "term_open", cols: 80, rows: 24, cwd: scratch });
|
|
236
|
+
const opened = await until((f) => f.type === "term" && (f.event === "open" || f.event === "error" || f.error), 10000, from);
|
|
237
|
+
if (!opened || opened.event !== "open") {
|
|
238
|
+
record("terminal", "a terminal opens", "fail", opened ? (opened.message || opened.error || JSON.stringify(opened)) : "no answer in 10 s");
|
|
239
|
+
} else {
|
|
240
|
+
await pause(800);
|
|
241
|
+
page.send({ type: "term_input", id: opened.id, text: WIN ? "echo beatrina-%NUMBER_OF_PROCESSORS%-ok\r\n" : "echo beatrina-$((6*7))\n" });
|
|
242
|
+
const hit = await until((f) => f.type === "term" && f.id === opened.id && f.event === "data" && (WIN ? /beatrina-\d+-ok/ : /beatrina-42/).test(f.text || ""), 10000, from);
|
|
243
|
+
record("terminal", "a terminal opens and runs a command", hit ? "pass" : "fail", hit ? "" : "no output in 10 s");
|
|
244
|
+
page.send({ type: "term_close", id: opened.id });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── other engines ────────────────────────────────────────────────────────────
|
|
249
|
+
for (const [engine, setup, probe, sleeper] of [
|
|
250
|
+
["python", "keep_py = 42\nprint('py keep', keep_py)", "print('py keep', keep_py)", "import time\ntime.sleep(60)\nprint('after-stop')"],
|
|
251
|
+
["js", "globalThis.keepJs = 42; console.log('js keep', keepJs)", "console.log('js keep', keepJs)", "for (let i = 0; i < 1e13; i++) {}\nconsole.log('after-stop')"],
|
|
252
|
+
]) {
|
|
253
|
+
if (!want(engine)) continue;
|
|
254
|
+
const row = Array.isArray(ready.engine_status) ? ready.engine_status.find((e) => e.engine === engine) : null;
|
|
255
|
+
if (!engines.includes(engine)) { record(engine, `${engine} chunks`, "skip", row ? row.detail || "engine not available" : "this host has no such engine"); continue; }
|
|
256
|
+
const first = await run(setup, { engine, ms: 120000 });
|
|
257
|
+
record(engine, `a ${engine} chunk runs (first run starts the engine)`, first.done?.status === "ok" && /keep 42/.test(first.out()) ? "pass" : "fail", first.out().trim() || first.done?.message || "", first.ms);
|
|
258
|
+
await stopCase(`${engine}: a busy chunk`, sleeper, { engine, probe, expect: /keep 42/, after: 1500, keep: setup });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── report ───────────────────────────────────────────────────────────────────
|
|
262
|
+
function writeReports() {
|
|
263
|
+
const summary = {
|
|
264
|
+
supervisor: sup.label, platform: `${os.type()} ${os.release()}`, arch: process.arch, node: process.version,
|
|
265
|
+
r: results.find((r) => r.r)?.r || "", when: new Date().toISOString(), repeat: REPEAT,
|
|
266
|
+
passed: results.filter((r) => r.status === "pass").length, failed: results.filter((r) => r.status === "fail").length,
|
|
267
|
+
skipped: results.filter((r) => r.status === "skip").length, results,
|
|
268
|
+
};
|
|
269
|
+
if (argv.json) fs.writeFileSync(argv.json, JSON.stringify(summary, null, 2));
|
|
270
|
+
if (argv.junit) {
|
|
271
|
+
const esc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "\"": """ })[c]);
|
|
272
|
+
const cases = results.map((r) => ` <testcase classname="beatrina.acceptance.${esc(r.area)}" name="${esc(r.name)}" time="${((r.ms || 0) / 1000).toFixed(3)}">${
|
|
273
|
+
r.status === "fail" ? `<failure message="${esc(r.detail)}"/>` : r.status === "skip" ? `<skipped message="${esc(r.detail)}"/>` : ""}</testcase>`);
|
|
274
|
+
fs.writeFileSync(argv.junit, `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="beatrina-acceptance" tests="${results.length}" failures="${summary.failed}" skipped="${summary.skipped}">\n${cases.join("\n")}\n</testsuite>\n`);
|
|
275
|
+
}
|
|
276
|
+
console.log(`\n${summary.passed} passed · ${summary.failed} failed · ${summary.skipped} skipped — ${sup.label}`);
|
|
277
|
+
}
|
|
278
|
+
await shutdown(results.some((r) => r.status === "fail") ? 1 : 0);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// session.mjs — start a Beatrix (or the retired R supervisor) and talk to it as the page does.
|
|
2
|
+
//
|
|
3
|
+
// Shared by tools/beatrina/acceptance.mjs and tools/beatrina/run-document.mjs.
|
|
4
|
+
// No dependencies beyond Node, so the folder can be copied to a Windows laptop.
|
|
5
|
+
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import net from "node:net";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
15
|
+
export const WIN = process.platform === "win32";
|
|
16
|
+
export const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
|
|
18
|
+
/** The installed carmar package's host/main.mjs, asked of R itself. */
|
|
19
|
+
export function installedHost() {
|
|
20
|
+
const r = spawnSync(WIN ? "Rscript.exe" : "Rscript",
|
|
21
|
+
["-e", "cat(system.file('app', 'host', 'main.mjs', package = 'carmar'))"], { encoding: "utf8" });
|
|
22
|
+
return String(r.stdout || "").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to start. `supervisor: "serve-r"` stages the retired R supervisor
|
|
27
|
+
* (comparison only); otherwise a Beatrix host: `host` path, the installed
|
|
28
|
+
* package's, or this checkout's.
|
|
29
|
+
*/
|
|
30
|
+
export function supervisorCommand({ supervisor = "beatrix", host = "", installed = false, binary = "", scratch }) {
|
|
31
|
+
if (binary) {
|
|
32
|
+
// A packaged single executable (the executable build is retired): it takes no script.
|
|
33
|
+
const abs = path.resolve(binary); // the session runs in a scratch cwd
|
|
34
|
+
if (!fs.existsSync(abs)) throw new Error(`no Beatrina executable at ${abs}`);
|
|
35
|
+
return { label: `Beatrina executable (${path.basename(abs)})`, cmd: abs, args: [] };
|
|
36
|
+
}
|
|
37
|
+
if (supervisor === "serve-r") {
|
|
38
|
+
const stage = path.join(scratch, "serve-r");
|
|
39
|
+
fs.mkdirSync(stage, { recursive: true });
|
|
40
|
+
for (const dir of [path.join(repo, "spike"), path.join(repo, "retired", "r-supervisor", "spike")]) {
|
|
41
|
+
for (const f of fs.readdirSync(dir)) {
|
|
42
|
+
const p = path.join(dir, f);
|
|
43
|
+
if (fs.statSync(p).isFile()) fs.copyFileSync(p, path.join(stage, f));
|
|
44
|
+
else if (f === "plugins" || f === "examples") fs.cpSync(p, path.join(stage, f), { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
fs.copyFileSync(path.join(repo, "kernel.protocol"), path.join(stage, "kernel-protocol"));
|
|
48
|
+
const version = String(spawnSync(process.execPath, ["-p", "require(process.argv[1]).version", path.join(repo, "notebook.version.cjs")], { encoding: "utf8" }).stdout).trim();
|
|
49
|
+
fs.writeFileSync(path.join(stage, "kernel-version"), `${version}\n`);
|
|
50
|
+
for (const f of fs.readdirSync(path.join(repo, "dist")).filter((x) => /^carmar_V.*\.html$/.test(x))) {
|
|
51
|
+
fs.copyFileSync(path.join(repo, "dist", f), path.join(stage, f));
|
|
52
|
+
}
|
|
53
|
+
return { label: "R supervisor (serve.R, retired)", cmd: WIN ? "Rscript.exe" : "Rscript", args: [path.join(stage, "serve.R")] };
|
|
54
|
+
}
|
|
55
|
+
// Absolute: the session runs in a scratch cwd, and check.R passes a path relative to the kit.
|
|
56
|
+
const main = path.resolve(host || (installed ? installedHost() : path.join(repo, "host", "main.mjs")));
|
|
57
|
+
if (!main || !fs.existsSync(main)) throw new Error(`no Beatrina host at ${main || "(installed package not found)"}`);
|
|
58
|
+
return { label: `Beatrina (${path.relative(process.cwd(), main) || main})`, cmd: process.execPath, args: [main] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── a WebSocket that can carry an Origin (Node's own cannot) ─────────────────
|
|
62
|
+
export function openSocket(port, origin) {
|
|
63
|
+
const key = crypto.randomBytes(16).toString("base64");
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const sock = net.connect(port, "127.0.0.1");
|
|
66
|
+
let buf = Buffer.alloc(0);
|
|
67
|
+
let open = false;
|
|
68
|
+
let partial = null;
|
|
69
|
+
const handlers = [];
|
|
70
|
+
const frame = (text) => {
|
|
71
|
+
const body = Buffer.from(text, "utf8");
|
|
72
|
+
const mask = crypto.randomBytes(4);
|
|
73
|
+
const head = body.length < 126 ? Buffer.from([0x81, 0x80 | body.length])
|
|
74
|
+
: body.length < 65536 ? Buffer.from([0x81, 0xfe, body.length >> 8, body.length & 0xff])
|
|
75
|
+
: Buffer.concat([Buffer.from([0x81, 0xff]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(body.length)); return b; })()]);
|
|
76
|
+
for (let i = 0; i < body.length; i += 1) body[i] ^= mask[i & 3];
|
|
77
|
+
return Buffer.concat([head, mask, body]);
|
|
78
|
+
};
|
|
79
|
+
sock.on("error", reject);
|
|
80
|
+
sock.on("connect", () => sock.write([`GET / HTTP/1.1`, `Host: 127.0.0.1:${port}`, "Upgrade: websocket", "Connection: Upgrade",
|
|
81
|
+
`Sec-WebSocket-Key: ${key}`, "Sec-WebSocket-Version: 13", `Origin: ${origin}`, "", ""].join("\r\n")));
|
|
82
|
+
sock.on("data", (chunk) => {
|
|
83
|
+
buf = Buffer.concat([buf, chunk]);
|
|
84
|
+
if (!open) {
|
|
85
|
+
const end = buf.indexOf("\r\n\r\n");
|
|
86
|
+
if (end < 0) return;
|
|
87
|
+
const status = buf.subarray(0, end).toString("latin1").split(" ")[1];
|
|
88
|
+
buf = buf.subarray(end + 4);
|
|
89
|
+
if (status !== "101") { sock.destroy(); reject(new Error(`socket refused: HTTP ${status}`)); return; }
|
|
90
|
+
open = true;
|
|
91
|
+
resolve(api);
|
|
92
|
+
}
|
|
93
|
+
for (;;) {
|
|
94
|
+
if (buf.length < 2) return;
|
|
95
|
+
const fin = (buf[0] & 0x80) !== 0;
|
|
96
|
+
const op = buf[0] & 0x0f;
|
|
97
|
+
let len = buf[1] & 0x7f;
|
|
98
|
+
let off = 2;
|
|
99
|
+
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; } else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; }
|
|
100
|
+
if (buf.length < off + len) return;
|
|
101
|
+
const payload = buf.subarray(off, off + len);
|
|
102
|
+
buf = buf.subarray(off + len);
|
|
103
|
+
if (op === 0x9) { const pong = Buffer.from(frame("")); pong[0] = 0x8a; sock.write(pong); continue; }
|
|
104
|
+
if (op === 0x8) { sock.end(); return; }
|
|
105
|
+
if (op === 0x1 || op === 0x0) {
|
|
106
|
+
partial = op === 0x1 ? payload : Buffer.concat([partial || Buffer.alloc(0), payload]);
|
|
107
|
+
if (!fin) continue;
|
|
108
|
+
const text = partial.toString("utf8");
|
|
109
|
+
partial = null;
|
|
110
|
+
handlers.forEach((fn) => fn(text));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
const api = {
|
|
115
|
+
send: (obj) => sock.write(frame(JSON.stringify(obj))),
|
|
116
|
+
onMessage: (fn) => handlers.push(fn),
|
|
117
|
+
close: () => { try { sock.destroy(); } catch { /* gone */ } },
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Start a supervisor on `port`, open a page socket, and wait for R's ready.
|
|
125
|
+
* @returns {Promise<object>} the session: frames, until, text, run, ask, ready, startupMs, log, shutdown
|
|
126
|
+
*/
|
|
127
|
+
export async function startSession({ command, port, cwd, env = {}, readyMs = 90000, logFile = "" }) {
|
|
128
|
+
const t0 = Date.now();
|
|
129
|
+
const child = spawn(command.cmd, command.args, {
|
|
130
|
+
cwd,
|
|
131
|
+
env: {
|
|
132
|
+
...process.env, CARMAR_PORT: String(port), CARMAR_NO_NATIVE_DIALOG: "1", CARMAR_NO_RUNTIME_FILE: "1",
|
|
133
|
+
CARMAR_HISTORY_DIR: path.join(cwd, ".beatrina-history"), CARMAR_NO_UPDATE_CHECK: "1", CARMAR_LINGER: "0",
|
|
134
|
+
BEATRIX_NO_OPEN: "1", // the `beatrix` command opens a browser unless told not to
|
|
135
|
+
...env,
|
|
136
|
+
},
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
let hostLog = "";
|
|
141
|
+
// A binary that cannot be started is a result to report, not a crash.
|
|
142
|
+
child.on("error", (e) => { hostLog += `\n${e.message}`; });
|
|
143
|
+
child.stdout.setEncoding("utf8");
|
|
144
|
+
child.stderr.setEncoding("utf8");
|
|
145
|
+
child.stderr.on("data", (d) => { hostLog = (hostLog + d).slice(-6000); });
|
|
146
|
+
// The whole host log, both streams, when asked (tools/beatrina/repeat-acceptance.mjs keeps one per run).
|
|
147
|
+
if (logFile) {
|
|
148
|
+
const log = fs.createWriteStream(logFile, { flags: "a" });
|
|
149
|
+
child.stdout.on("data", (d) => log.write(d));
|
|
150
|
+
child.stderr.on("data", (d) => log.write(d));
|
|
151
|
+
child.on("exit", (code, signal) => log.end(`\n[host exited: ${signal || code}]\n`));
|
|
152
|
+
}
|
|
153
|
+
const announced = await new Promise((resolve) => {
|
|
154
|
+
let out = "";
|
|
155
|
+
const timer = setTimeout(() => resolve(null), 120000);
|
|
156
|
+
child.stdout.on("data", (d) => {
|
|
157
|
+
out += d;
|
|
158
|
+
const line = out.split("\n").find((l) => /"url"/.test(l));
|
|
159
|
+
if (line) { clearTimeout(timer); try { resolve(JSON.parse(line)); } catch { resolve(null); } }
|
|
160
|
+
});
|
|
161
|
+
child.on("exit", () => { clearTimeout(timer); resolve(null); });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
let stopped = false;
|
|
165
|
+
const shutdown = async () => {
|
|
166
|
+
if (stopped) return;
|
|
167
|
+
stopped = true;
|
|
168
|
+
if (session.page) session.page.close();
|
|
169
|
+
try { await fetch(`http://127.0.0.1:${port}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) }); } catch { /* fall through */ }
|
|
170
|
+
const exited = child.exitCode !== null || await Promise.race([new Promise((r) => child.once("exit", () => r(true))), pause(8000).then(() => false)]);
|
|
171
|
+
if (!exited && child.exitCode === null) {
|
|
172
|
+
if (WIN) spawnSync("taskkill", ["/T", "/F", "/PID", String(child.pid)]);
|
|
173
|
+
else { try { process.kill(child.pid, "SIGKILL"); } catch { /* gone */ } }
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const session = { child, port, announced, page: null, frames: [], ready: null, startupMs: null, log: () => hostLog, shutdown };
|
|
177
|
+
if (!announced) return session;
|
|
178
|
+
|
|
179
|
+
const page = await openSocket(port, `http://127.0.0.1:${port}`);
|
|
180
|
+
session.page = page;
|
|
181
|
+
const frames = session.frames;
|
|
182
|
+
page.onMessage((t) => { try { frames.push(JSON.parse(t)); } catch { /* not JSON */ } });
|
|
183
|
+
session.until = async (pred, ms, from = 0) => {
|
|
184
|
+
const end = Date.now() + ms;
|
|
185
|
+
while (Date.now() < end) {
|
|
186
|
+
for (let i = from; i < frames.length; i += 1) if (pred(frames[i])) return frames[i];
|
|
187
|
+
await pause(15);
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
session.text = (id) => frames.filter((f) => (f.type === "stdout" || f.type === "stream" || f.type === "stderr") && f.id === id).map((f) => f.text || "").join("\n");
|
|
192
|
+
let seq = 0;
|
|
193
|
+
session.nextId = (prefix = "a") => `${prefix}${++seq}`;
|
|
194
|
+
session.send = (obj) => page.send(obj);
|
|
195
|
+
/** Run a chunk and wait for its done frame. */
|
|
196
|
+
session.run = async (source, { engine, document, srcname, ms = 30000, id = session.nextId("a") } = {}) => {
|
|
197
|
+
const from = frames.length;
|
|
198
|
+
const started = Date.now();
|
|
199
|
+
page.send({ type: "exec", id, source, ...(engine ? { engine } : {}), ...(document ? { document } : {}), ...(srcname ? { srcname } : {}) });
|
|
200
|
+
const done = await session.until((f) => f.type === "done" && f.id === id, ms, from);
|
|
201
|
+
return { id, done, ms: Date.now() - started, out: () => session.text(id), frames: () => frames.slice(from).filter((f) => f.id === id) };
|
|
202
|
+
};
|
|
203
|
+
/** Send a request op and wait for its reply. */
|
|
204
|
+
session.ask = async (type, payload, ms = 10000) => {
|
|
205
|
+
const id = session.nextId("q");
|
|
206
|
+
const from = frames.length;
|
|
207
|
+
const t = Date.now();
|
|
208
|
+
page.send({ type, id, ...payload });
|
|
209
|
+
const reply = await session.until((f) => f.id === id && f.type !== "stdout" && f.type !== "stderr", ms, from);
|
|
210
|
+
return { reply, ms: Date.now() - t };
|
|
211
|
+
};
|
|
212
|
+
session.ready = await session.until((f) => f.type === "ready", readyMs);
|
|
213
|
+
session.startupMs = Date.now() - t0;
|
|
214
|
+
return session;
|
|
215
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// document-exec.mjs — the notebook exec envelope (`document_exec_v1`) for JavaScript.
|
|
2
|
+
//
|
|
3
|
+
// The page refuses to run any chunk against a kernel whose ready frame does not
|
|
4
|
+
// claim `document_exec_v1`, and sends every run with a `document` descriptor
|
|
5
|
+
// (lib/document-execution.js). This is engines/python/document_exec.py in
|
|
6
|
+
// JavaScript — which is itself spike/knitr-run.R's run_document_cell() minus
|
|
7
|
+
// knitr: a document's `setup` chunk runs once per (document, setup source) and
|
|
8
|
+
// is then a Ready snapshot; a failed setup blocks dependent chunks until it is
|
|
9
|
+
// fixed, run explicitly, or retried. Chunk options are not applied (they are
|
|
10
|
+
// knitr's, render-time, and have no JavaScript meaning), so the done frame says
|
|
11
|
+
// `cacheHit: false` and `effectiveOptions: null` — nothing claimed that was not
|
|
12
|
+
// done. The error sentences are R's, verbatim: the page shows them and its tests
|
|
13
|
+
// assert them.
|
|
14
|
+
|
|
15
|
+
export const FEATURE = "document_exec_v1";
|
|
16
|
+
const LABEL_RE = /^[A-Za-z0-9_.-]+$/;
|
|
17
|
+
|
|
18
|
+
export class DocumentExecError extends Error {}
|
|
19
|
+
|
|
20
|
+
/** Per-document setup state for ONE evaluating session. */
|
|
21
|
+
export class DocumentSetups {
|
|
22
|
+
constructor() { this.docs = new Map(); }
|
|
23
|
+
|
|
24
|
+
reset() { this.docs.clear(); }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decide what an envelope asks for, before anything runs. Throws
|
|
28
|
+
* DocumentExecError with the user-facing sentence.
|
|
29
|
+
* @returns {{docId: string, label: string|null, setupSource: string|null, setupSrcname: string|null,
|
|
30
|
+
* runSetup: boolean, runChunk: boolean, execution: object}}
|
|
31
|
+
*/
|
|
32
|
+
plan(source, document) {
|
|
33
|
+
if (!document || typeof document !== "object" || typeof document.id !== "string" || !document.id) {
|
|
34
|
+
throw new DocumentExecError("A notebook run needs its document identity.");
|
|
35
|
+
}
|
|
36
|
+
const docId = document.id;
|
|
37
|
+
const setup = document.setup ?? null;
|
|
38
|
+
if (setup !== null && (typeof setup !== "object" || typeof setup.source !== "string")) {
|
|
39
|
+
throw new DocumentExecError("Invalid setup chunk descriptor.");
|
|
40
|
+
}
|
|
41
|
+
const isSetup = document.isSetup === true;
|
|
42
|
+
const retry = document.retrySetup === true;
|
|
43
|
+
const selection = document.selection === true;
|
|
44
|
+
const setupKey = setup ? setup.source : "";
|
|
45
|
+
const previous = this.docs.get(docId);
|
|
46
|
+
if (previous && previous.source === setupKey && previous.status === "failed" && !isSetup && !retry) {
|
|
47
|
+
throw new DocumentExecError("Setup previously failed. Fix it or explicitly run the setup chunk before running dependent chunks.");
|
|
48
|
+
}
|
|
49
|
+
const setupChanged = !previous || previous.source !== setupKey;
|
|
50
|
+
const runSetup = setup !== null && (setupChanged || isSetup || previous.status === "failed");
|
|
51
|
+
let label = null;
|
|
52
|
+
if (!isSetup) {
|
|
53
|
+
label = document.label;
|
|
54
|
+
if (typeof label !== "string" || !LABEL_RE.test(label)) {
|
|
55
|
+
throw new DocumentExecError("Use a unique chunk label containing letters, numbers, dots, hyphens or underscores.");
|
|
56
|
+
}
|
|
57
|
+
const isDict = (x) => x && typeof x === "object" && !Array.isArray(x);
|
|
58
|
+
if (!isDict(document.options ?? {}) || !isDict(document.expressions ?? {})) {
|
|
59
|
+
throw new DocumentExecError("Invalid chunk options.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
docId, label,
|
|
64
|
+
setupSource: setup ? setupKey : null,
|
|
65
|
+
setupSrcname: setup ? (setup.srcname ?? null) : null,
|
|
66
|
+
runSetup, runChunk: !isSetup,
|
|
67
|
+
execution: {
|
|
68
|
+
source: isSetup && setup ? setup.source : source,
|
|
69
|
+
setupSource: setup ? setup.source : "",
|
|
70
|
+
optionsKey: document.optionsKey ?? null,
|
|
71
|
+
full: !selection,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Invalidate BEFORE running: a failed partial setup must not leave a snapshot. */
|
|
77
|
+
setupStarted(docId) { this.docs.delete(docId); }
|
|
78
|
+
setupOk(docId, source) { this.docs.set(docId, { source, status: "ok" }); }
|
|
79
|
+
setupFailed(docId, source) { this.docs.set(docId, { source, status: "failed" }); }
|
|
80
|
+
/** No setup chunk: the snapshot is the empty source, so one added later is a CHANGE. */
|
|
81
|
+
noSetup(docId) { this.docs.set(docId, { source: "", status: "ok" }); }
|
|
82
|
+
}
|