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
package/host/planes/update.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// update.mjs — signed product updates: two ops, and a very narrow API.
|
|
2
2
|
//
|
|
3
3
|
// A port of the `update_*` half of spike/serve.R. The updater is a
|
|
4
|
-
// PRODUCT-OWNED SCRIPT injected by the launcher (`
|
|
4
|
+
// PRODUCT-OWNED SCRIPT injected by the launcher (`BEATRINA_UPDATE_SCRIPT`). The
|
|
5
5
|
// browser never supplies a path or a command line: it may name one action from
|
|
6
6
|
// a fixed four-word vocabulary, and the supervisor starts that exact script
|
|
7
7
|
// directly, as an argument vector.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// not poll a feed and has no clock of its own — that belongs to the R package's
|
|
11
11
|
// `check_upgrade()`, once a day, at a door the person opened. All this plane
|
|
12
12
|
// can do is read the script's six-field status record and ask it to act, which
|
|
13
|
-
// is why `
|
|
13
|
+
// is why `BEATRINA_NO_UPDATE_CHECK=1` holds here trivially: with no script there
|
|
14
14
|
// is no process, and with a script there is still no feed.
|
|
15
15
|
//
|
|
16
16
|
// Who may ask: page-only in BOTH senses (agentRefused at the seam,
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
import fs from "node:fs";
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { spawn, spawnSync } from "node:child_process";
|
|
31
|
+
import { whichSync } from "../which.mjs";
|
|
32
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
31
33
|
|
|
32
|
-
const PAGE_ONLY_CLASSES = ["served", "file", "local"];
|
|
33
34
|
const OPS = ["update_status", "update_action"];
|
|
34
35
|
const AGENT_WHY = {
|
|
35
36
|
update_status: "Agents cannot inspect desktop update state.",
|
|
36
|
-
update_action: "Agents cannot install, defer, or roll back
|
|
37
|
+
update_action: "Agents cannot install, defer, or roll back Beatrina.",
|
|
37
38
|
};
|
|
38
39
|
const ACTIONS = ["check", "defer", "install", "rollback"];
|
|
39
40
|
const STATES = ["unknown", "current", "available", "deferred", "installing",
|
|
@@ -55,14 +56,6 @@ export function updateRunnerSpec(script, args = [], platform = process.platform)
|
|
|
55
56
|
return { command: "/bin/sh", args: [script, ...args] };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function whichSync(name) {
|
|
59
|
-
for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
60
|
-
const cand = path.join(dir, name);
|
|
61
|
-
try { fs.accessSync(cand, fs.constants.X_OK); return cand; } catch { /* next */ }
|
|
62
|
-
}
|
|
63
|
-
return "";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
59
|
// eslint-disable-next-line no-control-regex
|
|
67
60
|
const updateText = (x, max = 240) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, max).trim();
|
|
68
61
|
const updateVersion = (x) => {
|
|
@@ -74,7 +67,7 @@ export function createPlane({ env, audit }) {
|
|
|
74
67
|
const job = { proc: null, action: "", started: 0 };
|
|
75
68
|
|
|
76
69
|
const scriptPath = (deployment) => {
|
|
77
|
-
const script = env("
|
|
70
|
+
const script = env("BEATRINA_UPDATE_SCRIPT", "").trim();
|
|
78
71
|
if (deployment.loopback !== true || !script || !fs.existsSync(script)) return "";
|
|
79
72
|
const real = fs.realpathSync(script);
|
|
80
73
|
return updateRunnerSpec(real) ? real : "";
|
package/host/plugin-store.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Three layers, searched in order, exactly as .libPaths() is:
|
|
11
11
|
//
|
|
12
|
-
// user R_user_dir("
|
|
13
|
-
// system $
|
|
12
|
+
// user R_user_dir("beatrina","data")/plugins/<kind>/<id>/ the ONLY layer written
|
|
13
|
+
// system $BEATRINA_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
|
|
14
14
|
// shipped <kernel>/plugins/<kind>/<id>/ the bundle's, read-only
|
|
15
15
|
//
|
|
16
16
|
// The first OK copy of an id wins. A BROKEN copy never wins: integrity is
|
|
@@ -32,15 +32,20 @@ import crypto from "node:crypto";
|
|
|
32
32
|
import fs from "node:fs";
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
|
-
import { appId } from "./user-dirs.mjs";
|
|
35
|
+
import { appId, rUserDir } from "./user-dirs.mjs";
|
|
36
36
|
|
|
37
37
|
export const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
38
38
|
export const PLUGIN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
39
39
|
export const PLUGIN_MANIFEST = "plugin.json";
|
|
40
40
|
export const PLUGIN_MANIFEST_MAX_BYTES = 65536;
|
|
41
41
|
export const PLUGIN_FETCH_TIMEOUT_S = 20;
|
|
42
|
-
export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/
|
|
43
|
-
export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/
|
|
42
|
+
export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/beatrina/plugin-policy.json";
|
|
43
|
+
export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/beatrina/plugins";
|
|
44
|
+
// Where an administrator deployed them for Beatrina 0.8.x: read when the current path is absent.
|
|
45
|
+
// beatrina-names: keep-start
|
|
46
|
+
const LEGACY_PLUGIN_POLICY_FILE = "/etc/carmar/plugin-policy.json";
|
|
47
|
+
const LEGACY_PLUGIN_SYSTEM_DIR = "/etc/carmar/plugins";
|
|
48
|
+
// beatrina-names: keep-end
|
|
44
49
|
|
|
45
50
|
/** A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in lib/editor-themes.js. */
|
|
46
51
|
export const THEME_TOKENS = Object.freeze([
|
|
@@ -130,8 +135,8 @@ export function cslLooksLikeStyle(file) {
|
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
/** The registry a `csl` install fetches by name from. Env-driven, as in R. */
|
|
133
|
-
export const cslIndexUrl = (env) => env("
|
|
134
|
-
export const cslStyleBase = (env) => env("
|
|
138
|
+
export const cslIndexUrl = (env) => env("BEATRINA_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
|
|
139
|
+
export const cslStyleBase = (env) => env("BEATRINA_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
|
|
135
140
|
|
|
136
141
|
/* ── the kinds table ──────────────────────────────────────────────────────
|
|
137
142
|
*
|
|
@@ -288,37 +293,12 @@ export function urlHost(url) {
|
|
|
288
293
|
return m[0].replace(/^https?:\/\//, "").replace(/:[0-9]+$/, "").toLowerCase();
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
* platform's own place, then `R/<package>` under it.
|
|
298
|
-
*/
|
|
299
|
-
export function rUserDir(pkg, which, env) {
|
|
300
|
-
const home = os.homedir();
|
|
301
|
-
const win = process.platform === "win32";
|
|
302
|
-
const mac = process.platform === "darwin";
|
|
303
|
-
let p = "";
|
|
304
|
-
if (which === "data") {
|
|
305
|
-
p = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
|
|
306
|
-
|| (win ? path.join(env("APPDATA"), "R", "data")
|
|
307
|
-
: mac ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
308
|
-
: path.join(home, ".local", "share"));
|
|
309
|
-
} else if (which === "cache") {
|
|
310
|
-
p = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
|
|
311
|
-
|| (win ? path.join(env("LOCALAPPDATA"), "R", "cache")
|
|
312
|
-
: mac ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
313
|
-
: path.join(home, ".cache"));
|
|
314
|
-
} else {
|
|
315
|
-
p = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
|
|
316
|
-
|| (win ? path.join(env("APPDATA"), "R", "config")
|
|
317
|
-
: mac ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
318
|
-
: path.join(home, ".config"));
|
|
319
|
-
}
|
|
320
|
-
return path.join(p, "R", pkg);
|
|
321
|
-
}
|
|
296
|
+
// rUserDir is IMPORTED. Both copies described themselves as faithful
|
|
297
|
+
// transcriptions of R 4.5's tools::R_user_dir, and they had already diverged on
|
|
298
|
+
// the fall-through branch — this one answered "config" where the other answered
|
|
299
|
+
// "cache" for an unrecognised `which`. Two transcriptions of one R function that
|
|
300
|
+
// already disagree is the definition of drift, and host/user-dirs.mjs explains
|
|
301
|
+
// at length why the host must not invent a second layout.
|
|
322
302
|
|
|
323
303
|
/* ── the administrator's policy ──────────────────────────────────────────── */
|
|
324
304
|
|
|
@@ -333,7 +313,7 @@ export function rUserDir(pkg, which, env) {
|
|
|
333
313
|
* network:boolean, kinds:string[], unknown:string[], note:string,
|
|
334
314
|
* system_dir:string, errors:string[]}}
|
|
335
315
|
*/
|
|
336
|
-
export function
|
|
316
|
+
export function beatrinaPluginPolicy(env, {
|
|
337
317
|
defaultFile = PLUGIN_POLICY_DEFAULT_FILE, defaultDir = PLUGIN_SYSTEM_DEFAULT_DIR,
|
|
338
318
|
} = {}) {
|
|
339
319
|
const kinds_table = pluginKinds(env);
|
|
@@ -344,19 +324,22 @@ export function carmarPluginPolicy(env, {
|
|
|
344
324
|
let sourcesRaw = null;
|
|
345
325
|
let kindsRaw = null;
|
|
346
326
|
let note = "";
|
|
347
|
-
let systemDir = env("
|
|
327
|
+
let systemDir = env("BEATRINA_PLUGIN_DIR", "").trim();
|
|
348
328
|
if (!systemDir && defaultDir && dirExists(defaultDir)) systemDir = defaultDir;
|
|
329
|
+
// The older deployment path counts only for the default, never for a caller's own.
|
|
330
|
+
if (!systemDir && defaultDir === PLUGIN_SYSTEM_DEFAULT_DIR && dirExists(LEGACY_PLUGIN_SYSTEM_DIR)) systemDir = LEGACY_PLUGIN_SYSTEM_DIR;
|
|
349
331
|
|
|
350
|
-
let file = env("
|
|
332
|
+
let file = env("BEATRINA_PLUGIN_POLICY", "").trim();
|
|
351
333
|
if (!file && defaultFile && fileExists(defaultFile)) file = defaultFile;
|
|
334
|
+
if (!file && defaultFile === PLUGIN_POLICY_DEFAULT_FILE && fileExists(LEGACY_PLUGIN_POLICY_FILE)) file = LEGACY_PLUGIN_POLICY_FILE;
|
|
352
335
|
if (file) {
|
|
353
336
|
source = file;
|
|
354
337
|
if (!fileExists(file)) {
|
|
355
|
-
errors.push(`
|
|
338
|
+
errors.push(`BEATRINA_PLUGIN_POLICY names a file that does not exist: ${file}`);
|
|
356
339
|
} else {
|
|
357
340
|
let doc = null;
|
|
358
341
|
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
359
|
-
catch (e) { errors.push(`
|
|
342
|
+
catch (e) { errors.push(`BEATRINA_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
|
|
360
343
|
if (doc && typeof doc === "object") {
|
|
361
344
|
install = String(doc.install ?? "").trim();
|
|
362
345
|
if (doc.sources != null) sourcesRaw = [].concat(doc.sources).map(String);
|
|
@@ -367,9 +350,9 @@ export function carmarPluginPolicy(env, {
|
|
|
367
350
|
}
|
|
368
351
|
}
|
|
369
352
|
} else {
|
|
370
|
-
if (env("
|
|
371
|
-
if (env("
|
|
372
|
-
if (env("
|
|
353
|
+
if (env("BEATRINA_PLUGIN_INSTALL", "")) { source = "env"; install = env("BEATRINA_PLUGIN_INSTALL", "").trim(); }
|
|
354
|
+
if (env("BEATRINA_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("BEATRINA_PLUGIN_SOURCES", "")); }
|
|
355
|
+
if (env("BEATRINA_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("BEATRINA_PLUGIN_KINDS", "")); }
|
|
373
356
|
}
|
|
374
357
|
|
|
375
358
|
if (!install) install = "user";
|
|
@@ -432,11 +415,11 @@ export function pluginDefaultHosts(env) {
|
|
|
432
415
|
/** The sentence shown when the policy refuses something. */
|
|
433
416
|
export function pluginPolicyReason(policy, what = "install") {
|
|
434
417
|
const base = {
|
|
435
|
-
install: "This
|
|
436
|
-
network: "This
|
|
437
|
-
host: `This
|
|
438
|
-
kind: "This
|
|
439
|
-
}[what] || "This
|
|
418
|
+
install: "This Beatrina is configured so that plugins are not installed by users; the set is fixed by the deployment.",
|
|
419
|
+
network: "This Beatrina is configured to fetch no plugins from the network; a plugin arrives as a file.",
|
|
420
|
+
host: `This Beatrina is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
|
|
421
|
+
kind: "This Beatrina is configured without that plugin kind.",
|
|
422
|
+
}[what] || "This Beatrina's plugin policy refuses that.";
|
|
440
423
|
return policy.note ? `${base} ${policy.note}` : base;
|
|
441
424
|
}
|
|
442
425
|
|
|
@@ -833,6 +816,6 @@ export function pluginPolicyDisclosure(here, policy, env) {
|
|
|
833
816
|
/** ONE policy per host process, as serve.R has ONE `plugin_policy`. */
|
|
834
817
|
let cachedPolicy = null;
|
|
835
818
|
export function pluginPolicyOnce(env, opts) {
|
|
836
|
-
if (!cachedPolicy) cachedPolicy =
|
|
819
|
+
if (!cachedPolicy) cachedPolicy = beatrinaPluginPolicy(env, opts);
|
|
837
820
|
return cachedPolicy;
|
|
838
821
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// recent-documents.mjs — the rules of the machine's list of documents this
|
|
2
|
+
// person opened, with no file system in them.
|
|
3
|
+
//
|
|
4
|
+
// One record per document: `{name, path, when}` — the name a row shows, the
|
|
5
|
+
// ABSOLUTE path a reopen reads, and the millisecond clock of the last time it
|
|
6
|
+
// was opened or saved. Newest first, one row per path, twenty deep.
|
|
7
|
+
//
|
|
8
|
+
// These rules are the page's (lib/recent-files.js), written a second time in
|
|
9
|
+
// the host, and test/recent-documents.test.mjs pins the two equal on the same
|
|
10
|
+
// inputs. The page keeps a cache of the list in its origin's storage and hands
|
|
11
|
+
// it over once per connection (`merge`), so a page and a host that disagreed
|
|
12
|
+
// about what a valid row is, or which of two rows of one path wins, would
|
|
13
|
+
// merge into a list neither of them wrote. Keep the two files in step.
|
|
14
|
+
//
|
|
15
|
+
// Pure: every function takes a list and returns a new one; `now` is a
|
|
16
|
+
// parameter so a test can pin what the clock does.
|
|
17
|
+
|
|
18
|
+
export const RECENT_DOCUMENTS_MAX = 20;
|
|
19
|
+
const PATH_MAX = 4096;
|
|
20
|
+
const NAME_MAX = 255;
|
|
21
|
+
|
|
22
|
+
// A POSIX root, a Windows drive (`C:\` or `C:/`) or a UNC share. The kernel
|
|
23
|
+
// hands the page resolved absolute paths, so a relative one is a row that was
|
|
24
|
+
// never openable — a picker's bare filename, a typo — and is dropped.
|
|
25
|
+
const ABSOLUTE_PATH = /^(\/|[A-Za-z]:[\\/]|\\\\)/;
|
|
26
|
+
|
|
27
|
+
const baseName = (p) => String(p || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One record as the list keeps it, or null when it could not be reopened.
|
|
31
|
+
*
|
|
32
|
+
* The path must be absolute, printable and bounded; the name is trimmed,
|
|
33
|
+
* stripped of line breaks and bounded, and falls back to the path's last
|
|
34
|
+
* segment; `when` is a positive millisecond clock, else `now`.
|
|
35
|
+
*
|
|
36
|
+
* @param {*} entry
|
|
37
|
+
* @param {number} [now]
|
|
38
|
+
* @returns {{name:string, path:string, when:number}|null}
|
|
39
|
+
*/
|
|
40
|
+
export function cleanRecentDocument(entry, now = Date.now()) {
|
|
41
|
+
if (!entry || typeof entry !== "object") return null;
|
|
42
|
+
const path = typeof entry.path === "string" ? entry.path : "";
|
|
43
|
+
if (!path || path.length > PATH_MAX || /[\r\n\0]/.test(path) || !ABSOLUTE_PATH.test(path)) return null;
|
|
44
|
+
const rawName = typeof entry.name === "string" ? entry.name.replace(/[\r\n\0]/g, " ").trim() : "";
|
|
45
|
+
const name = (rawName || baseName(path) || path).slice(0, NAME_MAX);
|
|
46
|
+
const clock = Number(entry.when);
|
|
47
|
+
const when = Number.isFinite(clock) && clock > 0 ? Math.floor(clock) : now;
|
|
48
|
+
return { name, path, when };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The list as it is kept: invalid rows dropped, one row per path — the one
|
|
53
|
+
* with the newest `when` wins, and on a tie the one that came FIRST — newest
|
|
54
|
+
* first, capped at RECENT_DOCUMENTS_MAX.
|
|
55
|
+
*
|
|
56
|
+
* The tie rule is what makes `note` deterministic: a row just noted is put in
|
|
57
|
+
* front with the clock's value, and two notes within one millisecond keep
|
|
58
|
+
* their order rather than being re-sorted by path. The sort is stable, so
|
|
59
|
+
* rows of one `when` keep the order they arrived in.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array} list
|
|
62
|
+
* @param {number} [now]
|
|
63
|
+
* @returns {Array<{name:string, path:string, when:number}>}
|
|
64
|
+
*/
|
|
65
|
+
export function cleanRecentDocuments(list, now = Date.now()) {
|
|
66
|
+
const byPath = new Map();
|
|
67
|
+
(Array.isArray(list) ? list : []).forEach((raw) => {
|
|
68
|
+
const row = cleanRecentDocument(raw, now);
|
|
69
|
+
if (!row) return;
|
|
70
|
+
const have = byPath.get(row.path);
|
|
71
|
+
if (!have || row.when > have.when) byPath.set(row.path, row);
|
|
72
|
+
});
|
|
73
|
+
return [...byPath.values()].sort((a, b) => b.when - a.when).slice(0, RECENT_DOCUMENTS_MAX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How many rows of `list` `cleanRecentDocuments` would drop as unopenable. */
|
|
77
|
+
export function countUnopenable(list, now = Date.now()) {
|
|
78
|
+
return (Array.isArray(list) ? list : []).filter((raw) => !cleanRecentDocument(raw, now)).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remember a document: it goes to the top, stamped `now`, and any older row
|
|
83
|
+
* of the same path goes.
|
|
84
|
+
*
|
|
85
|
+
* @param {Array} list
|
|
86
|
+
* @param {{name?:string, path:string}} document
|
|
87
|
+
* @param {number} [now]
|
|
88
|
+
*/
|
|
89
|
+
export function noteRecentDocument(list, document, now = Date.now()) {
|
|
90
|
+
const row = cleanRecentDocument({ ...(document || {}), when: now }, now);
|
|
91
|
+
if (!row) return cleanRecentDocuments(list, now);
|
|
92
|
+
return cleanRecentDocuments([row, ...(Array.isArray(list) ? list : [])], now);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drop one path. Clearing the whole list was the only deletion once, so a
|
|
97
|
+
* document that had moved could be removed only by throwing away the others.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array} list
|
|
100
|
+
* @param {string} path
|
|
101
|
+
* @param {number} [now]
|
|
102
|
+
*/
|
|
103
|
+
export function forgetRecentDocument(list, path, now = Date.now()) {
|
|
104
|
+
const gone = String(path || "");
|
|
105
|
+
return cleanRecentDocuments(list, now).filter((row) => row.path !== gone);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Two lists as one: every path of either, the newer row of each, newest
|
|
110
|
+
* first, capped. `a` comes first so that on a tie of `when` its row wins —
|
|
111
|
+
* the host passes the machine's list as `a` and a page's cache as `b`.
|
|
112
|
+
*
|
|
113
|
+
* @param {Array} a
|
|
114
|
+
* @param {Array} b
|
|
115
|
+
* @param {number} [now]
|
|
116
|
+
*/
|
|
117
|
+
export function mergeRecentDocuments(a, b, now = Date.now()) {
|
|
118
|
+
return cleanRecentDocuments([...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])], now);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default {
|
|
122
|
+
RECENT_DOCUMENTS_MAX, cleanRecentDocument, cleanRecentDocuments, countUnopenable,
|
|
123
|
+
noteRecentDocument, forgetRecentDocument, mergeRecentDocuments,
|
|
124
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// runtime-dir.mjs — where a running kernel leaves its `kernel-<port>.json`.
|
|
2
|
+
// beatrina-names: keep-file — this module reads the older directory on purpose.
|
|
3
|
+
//
|
|
4
|
+
// The product's runtime directory is `~/.beatrina/run` (`BEATRINA_RUNTIME_DIR`
|
|
5
|
+
// names another; `CARMAR_RUNTIME_DIR` is still accepted, host/env-names.mjs).
|
|
6
|
+
// Until 2026-09-16 it was `~/.carmar/run`, and a kernel started by an older
|
|
7
|
+
// launcher, or one still running from before the rename, wrote its record
|
|
8
|
+
// THERE. So the two questions have two answers:
|
|
9
|
+
//
|
|
10
|
+
// · WRITE goes to one directory: the explicit one, else `~/.beatrina/run`.
|
|
11
|
+
// · READ (the Sessions pane, `beatrina status`/`stop`, the MCP bridge's
|
|
12
|
+
// discovery) covers both, the current first, so a session is never lost
|
|
13
|
+
// to the rename. An EXPLICIT directory is read alone: a test that
|
|
14
|
+
// relocates the records must not see the user's real sessions.
|
|
15
|
+
//
|
|
16
|
+
// It is deliberately not keyed on the app id (host/user-dirs.mjs says why: it
|
|
17
|
+
// is how every tool finds every running kernel).
|
|
18
|
+
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { readEnv } from "./env-names.mjs";
|
|
22
|
+
|
|
23
|
+
export const RUNTIME_DIR_CURRENT = [".beatrina", "run"];
|
|
24
|
+
export const RUNTIME_DIR_LEGACY = [".carmar", "run"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Record<string,string|undefined>|((name: string, unset?: string) => string)} [env]
|
|
28
|
+
* process.env, or a `Sys.getenv`-style getter
|
|
29
|
+
* @param {string} [home]
|
|
30
|
+
* @returns {{write: string, read: string[], explicit: boolean}}
|
|
31
|
+
*/
|
|
32
|
+
export function runtimeDirs(env = process.env, home = os.homedir()) {
|
|
33
|
+
const explicit = readEnv(env, "RUNTIME_DIR").trim();
|
|
34
|
+
if (explicit) return { write: explicit, read: [explicit], explicit: true };
|
|
35
|
+
const current = path.join(home, ...RUNTIME_DIR_CURRENT);
|
|
36
|
+
return { write: current, read: [current, path.join(home, ...RUNTIME_DIR_LEGACY)], explicit: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The one directory a new record is written to. */
|
|
40
|
+
export function runtimeWriteDir(env = process.env, home = os.homedir()) {
|
|
41
|
+
return runtimeDirs(env, home).write;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every directory a record may be read from, current first. */
|
|
45
|
+
export function runtimeReadDirs(env = process.env, home = os.homedir()) {
|
|
46
|
+
return runtimeDirs(env, home).read;
|
|
47
|
+
}
|
package/host/server.mjs
CHANGED
|
@@ -31,8 +31,10 @@ import path from "node:path";
|
|
|
31
31
|
import { attachWebSocket } from "./ws.mjs";
|
|
32
32
|
import { rookOf, proxyUser } from "./deployment.mjs";
|
|
33
33
|
import { cleanSessionDocuments } from "./session-keep.mjs";
|
|
34
|
+
import { readEnv } from "./env-names.mjs";
|
|
35
|
+
import { gatewayCommands, gatewayTokenGate, GATEWAY_MANAGED_COMMANDS, GATEWAY_MANAGED_PATHS, GATEWAY_HIDDEN_CAPABILITIES } from "./gateway-token.mjs";
|
|
34
36
|
|
|
35
|
-
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
|
|
37
|
+
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "column_values", "rm", "packages", "doctor",
|
|
36
38
|
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
37
39
|
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
38
40
|
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
|
|
@@ -40,8 +42,6 @@ export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstat
|
|
|
40
42
|
// below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
|
|
41
43
|
// host asks it, after a restart it performed (WorkerPlane.startResume).
|
|
42
44
|
"suspend"]);
|
|
43
|
-
export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
|
|
44
|
-
"input_reply", "debug_cmd"]);
|
|
45
45
|
/** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
|
|
46
46
|
export const NOT_YET = Object.freeze({
|
|
47
47
|
files: "the file system", journal: "the document journal", ai: "the AI conversation store",
|
|
@@ -55,7 +55,9 @@ export const NOT_YET = Object.freeze({
|
|
|
55
55
|
});
|
|
56
56
|
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
|
|
57
57
|
// A copy of every object in the session, written to disk.
|
|
58
|
-
"suspend", "suspend_cancel"
|
|
58
|
+
"suspend", "suspend_cancel",
|
|
59
|
+
// The grid's clipboard door (a whole column, up to a million values); an agent reads data through a chunk.
|
|
60
|
+
"column_values"]);
|
|
59
61
|
/** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
|
|
60
62
|
export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
|
|
61
63
|
export const AGENT_WHY = Object.freeze({
|
|
@@ -63,6 +65,7 @@ export const AGENT_WHY = Object.freeze({
|
|
|
63
65
|
project_action: "Agents cannot install or restore project packages.",
|
|
64
66
|
suspend: "Agents cannot keep the user's workspace.",
|
|
65
67
|
suspend_cancel: "Agents cannot keep the user's workspace.",
|
|
68
|
+
column_values: "Agents read data through notebook chunks (chunk_run), not the grid's copy door.",
|
|
66
69
|
});
|
|
67
70
|
export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
|
|
68
71
|
export const FILE_ORIGIN = "null";
|
|
@@ -88,9 +91,16 @@ export function secureToken(n = 32) {
|
|
|
88
91
|
return out;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Stamp the served HTML with this session's identity, once per load — and, for a product other
|
|
96
|
+
* than Beatrina (`app`, BEATRIX_APP_ID: "beatrina"), with which product served it, so the page's
|
|
97
|
+
* Start R can name the door this product has (docs/launch-menu-plan.md L6). A Beatrina page is the
|
|
98
|
+
* installed file plus the ONE session meta, exactly as before (test/host-launch.test.mjs pins that
|
|
99
|
+
* byte for byte); a page opened from disk carries neither, and its Start R stays Beatrina's.
|
|
100
|
+
*/
|
|
101
|
+
export function stampSession(html, stamp, app = "") {
|
|
102
|
+
const product = String(app || "").replace(/[^A-Za-z0-9.]/g, "");
|
|
103
|
+
const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}`;
|
|
94
104
|
let m = html.match(/<head\b[^>]*>/);
|
|
95
105
|
if (!m) m = html.match(/<html\b[^>]*>/);
|
|
96
106
|
if (!m) return meta + html;
|
|
@@ -100,7 +110,7 @@ export function stampSession(html, stamp) {
|
|
|
100
110
|
|
|
101
111
|
/**
|
|
102
112
|
* @param {Object} opts
|
|
103
|
-
* @param {ReturnType<import("./deployment.mjs").
|
|
113
|
+
* @param {ReturnType<import("./deployment.mjs").beatrinaDeployment>} opts.deployment
|
|
104
114
|
* @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
|
|
105
115
|
* router; it presents one WorkerPlane's surface, so everything here reads as
|
|
106
116
|
* it did when there was one engine (WP4, docs/wp/wp4-python.md)
|
|
@@ -113,9 +123,15 @@ export function stampSession(html, stamp) {
|
|
|
113
123
|
* @param {(event: string, fields?: object) => void} opts.audit
|
|
114
124
|
* @param {() => void} opts.onShutdown
|
|
115
125
|
* @param {string} opts.here the directory holding the fallback spike page
|
|
126
|
+
* @param {string} [opts.gatewayToken] private gateway credential, required on HTTP and WS
|
|
116
127
|
*/
|
|
117
128
|
export function createHostServer(opts) {
|
|
118
129
|
const { deployment, plane, audit, onShutdown, planes = [] } = opts;
|
|
130
|
+
const gatewayOk = gatewayTokenGate(opts.gatewayToken);
|
|
131
|
+
const gatewayMode = opts.gatewayToken !== undefined;
|
|
132
|
+
const commands = gatewayCommands(planes.flatMap((pl) => pl.commands || []), gatewayMode);
|
|
133
|
+
const capabilities = planes.flatMap((pl) => pl.capabilities || []).filter((capability) => !gatewayMode || !GATEWAY_HIDDEN_CAPABILITIES.includes(capability));
|
|
134
|
+
const managedError = "Sessions and software updates are managed by the Beatrina server. Use the server workspace controls.";
|
|
119
135
|
const originsOk = deployment.origins;
|
|
120
136
|
const hostsOk = deployment.hosts;
|
|
121
137
|
const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
|
|
@@ -124,7 +140,19 @@ export function createHostServer(opts) {
|
|
|
124
140
|
// The documents open in this session, as the pages last reported them (host/session-keep.mjs).
|
|
125
141
|
sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
|
|
126
142
|
plane.sockets = () => sockets;
|
|
127
|
-
|
|
143
|
+
// A TERMINAL worker death leaves the page stranded: no R, but a supervisor that still answers the
|
|
144
|
+
// handoff (src/r-kernel.js `stranded`). A page that connects AFTER the death — the page's own
|
|
145
|
+
// reconnect does exactly that — never saw a ready frame to learn this session's build or its
|
|
146
|
+
// doors from, so the notice carries them. Our own frame, re-encoded; never a worker's bytes.
|
|
147
|
+
const strandedNotice = (payload) => {
|
|
148
|
+
if (typeof payload !== "string" || !payload.includes('"worker-died"') || !payload.includes('"terminal":true')) return payload;
|
|
149
|
+
try {
|
|
150
|
+
const frame = JSON.parse(payload);
|
|
151
|
+
const doors = commands.filter((c) => c === "session_upgrade" || c === "session_restart");
|
|
152
|
+
return enc({ ...frame, session: { kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(), commands: doors } });
|
|
153
|
+
} catch { return payload; }
|
|
154
|
+
};
|
|
155
|
+
plane.on("broadcast", (payload) => { const out = strandedNotice(payload); sockets.forEach((r) => r.ws.send(out)); });
|
|
128
156
|
// A restore's report is about every object in the session: pages only, never an agent or a native client.
|
|
129
157
|
plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
|
|
130
158
|
|
|
@@ -226,8 +254,13 @@ export function createHostServer(opts) {
|
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
const server = http.createServer((req, res) => {
|
|
257
|
+
if (!gatewayOk(req)) return reject(res, "gateway authentication required");
|
|
229
258
|
if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
|
|
230
259
|
const pathname = new URL(req.url, "http://x").pathname;
|
|
260
|
+
if (gatewayMode && GATEWAY_MANAGED_PATHS.includes(pathname)) {
|
|
261
|
+
audit("server-managed-refused", { path: pathname });
|
|
262
|
+
return respond(res, 403, "application/json", enc({ ok: false, reason: "server-managed", error: managedError }));
|
|
263
|
+
}
|
|
231
264
|
if (pathname === "/health") {
|
|
232
265
|
if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
|
|
233
266
|
const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
|
|
@@ -243,14 +276,14 @@ export function createHostServer(opts) {
|
|
|
243
276
|
// Which build this kernel replaced, for the first 90 s of its life —
|
|
244
277
|
// how a page recognises its successor. Never the page or its capability.
|
|
245
278
|
...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
|
|
246
|
-
capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...
|
|
279
|
+
capabilities: [...(gatewayMode ? [] : ["published-direct-v1", "published-pairing-v3", "session-restart-v1"]), ...capabilities],
|
|
247
280
|
// `engines` is what a chunk may be RUN on, so a file page probing for
|
|
248
281
|
// a kernel can tell a two-engine host from a one-engine one before it
|
|
249
282
|
// opens a socket. It names every engine a runtime was found for, not
|
|
250
283
|
// only the ones that have started (WP4; docs/wp/wp4-python.md).
|
|
251
284
|
host: "beatrix",
|
|
252
285
|
// Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
|
|
253
|
-
app: opts.app || "
|
|
286
|
+
app: opts.app || "beatrina",
|
|
254
287
|
engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
|
|
255
288
|
engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
|
|
256
289
|
}), { "Access-Control-Allow-Origin": "*" });
|
|
@@ -271,13 +304,17 @@ export function createHostServer(opts) {
|
|
|
271
304
|
const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
|
|
272
305
|
let html;
|
|
273
306
|
try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
|
|
274
|
-
return respond(res, 200, "text/html", stampSession(html, sessionStamp));
|
|
307
|
+
return respond(res, 200, "text/html", stampSession(html, sessionStamp, opts.app || "beatrina"));
|
|
275
308
|
}
|
|
276
309
|
return respond(res, 404, "text/plain", "not found");
|
|
277
310
|
});
|
|
278
311
|
|
|
279
312
|
attachWebSocket(server, {
|
|
280
313
|
gate: (req) => {
|
|
314
|
+
if (!gatewayOk(req)) {
|
|
315
|
+
audit("rejected", { reason: "gateway authentication required" });
|
|
316
|
+
return { status: 403, body: "forbidden: gateway authentication required" };
|
|
317
|
+
}
|
|
281
318
|
if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
|
|
282
319
|
const origin = req.headers.origin;
|
|
283
320
|
const approved = origin != null && approvals.has(origin);
|
|
@@ -291,13 +328,16 @@ export function createHostServer(opts) {
|
|
|
291
328
|
const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
|
|
292
329
|
user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
|
|
293
330
|
sockets.push(rec);
|
|
331
|
+
// A pong is the transport answering: the browser's network process sends it even while the
|
|
332
|
+
// page's thread is busy rendering or throttled behind another window, so it counts as life.
|
|
333
|
+
ws.onPong(() => { rec.lastSeen = Date.now(); });
|
|
294
334
|
state.everConnected = true;
|
|
295
335
|
audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
|
|
296
336
|
// The engine announced itself long before this page connected; replay
|
|
297
337
|
// it — and every other engine that has since started, each under its own
|
|
298
338
|
// `engine-ready` (host/engine-pool.mjs replay()).
|
|
299
339
|
if (plane.hello) ws.send(plane.hello);
|
|
300
|
-
if (plane.notice) ws.send(plane.notice);
|
|
340
|
+
if (plane.notice) ws.send(strandedNotice(plane.notice));
|
|
301
341
|
if (typeof plane.replay === "function") plane.replay(ws);
|
|
302
342
|
// …and a restore that finished before this page arrived (a page following a handoff).
|
|
303
343
|
if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
|
|
@@ -340,6 +380,11 @@ export function createHostServer(opts) {
|
|
|
340
380
|
if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
|
|
341
381
|
rec.lastSeen = Date.now();
|
|
342
382
|
const type = cmd.type;
|
|
383
|
+
if (gatewayMode && GATEWAY_MANAGED_COMMANDS.includes(type)) {
|
|
384
|
+
audit("server-managed-refused", { type });
|
|
385
|
+
rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), ok: false, reason: "server-managed", error: managedError }));
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
343
388
|
|
|
344
389
|
if (type === "hb") {
|
|
345
390
|
rec.beats = true;
|
|
@@ -455,13 +500,25 @@ export function createHostServer(opts) {
|
|
|
455
500
|
return undefined;
|
|
456
501
|
}
|
|
457
502
|
|
|
458
|
-
/** Close and forget sockets that have stopped answering (only those that beat).
|
|
459
|
-
|
|
503
|
+
/** Close and forget sockets that have stopped answering (only those that beat).
|
|
504
|
+
*
|
|
505
|
+
* Silence alone used to be the verdict, and a page whose OWN thread was busy — rendering a
|
|
506
|
+
* large result, or throttled by the browser behind another window during a long chunk — sent
|
|
507
|
+
* no `hb` for 90 s and was cut off while R worked; the page then printed "Connection to the R
|
|
508
|
+
* kernel closed", redialled and adopted the run back. Misleading, and the owner said so
|
|
509
|
+
* (2026-09-17). So a page silent for half the threshold is PINGED at the protocol level: the
|
|
510
|
+
* browser answers a ping from its network process whatever the page's thread is doing, the
|
|
511
|
+
* pong refreshes `lastSeen` (onPong above), and only a socket that answers nothing — a
|
|
512
|
+
* sleeping laptop, a dropped link, a killed browser — reaches the threshold and is closed. */
|
|
513
|
+
const silenceMs = Math.max(0, Number(readEnv(process.env, "SOCKET_SILENCE", "90"))) * 1000;
|
|
460
514
|
const reaper = setInterval(() => {
|
|
461
515
|
if (!silenceMs || !sockets.length) return;
|
|
462
516
|
const now = Date.now();
|
|
463
517
|
for (const rec of [...sockets]) {
|
|
464
|
-
if (rec.beats
|
|
518
|
+
if (!rec.beats) continue;
|
|
519
|
+
const silent = now - rec.lastSeen;
|
|
520
|
+
if (silent >= silenceMs / 2 && silent < silenceMs) { rec.ws.ping(); continue; }
|
|
521
|
+
if (silent >= silenceMs) {
|
|
465
522
|
audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
|
|
466
523
|
const at = sockets.indexOf(rec);
|
|
467
524
|
if (at >= 0) sockets.splice(at, 1);
|
|
@@ -479,8 +536,8 @@ export function createHostServer(opts) {
|
|
|
479
536
|
sessionStamp,
|
|
480
537
|
ctx,
|
|
481
538
|
/** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
|
|
482
|
-
commands
|
|
483
|
-
capabilities
|
|
539
|
+
commands,
|
|
540
|
+
capabilities,
|
|
484
541
|
close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
|
|
485
542
|
};
|
|
486
543
|
}
|
package/host/session-keep.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
|
|
8
8
|
//
|
|
9
9
|
// Transcribed rule for rule from spike/workspace-keep.R (keep_token_ok) and spike/session-documents.R
|
|
10
|
-
// (session_documents_clean/label/encode/decode), which
|
|
10
|
+
// (session_documents_clean/label/encode/decode), which Beatrina's R supervisor sources; pinned against
|
|
11
11
|
// the same cases by test/session-keep.test.mjs.
|
|
12
12
|
|
|
13
13
|
import path from "node:path";
|