beatrina 0.8.6 → 0.8.7
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/bin/beatrina.mjs +7 -5
- package/bin/shortcut.mjs +34 -6
- package/build-info.json +1 -1
- package/carmar_V0.8.7.html +1522 -0
- package/failsafe/serve.R +130 -5
- package/failsafe/session-documents.R +104 -0
- package/host/engine-js.mjs +4 -0
- package/host/engine-pool.mjs +8 -2
- package/host/engine-r.mjs +20 -13
- package/host/engine-stdio.mjs +10 -2
- package/host/main.mjs +28 -4
- package/host/planes/latex.mjs +27 -1
- package/host/planes/sessions.mjs +9 -5
- package/host/server.mjs +49 -4
- package/host/session-keep.mjs +70 -0
- package/host/worker-plane.mjs +47 -2
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +8 -5
- package/kernel/latex.R +305 -4
- package/kernel/worker.R +85 -9
- package/kernel/workspace-keep.R +188 -0
- package/package.json +2 -2
- package/carmar_V0.8.6.html +0 -1310
package/host/planes/sessions.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { spawn, execFileSync } from "node:child_process";
|
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
38
|
import { PAGE_ONLY_CLASSES, secureToken } from "../server.mjs";
|
|
39
39
|
import { stateDir } from "../user-dirs.mjs";
|
|
40
|
+
import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
|
|
40
41
|
|
|
41
42
|
export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
|
|
42
43
|
export const HANDOFF_OPS = Object.freeze(["session_upgrade", "session_restart"]);
|
|
@@ -268,7 +269,7 @@ export function createPlane({ root, env, audit }) {
|
|
|
268
269
|
return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
|
|
269
270
|
};
|
|
270
271
|
|
|
271
|
-
const handoffBegin = (ctx, mode = "upgrade", force = false) => {
|
|
272
|
+
const handoffBegin = (ctx, mode = "upgrade", force = false, resume = null) => {
|
|
272
273
|
mode = mode === "restart" ? "restart" : "upgrade";
|
|
273
274
|
force = force === true && mode === "restart";
|
|
274
275
|
const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
|
|
@@ -281,13 +282,13 @@ export function createPlane({ root, env, audit }) {
|
|
|
281
282
|
const cap = secureToken(32);
|
|
282
283
|
const page = ctx.notebookFileUrl(ctx.notebookPage(installed), ctx.port(), cap);
|
|
283
284
|
const started = ctx.startedSeconds;
|
|
284
|
-
handoff = { to: installed, cap, page, mode, force };
|
|
285
|
+
handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
|
|
285
286
|
audit(auditName, { from: ctx.kernelBuild, to: installed, force });
|
|
286
287
|
// Every page learns where its successor's notebook is — over the gated
|
|
287
288
|
// socket only; the capability never appears in /health. `started` is this
|
|
288
289
|
// kernel's boot time: a page recognises its successor by a LATER one, the
|
|
289
290
|
// 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 });
|
|
291
|
+
const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
|
|
291
292
|
ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
|
|
292
293
|
const spawned = handoffSpawn(ctx);
|
|
293
294
|
if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
|
|
@@ -314,7 +315,10 @@ export function createPlane({ root, env, audit }) {
|
|
|
314
315
|
// polling a dead one; strict means it refuses and its log says why.
|
|
315
316
|
CARMAR_PORT_STRICT: "1",
|
|
316
317
|
CARMAR_FILE_LAUNCH_CAP: h.cap, CARMAR_HANDOFF_FROM: ctx.kernelBuild,
|
|
317
|
-
CARMAR_SESSION_TITLE: record.title || ""
|
|
318
|
+
CARMAR_SESSION_TITLE: record.title || "",
|
|
319
|
+
CARMAR_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
|
|
320
|
+
// Only a token travels, never a path; the successor re-checks it.
|
|
321
|
+
CARMAR_RESUME_TOKEN: h.resume || "" };
|
|
318
322
|
if (record.listen) childEnv.CARMAR_LISTEN = "1"; else delete childEnv.CARMAR_LISTEN;
|
|
319
323
|
let out = "ignore";
|
|
320
324
|
try { out = fs.openSync(path.join(dir, `kernel-handoff-${port}.log`), "a"); } catch { out = "ignore"; }
|
|
@@ -375,7 +379,7 @@ export function createPlane({ root, env, audit }) {
|
|
|
375
379
|
return reply({ error: "Only the local notebook page may ask this." });
|
|
376
380
|
}
|
|
377
381
|
if (HANDOFF_OPS.includes(cmd.type)) {
|
|
378
|
-
return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true));
|
|
382
|
+
return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true, keepTokenOk(cmd.resume) ? cmd.resume : null));
|
|
379
383
|
}
|
|
380
384
|
if (cmd.type === "session_list") {
|
|
381
385
|
const own = ctx.port();
|
package/host/server.mjs
CHANGED
|
@@ -30,11 +30,16 @@ import http from "node:http";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { attachWebSocket } from "./ws.mjs";
|
|
32
32
|
import { rookOf, proxyUser } from "./deployment.mjs";
|
|
33
|
+
import { cleanSessionDocuments } from "./session-keep.mjs";
|
|
33
34
|
|
|
34
35
|
export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
|
|
35
36
|
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
36
37
|
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
37
|
-
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"
|
|
38
|
+
"hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
|
|
39
|
+
// "Restart, keep variables", step one (spike/workspace-keep.R). Page-only in both senses: AGENT_REFUSED
|
|
40
|
+
// below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
|
|
41
|
+
// host asks it, after a restart it performed (WorkerPlane.startResume).
|
|
42
|
+
"suspend"]);
|
|
38
43
|
export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
|
|
39
44
|
"input_reply", "debug_cmd"]);
|
|
40
45
|
/** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
|
|
@@ -48,12 +53,16 @@ export const NOT_YET = Object.freeze({
|
|
|
48
53
|
"open-request": "the open-file door", "page-title": "", "session-new": "session management",
|
|
49
54
|
cite_styles: "citations",
|
|
50
55
|
});
|
|
51
|
-
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"
|
|
56
|
+
export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
|
|
57
|
+
// A copy of every object in the session, written to disk.
|
|
58
|
+
"suspend", "suspend_cancel"]);
|
|
52
59
|
/** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
|
|
53
60
|
export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
|
|
54
61
|
export const AGENT_WHY = Object.freeze({
|
|
55
62
|
input_reply: "Agents cannot answer a prompt on the user's behalf.",
|
|
56
63
|
project_action: "Agents cannot install or restore project packages.",
|
|
64
|
+
suspend: "Agents cannot keep the user's workspace.",
|
|
65
|
+
suspend_cancel: "Agents cannot keep the user's workspace.",
|
|
57
66
|
});
|
|
58
67
|
export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
|
|
59
68
|
export const FILE_ORIGIN = "null";
|
|
@@ -111,9 +120,13 @@ export function createHostServer(opts) {
|
|
|
111
120
|
const hostsOk = deployment.hosts;
|
|
112
121
|
const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
|
|
113
122
|
const approvals = new Set(); // exact origins the user approved this session
|
|
114
|
-
const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false
|
|
123
|
+
const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false,
|
|
124
|
+
// The documents open in this session, as the pages last reported them (host/session-keep.mjs).
|
|
125
|
+
sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
|
|
115
126
|
plane.sockets = () => sockets;
|
|
116
127
|
plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
|
|
128
|
+
// A restore's report is about every object in the session: pages only, never an agent or a native client.
|
|
129
|
+
plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
|
|
117
130
|
|
|
118
131
|
// The session stamp: ONE string, rendered once, that the page's meta and
|
|
119
132
|
// /health's `started` share so they can never differ in their digits.
|
|
@@ -187,6 +200,7 @@ export function createHostServer(opts) {
|
|
|
187
200
|
const ctx = {
|
|
188
201
|
deployment, plane, audit, sockets, approvals, state, opts,
|
|
189
202
|
enc, scalarChr, pageRecs, socketClass,
|
|
203
|
+
sessionDocuments: () => state.sessionDocuments,
|
|
190
204
|
port: () => (server.address() ? server.address().port : deployment.port),
|
|
191
205
|
respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
|
|
192
206
|
reject: (res, reason, detail) => reject(res, reason, detail),
|
|
@@ -285,6 +299,11 @@ export function createHostServer(opts) {
|
|
|
285
299
|
if (plane.hello) ws.send(plane.hello);
|
|
286
300
|
if (plane.notice) ws.send(plane.notice);
|
|
287
301
|
if (typeof plane.replay === "function") plane.replay(ws);
|
|
302
|
+
// …and a restore that finished before this page arrived (a page following a handoff).
|
|
303
|
+
if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
|
|
304
|
+
const report = plane.freshResumeReport();
|
|
305
|
+
if (report) ws.send(report);
|
|
306
|
+
}
|
|
288
307
|
for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
|
|
289
308
|
ws.onMessage((message) => {
|
|
290
309
|
try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
|
|
@@ -346,6 +365,24 @@ export function createHostServer(opts) {
|
|
|
346
365
|
rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
|
|
347
366
|
return undefined;
|
|
348
367
|
}
|
|
368
|
+
// The documents this session has open. Page-only in BOTH senses: the list is file paths, and a page
|
|
369
|
+
// that could not open a terminal must not be able to rewrite what the session reopens either.
|
|
370
|
+
if (type === "page-documents") {
|
|
371
|
+
if (rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
372
|
+
state.sessionDocuments = cleanSessionDocuments(cmd.documents);
|
|
373
|
+
opts.onSessionDocuments?.(state.sessionDocuments);
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
// A page asks what this session had open, to reopen it. `others` counts the OTHER pages attached: a
|
|
378
|
+
// page joining a session another window shows must not open the same documents a second time.
|
|
379
|
+
if (type === "session-documents") {
|
|
380
|
+
if (!scalarChr(cmd.id)) return undefined;
|
|
381
|
+
const allowed = rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class);
|
|
382
|
+
const others = pageRecs().filter((r) => r !== rec).length;
|
|
383
|
+
rec.ws.send(enc({ type, id: cmd.id, documents: allowed ? state.sessionDocuments : [], others, refused: !allowed }));
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
349
386
|
if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
|
|
350
387
|
audit("mcp-refused", { reason: `agent asked for ${type}` });
|
|
351
388
|
// serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
|
|
@@ -363,14 +400,22 @@ export function createHostServer(opts) {
|
|
|
363
400
|
audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
|
|
364
401
|
return undefined;
|
|
365
402
|
}
|
|
403
|
+
// "Restart, keep variables": the save is a copy of the whole session, so it is page-only in BOTH
|
|
404
|
+
// senses, and its cancel interrupts only a save THIS page asked for.
|
|
405
|
+
if ((type === "suspend" || type === "suspend_cancel") && !PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
406
|
+
if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: "Only the local notebook page may keep the workspace." }));
|
|
407
|
+
audit("suspend-refused", { reason: "class", class: rec.class || "unknown" });
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
366
410
|
switch (type) {
|
|
411
|
+
case "suspend_cancel": if (typeof plane.cancelSuspend === "function") plane.cancelSuspend(rec); return undefined;
|
|
367
412
|
case "exec": return plane.exec(cmd, rec);
|
|
368
413
|
case "interrupt": return plane.interrupt(cmd, rec);
|
|
369
414
|
// Both carry an OPTIONAL engine. `restart` with none is the primary
|
|
370
415
|
// engine's — which is what "Restart R" in the page means and what every
|
|
371
416
|
// client built before there was a second engine sends (§6.5).
|
|
372
417
|
case "force_stop": return plane.forceStop(rec, cmd);
|
|
373
|
-
case "restart": Promise.resolve(plane.restart(cmd.engine)).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
|
|
418
|
+
case "restart": Promise.resolve(plane.restart(cmd.engine, { resume: cmd.resume })).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
|
|
374
419
|
case "runstate": return plane.runstate(cmd, rec);
|
|
375
420
|
case "runs": return plane.runs(cmd, rec);
|
|
376
421
|
case "adopt": return plane.adopt(cmd, rec);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// session-keep.mjs — the pure rules behind two CarmaR 0.8.5 features, as the host answers them.
|
|
2
|
+
//
|
|
3
|
+
// · "Restart, keep variables": the worker saves the session (`suspend`, spike/workspace-keep.R) and
|
|
4
|
+
// answers a TOKEN; the host never reads a kept workspace, it only checks that a token is one the
|
|
5
|
+
// worker could have minted and carries it to the next worker (`resume`). A path is never a token.
|
|
6
|
+
// · "A session keeps its documents": the page reports what it has open (`page-documents`), the host
|
|
7
|
+
// keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
|
|
8
|
+
//
|
|
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 CarmaR's R supervisor sources; pinned against
|
|
11
|
+
// the same cases by test/session-keep.test.mjs.
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
export const KEEP_TOKEN_RE = /^[0-9]{14}-[0-9a-f]{16}$/;
|
|
16
|
+
export const SESSION_DOCUMENTS_MAX = 64;
|
|
17
|
+
export const SESSION_DOCUMENT_FORMATS = Object.freeze(["qmd", "Rmd", "md", "carmd"]);
|
|
18
|
+
/** How long a restore's report is replayed to a page that connects late (serve.R: 120 s). */
|
|
19
|
+
export const RESUME_REPORT_MS = 120000;
|
|
20
|
+
|
|
21
|
+
/** Is `x` a token the worker could have minted? */
|
|
22
|
+
export const keepTokenOk = (x) => typeof x === "string" && KEEP_TOKEN_RE.test(x);
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const CONTROL = /[\x00-\x1f\x7f]/g;
|
|
26
|
+
const textOf = (value, cap) => {
|
|
27
|
+
if (typeof value !== "string") return null;
|
|
28
|
+
const v = value.replace(CONTROL, " ");
|
|
29
|
+
return v.length > cap ? null : v;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The documents a page reported, cleaned: absolute paths, bounded names, known formats, no duplicates. */
|
|
33
|
+
export function cleanSessionDocuments(documents) {
|
|
34
|
+
if (!Array.isArray(documents) || !documents.length) return [];
|
|
35
|
+
const rows = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const row of documents) {
|
|
38
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
39
|
+
const p = textOf(row.path ?? "", 4096);
|
|
40
|
+
const name = textOf(row.name ?? "", 200);
|
|
41
|
+
let format = textOf(row.format ?? "", 16);
|
|
42
|
+
if (p == null || name == null || format == null) continue;
|
|
43
|
+
// Reopened through the file ops, which resolve it themselves; here it need only be absolute.
|
|
44
|
+
if (p && !/^(\/|[A-Za-z]:[/\\])/.test(p)) continue;
|
|
45
|
+
if (!p && !name.trim()) continue;
|
|
46
|
+
if (!SESSION_DOCUMENT_FORMATS.includes(format)) format = "qmd";
|
|
47
|
+
if (p) { if (seen.has(p)) continue; seen.add(p); }
|
|
48
|
+
rows.push({ path: p, name: name.trim(), format, active: row.active === true });
|
|
49
|
+
if (rows.length >= SESSION_DOCUMENTS_MAX) break;
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "SezerProfiles.qmd + 1 more": what the menu helper shows for a session. */
|
|
55
|
+
export function sessionDocumentsLabel(documents) {
|
|
56
|
+
if (!Array.isArray(documents) || !documents.length) return "";
|
|
57
|
+
const lead = documents.find((d) => d.active === true) || documents[0];
|
|
58
|
+
let name = lead.path ? path.basename(lead.path.replace(/\\/g, "/")) : lead.name;
|
|
59
|
+
if (!lead.path) name = `${name} (unsaved)`;
|
|
60
|
+
const rest = documents.length - 1;
|
|
61
|
+
const label = rest > 0 ? `${name} + ${rest} more` : name;
|
|
62
|
+
return label.replace(CONTROL, " ").replace(/["\\]/g, "'").slice(0, 120).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const encodeSessionDocuments = (documents) => (Array.isArray(documents) && documents.length ? JSON.stringify(documents) : "");
|
|
66
|
+
|
|
67
|
+
export function decodeSessionDocuments(text) {
|
|
68
|
+
if (typeof text !== "string" || !text) return [];
|
|
69
|
+
try { return cleanSessionDocuments(JSON.parse(text)); } catch { return []; }
|
|
70
|
+
}
|
package/host/worker-plane.mjs
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// an explicit `restart` is the one door out of it.
|
|
36
36
|
|
|
37
37
|
import { EventEmitter } from "node:events";
|
|
38
|
+
import { RESUME_REPORT_MS, keepTokenOk } from "./session-keep.mjs";
|
|
38
39
|
|
|
39
40
|
export const WORKER_DEADLINE_FAST = 120;
|
|
40
41
|
export const WORKER_DEADLINE_SLOW = 600;
|
|
@@ -119,6 +120,10 @@ export class WorkerPlane extends EventEmitter {
|
|
|
119
120
|
this.terminal = null; // {wireId, route, frame, timer}
|
|
120
121
|
this.running = new Set(); // exec wire ids in flight (the busy guard's evidence)
|
|
121
122
|
this.hello = null; // the decorated ready payload, replayed to late pages
|
|
123
|
+
// "Restart, keep variables" (host/session-keep.mjs): a token for the NEXT ready worker, consumed
|
|
124
|
+
// once; and the restore's report, replayed to a page that connects within RESUME_REPORT_MS.
|
|
125
|
+
this.resumePending = null;
|
|
126
|
+
this.resumeReport = null;
|
|
122
127
|
this.notice = null; // a kept give-up notice
|
|
123
128
|
this.readyFrame = null;
|
|
124
129
|
this.workerWd = process.cwd();
|
|
@@ -314,8 +319,8 @@ export class WorkerPlane extends EventEmitter {
|
|
|
314
319
|
if (this.alive) this.engine.kill();
|
|
315
320
|
}
|
|
316
321
|
|
|
317
|
-
async restart() {
|
|
318
|
-
this.audit("restart", {});
|
|
322
|
+
async restart({ resume } = {}) {
|
|
323
|
+
this.audit("restart", { keep: keepTokenOk(resume) });
|
|
319
324
|
this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
|
|
320
325
|
this.expectedExit = true;
|
|
321
326
|
if (this.engine) await this.engine.stop(1000);
|
|
@@ -325,9 +330,48 @@ export class WorkerPlane extends EventEmitter {
|
|
|
325
330
|
this.restartAttempts = 0;
|
|
326
331
|
this.gaveUp = false;
|
|
327
332
|
this.notice = null;
|
|
333
|
+
this.resumePending = keepTokenOk(resume) ? resume : null;
|
|
328
334
|
await this.start();
|
|
329
335
|
}
|
|
330
336
|
|
|
337
|
+
/** A token for the next ready worker (a handoff successor's CARMAR_RESUME_TOKEN). */
|
|
338
|
+
setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Ask the fresh worker to restore a kept workspace, and tell every page what came back. Queued like
|
|
342
|
+
* any command, so a chunk run pressed meanwhile waits for the restore rather than racing it.
|
|
343
|
+
*/
|
|
344
|
+
startResume() {
|
|
345
|
+
const token = this.resumePending;
|
|
346
|
+
this.resumePending = null;
|
|
347
|
+
if (!keepTokenOk(token)) return;
|
|
348
|
+
this.audit("resume-start", {});
|
|
349
|
+
this.internal({ type: "resume", token }, (frame) => {
|
|
350
|
+
if (scalarChr(frame.cwd)) this.workerWd = frame.cwd;
|
|
351
|
+
const report = { type: "workspace_restored", restored: frame.restored ?? 0, failed: frame.failed ?? [],
|
|
352
|
+
skipped: frame.skipped ?? [], wd_missing: frame.wd_missing ?? null, error: frame.error ?? null };
|
|
353
|
+
const payload = enc(report);
|
|
354
|
+
this.resumeReport = { at: Date.now(), payload };
|
|
355
|
+
this.audit("resume-done", { restored: report.restored, failed: Array.isArray(report.failed) ? report.failed.length : 0, error: report.error != null });
|
|
356
|
+
this.emit("broadcast-pages", payload);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The report of a restore that finished in the last RESUME_REPORT_MS, or null. */
|
|
361
|
+
freshResumeReport(now = Date.now()) {
|
|
362
|
+
return this.resumeReport && now - this.resumeReport.at < RESUME_REPORT_MS ? this.resumeReport.payload : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Cancel a `suspend` — only one THIS page asked for, never someone else's run. */
|
|
366
|
+
cancelSuspend(rec) {
|
|
367
|
+
const route = this.active != null ? this.routes.get(this.active) : null;
|
|
368
|
+
if (this.activeType !== "suspend" || !route || route.rec !== rec) return false;
|
|
369
|
+
this.audit("suspend-cancel", {});
|
|
370
|
+
const wireId = this.active;
|
|
371
|
+
this.engine.interrupt(() => wireId === this.active);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
331
375
|
resetSession() {
|
|
332
376
|
this.running.clear();
|
|
333
377
|
this.routes.clear();
|
|
@@ -535,6 +579,7 @@ export class WorkerPlane extends EventEmitter {
|
|
|
535
579
|
this.hello = payload;
|
|
536
580
|
this.emit("broadcast", payload);
|
|
537
581
|
this.emit("ready", decorated);
|
|
582
|
+
if (this.resumePending) this.startResume();
|
|
538
583
|
return;
|
|
539
584
|
}
|
|
540
585
|
this.emit("broadcast", relayFrame(e));
|
package/kernel/kernel-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.8.
|
|
1
|
+
0.8.7
|
package/kernel/kernel.R
CHANGED
|
@@ -246,16 +246,18 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
|
|
|
246
246
|
clean_env[["CARMAR_SENTINEL"]] <- sentinel
|
|
247
247
|
clean_env[["CARMAR_CMD_TAG"]] <- cmdtag
|
|
248
248
|
clean_env[["CARMAR_WORKER_MODE"]] <- "interactive"
|
|
249
|
+
# readline's screen width: at 80 a long command line echoes as "<" + tail with
|
|
250
|
+
# no cmdtag (Linux; host/engine-r.mjs has the measurement). The boot line gives
|
|
251
|
+
# user code back its own COLUMNS.
|
|
252
|
+
clean_env[["CARMAR_USER_COLUMNS"]] <- Sys.getenv("COLUMNS", "")
|
|
253
|
+
clean_env[["COLUMNS"]] <- "100000"
|
|
249
254
|
clean_env[["CARMAR_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
|
|
250
255
|
}
|
|
251
256
|
spawn_bin <- if (identical(mode, "interactive")) r_binary else rscript
|
|
252
257
|
spawn_args <- if (identical(mode, "interactive")) {
|
|
253
258
|
# --no-echo suppresses the "> " prompt; the input ECHO it does not suppress
|
|
254
259
|
# is scrubbed in kernel_poll by the cmdtag / pending-echo machinery.
|
|
255
|
-
|
|
256
|
-
# R echoes each line read — on Linux as a scrolled "<" + tail that carries no
|
|
257
|
-
# cmdtag (host/engine-r.mjs has the measurement).
|
|
258
|
-
c("--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file")
|
|
260
|
+
c("--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file")
|
|
259
261
|
} else {
|
|
260
262
|
# --vanilla, NOT --no-init-file: the user's .Rprofile/.Renviron are part of
|
|
261
263
|
# their R (library paths, repos, options). Only history and saved workspaces
|
|
@@ -292,7 +294,8 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
|
|
|
292
294
|
if (!file.exists(real_worker)) real_worker <- worker_path
|
|
293
295
|
worker_boot <- sprintf('sys.source("%s", envir = globalenv(), keep.source = FALSE)',
|
|
294
296
|
encodeString(normalizePath(real_worker)))
|
|
295
|
-
|
|
297
|
+
width_boot <- 'local({ cols <- Sys.getenv("CARMAR_USER_COLUMNS"); Sys.unsetenv("CARMAR_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") })'
|
|
298
|
+
boot <- paste(Filter(nzchar, c(width_boot, macos_background_boot(worker_path), worker_boot)),
|
|
296
299
|
collapse = ";")
|
|
297
300
|
kernel_console(k, boot)
|
|
298
301
|
}
|