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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ai-authority.mjs — the supervisor's half of the chat authority contract.
|
|
2
|
+
//
|
|
3
|
+
// The page's half is `lib/ai-authority.js`, and the two lists are ONE list in
|
|
4
|
+
// two files for the reason every other pair in this project is (THEME_TOKENS /
|
|
5
|
+
// EDITOR_TOKENS, LATEX_TEMPLATE_KEYS / TEMPLATE_KEYS): the kernel ships `host/`
|
|
6
|
+
// and `spike/` without `lib/`, and no host module imports across that line.
|
|
7
|
+
// `test/ai-chat-authority.test.mjs` reads both files and fails if they drift.
|
|
8
|
+
//
|
|
9
|
+
// WHY THE HOST IS WHERE THIS IS DECIDED. A CLI turn is a process THIS process
|
|
10
|
+
// spawns. The tool list handed to that process is the only gate with no
|
|
11
|
+
// page-side bypass: there is nothing in the browser to work around, because
|
|
12
|
+
// the spawn was never made with those tools in it. The page's copy exists to
|
|
13
|
+
// ASK for a level and to describe the lane honestly; the host's copy is what
|
|
14
|
+
// happens.
|
|
15
|
+
//
|
|
16
|
+
// The level arriving on the wire is a REQUEST, and `normalizeAuthority` fails
|
|
17
|
+
// closed — absent or unrecognised is `read`. An older page that never heard of
|
|
18
|
+
// the field asks for the reading tools and gets them; silence never grants the
|
|
19
|
+
// most (fix.md P1.5: "switching providers never silently increases authority"
|
|
20
|
+
// covers switching anything else, too).
|
|
21
|
+
|
|
22
|
+
export const READ_TOOLS = Object.freeze([
|
|
23
|
+
"beatrina_status", "notebook_read", "chunk_read", "file_list", "file_read",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const WRITE_TOOLS = Object.freeze([
|
|
27
|
+
"chunk_insert", "chunk_update", "chunk_run",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const AUTHOR_TOOLS = Object.freeze([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
31
|
+
|
|
32
|
+
export const AUTHORITY_LEVELS = Object.freeze(["text", "read", "author"]);
|
|
33
|
+
|
|
34
|
+
/** Read a level off the wire, failing closed. */
|
|
35
|
+
export function normalizeAuthority(value) {
|
|
36
|
+
const asked = String(value || "").toLowerCase();
|
|
37
|
+
return AUTHORITY_LEVELS.includes(asked) ? asked : "read";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The tools a level may use. `text` gets none. */
|
|
41
|
+
export function toolsFor(authority) {
|
|
42
|
+
if (authority === "author") return AUTHOR_TOOLS.slice();
|
|
43
|
+
if (authority === "read") return READ_TOOLS.slice();
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `--allowedTools` value for a spawned Claude Code turn at this level. */
|
|
48
|
+
export function allowedToolsArg(authority) {
|
|
49
|
+
return toolsFor(authority).map((name) => `mcp__beatrina__${name}`).join(",");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
READ_TOOLS, WRITE_TOOLS, AUTHOR_TOOLS, AUTHORITY_LEVELS,
|
|
54
|
+
normalizeAuthority, toolsFor, allowedToolsArg,
|
|
55
|
+
};
|
package/host/ai-policy.mjs
CHANGED
|
@@ -26,15 +26,16 @@
|
|
|
26
26
|
//
|
|
27
27
|
// ── where a policy comes from ──────────────────────────────────────────────
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// BEATRINA_AI_POLICY=/etc/beatrina/ai-policy.json a file the user cannot write
|
|
30
|
+
// BEATRINA_AI_PROVIDERS=anthropic,lmstudio the quick form
|
|
31
|
+
// BEATRINA_AI_LOCAL_ONLY=1 only on-machine providers
|
|
32
32
|
//
|
|
33
33
|
// The file wins where both are set, because a file is the form an administrator
|
|
34
34
|
// can own while an environment variable is inherited from whoever started the
|
|
35
35
|
// process — on a shared host, that may be the user.
|
|
36
36
|
|
|
37
37
|
import fs from "node:fs";
|
|
38
|
+
import { envReader } from "./env-names.mjs";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* The providers whose text never leaves the machine.
|
|
@@ -54,7 +55,7 @@ export const KEY_PROVIDERS = Object.freeze(["openai", "anthropic", "google", "gr
|
|
|
54
55
|
export const CLI_PROVIDERS = Object.freeze(["claudecode", "codex"]);
|
|
55
56
|
export const ALL_PROVIDERS = Object.freeze([...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS]);
|
|
56
57
|
|
|
57
|
-
const sysEnv = (
|
|
58
|
+
const sysEnv = envReader(process.env); // dual-read: BEATRINA_AI_POLICY or its older spelling (host/env-names.mjs)
|
|
58
59
|
const asList = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -101,6 +102,12 @@ export function normaliseBaseUrls(raw) {
|
|
|
101
102
|
return { values, errors };
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// Every key this file reads. A policy carrying anything else is a policy
|
|
106
|
+
// whose author meant something the supervisor will not do — `provider` for
|
|
107
|
+
// `providers` was accepted in silence and read as "permit all fifteen"
|
|
108
|
+
// (fix.md P1.4). An unknown key is a refusal, with the key named.
|
|
109
|
+
export const POLICY_KEYS = Object.freeze(["providers", "local_only", "note", "models", "base_urls"]);
|
|
110
|
+
|
|
104
111
|
const policySplit = (value) => String(value || "").split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length);
|
|
105
112
|
// Control characters are 0x00-0x1f and 0x7f — R's [[:cntrl:]], spelled out.
|
|
106
113
|
// eslint-disable-next-line no-control-regex
|
|
@@ -115,12 +122,13 @@ const stripControl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
|
115
122
|
* named nothing / named something valid / named only junk — and conflating the
|
|
116
123
|
* last two is the fail-open bug.
|
|
117
124
|
*/
|
|
118
|
-
export function
|
|
119
|
-
const file = String(env("
|
|
125
|
+
export function beatrinaAiPolicy(env = sysEnv) {
|
|
126
|
+
const file = String(env("BEATRINA_AI_POLICY", "")).trim();
|
|
120
127
|
let named = [];
|
|
121
128
|
let models = {};
|
|
122
129
|
let baseUrls = {};
|
|
123
|
-
let localOnly = env("
|
|
130
|
+
let localOnly = env("BEATRINA_AI_LOCAL_ONLY", "") === "1";
|
|
131
|
+
let namedKeyPresent = false;
|
|
124
132
|
let note = "";
|
|
125
133
|
let source = "";
|
|
126
134
|
let errors = [];
|
|
@@ -128,13 +136,25 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
128
136
|
if (file.length) {
|
|
129
137
|
source = file;
|
|
130
138
|
if (!fs.existsSync(file)) {
|
|
131
|
-
errors.push(`
|
|
139
|
+
errors.push(`BEATRINA_AI_POLICY names a file that does not exist: ${file}`);
|
|
132
140
|
} else {
|
|
133
141
|
let doc = null;
|
|
134
142
|
let parseError = "";
|
|
135
143
|
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { parseError = e.message; }
|
|
136
|
-
if (parseError) errors.push(`
|
|
137
|
-
else {
|
|
144
|
+
if (parseError) errors.push(`BEATRINA_AI_POLICY is not valid JSON: ${parseError}`);
|
|
145
|
+
else if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
146
|
+
errors.push("BEATRINA_AI_POLICY must be a JSON object of policy keys.");
|
|
147
|
+
} else {
|
|
148
|
+
const unknownKeys = Object.keys(doc).filter((k) => !POLICY_KEYS.includes(k));
|
|
149
|
+
if (unknownKeys.length) {
|
|
150
|
+
errors.push(`AI policy: ${unknownKeys.map((k) => `'${k}'`).join(", ")} `
|
|
151
|
+
+ `${unknownKeys.length === 1 ? "is not a policy key" : "are not policy keys"}. `
|
|
152
|
+
+ `A policy may name: ${POLICY_KEYS.join(", ")}. A key nobody reads permits everything, which is never what the file meant.`);
|
|
153
|
+
}
|
|
154
|
+
// "the key is present" and "the key names something" are different
|
|
155
|
+
// facts: `providers: []` used to read as "said nothing", which the
|
|
156
|
+
// default answers with every provider.
|
|
157
|
+
namedKeyPresent = Object.prototype.hasOwnProperty.call(doc, "providers");
|
|
138
158
|
named = asList(doc && doc.providers).flat().map((v) => String(v));
|
|
139
159
|
if (doc && doc.local_only === true) localOnly = true;
|
|
140
160
|
note = String(asList(doc && doc.note).flat()[0] ?? "").trim();
|
|
@@ -148,20 +168,34 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
148
168
|
errors = errors.concat(endpoints.errors);
|
|
149
169
|
}
|
|
150
170
|
}
|
|
151
|
-
} else if (String(env("
|
|
171
|
+
} else if (String(env("BEATRINA_AI_PROVIDERS", "")).length) {
|
|
152
172
|
source = "env";
|
|
153
|
-
named = policySplit(env("
|
|
173
|
+
named = policySplit(env("BEATRINA_AI_PROVIDERS", ""));
|
|
154
174
|
} else if (localOnly) {
|
|
155
175
|
source = "env";
|
|
156
176
|
}
|
|
157
177
|
|
|
158
178
|
const unknown = named.filter((n) => !ALL_PROVIDERS.includes(n));
|
|
159
|
-
|
|
179
|
+
// ASKED means the policy spoke about providers at all — including an empty
|
|
180
|
+
// list, which speaks and names nothing. Only silence inherits the default.
|
|
181
|
+
const asked = named.length > 0 || namedKeyPresent;
|
|
160
182
|
named = named.filter((n) => ALL_PROVIDERS.includes(n));
|
|
161
183
|
|
|
162
184
|
let providers = named.length ? named : (asked ? [] : [...ALL_PROVIDERS]);
|
|
163
185
|
if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
|
|
164
186
|
|
|
187
|
+
// local_only is a promise about where the words GO, so an endpoint that
|
|
188
|
+
// leaves the machine contradicts it — and an on-machine provider pointed at
|
|
189
|
+
// a hosted gateway is exactly how that happens without looking wrong.
|
|
190
|
+
if (localOnly) {
|
|
191
|
+
Object.entries(baseUrls).forEach(([provider, url]) => {
|
|
192
|
+
if (!LOOPBACK_RE.test(url)) {
|
|
193
|
+
errors.push(`AI policy: local_only is set, but base_urls.${provider} is ${url}, which leaves this machine. `
|
|
194
|
+
+ "Point it at loopback, or drop local_only.");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
165
199
|
let set = source.length > 0 && errors.length === 0;
|
|
166
200
|
if (set && !providers.length) {
|
|
167
201
|
errors.push(`The AI policy permits no provider at all${
|
|
@@ -193,18 +227,6 @@ export function carmarAiPolicy(env = sysEnv) {
|
|
|
193
227
|
export const aiProviderAllowed = (provider, policy) =>
|
|
194
228
|
policy.set !== true || (typeof provider === "string" && provider.length > 0 && policy.providers.includes(provider));
|
|
195
229
|
|
|
196
|
-
/**
|
|
197
|
-
* May this model be used with this provider? TRUE when no models are pinned for
|
|
198
|
-
* that provider — pinning is opt-in per provider. DISCLOSURE ONLY: a model id
|
|
199
|
-
* is a field in a request body the page builds and sends itself, so there is no
|
|
200
|
-
* door here to close.
|
|
201
|
-
*/
|
|
202
|
-
export const aiModelAllowed = (provider, model, policy) => {
|
|
203
|
-
if (policy.set !== true) return true;
|
|
204
|
-
const allowed = policy.models[provider || ""];
|
|
205
|
-
if (!allowed || !allowed.length) return true;
|
|
206
|
-
return typeof model === "string" && model.length > 0 && allowed.includes(model);
|
|
207
|
-
};
|
|
208
230
|
|
|
209
231
|
/**
|
|
210
232
|
* The sentence the notebook shows when something is refused. Names the
|
|
@@ -213,6 +235,6 @@ export const aiModelAllowed = (provider, model, policy) => {
|
|
|
213
235
|
* message.
|
|
214
236
|
*/
|
|
215
237
|
export const aiPolicyReason = (policy) => {
|
|
216
|
-
const base = `This
|
|
238
|
+
const base = `This Beatrina is configured to allow only: ${policy.providers.join(", ")}.`;
|
|
217
239
|
return policy.note.length ? `${base} ${policy.note}` : base;
|
|
218
240
|
};
|
package/host/bundle.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// bundle.mjs — `.btrn`, the Beatrina document that travels (docs/results-plan.md R2).
|
|
2
|
+
//
|
|
3
|
+
// A standard ZIP (host/zip.mjs) holding the document, its results and the files
|
|
4
|
+
// it needs — "transfer the whole document like carmnote" (owner, 2026-09-19): // beatrina-names: keep
|
|
5
|
+
//
|
|
6
|
+
// manifest.json format, version, app, created, title, what is inside
|
|
7
|
+
// (every entry: size + SHA-256), and the data: its mode
|
|
8
|
+
// and every file's path, size and SHA-256
|
|
9
|
+
// <name>.qmd the document, plain text (its own .Rmd/.md if it was one)
|
|
10
|
+
// results/results.json the beatrina-results/1 record (lib/document-results.js)
|
|
11
|
+
// results/figures/* the record's figures, stored (PNG is compressed already)
|
|
12
|
+
// data/<relative path> every file the document needs, deflated, at the path it
|
|
13
|
+
// uses: data, bibliography, citation style, images, included
|
|
14
|
+
// files, project settings (lib/bundle.js documentFiles)
|
|
15
|
+
//
|
|
16
|
+
// NO HISTORY, by the owner's decision (2026-09-19): Undo history holds every
|
|
17
|
+
// keystroke, deleted text included — a document sent to someone must not carry
|
|
18
|
+
// what its author removed. A CarmNote carries none either; the author's own
|
|
19
|
+
// history stays in their session's journal.
|
|
20
|
+
//
|
|
21
|
+
// DATA ≤ 100 MB (compressed, in total) travels inside. Past that it goes BESIDE
|
|
22
|
+
// the bundle, in `<name>.btrn-data/` at the same relative paths, and the manifest
|
|
23
|
+
// keeps each file's size and SHA-256 so the receiver can tell a missing or a
|
|
24
|
+
// changed file by name. A folder, not a second zip: big data stays usable as it
|
|
25
|
+
// is, and ZIP's 4 GB ceiling never comes up.
|
|
26
|
+
//
|
|
27
|
+
// Pure where it can be: buildBundle / readBundle take bytes and return bytes or
|
|
28
|
+
// parts; the plane (host/planes/bundle.mjs) does the file system and the doors.
|
|
29
|
+
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { writeZip, readZip, listZip, safeEntryName } from "./zip.mjs";
|
|
33
|
+
|
|
34
|
+
export const BUNDLE_FORMAT = "beatrina-bundle";
|
|
35
|
+
export const BUNDLE_VERSION = 1;
|
|
36
|
+
export const BUNDLE_EXT = ".btrn";
|
|
37
|
+
/** Data travels inside up to this many compressed bytes; past it, beside. */
|
|
38
|
+
export const DATA_INSIDE_MAX = 100 * 1024 * 1024;
|
|
39
|
+
/** Reading: the whole archive may not expand past this. */
|
|
40
|
+
export const BUNDLE_READ_MAX = 2 * 1024 ** 3;
|
|
41
|
+
|
|
42
|
+
export const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
44
|
+
|
|
45
|
+
/** `/w/paper.btrn` → `/w/paper.btrn-data` — where beside-mode data lives. */
|
|
46
|
+
export const dataFolderFor = (bundlePath) => `${bundlePath}-data`;
|
|
47
|
+
|
|
48
|
+
/** A data path the bundle may carry: relative, safe, a file (not a folder). */
|
|
49
|
+
export function safeDataPath(rel) {
|
|
50
|
+
const r = String(rel ?? "").replace(/\\/g, "/");
|
|
51
|
+
return safeEntryName(r) && !r.endsWith("/") && r.length <= 512;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a .btrn.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} parts
|
|
58
|
+
* @param {string} parts.source the document's text
|
|
59
|
+
* @param {string} parts.sourceName its name inside, e.g. "paper.qmd"
|
|
60
|
+
* @param {string} [parts.title]
|
|
61
|
+
* @param {string|null} [parts.results] the results record, JSON text (figures external)
|
|
62
|
+
* @param {Array<{name:string, data:Buffer, mime?:string}>} [parts.figures] results/figures/<name>
|
|
63
|
+
* @param {Array<{path:string, bytes:Buffer}>} [parts.data] files the code reads, by relative path
|
|
64
|
+
* @param {string} [parts.app] the app version that wrote it
|
|
65
|
+
* @param {Date} [parts.created]
|
|
66
|
+
* @param {number} [parts.insideMax] DATA_INSIDE_MAX unless a test says otherwise
|
|
67
|
+
* @param {string} [parts.bundleName] the .btrn's own file name, for the beside folder's name
|
|
68
|
+
* @returns {{zip:Buffer, manifest:object, beside:Array<{path:string, bytes:Buffer}>}}
|
|
69
|
+
* `beside` is the data to write in <bundle>-data/ when it did not fit inside
|
|
70
|
+
*/
|
|
71
|
+
export function buildBundle(parts) {
|
|
72
|
+
const {
|
|
73
|
+
source, sourceName, title = "", results = null, figures = [],
|
|
74
|
+
data = [], app = "", created = new Date(), insideMax = DATA_INSIDE_MAX, bundleName = "",
|
|
75
|
+
} = parts || {};
|
|
76
|
+
if (typeof source !== "string") throw fail("bundle_input", "a bundle needs the document's source");
|
|
77
|
+
if (!/^[^/\\]+\.(qmd|Rmd|rmd|md)$/.test(String(sourceName || ""))) {
|
|
78
|
+
throw fail("bundle_input", `the document inside must be a .qmd, .Rmd or .md file name, not ${JSON.stringify(sourceName)}`);
|
|
79
|
+
}
|
|
80
|
+
const files = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const add = (name, bytes, compress = true) => {
|
|
83
|
+
if (seen.has(name)) throw fail("bundle_input", `${name} twice`);
|
|
84
|
+
seen.add(name);
|
|
85
|
+
files.push({ name, data: bytes, compress });
|
|
86
|
+
};
|
|
87
|
+
add(sourceName, Buffer.from(source, "utf8"));
|
|
88
|
+
if (results != null) {
|
|
89
|
+
add("results/results.json", Buffer.from(String(results), "utf8"));
|
|
90
|
+
for (const f of figures || []) {
|
|
91
|
+
if (!safeEntryName(`results/figures/${f.name}`) || f.name.includes("/")) throw fail("bundle_input", `bad figure name ${JSON.stringify(f.name)}`);
|
|
92
|
+
add(`results/figures/${f.name}`, f.data, false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const dataFiles = (data || []).map((d) => { // each: {path, bytes, kind?}
|
|
96
|
+
const rel = String(d.path).replace(/\\/g, "/");
|
|
97
|
+
if (!safeDataPath(rel)) throw fail("bundle_bad_path", `a data path must be relative and stay inside the document's folder: ${JSON.stringify(d.path)}`);
|
|
98
|
+
return { path: rel, bytes: d.bytes, size: d.bytes.length, sha256: sha256(d.bytes), kind: d.kind || "data" };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const manifestFor = (mode, dataEntries) => ({
|
|
102
|
+
format: BUNDLE_FORMAT, version: BUNDLE_VERSION, app, created: created.toISOString(), title,
|
|
103
|
+
source: sourceName,
|
|
104
|
+
entries: dataEntries.concat(files).map((f) => ({ name: f.name, size: f.data.length, sha256: sha256(f.data) })),
|
|
105
|
+
data: {
|
|
106
|
+
mode, ...(mode === "beside" ? { folder: `${path.basename(bundleName || sourceName.replace(/\.[^.]+$/, ".btrn"))}-data` } : {}),
|
|
107
|
+
files: dataFiles.map(({ path: p, size, sha256: h, kind }) => ({ path: p, size, sha256: h, kind })),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const zipWith = (manifest, dataEntries) => writeZip([
|
|
111
|
+
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest, null, 1), "utf8") },
|
|
112
|
+
...files, ...dataEntries,
|
|
113
|
+
], { date: created });
|
|
114
|
+
|
|
115
|
+
// Inside first; measure what the data costs compressed, and move it beside
|
|
116
|
+
// only when it passes the cap.
|
|
117
|
+
const inside = dataFiles.map((d) => ({ name: `data/${d.path}`, data: d.bytes, compress: true }));
|
|
118
|
+
let zip = zipWith(manifestFor(dataFiles.length ? "inside" : "none", inside), inside);
|
|
119
|
+
const dataBytes = listZip(zip).filter((e) => e.name.startsWith("data/")).reduce((a, e) => a + e.csize, 0);
|
|
120
|
+
if (dataBytes <= insideMax) {
|
|
121
|
+
return { zip, manifest: manifestFor(dataFiles.length ? "inside" : "none", inside), beside: [] };
|
|
122
|
+
}
|
|
123
|
+
const manifest = manifestFor("beside", []);
|
|
124
|
+
zip = zipWith(manifest, []);
|
|
125
|
+
return { zip, manifest, beside: dataFiles.map((d) => ({ path: d.path, bytes: d.bytes })), dataCompressed: dataBytes };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read a .btrn: every part, VERIFIED. The manifest's SHA-256 for each entry is
|
|
130
|
+
* checked, and an entry the manifest does not list is refused — nothing is
|
|
131
|
+
* half-loaded.
|
|
132
|
+
*
|
|
133
|
+
* @param {Buffer} buf
|
|
134
|
+
* @returns {{manifest:object, source:string, sourceName:string, results:string|null,
|
|
135
|
+
* figures:Map<string,Buffer>, data:Map<string,Buffer>}}
|
|
136
|
+
*/
|
|
137
|
+
export function readBundle(buf, { maxTotal = BUNDLE_READ_MAX } = {}) {
|
|
138
|
+
const all = readZip(buf, { maxTotal });
|
|
139
|
+
const rawManifest = all.get("manifest.json");
|
|
140
|
+
if (!rawManifest) throw fail("bundle_no_manifest", "not a Beatrina document (no manifest.json)");
|
|
141
|
+
let manifest;
|
|
142
|
+
try { manifest = JSON.parse(rawManifest.toString("utf8")); } catch { throw fail("bundle_bad_manifest", "manifest.json is not JSON"); }
|
|
143
|
+
if (manifest.format !== BUNDLE_FORMAT) throw fail("bundle_bad_manifest", `not a Beatrina document (format ${JSON.stringify(manifest.format)})`);
|
|
144
|
+
if (!(Number(manifest.version) >= 1)) throw fail("bundle_bad_manifest", "the manifest names no version");
|
|
145
|
+
if (Number(manifest.version) > BUNDLE_VERSION) {
|
|
146
|
+
throw fail("bundle_newer", `this document was written by a newer Beatrina (bundle version ${manifest.version}); update Beatrina to open it`);
|
|
147
|
+
}
|
|
148
|
+
const listed = new Map((manifest.entries || []).map((e) => [e.name, e]));
|
|
149
|
+
for (const [name, bytes] of all) {
|
|
150
|
+
if (name === "manifest.json") continue;
|
|
151
|
+
const want = listed.get(name);
|
|
152
|
+
if (!want) throw fail("bundle_unlisted", `${name} is in the archive but not in its manifest`);
|
|
153
|
+
if (want.size !== bytes.length || want.sha256 !== sha256(bytes)) throw fail("bundle_digest", `${name} does not match its manifest — the document is damaged`);
|
|
154
|
+
}
|
|
155
|
+
for (const name of listed.keys()) if (!all.has(name)) throw fail("bundle_missing", `the manifest lists ${name}, which is not in the archive`);
|
|
156
|
+
const sourceName = String(manifest.source || "");
|
|
157
|
+
if (!all.has(sourceName)) throw fail("bundle_missing", "the document itself is missing");
|
|
158
|
+
const figures = new Map();
|
|
159
|
+
const data = new Map();
|
|
160
|
+
for (const [name, bytes] of all) {
|
|
161
|
+
if (name.startsWith("results/figures/")) figures.set(name.slice("results/figures/".length), bytes);
|
|
162
|
+
else if (name.startsWith("data/")) data.set(name.slice("data/".length), bytes);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
manifest, sourceName, source: all.get(sourceName).toString("utf8"),
|
|
166
|
+
results: all.has("results/results.json") ? all.get("results/results.json").toString("utf8") : null,
|
|
167
|
+
figures, data,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What opening a bundle would do to each data file, pure: compare the
|
|
173
|
+
* manifest's digest with the file at the document's folder.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} manifest
|
|
176
|
+
* @param {(rel:string) => (Buffer|null)} readLocal the file beside the document, or null
|
|
177
|
+
* @returns {Array<{path:string, size:number, kind:string, state:'absent'|'same'|'differs'}>}
|
|
178
|
+
*/
|
|
179
|
+
export function dataPlan(manifest, readLocal) {
|
|
180
|
+
return ((manifest && manifest.data && manifest.data.files) || []).map((f) => {
|
|
181
|
+
const local = readLocal(f.path);
|
|
182
|
+
const state = !local ? "absent" : sha256(local) === f.sha256 ? "same" : "differs";
|
|
183
|
+
return { path: f.path, size: f.size, kind: f.kind || "data", state };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `paper.btrn` → `paper.qmd` — the name the document takes inside. */
|
|
188
|
+
export function innerNameFor(bundlePath, format = "qmd") {
|
|
189
|
+
const stem = path.basename(String(bundlePath || "document")).replace(/\.btrn$/i, "") || "document";
|
|
190
|
+
const ext = { qmd: "qmd", rmd: "Rmd", md: "md" }[String(format).toLowerCase()] || "qmd";
|
|
191
|
+
return `${stem}.${ext}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default { buildBundle, readBundle, dataPlan, innerNameFor, dataFolderFor, safeDataPath, sha256 };
|
package/host/deployment.mjs
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// client is not a browser and therefore is this same OS user; anywhere the port
|
|
14
14
|
// is reachable by someone else the same header proves nothing, so it is refused.
|
|
15
15
|
|
|
16
|
+
import { envReader } from "./env-names.mjs";
|
|
17
|
+
|
|
16
18
|
export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "[::1]", "localhost"];
|
|
17
19
|
|
|
18
20
|
/** Split a comma/space-separated environment value into trimmed, non-empty parts. */
|
|
@@ -45,8 +47,8 @@ export function isLoopbackBind(bind) {
|
|
|
45
47
|
* `errors` is EMPTY when the posture is safe to serve. A non-empty `errors`
|
|
46
48
|
* must abort startup; it is never a warning.
|
|
47
49
|
*/
|
|
48
|
-
export function
|
|
49
|
-
let bind = env("
|
|
50
|
+
export function beatrinaDeployment(port, env = envFromProcess()) {
|
|
51
|
+
let bind = env("BEATRINA_BIND", "127.0.0.1").trim();
|
|
50
52
|
if (!bind) bind = "127.0.0.1";
|
|
51
53
|
const loopback = isLoopbackBind(bind);
|
|
52
54
|
|
|
@@ -54,9 +56,9 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
54
56
|
// the same-origin case. Off loopback the server is behind a proxy, so its own
|
|
55
57
|
// page arrives with the PROXY's origin — which the operator must name.
|
|
56
58
|
const selfOrigins = [`http://${bind}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
57
|
-
const origins = unique([...selfOrigins, ...splitList(env("
|
|
59
|
+
const origins = unique([...selfOrigins, ...splitList(env("BEATRINA_ORIGINS", ""))]);
|
|
58
60
|
|
|
59
|
-
let hostsEnv = splitList(env("
|
|
61
|
+
let hostsEnv = splitList(env("BEATRINA_HOSTS", ""));
|
|
60
62
|
const defaultHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
|
|
61
63
|
// A configured host may or may not carry a port. Accept both spellings so an
|
|
62
64
|
// operator writing `stats.example.edu` is not silently refused behind a proxy
|
|
@@ -68,31 +70,31 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
68
70
|
// allowing an Origin-less client. Binding off loopback breaks it, but so does
|
|
69
71
|
// a shared multi-user host or a container whose 127.0.0.1 is forwarded out,
|
|
70
72
|
// so the posture is declarable in its own right.
|
|
71
|
-
const requireOrigin = env("
|
|
73
|
+
const requireOrigin = env("BEATRINA_REQUIRE_ORIGIN", "") === "1" || !loopback;
|
|
72
74
|
|
|
73
|
-
const trustProxy = env("
|
|
74
|
-
const userHeader = env("
|
|
75
|
-
const proxyAddrs = splitList(env("
|
|
76
|
-
const allowOpen = env("
|
|
75
|
+
const trustProxy = env("BEATRINA_TRUST_PROXY", "") === "1";
|
|
76
|
+
const userHeader = env("BEATRINA_USER_HEADER", "X-Forwarded-User").trim();
|
|
77
|
+
const proxyAddrs = splitList(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"));
|
|
78
|
+
const allowOpen = env("BEATRINA_ALLOW_UNAUTHENTICATED", "") === "1";
|
|
77
79
|
|
|
78
80
|
const errors = [];
|
|
79
81
|
if (!loopback) {
|
|
80
82
|
if (!hostsEnv.length) {
|
|
81
|
-
errors.push(`
|
|
83
|
+
errors.push(`BEATRINA_BIND is ${bind} but BEATRINA_HOSTS is unset. Off loopback the `
|
|
82
84
|
+ "Host allow-list is the only defence against DNS rebinding, so it must "
|
|
83
|
-
+ "name the hostname readers will use (e.g.
|
|
85
|
+
+ "name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu).");
|
|
84
86
|
}
|
|
85
87
|
if (!trustProxy && !allowOpen) {
|
|
86
|
-
errors.push(`
|
|
88
|
+
errors.push(`BEATRINA_BIND is ${bind} with no authentication in front of it. Put `
|
|
87
89
|
+ "Beatrina behind a reverse proxy that authenticates and set "
|
|
88
|
-
+ "
|
|
90
|
+
+ "BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve code "
|
|
89
91
|
+ "execution to everyone who can reach this port. See docs/server.md.");
|
|
90
92
|
}
|
|
91
93
|
if (trustProxy && !userHeader) {
|
|
92
|
-
errors.push("
|
|
94
|
+
errors.push("BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.");
|
|
93
95
|
}
|
|
94
96
|
if (trustProxy && !proxyAddrs.length) {
|
|
95
|
-
errors.push("
|
|
97
|
+
errors.push("BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.");
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -127,7 +129,7 @@ export function carmarDeployment(port, env = envFromProcess()) {
|
|
|
127
129
|
*
|
|
128
130
|
* @param {{REMOTE_ADDR?: string} & Record<string, string>} req a Rook-shaped
|
|
129
131
|
* request: `REMOTE_ADDR` plus `HTTP_*` header keys
|
|
130
|
-
* @param {ReturnType<typeof
|
|
132
|
+
* @param {ReturnType<typeof beatrinaDeployment>} deployment
|
|
131
133
|
* @returns {string}
|
|
132
134
|
*/
|
|
133
135
|
export function proxyUser(req, deployment) {
|
|
@@ -150,9 +152,14 @@ export function rookOf(req) {
|
|
|
150
152
|
return out;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* A getter with `Sys.getenv`'s signature over `process.env` — dual-read: a
|
|
157
|
+
* `CARMAR_*` name asked for here is also answered by its `BEATRINA_*` spelling
|
|
158
|
+
* (host/env-names.mjs), so the call sites above keep the vocabulary the
|
|
159
|
+
* deployment vectors in test/host-deployment.test.mjs use.
|
|
160
|
+
*/
|
|
154
161
|
export function envFromProcess(source = process.env) {
|
|
155
|
-
return (
|
|
162
|
+
return envReader(source);
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
function unique(list) {
|
package/host/engine-js.mjs
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// can name. It speaks the wire worker.R and worker.py speak, so everything but
|
|
5
5
|
// three answers is StdioEngine's:
|
|
6
6
|
//
|
|
7
|
-
// · WHICH NODE. The host's own `process.execPath`
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// · WHICH NODE. The host's own `process.execPath` — the Node the R launcher
|
|
8
|
+
// already found and version-checked — or `BEATRIX_NODE`/`BEATRINA_NODE`
|
|
9
|
+
// when one is named. There is no self-hosted rung: the node:sea build that
|
|
10
|
+
// needed one is retired (f5979e5, 2026-09-16), and every distribution now
|
|
11
|
+
// stages `engines/js/` beside the host, so the worker is a script on disk
|
|
12
|
+
// in all three of them.
|
|
12
13
|
// · A CONSOLE LINE MEANS NOTHING. The worker reads commands only; there is
|
|
13
14
|
// no prompt, so canInput and canDebug are false and the plane refuses
|
|
14
15
|
// input_reply and debug_cmd for this engine by those capabilities.
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
|
|
19
20
|
import fs from "node:fs";
|
|
20
21
|
import { StdioEngine } from "./engine-stdio.mjs";
|
|
22
|
+
import { mirrorEnv, readEnv } from "./env-names.mjs";
|
|
21
23
|
import { findInterruptTool } from "./windows-runtime.mjs";
|
|
22
24
|
|
|
23
25
|
export const JS_MIN_NODE = 22;
|
|
@@ -26,17 +28,12 @@ export const JS_MIN_NODE = 22;
|
|
|
26
28
|
* Find a Node for the JavaScript engine, and SAY WHAT WAS DECIDED.
|
|
27
29
|
* @returns {{available: boolean, path: string, version: string, detail: string}}
|
|
28
30
|
*/
|
|
29
|
-
export function detectNode({ env = process.env,
|
|
30
|
-
const explicit = env.BEATRIX_NODE || env
|
|
31
|
+
export function detectNode({ env = process.env, execPath = process.execPath, versions = process.versions } = {}) {
|
|
32
|
+
const explicit = env.BEATRIX_NODE || readEnv(env, "NODE");
|
|
31
33
|
if (explicit) {
|
|
32
34
|
if (!fs.existsSync(explicit)) return { available: false, path: "", version: "", detail: `BEATRIX_NODE points at ${explicit}, which does not exist.` };
|
|
33
35
|
return { available: true, path: explicit, version: "", detail: `Node at ${explicit} (BEATRIX_NODE)` };
|
|
34
36
|
}
|
|
35
|
-
if (packaged) {
|
|
36
|
-
// A single executable IS a Node: it runs the worker bundled inside it when
|
|
37
|
-
// started with BEATRIX_ENGINE_ROLE=js (the retired executable build; kept for any old binary).
|
|
38
|
-
return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node} (inside this executable)`, selfHosted: true };
|
|
39
|
-
}
|
|
40
37
|
const major = Number(String(versions.node || "").split(".")[0]);
|
|
41
38
|
if (!(major >= JS_MIN_NODE)) return { available: false, path: "", version: versions.node || "", detail: `JavaScript chunks need Node ${JS_MIN_NODE} or newer; this host runs on ${versions.node}.` };
|
|
42
39
|
return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node}` };
|
|
@@ -51,10 +48,9 @@ export class JsEngine extends StdioEngine {
|
|
|
51
48
|
* @param {Record<string,string>} [opts.env]
|
|
52
49
|
* @param {string} [opts.cwd]
|
|
53
50
|
*/
|
|
54
|
-
constructor({ workerPath, node, env = {}, cwd, platform = process.platform
|
|
51
|
+
constructor({ workerPath, node, env = {}, cwd, platform = process.platform } = {}) {
|
|
55
52
|
super({ language: "js", workerPath, env, cwd, platform });
|
|
56
|
-
|
|
57
|
-
if (!selfHosted && (!workerPath || !fs.existsSync(workerPath))) throw new Error(`JsEngine: no worker at ${workerPath}`);
|
|
53
|
+
if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`JsEngine: no worker at ${workerPath}`);
|
|
58
54
|
if (!node) throw new Error("JsEngine: no Node found (set BEATRIX_NODE)");
|
|
59
55
|
this.node = node;
|
|
60
56
|
if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
|
|
@@ -69,8 +65,7 @@ export class JsEngine extends StdioEngine {
|
|
|
69
65
|
// which Node still labels experimental and announces on stderr — into the
|
|
70
66
|
// first chunk that imports anything. The label is Node's; the output is not
|
|
71
67
|
// the user's, so it is not printed.
|
|
72
|
-
|
|
73
|
-
return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: { CARMAR_WORKER_MODE: "batch" } };
|
|
68
|
+
return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }) };
|
|
74
69
|
}
|
|
75
70
|
|
|
76
71
|
/**
|
package/host/engine-pool.mjs
CHANGED
|
@@ -347,16 +347,58 @@ export class EnginePool extends EventEmitter {
|
|
|
347
347
|
freshResumeReport(now) { return this.primary ? this.primary.freshResumeReport(now) : null; }
|
|
348
348
|
cancelSuspend(rec) { return this.primary ? this.primary.cancelSuspend(rec) : false; }
|
|
349
349
|
|
|
350
|
+
/** Controls name the originating exec in `run`; `id` identifies the reply.
|
|
351
|
+
* Older pages omit `run`, so allow that only when this socket owns exactly
|
|
352
|
+
* one matching prompt. Never choose another page's or another run's engine.
|
|
353
|
+
*
|
|
354
|
+
* THE NARROWING IS IN STAGES, AND THAT IS THE POINT. The routing rule is one
|
|
355
|
+
* predicate — waiting, mine, the run named — but written as one `filter` it
|
|
356
|
+
* can only ever answer "no", and every refusal read alike: a debugger command
|
|
357
|
+
* sent when nothing is paused, an answer to a question another window is
|
|
358
|
+
* holding, and a reply naming a run that has already finished all came back
|
|
359
|
+
* as "This page has no matching run waiting for this control." Those are
|
|
360
|
+
* three different situations for the person reading the message and three
|
|
361
|
+
* different bugs for whoever is reading the log, so the list is narrowed a
|
|
362
|
+
* stage at a time and whichever stage empties it names the reason (S3,
|
|
363
|
+
* 2026-09-18). The routing itself is unchanged — the stages compose to the
|
|
364
|
+
* same predicate, and `test/engine-pool-controls.test.mjs` pins that across
|
|
365
|
+
* all seven ownership scenarios.
|
|
366
|
+
*/
|
|
367
|
+
controlPlane(cmd, rec, state, type) {
|
|
368
|
+
const noun = type === "debug_cmd" ? "a debugger command" : "an answer";
|
|
369
|
+
// Stage 0: every engine actually waiting for this control, whoever it
|
|
370
|
+
// belongs to. The route is carried along because each later stage asks it
|
|
371
|
+
// a different question.
|
|
372
|
+
const waiting = [...this.planes.values()]
|
|
373
|
+
.map((plane) => ({ plane, route: plane.engine && plane[state] ? plane.routes.get(plane.active) : null }))
|
|
374
|
+
.filter((c) => c.route?.kind === "exec");
|
|
375
|
+
// Stage 1: the run this control names, if it names one. A queued run is
|
|
376
|
+
// correctly absent here — it is not waiting, it has not started.
|
|
377
|
+
const named = scalarChr(cmd.run) ? cmd.run : "";
|
|
378
|
+
const forRun = named ? waiting.filter((c) => c.route.clientId === named) : waiting;
|
|
379
|
+
// Stage 2: of those, the ones this socket started.
|
|
380
|
+
const candidates = forRun.filter((c) => c.route.rec === rec);
|
|
381
|
+
if (candidates.length === 1) return candidates[0].plane;
|
|
382
|
+
|
|
383
|
+
const error = candidates.length > 1
|
|
384
|
+
? "More than one run is waiting; name the originating run in this control."
|
|
385
|
+
: forRun.length > 0
|
|
386
|
+
// It IS waiting — for somebody else. The distinction the reload path
|
|
387
|
+
// depends on: `adopt` is how the other page takes it over.
|
|
388
|
+
? `That prompt is not this page's — another page owns the run waiting for ${noun}.`
|
|
389
|
+
: waiting.length > 0 && named
|
|
390
|
+
? `The run named in this control is not waiting for ${noun}.`
|
|
391
|
+
: `This session is not waiting for ${noun}.`;
|
|
392
|
+
rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), error }));
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
350
396
|
inputReply(cmd, rec) {
|
|
351
|
-
|
|
352
|
-
if (waiting) return waiting.inputReply(cmd, rec);
|
|
353
|
-
return this.primary ? this.primary.inputReply(cmd, rec) : undefined;
|
|
397
|
+
return this.controlPlane(cmd, rec, "inputWaiting", "input_reply")?.inputReply(cmd, rec);
|
|
354
398
|
}
|
|
355
399
|
|
|
356
400
|
debugCmd(cmd, rec) {
|
|
357
|
-
|
|
358
|
-
if (paused) return paused.debugCmd(cmd, rec);
|
|
359
|
-
return this.primary ? this.primary.debugCmd(cmd, rec) : undefined;
|
|
401
|
+
return this.controlPlane(cmd, rec, "debugPaused", "debug_cmd")?.debugCmd(cmd, rec);
|
|
360
402
|
}
|
|
361
403
|
|
|
362
404
|
runstate(cmd, rec) {
|