beatrina 0.8.6 → 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 +156 -26
- 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 +271 -35
- 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 +364 -239
- package/failsafe/session-documents.R +104 -0
- 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 +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- 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 +216 -76
- 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 +41 -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 +230 -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 +123 -21
- package/host/session-keep.mjs +70 -0
- 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 +80 -7
- 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 +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -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 +602 -123
- package/kernel/workspace-keep.R +188 -0
- 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.6.html +0 -1310
package/host/planes/sessions.mjs
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
// POST /session/upgrade and POST /session/restart. A port of serve.R's
|
|
19
19
|
// handoff_refusal / session_handoff_begin / session_handoff_spawn: a successor
|
|
20
20
|
// supervisor is started on the SAME port from the files beside this one (after
|
|
21
|
-
// an in-place upgrade, the installed build's)
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// an in-place upgrade, the installed build's) and must PROVE it can run before
|
|
22
|
+
// anything of this session is touched (handoffBegin: prepare, then retire);
|
|
23
|
+
// then every page is told where its successor's notebook is, the worker's whole
|
|
24
|
+
// tree is killed, and this process exits. The successor's R is FRESH — no save.image() bridge, by decision
|
|
24
25
|
// (docs/upgrade.md); a restart says so and does not pretend otherwise.
|
|
25
26
|
//
|
|
26
|
-
// `
|
|
27
|
+
// `BEATRINA_SESSION_BASE` is Beatrix's only addition to the wire's behaviour
|
|
27
28
|
// here: serve.R hardcodes the 4747..4947 scan, and a TEST may not bind 4747
|
|
28
29
|
// (FORK.md), so the base is readable from the environment and defaults to
|
|
29
30
|
// 4747 — the published-page port, fixed because browsers key storage to the
|
|
@@ -37,6 +38,9 @@ import { spawn, execFileSync } from "node:child_process";
|
|
|
37
38
|
import { fileURLToPath } from "node:url";
|
|
38
39
|
import { PAGE_ONLY_CLASSES, secureToken } from "../server.mjs";
|
|
39
40
|
import { stateDir } from "../user-dirs.mjs";
|
|
41
|
+
import { mirrorEnv, unsetEnv } from "../env-names.mjs";
|
|
42
|
+
import { runtimeReadDirs } from "../runtime-dir.mjs";
|
|
43
|
+
import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
|
|
40
44
|
|
|
41
45
|
export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
|
|
42
46
|
export const HANDOFF_OPS = Object.freeze(["session_upgrade", "session_restart"]);
|
|
@@ -45,28 +49,39 @@ export const SESSION_BAND = 201;
|
|
|
45
49
|
|
|
46
50
|
const RUNTIME_NAME = /^kernel-([0-9]+)\.json$/;
|
|
47
51
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The first port of the session band: BEATRINA_SESSION_BASE when it names one, else 4747. ONE
|
|
54
|
+
* definition — the sibling a page starts (below) and the host's own choosePort (main.mjs) read
|
|
55
|
+
* it, so the two starters agree on where a second session lands.
|
|
56
|
+
*/
|
|
57
|
+
export function sessionBase(env) {
|
|
58
|
+
const given = Number.parseInt(env("BEATRINA_SESSION_BASE", "4747"), 10);
|
|
59
|
+
return Number.isInteger(given) && given >= 1024 && given <= 65000 ? given : 4747;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Every `kernel-<port>.json` a kernel may have left, parsed; unreadable ones
|
|
64
|
+
* dropped. Read from the current directory AND the legacy `~/.carmar/run` (beatrina-names: keep)
|
|
65
|
+
* (a kernel started before the rename, or by an older launcher, is still a
|
|
66
|
+
* session); one record per port, the current directory's winning.
|
|
67
|
+
*/
|
|
54
68
|
export function runtimeRecords(env) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const byPort = new Map();
|
|
70
|
+
for (const dir of runtimeReadDirs(env)) {
|
|
71
|
+
let names = [];
|
|
72
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
const m = RUNTIME_NAME.exec(name);
|
|
75
|
+
if (!m) continue;
|
|
76
|
+
let rec = null;
|
|
77
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { rec = null; }
|
|
78
|
+
if (!rec || typeof rec !== "object" || rec.port == null || rec.url == null) continue;
|
|
79
|
+
const port = Number.parseInt(rec.port, 10);
|
|
80
|
+
if (!Number.isInteger(port) || byPort.has(port)) continue;
|
|
81
|
+
byPort.set(port, { ...rec, port });
|
|
82
|
+
}
|
|
68
83
|
}
|
|
69
|
-
return
|
|
84
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
/**
|
|
@@ -77,15 +92,22 @@ export function runtimeRecords(env) {
|
|
|
77
92
|
* connected page for a full minute per port.
|
|
78
93
|
*/
|
|
79
94
|
export async function healthy(url, timeoutMs = 1000) {
|
|
95
|
+
return (await healthOf(url, timeoutMs)) != null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The /health frame of a kernel that is answering on `url` (`"ok":true`), else null. Same one-second rule. */
|
|
99
|
+
export async function healthOf(url, timeoutMs = 1000) {
|
|
80
100
|
const target = `${String(url).replace(/\/+$/, "")}/health`;
|
|
81
101
|
const ctrl = new AbortController();
|
|
82
102
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
83
103
|
try {
|
|
84
104
|
const res = await fetch(target, { signal: ctrl.signal, cache: "no-store" });
|
|
85
|
-
if (!res.ok) return
|
|
86
|
-
|
|
105
|
+
if (!res.ok) return null;
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
if (!text.includes('"ok":true')) return null;
|
|
108
|
+
try { const h = JSON.parse(text); return h && h.ok === true ? h : null; } catch { return null; }
|
|
87
109
|
} catch {
|
|
88
|
-
return
|
|
110
|
+
return null;
|
|
89
111
|
} finally {
|
|
90
112
|
clearTimeout(timer);
|
|
91
113
|
}
|
|
@@ -180,10 +202,9 @@ export function createPlane({ root, env, audit }) {
|
|
|
180
202
|
const siblingArgs = mainPath ? [mainPath] : [];
|
|
181
203
|
let versionsCache = null;
|
|
182
204
|
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
})();
|
|
205
|
+
const base = sessionBase(env);
|
|
206
|
+
/** Ports chosen by a sibling start that has not finished yet. */
|
|
207
|
+
const pendingPorts = new Set();
|
|
187
208
|
|
|
188
209
|
/**
|
|
189
210
|
* Start another supervisor on the next free adjacent port.
|
|
@@ -191,47 +212,73 @@ export function createPlane({ root, env, audit }) {
|
|
|
191
212
|
* Adjacent and stable on purpose (browsers key storage to the origin), and
|
|
192
213
|
* DETACHED — closing this session must not close that one. The port is a
|
|
193
214
|
* PREFERENCE, not a promise: between the probe and the child's own bind the
|
|
194
|
-
* port can go, so
|
|
195
|
-
* serve.R learned that too: waiting for the
|
|
196
|
-
* a session that had started perfectly well,
|
|
215
|
+
* port can go, so the answer is the port the child ANNOUNCES in its runtime
|
|
216
|
+
* record, never the one asked for. serve.R learned that too: waiting for the
|
|
217
|
+
* exact port reported failure for a session that had started perfectly well,
|
|
218
|
+
* and orphaned it.
|
|
219
|
+
*
|
|
220
|
+
* The session waited for is THIS child's, identified by a launch id carried
|
|
221
|
+
* in its environment (BEATRINA_LAUNCH_ID → the record's `launch`) and by its
|
|
222
|
+
* pid. Until 0.9.6 it was "the first live port that was not there before",
|
|
223
|
+
* so two New Session clicks at once — or any launch from the menu bar in the
|
|
224
|
+
* same seconds — could each be handed the other's session (hardening.md 3).
|
|
225
|
+
*
|
|
226
|
+
* Resolves {port, url} or {error, log}: an early exit or a timeout is said
|
|
227
|
+
* with the tail of the child's own log, not folded into a bare null.
|
|
197
228
|
*/
|
|
198
229
|
const startSibling = async (ctx) => {
|
|
199
230
|
const own = ctx.port();
|
|
200
231
|
const used = new Set(await liveSessionPorts(env, own));
|
|
232
|
+
// A port another start in THIS process has chosen is taken too: two
|
|
233
|
+
// requests probing at once both saw the same free port, and the second
|
|
234
|
+
// child found it bound (the concurrent case in test/host-sessions).
|
|
201
235
|
let chosen = null;
|
|
202
|
-
for (let p =
|
|
203
|
-
if (!used.has(p)) { chosen = p; break; }
|
|
236
|
+
for (let p = base; p <= base + SESSION_BAND - 1; p += 1) {
|
|
237
|
+
if (!used.has(p) && !pendingPorts.has(p)) { chosen = p; break; }
|
|
204
238
|
}
|
|
205
|
-
if (chosen == null) return
|
|
206
|
-
|
|
239
|
+
if (chosen == null) return { error: `Every port from ${base} to ${base + SESSION_BAND - 1} holds a session.` };
|
|
240
|
+
pendingPorts.add(chosen);
|
|
241
|
+
try { return await startOn(chosen); } finally { pendingPorts.delete(chosen); }
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The start itself, on a port already reserved by startSibling. */
|
|
245
|
+
const startOn = async (chosen) => {
|
|
246
|
+
const launch = `sibling-${secureToken(12).replace(/[^A-Za-z0-9]/g, "").slice(0, 12)}`;
|
|
207
247
|
// The pipes go to a FILE, not to a pipe nobody reads: a detached sibling
|
|
208
248
|
// wedges once ~64 KB of stderr accumulates in an unread buffer.
|
|
249
|
+
const logPath = path.join(os.tmpdir(), `beatrix-session-${process.pid}-${launch}.log`);
|
|
209
250
|
let logFd = "ignore";
|
|
210
|
-
try {
|
|
211
|
-
|
|
212
|
-
|
|
251
|
+
try { logFd = fs.openSync(logPath, "a"); } catch { logFd = "ignore"; }
|
|
252
|
+
const logTail = () => {
|
|
253
|
+
try { return fs.readFileSync(logPath, "utf8").trim().split("\n").slice(-6).join("\n"); } catch { return ""; }
|
|
254
|
+
};
|
|
213
255
|
let child;
|
|
214
256
|
try {
|
|
215
257
|
child = spawn(process.execPath, siblingArgs, {
|
|
216
258
|
cwd: root,
|
|
217
|
-
env: { ...process.env,
|
|
259
|
+
env: mirrorEnv({ ...process.env, BEATRINA_PORT: String(chosen), BEATRINA_LAUNCH_ID: launch, BEATRINA_LAUNCH_VIA: "session-menu" }),
|
|
218
260
|
detached: true,
|
|
219
261
|
stdio: ["ignore", logFd, logFd],
|
|
220
262
|
});
|
|
221
263
|
child.unref();
|
|
222
|
-
} catch {
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return { error: `The session could not be started: ${e.message}` };
|
|
266
|
+
} finally {
|
|
267
|
+
if (typeof logFd === "number") { try { fs.closeSync(logFd); } catch { /* the child holds its own copy */ } }
|
|
268
|
+
}
|
|
269
|
+
let spawnError = null;
|
|
270
|
+
child.on("error", (e) => { spawnError = e; });
|
|
223
271
|
const deadline = Date.now() + 60000;
|
|
224
272
|
while (Date.now() < deadline) {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return {
|
|
273
|
+
const rec = runtimeRecords(env).find((r) => r.launch === launch && Number(r.pid) === child.pid);
|
|
274
|
+
if (rec && rec.url && await healthy(rec.url)) return { port: rec.port, url: rec.url, launch };
|
|
275
|
+
if (spawnError) return { error: `The session could not be started: ${spawnError.message}`, log: logTail() };
|
|
276
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
277
|
+
return { error: `The new session stopped before it was ready (${child.signalCode || `exit ${child.exitCode}`}).`, log: logTail() };
|
|
230
278
|
}
|
|
231
|
-
if (child.exitCode !== null || child.signalCode !== null) return null;
|
|
232
279
|
await new Promise((r) => setTimeout(r, 250));
|
|
233
280
|
}
|
|
234
|
-
return
|
|
281
|
+
return { error: "The new session did not become ready within 60 seconds.", log: logTail() };
|
|
235
282
|
};
|
|
236
283
|
|
|
237
284
|
// ── the handoff ─────────────────────────────────────────────────────────
|
|
@@ -241,12 +288,12 @@ export function createPlane({ root, env, audit }) {
|
|
|
241
288
|
const handoffRefusal = (ctx, mode, force) => {
|
|
242
289
|
const refuse = (error, reason) => ({ error, reason });
|
|
243
290
|
const installed = ctx.installedBuild();
|
|
244
|
-
if (!installed || installed === "unknown") return refuse("This kernel cannot tell which
|
|
291
|
+
if (!installed || installed === "unknown") return refuse("This kernel cannot tell which Beatrina is installed.", "unknown-install");
|
|
245
292
|
if (mode === "upgrade" && installed === ctx.kernelBuild) {
|
|
246
|
-
return refuse(`This session already runs the installed
|
|
293
|
+
return refuse(`This session already runs the installed Beatrina ${installed}.`, "same-build");
|
|
247
294
|
}
|
|
248
295
|
if (!ctx.notebookPage(installed)) {
|
|
249
|
-
return refuse(`The installed
|
|
296
|
+
return refuse(`The installed Beatrina ${installed} has no notebook page beside this kernel.`, "no-page");
|
|
250
297
|
}
|
|
251
298
|
// An attempt that has spawned owns the port until this process exits.
|
|
252
299
|
if (handoff) return refuse("A session restart is already under way.", "under-way");
|
|
@@ -268,7 +315,24 @@ export function createPlane({ root, env, audit }) {
|
|
|
268
315
|
return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
|
|
269
316
|
};
|
|
270
317
|
|
|
271
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Begin a handoff. TWO PHASES (docs/runplan.md, "Two-phase handoff"):
|
|
320
|
+
*
|
|
321
|
+
* 1. PREPARE — the successor is started with a ready file to write. It reads its protocol and
|
|
322
|
+
* build, finds its page and its worker, and runs its R once, all BEFORE it waits for the
|
|
323
|
+
* port (host/main.mjs, handoffPreflight). Nothing of this session has been touched: pages
|
|
324
|
+
* have not been told, workers are running, the port is ours.
|
|
325
|
+
* 2. RETIRE — only once the successor says `ok`: pages are told where it is, workers are
|
|
326
|
+
* stopped, this process leaves and the successor takes the port.
|
|
327
|
+
*
|
|
328
|
+
* Until 0.9.5 there was one phase: spawn, kill the workers, exit 300 ms later — so a successor
|
|
329
|
+
* that could not start (a broken install, a missing page, an R that would not run) took the
|
|
330
|
+
* session down with it. A successor that fails or stays silent is now killed and the session
|
|
331
|
+
* carries on, and the reply says why.
|
|
332
|
+
*
|
|
333
|
+
* @returns {Promise<object>} the reply — `{ok:true, …}` or `{ok:false, error, reason}`
|
|
334
|
+
*/
|
|
335
|
+
const handoffBegin = async (ctx, mode = "upgrade", force = false, resume = null) => {
|
|
272
336
|
mode = mode === "restart" ? "restart" : "upgrade";
|
|
273
337
|
force = force === true && mode === "restart";
|
|
274
338
|
const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
|
|
@@ -281,55 +345,126 @@ export function createPlane({ root, env, audit }) {
|
|
|
281
345
|
const cap = secureToken(32);
|
|
282
346
|
const page = ctx.notebookFileUrl(ctx.notebookPage(installed), ctx.port(), cap);
|
|
283
347
|
const started = ctx.startedSeconds;
|
|
284
|
-
handoff = { to: installed, cap, page, mode, force };
|
|
348
|
+
handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
|
|
285
349
|
audit(auditName, { from: ctx.kernelBuild, to: installed, force });
|
|
350
|
+
|
|
351
|
+
const prepared = await handoffPrepare(ctx);
|
|
352
|
+
if (!prepared.ok) {
|
|
353
|
+
handoff = null;
|
|
354
|
+
audit("session-upgrade-failed", { reason: prepared.reason, detail: prepared.error });
|
|
355
|
+
return { ok: false, error: prepared.error, reason: prepared.reason };
|
|
356
|
+
}
|
|
357
|
+
// R may have been given a chunk while the successor prepared. Retiring now would kill it; a
|
|
358
|
+
// forced restart was asked for exactly that, anything else is refused and the successor goes.
|
|
359
|
+
const pl = ctx.plane;
|
|
360
|
+
if (!force && ((pl.running && pl.running.size > 0) || (pl.primary ? pl.primary.active != null : pl.active != null))) {
|
|
361
|
+
killSuccessor(prepared.child);
|
|
362
|
+
handoff = null;
|
|
363
|
+
audit(`${auditName}-refused`, { reason: "busy", force, phase: "prepared" });
|
|
364
|
+
return { ok: false, error: "R became busy while the new session was being prepared — wait for the running chunk, or interrupt it, then try again.", reason: "busy" };
|
|
365
|
+
}
|
|
286
366
|
// Every page learns where its successor's notebook is — over the gated
|
|
287
367
|
// socket only; the capability never appears in /health. `started` is this
|
|
288
368
|
// kernel's boot time: a page recognises its successor by a LATER one, the
|
|
289
369
|
// only test that also works when the build does not change.
|
|
290
|
-
const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force });
|
|
370
|
+
const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
|
|
291
371
|
ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
|
|
292
|
-
|
|
293
|
-
if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
|
|
372
|
+
handoffRetire(ctx);
|
|
294
373
|
return { ok: true, from: ctx.kernelBuild, to: installed, page, started, mode, force };
|
|
295
374
|
};
|
|
296
375
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
376
|
+
/** A successor that will not be used: its whole group goes, since it was started detached. */
|
|
377
|
+
const killSuccessor = (child) => {
|
|
378
|
+
if (!child || !child.pid) return;
|
|
379
|
+
try { process.kill(-child.pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch { /* gone */ } }
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** How long a successor may take to prove it can run. */
|
|
383
|
+
const prepareSeconds = () => {
|
|
384
|
+
const v = Number(env("BEATRINA_HANDOFF_PREPARE_S", "20"));
|
|
385
|
+
return Number.isFinite(v) && v > 0 ? v : 20;
|
|
302
386
|
};
|
|
303
387
|
|
|
304
|
-
/**
|
|
305
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Phase 1: start the successor on this port and wait for its ready file.
|
|
390
|
+
* @returns {Promise<{ok:true, child:object}|{ok:false, error:string, reason:string}>}
|
|
391
|
+
*/
|
|
392
|
+
const handoffPrepare = async (ctx) => {
|
|
306
393
|
const h = handoff;
|
|
307
394
|
const dir = stateDir();
|
|
308
395
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the log is a courtesy */ }
|
|
309
396
|
const port = ctx.port();
|
|
310
397
|
const record = ctx.runtimeRecord();
|
|
311
|
-
const
|
|
312
|
-
|
|
398
|
+
const readyFile = path.join(dir, `handoff-ready-${port}-${process.pid}-${secureToken(8)}.json`);
|
|
399
|
+
try { fs.rmSync(readyFile, { force: true }); } catch { /* not there */ }
|
|
400
|
+
// Canonical names, mirrored at the spawn so an installed Beatrina-era host
|
|
401
|
+
// (one that reads only CARMAR_*) starts as a successor too.
|
|
402
|
+
const childEnv = unsetEnv({ ...process.env,
|
|
403
|
+
BEATRINA_PORT: String(port), BEATRINA_WAIT_PORT: "30",
|
|
313
404
|
// A successor that quietly moved to a free port would leave the page
|
|
314
405
|
// polling a dead one; strict means it refuses and its log says why.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
406
|
+
BEATRINA_PORT_STRICT: "1",
|
|
407
|
+
BEATRINA_FILE_LAUNCH_CAP: h.cap, BEATRINA_HANDOFF_FROM: ctx.kernelBuild,
|
|
408
|
+
BEATRINA_HANDOFF_READY: readyFile,
|
|
409
|
+
BEATRINA_SESSION_TITLE: record.title || "",
|
|
410
|
+
BEATRINA_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
|
|
411
|
+
// Only a token travels, never a path; the successor re-checks it.
|
|
412
|
+
BEATRINA_RESUME_TOKEN: h.resume || "" }, "LISTEN");
|
|
413
|
+
if (record.listen) childEnv.BEATRINA_LISTEN = "1";
|
|
414
|
+
const logPath = path.join(dir, `kernel-handoff-${port}.log`);
|
|
415
|
+
// The log is appended across attempts: only what THIS successor wrote is quoted.
|
|
416
|
+
let logFrom = 0;
|
|
417
|
+
try { logFrom = fs.statSync(logPath).size; } catch { logFrom = 0; }
|
|
319
418
|
let out = "ignore";
|
|
320
|
-
try { out = fs.openSync(
|
|
419
|
+
try { out = fs.openSync(logPath, "a"); } catch { out = "ignore"; }
|
|
321
420
|
let child = null;
|
|
322
421
|
try {
|
|
323
422
|
const { cmd, args } = successorCommand();
|
|
324
|
-
child = spawn(cmd, args, { env: childEnv, stdio: ["ignore", out, out], detached: true });
|
|
423
|
+
child = spawn(cmd, args, { env: mirrorEnv(childEnv), stdio: ["ignore", out, out], detached: true });
|
|
325
424
|
child.unref();
|
|
425
|
+
child.on("error", () => { /* reported through exitCode / the deadline below */ });
|
|
326
426
|
} catch { child = null; }
|
|
327
427
|
if (typeof out === "number") { try { fs.closeSync(out); } catch { /* closed */ } }
|
|
328
|
-
if (!child || !child.pid) {
|
|
428
|
+
if (!child || !child.pid) return { ok: false, error: "The installed Beatrina could not be started.", reason: "spawn" };
|
|
329
429
|
audit("session-upgrade-spawned", { to: h.to, pid: child.pid, mode: h.mode });
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
//
|
|
430
|
+
|
|
431
|
+
// The sentence a failed successor said about itself: its own "Beatrina …" line, else the first
|
|
432
|
+
// error line, else the last line. A stack's LAST line is a frame of Node's loader, not a reason.
|
|
433
|
+
const logTail = () => {
|
|
434
|
+
let lines = [];
|
|
435
|
+
try { lines = fs.readFileSync(logPath).subarray(logFrom).toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean); } catch { return ""; }
|
|
436
|
+
return lines.find((l) => /^Beatrina\b/.test(l)) || lines.find((l) => /Error\b/.test(l)) || lines[lines.length - 1] || "";
|
|
437
|
+
};
|
|
438
|
+
const deadline = Date.now() + prepareSeconds() * 1000;
|
|
439
|
+
try {
|
|
440
|
+
while (Date.now() < deadline) {
|
|
441
|
+
let said = null;
|
|
442
|
+
try { said = JSON.parse(fs.readFileSync(readyFile, "utf8")); } catch { said = null; }
|
|
443
|
+
if (said && said.ok === true) {
|
|
444
|
+
audit("session-upgrade-prepared", { to: h.to, pid: child.pid, build: said.build || "" });
|
|
445
|
+
return { ok: true, child };
|
|
446
|
+
}
|
|
447
|
+
if (said && said.ok === false) {
|
|
448
|
+
killSuccessor(child);
|
|
449
|
+
return { ok: false, error: `The installed Beatrina ${h.to} could not take over this session: ${said.error || "it refused to start"}. This session is untouched.`, reason: "successor" };
|
|
450
|
+
}
|
|
451
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
452
|
+
const tail = logTail();
|
|
453
|
+
return { ok: false, error: `The installed Beatrina ${h.to} exited before it was ready${tail ? ` (${tail.slice(0, 300)})` : ""}. This session is untouched.`, reason: "successor" };
|
|
454
|
+
}
|
|
455
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
456
|
+
}
|
|
457
|
+
killSuccessor(child);
|
|
458
|
+
return { ok: false, error: `The installed Beatrina ${h.to} did not say it was ready within ${prepareSeconds()} seconds. This session is untouched.`, reason: "successor-timeout" };
|
|
459
|
+
} finally {
|
|
460
|
+
try { fs.rmSync(readyFile, { force: true }); } catch { /* gone */ }
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/** Phase 2: stop the workers and leave; the prepared successor is waiting for the port. */
|
|
465
|
+
const handoffRetire = (ctx) => {
|
|
466
|
+
// The worker's whole process GROUP goes down only once the successor has
|
|
467
|
+
// proved it can run. The group kill reaches the shell grandchildren a hung R never signals.
|
|
333
468
|
const planes = ctx.plane.planes ? [...ctx.plane.planes.values()] : [ctx.plane];
|
|
334
469
|
for (const pl of planes) {
|
|
335
470
|
pl.expectedExit = true;
|
|
@@ -338,7 +473,6 @@ export function createPlane({ root, env, audit }) {
|
|
|
338
473
|
}
|
|
339
474
|
// Deferred, so the reply and the broadcast flush before the sockets close.
|
|
340
475
|
setTimeout(() => ctx.shutdown("session handoff"), 300);
|
|
341
|
-
return true;
|
|
342
476
|
};
|
|
343
477
|
|
|
344
478
|
return {
|
|
@@ -356,7 +490,7 @@ export function createPlane({ root, env, audit }) {
|
|
|
356
490
|
agentRefusalFor: (op) => ({
|
|
357
491
|
session_list: "Agents cannot list the user's sessions.",
|
|
358
492
|
r_versions: "Agents cannot list or choose R installations.",
|
|
359
|
-
session_upgrade: "Agents cannot restart the user's session into another
|
|
493
|
+
session_upgrade: "Agents cannot restart the user's session into another Beatrina.",
|
|
360
494
|
session_restart: "Agents cannot restart the user's session.",
|
|
361
495
|
"session-new": "Agents cannot start a session for the user.",
|
|
362
496
|
})[op] || null,
|
|
@@ -375,15 +509,24 @@ export function createPlane({ root, env, audit }) {
|
|
|
375
509
|
return reply({ error: "Only the local notebook page may ask this." });
|
|
376
510
|
}
|
|
377
511
|
if (HANDOFF_OPS.includes(cmd.type)) {
|
|
378
|
-
return
|
|
512
|
+
return handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true, keepTokenOk(cmd.resume) ? cmd.resume : null)
|
|
513
|
+
.then(reply, (e) => { handoff = null; reply({ ok: false, error: `The session restart failed: ${e.message}`, reason: "internal" }); });
|
|
379
514
|
}
|
|
380
515
|
if (cmd.type === "session_list") {
|
|
381
516
|
const own = ctx.port();
|
|
382
517
|
const recs = runtimeRecords(env);
|
|
383
518
|
return Promise.all(recs.map(async (r) => {
|
|
384
519
|
const isThis = r.port === own;
|
|
520
|
+
const h = isThis ? null : await healthOf(r.url);
|
|
521
|
+
// The fields the page reads (port, url, title, pid, alive, this) as they were; beside them,
|
|
522
|
+
// ADDITIVE, what the record says a session is (`app`, `document`, `documents`, `started`)
|
|
523
|
+
// and what its /health says it runs (`kernel_build`) — the same rows `beatrina status`
|
|
524
|
+
// and the menu describe a session by (docs/launch-menu-plan.md L1).
|
|
385
525
|
return { port: r.port, url: String(r.url), title: r.title ?? "",
|
|
386
|
-
pid: r.pid ?? null, alive: isThis ? true :
|
|
526
|
+
pid: r.pid ?? null, alive: isThis ? true : h != null, this: isThis,
|
|
527
|
+
app: typeof r.app === "string" ? r.app : "", document: typeof r.document === "string" ? r.document : "",
|
|
528
|
+
documents: typeof r.documents === "string" ? r.documents : "", started: typeof r.started === "string" ? r.started : "",
|
|
529
|
+
kernel_build: isThis ? ctx.kernelBuild : (h && typeof h.kernel_build === "string" ? h.kernel_build : "") };
|
|
387
530
|
})).then((sessions) => reply({ sessions }))
|
|
388
531
|
.catch((e) => reply({ error: e.message }));
|
|
389
532
|
}
|
|
@@ -401,7 +544,11 @@ export function createPlane({ root, env, audit }) {
|
|
|
401
544
|
if (cmd.type === "session-new") {
|
|
402
545
|
audit("session-new", { id: cmd.id });
|
|
403
546
|
return startSibling(ctx).then((started) => {
|
|
404
|
-
if (!started
|
|
547
|
+
if (!started || started.error) {
|
|
548
|
+
audit("session-new-failed", { detail: started ? started.error : "no answer" });
|
|
549
|
+
return reply({ error: started ? started.error : "Could not start another session.", log: started ? started.log || "" : "" });
|
|
550
|
+
}
|
|
551
|
+
audit("session-new-started", { port: started.port, launch: started.launch });
|
|
405
552
|
return reply({ port: started.port, url: started.url });
|
|
406
553
|
}).catch((e) => {
|
|
407
554
|
audit("session-new-failed", { detail: e.message });
|
|
@@ -421,8 +568,9 @@ export function createPlane({ root, env, audit }) {
|
|
|
421
568
|
return true;
|
|
422
569
|
}
|
|
423
570
|
const restart = pathname === "/session/restart";
|
|
424
|
-
|
|
425
|
-
|
|
571
|
+
handoffBegin(ctx, restart ? "restart" : "upgrade", restart && ctx.queryParam(req, "force") === "1")
|
|
572
|
+
.catch((e) => { handoff = null; return { ok: false, error: `The session restart failed: ${e.message}`, reason: "internal" }; })
|
|
573
|
+
.then((begun) => ctx.respond(res, begun.ok === true ? 200 : 409, "application/json", ctx.enc(begun)));
|
|
426
574
|
return true;
|
|
427
575
|
},
|
|
428
576
|
|
package/host/planes/settings.mjs
CHANGED
|
@@ -28,9 +28,9 @@ const AGENT_WHY = Object.freeze({
|
|
|
28
28
|
// secret, but because handing back the confinement root, the audit-log path
|
|
29
29
|
// and whether AI-text logging is on is a MAP OF THE DEPLOYMENT'S CONTROLS,
|
|
30
30
|
// given to a process whose entire premise is that it is not the user.
|
|
31
|
-
settings_get: "Agents cannot read
|
|
32
|
-
settings_set: "Agents cannot change
|
|
33
|
-
settings_reset: "Agents cannot reset
|
|
31
|
+
settings_get: "Agents cannot read Beatrina's settings.",
|
|
32
|
+
settings_set: "Agents cannot change Beatrina's settings.",
|
|
33
|
+
settings_reset: "Agents cannot reset Beatrina's settings.",
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
export function createPlane({ audit, settings } = {}) {
|
|
@@ -48,7 +48,7 @@ export function createPlane({ audit, settings } = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
50
50
|
audit?.("settings-refused", { reason: "class", detail: cmd.type, class: rec.class || "unknown" });
|
|
51
|
-
reply({ ok: false, reason: "class", error: "Only the notebook page may read or change
|
|
51
|
+
reply({ ok: false, reason: "class", error: "Only the notebook page may read or change Beatrina's settings." });
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
54
|
// An unauthenticated shared kernel has no "the user" whose home directory
|
package/host/planes/terminal.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A port of `term_open`, `term_input`, `term_close`, `term_owned`,
|
|
4
4
|
// `term_shell` and `pump_terms` in spike/serve.R, plus `term_resize`, which
|
|
5
|
-
// carmapy added (
|
|
5
|
+
// carmapy added (`docs/python-kernel-records/kernel-protocol.md` §3: "R only sizes
|
|
6
6
|
// at open"). Both are served here; a page that never resizes behaves exactly
|
|
7
7
|
// as it does against serve.R.
|
|
8
8
|
//
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// `exec` door the MCP plane refuses, wearing a prompt.
|
|
17
17
|
//
|
|
18
18
|
// What the shell can do is what the user can do; this grants nothing new and
|
|
19
|
-
// is audited like every other door. `
|
|
19
|
+
// is audited like every other door. `BEATRINA_NO_TERMINAL=1` refuses it BY
|
|
20
20
|
// NAME, with the reason, rather than dropping the frame — a dropped frame
|
|
21
21
|
// reads as a hang, and lib/terminal.js already renders `event: "refused"`.
|
|
22
22
|
//
|
|
@@ -30,6 +30,7 @@ import path from "node:path";
|
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { killProcessTree } from "./proc-tree.mjs";
|
|
32
32
|
import { PIPE_NOTICE, pipeShell, pipeSpawn } from "./pipe-term.mjs";
|
|
33
|
+
import { envSetName, mirrorEnv } from "../env-names.mjs";
|
|
33
34
|
|
|
34
35
|
const MAX_TERMS = 4;
|
|
35
36
|
const TERM_INPUT_MAX = 65536;
|
|
@@ -127,7 +128,7 @@ export function createPlane({ env, audit }) {
|
|
|
127
128
|
|
|
128
129
|
const enc = (o) => JSON.stringify(o);
|
|
129
130
|
const send = (rec, id, fields) => { try { rec.ws.send(enc({ type: "term", id, ...fields })); } catch { /* the page is gone */ } };
|
|
130
|
-
const turnedOff = () => env("
|
|
131
|
+
const turnedOff = () => env("BEATRINA_NO_TERMINAL") === "1";
|
|
131
132
|
|
|
132
133
|
/** Only the page that opened a terminal may type into it, resize it or close it. */
|
|
133
134
|
const owned = (rec, id) => {
|
|
@@ -161,8 +162,8 @@ export function createPlane({ env, audit }) {
|
|
|
161
162
|
audit("term-refused", { detail: why });
|
|
162
163
|
send(rec, "", { event: "refused", error: why });
|
|
163
164
|
};
|
|
164
|
-
if (turnedOff()) return refuse(
|
|
165
|
-
// serve.R refuses Windows outright ("
|
|
165
|
+
if (turnedOff()) return refuse(`The terminal is turned off on this kernel (${envSetName(env, "NO_TERMINAL")}=1).`);
|
|
166
|
+
// serve.R refuses Windows outright ("Beatrina does not provide a pty on
|
|
166
167
|
// Windows yet"), because processx has none there. node-pty ships ConPTY
|
|
167
168
|
// prebuilds, so the refusal is no longer true by construction — the
|
|
168
169
|
// platform is not asked, the loader is, and it says so if the prebuild
|
|
@@ -170,9 +171,9 @@ export function createPlane({ env, audit }) {
|
|
|
170
171
|
const mine = [...terms.values()].filter((t) => t.rec === rec);
|
|
171
172
|
if (mine.length >= MAX_TERMS) return refuse(`${mine.length} terminals are already open on this page.`);
|
|
172
173
|
// node-pty first; with none (every installed R package: it is a native
|
|
173
|
-
// module) the pipe terminal in pipe-term.mjs.
|
|
174
|
+
// module) the pipe terminal in pipe-term.mjs. BEATRINA_TERMINAL_PTY=0 forces
|
|
174
175
|
// the pipe, which is how a machine that HAS node-pty tests the other rung.
|
|
175
|
-
if (!loaded) loaded = env("
|
|
176
|
+
if (!loaded) loaded = env("BEATRINA_TERMINAL_PTY") === "0" ? { pty: null, error: `${envSetName(env, "TERMINAL_PTY")}=0` } : loadPty(audit);
|
|
176
177
|
|
|
177
178
|
const cols = clamp(cmd.cols, 20, 400, 100);
|
|
178
179
|
const rows = clamp(cmd.rows, 5, 200, 30);
|
|
@@ -193,11 +194,12 @@ export function createPlane({ env, audit }) {
|
|
|
193
194
|
try {
|
|
194
195
|
term = usePipe
|
|
195
196
|
? pipeSpawn(argv.file, argv.args, { cwd: real,
|
|
196
|
-
env: { ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows),
|
|
197
|
+
env: mirrorEnv({ ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }) })
|
|
197
198
|
: loaded.pty.spawn(argv.file, argv.args, {
|
|
198
199
|
name: "xterm-256color", cols, rows, cwd: real,
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
// The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
|
|
201
|
+
env: mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
202
|
+
COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }),
|
|
201
203
|
});
|
|
202
204
|
} catch (e) { return refuse(`the shell could not start: ${e.message}`); }
|
|
203
205
|
|
|
@@ -231,7 +233,7 @@ export function createPlane({ env, audit }) {
|
|
|
231
233
|
// own words and a suite pins them (test/terminal.test.mjs).
|
|
232
234
|
agentRefused: [],
|
|
233
235
|
// serve.R advertises no terminal verb in ready.commands; neither does this.
|
|
234
|
-
// A kernel with
|
|
236
|
+
// A kernel with BEATRINA_NO_TERMINAL=1 still answers the op — with a refusal
|
|
235
237
|
// that names the reason, which is what the pane renders.
|
|
236
238
|
commands: [],
|
|
237
239
|
capabilities: [],
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// THE PATH IS A COPY, NEVER THE SHIPPED FILE. The shipped copy lives inside the
|
|
9
9
|
// app bundle or the installed package, which a Save must not write into (and on
|
|
10
10
|
// an installed Mac cannot). So the first ask copies it to
|
|
11
|
-
// `R_user_dir("
|
|
11
|
+
// `R_user_dir("beatrina", "data")/test-files/` and every later ask returns that
|
|
12
12
|
// copy UNTOUCHED — edits a person made to it are theirs, and a menu row that
|
|
13
13
|
// silently reverted them would be the wrong kind of "always there". A copy that
|
|
14
14
|
// was deleted comes back on the next click. `fresh: true` asks for a new copy
|
|
@@ -34,7 +34,7 @@ const OPS = ["test_file"];
|
|
|
34
34
|
export function testFileCopy(kernelDir, { fresh = false } = {}) {
|
|
35
35
|
const source = path.join(kernelDir, "examples", TEST_FILE_NAME);
|
|
36
36
|
if (!fs.existsSync(source)) {
|
|
37
|
-
return { ok: false, error: "This
|
|
37
|
+
return { ok: false, error: "This Beatrina does not carry its test file (examples/tna-complete-tutorial.Rmd is missing). Reinstall Beatrina." };
|
|
38
38
|
}
|
|
39
39
|
const dir = path.join(ensureUserDir(appId(), "data"), "test-files");
|
|
40
40
|
try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return { ok: false, error: `Could not create ${dir}: ${e.message}` }; }
|