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/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,8 @@ 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";
|
|
40
43
|
import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
|
|
41
44
|
|
|
42
45
|
export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
|
|
@@ -46,28 +49,39 @@ export const SESSION_BAND = 201;
|
|
|
46
49
|
|
|
47
50
|
const RUNTIME_NAME = /^kernel-([0-9]+)\.json$/;
|
|
48
51
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
/**
|
|
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
|
+
*/
|
|
55
68
|
export function runtimeRecords(env) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
83
|
}
|
|
70
|
-
return
|
|
84
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
/**
|
|
@@ -78,15 +92,22 @@ export function runtimeRecords(env) {
|
|
|
78
92
|
* connected page for a full minute per port.
|
|
79
93
|
*/
|
|
80
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) {
|
|
81
100
|
const target = `${String(url).replace(/\/+$/, "")}/health`;
|
|
82
101
|
const ctrl = new AbortController();
|
|
83
102
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
84
103
|
try {
|
|
85
104
|
const res = await fetch(target, { signal: ctrl.signal, cache: "no-store" });
|
|
86
|
-
if (!res.ok) return
|
|
87
|
-
|
|
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; }
|
|
88
109
|
} catch {
|
|
89
|
-
return
|
|
110
|
+
return null;
|
|
90
111
|
} finally {
|
|
91
112
|
clearTimeout(timer);
|
|
92
113
|
}
|
|
@@ -181,10 +202,9 @@ export function createPlane({ root, env, audit }) {
|
|
|
181
202
|
const siblingArgs = mainPath ? [mainPath] : [];
|
|
182
203
|
let versionsCache = null;
|
|
183
204
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
})();
|
|
205
|
+
const base = sessionBase(env);
|
|
206
|
+
/** Ports chosen by a sibling start that has not finished yet. */
|
|
207
|
+
const pendingPorts = new Set();
|
|
188
208
|
|
|
189
209
|
/**
|
|
190
210
|
* Start another supervisor on the next free adjacent port.
|
|
@@ -192,47 +212,73 @@ export function createPlane({ root, env, audit }) {
|
|
|
192
212
|
* Adjacent and stable on purpose (browsers key storage to the origin), and
|
|
193
213
|
* DETACHED — closing this session must not close that one. The port is a
|
|
194
214
|
* PREFERENCE, not a promise: between the probe and the child's own bind the
|
|
195
|
-
* port can go, so
|
|
196
|
-
* serve.R learned that too: waiting for the
|
|
197
|
-
* 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.
|
|
198
228
|
*/
|
|
199
229
|
const startSibling = async (ctx) => {
|
|
200
230
|
const own = ctx.port();
|
|
201
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).
|
|
202
235
|
let chosen = null;
|
|
203
|
-
for (let p =
|
|
204
|
-
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; }
|
|
205
238
|
}
|
|
206
|
-
if (chosen == null) return
|
|
207
|
-
|
|
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)}`;
|
|
208
247
|
// The pipes go to a FILE, not to a pipe nobody reads: a detached sibling
|
|
209
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`);
|
|
210
250
|
let logFd = "ignore";
|
|
211
|
-
try {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
};
|
|
214
255
|
let child;
|
|
215
256
|
try {
|
|
216
257
|
child = spawn(process.execPath, siblingArgs, {
|
|
217
258
|
cwd: root,
|
|
218
|
-
env: { ...process.env,
|
|
259
|
+
env: mirrorEnv({ ...process.env, BEATRINA_PORT: String(chosen), BEATRINA_LAUNCH_ID: launch, BEATRINA_LAUNCH_VIA: "session-menu" }),
|
|
219
260
|
detached: true,
|
|
220
261
|
stdio: ["ignore", logFd, logFd],
|
|
221
262
|
});
|
|
222
263
|
child.unref();
|
|
223
|
-
} 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; });
|
|
224
271
|
const deadline = Date.now() + 60000;
|
|
225
272
|
while (Date.now() < deadline) {
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
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() };
|
|
231
278
|
}
|
|
232
|
-
if (child.exitCode !== null || child.signalCode !== null) return null;
|
|
233
279
|
await new Promise((r) => setTimeout(r, 250));
|
|
234
280
|
}
|
|
235
|
-
return
|
|
281
|
+
return { error: "The new session did not become ready within 60 seconds.", log: logTail() };
|
|
236
282
|
};
|
|
237
283
|
|
|
238
284
|
// ── the handoff ─────────────────────────────────────────────────────────
|
|
@@ -242,12 +288,12 @@ export function createPlane({ root, env, audit }) {
|
|
|
242
288
|
const handoffRefusal = (ctx, mode, force) => {
|
|
243
289
|
const refuse = (error, reason) => ({ error, reason });
|
|
244
290
|
const installed = ctx.installedBuild();
|
|
245
|
-
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");
|
|
246
292
|
if (mode === "upgrade" && installed === ctx.kernelBuild) {
|
|
247
|
-
return refuse(`This session already runs the installed
|
|
293
|
+
return refuse(`This session already runs the installed Beatrina ${installed}.`, "same-build");
|
|
248
294
|
}
|
|
249
295
|
if (!ctx.notebookPage(installed)) {
|
|
250
|
-
return refuse(`The installed
|
|
296
|
+
return refuse(`The installed Beatrina ${installed} has no notebook page beside this kernel.`, "no-page");
|
|
251
297
|
}
|
|
252
298
|
// An attempt that has spawned owns the port until this process exits.
|
|
253
299
|
if (handoff) return refuse("A session restart is already under way.", "under-way");
|
|
@@ -269,7 +315,24 @@ export function createPlane({ root, env, audit }) {
|
|
|
269
315
|
return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
|
|
270
316
|
};
|
|
271
317
|
|
|
272
|
-
|
|
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) => {
|
|
273
336
|
mode = mode === "restart" ? "restart" : "upgrade";
|
|
274
337
|
force = force === true && mode === "restart";
|
|
275
338
|
const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
|
|
@@ -284,56 +347,124 @@ export function createPlane({ root, env, audit }) {
|
|
|
284
347
|
const started = ctx.startedSeconds;
|
|
285
348
|
handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
|
|
286
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
|
+
}
|
|
287
366
|
// Every page learns where its successor's notebook is — over the gated
|
|
288
367
|
// socket only; the capability never appears in /health. `started` is this
|
|
289
368
|
// kernel's boot time: a page recognises its successor by a LATER one, the
|
|
290
369
|
// only test that also works when the build does not change.
|
|
291
370
|
const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
|
|
292
371
|
ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
|
|
293
|
-
|
|
294
|
-
if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
|
|
372
|
+
handoffRetire(ctx);
|
|
295
373
|
return { ok: true, from: ctx.kernelBuild, to: installed, page, started, mode, force };
|
|
296
374
|
};
|
|
297
375
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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;
|
|
303
386
|
};
|
|
304
387
|
|
|
305
|
-
/**
|
|
306
|
-
|
|
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) => {
|
|
307
393
|
const h = handoff;
|
|
308
394
|
const dir = stateDir();
|
|
309
395
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the log is a courtesy */ }
|
|
310
396
|
const port = ctx.port();
|
|
311
397
|
const record = ctx.runtimeRecord();
|
|
312
|
-
const
|
|
313
|
-
|
|
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",
|
|
314
404
|
// A successor that quietly moved to a free port would leave the page
|
|
315
405
|
// polling a dead one; strict means it refuses and its log says why.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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?.() || []),
|
|
320
411
|
// Only a token travels, never a path; the successor re-checks it.
|
|
321
|
-
|
|
322
|
-
if (record.listen) childEnv.
|
|
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; }
|
|
323
418
|
let out = "ignore";
|
|
324
|
-
try { out = fs.openSync(
|
|
419
|
+
try { out = fs.openSync(logPath, "a"); } catch { out = "ignore"; }
|
|
325
420
|
let child = null;
|
|
326
421
|
try {
|
|
327
422
|
const { cmd, args } = successorCommand();
|
|
328
|
-
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 });
|
|
329
424
|
child.unref();
|
|
425
|
+
child.on("error", () => { /* reported through exitCode / the deadline below */ });
|
|
330
426
|
} catch { child = null; }
|
|
331
427
|
if (typeof out === "number") { try { fs.closeSync(out); } catch { /* closed */ } }
|
|
332
|
-
if (!child || !child.pid) {
|
|
428
|
+
if (!child || !child.pid) return { ok: false, error: "The installed Beatrina could not be started.", reason: "spawn" };
|
|
333
429
|
audit("session-upgrade-spawned", { to: h.to, pid: child.pid, mode: h.mode });
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
//
|
|
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.
|
|
337
468
|
const planes = ctx.plane.planes ? [...ctx.plane.planes.values()] : [ctx.plane];
|
|
338
469
|
for (const pl of planes) {
|
|
339
470
|
pl.expectedExit = true;
|
|
@@ -342,7 +473,6 @@ export function createPlane({ root, env, audit }) {
|
|
|
342
473
|
}
|
|
343
474
|
// Deferred, so the reply and the broadcast flush before the sockets close.
|
|
344
475
|
setTimeout(() => ctx.shutdown("session handoff"), 300);
|
|
345
|
-
return true;
|
|
346
476
|
};
|
|
347
477
|
|
|
348
478
|
return {
|
|
@@ -360,7 +490,7 @@ export function createPlane({ root, env, audit }) {
|
|
|
360
490
|
agentRefusalFor: (op) => ({
|
|
361
491
|
session_list: "Agents cannot list the user's sessions.",
|
|
362
492
|
r_versions: "Agents cannot list or choose R installations.",
|
|
363
|
-
session_upgrade: "Agents cannot restart the user's session into another
|
|
493
|
+
session_upgrade: "Agents cannot restart the user's session into another Beatrina.",
|
|
364
494
|
session_restart: "Agents cannot restart the user's session.",
|
|
365
495
|
"session-new": "Agents cannot start a session for the user.",
|
|
366
496
|
})[op] || null,
|
|
@@ -379,15 +509,24 @@ export function createPlane({ root, env, audit }) {
|
|
|
379
509
|
return reply({ error: "Only the local notebook page may ask this." });
|
|
380
510
|
}
|
|
381
511
|
if (HANDOFF_OPS.includes(cmd.type)) {
|
|
382
|
-
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" }); });
|
|
383
514
|
}
|
|
384
515
|
if (cmd.type === "session_list") {
|
|
385
516
|
const own = ctx.port();
|
|
386
517
|
const recs = runtimeRecords(env);
|
|
387
518
|
return Promise.all(recs.map(async (r) => {
|
|
388
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).
|
|
389
525
|
return { port: r.port, url: String(r.url), title: r.title ?? "",
|
|
390
|
-
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 : "") };
|
|
391
530
|
})).then((sessions) => reply({ sessions }))
|
|
392
531
|
.catch((e) => reply({ error: e.message }));
|
|
393
532
|
}
|
|
@@ -405,7 +544,11 @@ export function createPlane({ root, env, audit }) {
|
|
|
405
544
|
if (cmd.type === "session-new") {
|
|
406
545
|
audit("session-new", { id: cmd.id });
|
|
407
546
|
return startSibling(ctx).then((started) => {
|
|
408
|
-
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 });
|
|
409
552
|
return reply({ port: started.port, url: started.url });
|
|
410
553
|
}).catch((e) => {
|
|
411
554
|
audit("session-new-failed", { detail: e.message });
|
|
@@ -425,8 +568,9 @@ export function createPlane({ root, env, audit }) {
|
|
|
425
568
|
return true;
|
|
426
569
|
}
|
|
427
570
|
const restart = pathname === "/session/restart";
|
|
428
|
-
|
|
429
|
-
|
|
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)));
|
|
430
574
|
return true;
|
|
431
575
|
},
|
|
432
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}` }; }
|