beatrina 0.8.6

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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env python3
2
+ """worker.py — the Python engine, speaking the same wire as `spike/worker.R`.
3
+
4
+ Beatrix's host (`host/worker-plane.mjs`) is engine-neutral: it owns wire ids, a
5
+ single-flight queue, deadlines, the interrupt ladder and respawn, and it talks
6
+ to an ENGINE ADAPTER. `host/engine-python.mjs` is this engine's adapter; this
7
+ file is the process behind it, and everything it says is a frame in the shape
8
+ `docs/worker-protocol.md` §3.1 records for R.
9
+
10
+ stdin `#<cmdtag> {json}\\n` — one command per line; a comment, so a line
11
+ that ever reached a raw prompt is inert.
12
+ Over 32,000 bytes it is a `cmdfile`.
13
+ stdout `<sentinel>{json}\\n` — one control frame per line.
14
+ SIGINT to this process's group — Stop.
15
+
16
+ WHY SIGINT AND NOT A COMMAND. jupyter_client starts the ipykernel in its OWN
17
+ session (measured 2026-09-14 on this machine: kernel sid == kernel pid), so the
18
+ host's group signal reaches THIS process and not the kernel. That is the
19
+ arrangement we want: a signal cannot be queued behind a running cell the way a
20
+ stdin command can, and this process is never the one evaluating, so it is
21
+ always free to catch it and forward `interrupt_request` to the kernel.
22
+
23
+ WHAT THIS PROCESS NEVER DOES. It does not evaluate user code — the ipykernel
24
+ does, one process further out. It does not open a port (the kernel's own ZMQ
25
+ sockets are jupyter_client's, on loopback, and are not Beatrix's wire). It does
26
+ not interpret chunk options; the host coordinates, the engine executes.
27
+
28
+ Ported from `../carmar-python/py/carmapy/` — `adapter.py` from
29
+ `kernel_adapter.py`, `bootstrap.py`, `dataview.py` and `document_exec.py`
30
+ verbatim or nearly so. That repository is read-only to this one.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import os
38
+ import signal
39
+ import sys
40
+ import threading
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
+
44
+ from adapter import PythonSession # noqa: E402
45
+ from document_exec import (DocumentExecError, # noqa: E402
46
+ DocumentSetups, FEATURE)
47
+
48
+ SENTINEL = os.environ.get("CARMAR_SENTINEL", "")
49
+ CMD_TAG = os.environ.get("CARMAR_CMD_TAG", "")
50
+ KERNEL_NAME = os.environ.get("CARMAR_PYTHON_KERNEL", "python3")
51
+
52
+ #: Every verb this engine answers. The host merges it into `ready.commands`,
53
+ #: and `lib/kernel-capability.js` is the reason it matters: an op a kernel
54
+ #: never advertised is refused BY NAME instead of waiting on silence.
55
+ COMMANDS = [
56
+ "exec", "view", "colstats", "env", "rm", "complete", "parse", "wd",
57
+ "doctor", "input_reply", "interrupt", "shutdown",
58
+ # The debugger (docs/wp/wp8b-python-debugger.md): breakpoints by chunk
59
+ # srcname and line, and the step commands of a paused chunk.
60
+ "debug_breaks", "debug_cmd",
61
+ ]
62
+
63
+ #: The step commands a paused chunk understands — lib/debugger.js's and
64
+ #: lib/console.js's vocabulary, the same words R's plane maps to browser() keys.
65
+ DEBUG_ACTIONS = ("continue", "over", "into", "out", "where", "abort", "eval")
66
+
67
+ #: Claimed on `ready`. `document_exec_v1` is what `verbs.js` requires before it
68
+ #: will run any chunk at all, and `document_exec.py` implements the subset of
69
+ #: `knitr-run.R` that is not knitr (see its docstring for what is NOT kept).
70
+ FEATURES = [FEATURE]
71
+
72
+ _write_lock = threading.Lock()
73
+
74
+
75
+ def emit(frame: dict) -> None:
76
+ """One control frame, on its own line, behind the session's sentinel.
77
+
78
+ `allow_nan=False` on purpose: `NaN` and `Infinity` are not JSON and
79
+ `JSON.parse` rejects them, so a frame carrying one would reach the page as
80
+ a parse failure rather than as a number. Every payload that can hold a
81
+ float has already mapped them to null (dataview's `_num`); this is the
82
+ backstop that makes a miss loud here instead of silent there.
83
+
84
+ COMPACT, and that is a wire requirement, not a taste. `relayFrame` in
85
+ host/worker-plane.mjs rewrites the routing id INSIDE the engine's original
86
+ bytes — it searches for the literal `"id":"wire-7"` so the rest of the
87
+ frame is never re-encoded (re-encoding is what rounded 1.2345e-05 to 0 in
88
+ serve.R). Python's default `json.dumps` writes `"id": "wire-7"`, with a
89
+ space, which that search does not find; measured 2026-09-14, every frame of
90
+ the first Python chunk reached the page addressed to `wire-1`, and the page
91
+ dropped all of them. jsonlite writes compact, so R never showed it.
92
+ """
93
+ try:
94
+ line = SENTINEL + json.dumps(frame, allow_nan=False, default=str,
95
+ separators=(",", ":")) + "\n"
96
+ except (ValueError, TypeError) as exc:
97
+ line = SENTINEL + json.dumps(
98
+ {"type": frame.get("type", "error"), "id": frame.get("id"),
99
+ "error": f"this engine produced a frame that is not JSON: {exc}"},
100
+ separators=(",", ":")) + "\n"
101
+ with _write_lock:
102
+ sys.stdout.write(line)
103
+ sys.stdout.flush()
104
+
105
+
106
+ class Worker:
107
+ def __init__(self) -> None:
108
+ self.session: PythonSession | None = None
109
+ self.docs = DocumentSetups()
110
+ self.queue: asyncio.Queue = asyncio.Queue()
111
+ self.loop: asyncio.AbstractEventLoop | None = None
112
+ self.stopping = False
113
+ #: srcname -> sorted 1-based lines. Held HERE, in the process that is
114
+ #: never busy, so a breakpoint toggled while a chunk runs is recorded
115
+ #: at once; it reaches the kernel with the next run, or with the next
116
+ #: command of a pause.
117
+ self.breaks: dict[str, list[int]] = {}
118
+
119
+ # ── boot ────────────────────────────────────────────────────────────────
120
+
121
+ async def start(self) -> None:
122
+ self.loop = asyncio.get_running_loop()
123
+ self.session = PythonSession(kernel_name=KERNEL_NAME, on_input=self._ask_input,
124
+ on_debug=self._debug_prompt)
125
+ info = await self.session.start()
126
+ emit({
127
+ "type": "ready",
128
+ # The pid the HOST supervises, which is this process — not the
129
+ # ipykernel's. The plane's respawn watches this process's exit, and
130
+ # a test that kills `ready.pid` must kill the thing that comes back.
131
+ "pid": os.getpid(),
132
+ "kernel_pid": info.get("pid"),
133
+ "python": info.get("python"),
134
+ "language": "python",
135
+ "language_version": info.get("python"),
136
+ "engines": ["python"],
137
+ "executable": info.get("executable"),
138
+ "cwd": info.get("cwd"),
139
+ "home": os.path.expanduser("~"),
140
+ "libs": [p for p in sys.path if p],
141
+ # `interactive` is the mode word the plane reads for "this engine
142
+ # can answer a question while a cell runs" (input_reply) — and,
143
+ # as for R's interactive worker, it has a debugger
144
+ # (debug_breaks/debug_cmd in COMMANDS, canDebug in the adapter).
145
+ "mode": "interactive",
146
+ "features": FEATURES,
147
+ "commands": COMMANDS,
148
+ })
149
+
150
+ # ── the command reader ──────────────────────────────────────────────────
151
+
152
+ def read_stdin(self) -> None:
153
+ """A THREAD, not the loop. `input_reply` and `interrupt` must be read
154
+ while a chunk is running, and a blocking readline on the loop would
155
+ make Stop a lie one process further in — the same argument that put
156
+ the supervisor and the worker in different processes."""
157
+ tag = f"#{CMD_TAG} " if CMD_TAG else None
158
+ while True:
159
+ line = sys.stdin.readline()
160
+ if not line:
161
+ break
162
+ line = line.strip()
163
+ if not line:
164
+ continue
165
+ if tag and line.startswith(tag):
166
+ line = line[len(tag):]
167
+ elif tag and line.startswith("#"):
168
+ continue # a comment that is not ours
169
+ try:
170
+ cmd = json.loads(line)
171
+ except Exception:
172
+ continue
173
+ if not isinstance(cmd, dict) or not isinstance(cmd.get("type"), str):
174
+ continue
175
+ if cmd["type"] == "cmdfile":
176
+ cmd = self._read_cmdfile(cmd.get("path"))
177
+ if cmd is None:
178
+ continue
179
+ self._accept(cmd)
180
+ if self.loop is not None:
181
+ self.loop.call_soon_threadsafe(self._eof)
182
+
183
+ @staticmethod
184
+ def _read_cmdfile(path):
185
+ """A command past the console-line limit, delivered through a 0600 file.
186
+
187
+ Read, deleted, dispatched — and never accepted from a page: the host
188
+ writes these itself (`host/engine-stdio.mjs`), the browser cannot.
189
+ """
190
+ if not isinstance(path, str) or not path:
191
+ return None
192
+ try:
193
+ with open(path, "r", encoding="utf-8") as fh:
194
+ cmd = json.loads(fh.read())
195
+ except Exception:
196
+ return None
197
+ finally:
198
+ try:
199
+ os.unlink(path)
200
+ except OSError:
201
+ pass
202
+ return cmd if isinstance(cmd, dict) and isinstance(cmd.get("type"), str) else None
203
+
204
+ def _accept(self, cmd: dict) -> None:
205
+ """Decide, on the READER's thread, whether this command may jump the
206
+ queue. Two may: an answer to a question the kernel is already asking,
207
+ and a Stop. Everything else is work, and work is serial."""
208
+ kind = cmd["type"]
209
+ if kind in ("input_reply", "interrupt", "shutdown", "debug_cmd", "debug_breaks"):
210
+ self.loop.call_soon_threadsafe(self._urgent, cmd)
211
+ else:
212
+ self.loop.call_soon_threadsafe(self.queue.put_nowait, cmd)
213
+
214
+ def _urgent(self, cmd: dict) -> None:
215
+ kind = cmd["type"]
216
+ if kind == "input_reply":
217
+ value = cmd.get("value")
218
+ running = self.session.current if self.session else None
219
+ if isinstance(value, str) and self.session and self.session.answer_input(value):
220
+ emit({"type": "input_done", "id": running})
221
+ return
222
+ if kind == "interrupt":
223
+ self.on_interrupt()
224
+ return
225
+ if kind == "debug_breaks":
226
+ self.set_breaks(cmd)
227
+ return
228
+ if kind == "debug_cmd":
229
+ action = cmd.get("action")
230
+ expr = cmd.get("expr")
231
+ if action not in DEBUG_ACTIONS or (action == "eval" and not isinstance(expr, str)):
232
+ return
233
+ if self.session is not None:
234
+ self.session.answer_debug({"action": action, "expr": expr if action == "eval" else None,
235
+ "breaks": self.breaks})
236
+ return
237
+ if kind == "shutdown":
238
+ self.stopping = True
239
+ self.loop.create_task(self._shutdown())
240
+
241
+ def _eof(self) -> None:
242
+ """The host closed our stdin: it is gone, and so are we."""
243
+ self.stopping = True
244
+ self.loop.create_task(self._shutdown())
245
+
246
+ async def _shutdown(self) -> None:
247
+ if self.session:
248
+ await self.session.shutdown()
249
+ # os._exit, not sys.exit: an asyncio task raising SystemExit inside a
250
+ # running loop is caught by the loop, and the process would linger with
251
+ # its kernel already gone.
252
+ sys.stdout.flush()
253
+ os._exit(0)
254
+
255
+ # ── Stop ────────────────────────────────────────────────────────────────
256
+
257
+ def on_interrupt(self, *_a) -> None:
258
+ """SIGINT reached THIS process. Forward it to the kernel.
259
+
260
+ Scheduled onto the loop rather than awaited here: a signal handler runs
261
+ between bytecodes and `interrupt_kernel` is a coroutine. Nothing else
262
+ happens — this process has nothing of its own to interrupt.
263
+ """
264
+ if self.session is None or self.loop is None:
265
+ return
266
+ # A Stop THIS worker forwarded, recorded against the run that is going.
267
+ # ipykernel does not always say "KeyboardInterrupt" in a shape that can
268
+ # be told from user output (see adapter._translate), so the fact that
269
+ # Stop was pressed during this run is the reliable half; the shell
270
+ # reply's non-ok status is the other half, and both are required.
271
+ if self.session.current is not None:
272
+ self.session.interrupted = True
273
+ self.loop.call_soon_threadsafe(
274
+ lambda: self.loop.create_task(self._interrupt()))
275
+
276
+ async def _interrupt(self) -> None:
277
+ try:
278
+ await self.session.interrupt()
279
+ except Exception as exc:
280
+ emit({"type": "stream", "kind": "warning",
281
+ "text": f"Beatrina could not interrupt the Python kernel: {exc}"})
282
+
283
+ async def _ask_input(self, prompt: str, wire_id) -> None:
284
+ emit({"type": "input_request", "id": wire_id, "prompt": prompt})
285
+
286
+ # ── the debugger ────────────────────────────────────────────────────────
287
+
288
+ def set_breaks(self, cmd: dict) -> None:
289
+ """`debug_breaks {file, lines}` — spike/worker.R `emit_debug_breaks`.
290
+
291
+ Same validation, same reply shape (`file`, `lines`), answered at once:
292
+ the registry lives in this process, not in the kernel, so there is no
293
+ queue to wait behind. `functions` — R's count of armed function traces
294
+ — is ABSENT: a Python breakpoint addresses every function the chunk
295
+ defined by the code's own filename, and there is no count to report.
296
+ """
297
+ wire = cmd.get("id")
298
+ file = cmd.get("file")
299
+ if not isinstance(file, str) or not file:
300
+ if wire is not None:
301
+ emit({"type": "debug_breaks", "id": wire, "error": "bad file"})
302
+ return
303
+ raw = cmd.get("lines")
304
+ raw = raw if isinstance(raw, list) else ([] if raw is None else [raw])
305
+ lines = []
306
+ for x in raw:
307
+ try:
308
+ n = int(x)
309
+ except (TypeError, ValueError):
310
+ continue
311
+ if n >= 1 and n not in lines:
312
+ lines.append(n)
313
+ lines.sort()
314
+ if lines:
315
+ self.breaks[file] = lines
316
+ else:
317
+ self.breaks.pop(file, None)
318
+ if wire is not None:
319
+ emit({"type": "debug_breaks", "id": wire, "file": file, "lines": lines})
320
+
321
+ async def _debug_prompt(self, payload: str, wire_id) -> None:
322
+ """The paused chunk's prompt. `paused` and `where` become the `debug`
323
+ frame R emits (no id: the plane addresses it to the run that owns the
324
+ slot, as it does R's); a bare re-prompt after an `eval` says nothing,
325
+ because R's Browse prompt coming back says nothing either."""
326
+ try:
327
+ state = json.loads(payload)
328
+ except Exception:
329
+ return
330
+ if not isinstance(state, dict) or state.get("event") not in ("paused", "where"):
331
+ return
332
+ frame = {"type": "debug", "event": state["event"]}
333
+ for key in ("file", "line", "reason", "stack", "locals"):
334
+ if state.get(key) is not None:
335
+ frame[key] = state[key]
336
+ emit(frame)
337
+
338
+ # ── the work loop ───────────────────────────────────────────────────────
339
+
340
+ async def run(self) -> None:
341
+ while not self.stopping:
342
+ cmd = await self.queue.get()
343
+ try:
344
+ await self.dispatch(cmd)
345
+ except Exception as exc: # never lose the loop
346
+ wire = cmd.get("id")
347
+ if cmd.get("type") == "exec":
348
+ emit({"type": "done", "id": wire, "status": "error",
349
+ "message": f"{type(exc).__name__}: {exc}",
350
+ "cwd": self.cwd()})
351
+ elif wire is not None:
352
+ emit({"type": cmd.get("type"), "id": wire,
353
+ "error": f"{type(exc).__name__}: {exc}"})
354
+
355
+ def cwd(self) -> str:
356
+ return (self.session.cwd if self.session else "") or os.getcwd()
357
+
358
+ async def dispatch(self, cmd: dict) -> None:
359
+ kind = cmd["type"]
360
+ wire = cmd.get("id")
361
+ if kind == "exec":
362
+ return await self.exec(cmd)
363
+ if kind in ("view", "colstats", "env", "rm"):
364
+ spec = {k: v for k, v in cmd.items() if k not in ("type", "id", "engine")}
365
+ answer = await self.session.request(kind, spec)
366
+ return emit({"type": kind, "id": wire, **answer})
367
+ if kind == "complete":
368
+ line = cmd.get("line") or ""
369
+ cursor = cmd.get("cursor")
370
+ cursor = len(line) if not isinstance(cursor, int) else cursor
371
+ answer = await self.session.complete(line, cursor)
372
+ if answer is None:
373
+ # BUSY, not broken. The page backs off and retries; an error
374
+ # here would latch completion off for the rest of the session.
375
+ return emit({"type": "complete", "id": wire, "busy": True,
376
+ "start": cursor, "end": cursor, "matches": []})
377
+ return emit({"type": "complete", "id": wire, **answer})
378
+ if kind == "parse":
379
+ return emit({"type": "parse", "id": wire, **_parse_state(cmd.get("source"))})
380
+ if kind == "wd":
381
+ path = cmd.get("path")
382
+ if isinstance(path, str) and path:
383
+ await self.session.execute(
384
+ f"import os as _os; _os.chdir({path!r})", "wd", None,
385
+ store_history=False, allow_stdin=False)
386
+ return emit({"type": "wd", "id": wire, "path": self.cwd()})
387
+ if kind == "doctor":
388
+ return emit({"type": "doctor", "id": wire, **self.doctor()})
389
+ # An unknown type falls off the end SILENTLY, exactly as worker.R's
390
+ # dispatch chain does — which is why the plane has per-class deadlines
391
+ # and why `commands` above is the contract.
392
+ return None
393
+
394
+ def doctor(self) -> dict:
395
+ import platform
396
+ return {
397
+ "language": "python", "version": sys.version.split()[0],
398
+ "executable": sys.executable, "prefix": sys.prefix,
399
+ "kernel": KERNEL_NAME, "platform": platform.platform(),
400
+ "cwd": self.cwd(), "path": [p for p in sys.path if p],
401
+ }
402
+
403
+ # ── exec ────────────────────────────────────────────────────────────────
404
+
405
+ async def exec(self, cmd: dict) -> None:
406
+ wire = cmd.get("id")
407
+ source = cmd.get("source", cmd.get("code", ""))
408
+ if not isinstance(source, str):
409
+ return emit({"type": "done", "id": wire, "status": "error",
410
+ "message": "no source", "cwd": self.cwd()})
411
+ engine = cmd.get("engine")
412
+ if engine is not None and str(engine).lower() not in ("python", "py"):
413
+ # THE SECOND LOCK. The page refuses a chunk whose engine the host
414
+ # has no runtime for, and the engine refuses it again, so a page
415
+ # patched wrong cannot run an R chunk in a Python session.
416
+ return emit({"type": "done", "id": wire, "status": "error",
417
+ "message": f"This engine runs python; it was asked to "
418
+ f"run a {engine} chunk.", "cwd": self.cwd()})
419
+
420
+ async def out(frame):
421
+ emit(frame)
422
+
423
+ dims = cmd.get("dims")
424
+ self.session.dims = dims if isinstance(dims, dict) else None
425
+ document = cmd.get("document")
426
+ if document is not None:
427
+ return await self.exec_document(wire, source, document, out, srcname=_srcname(cmd))
428
+ self.progress(wire, source)
429
+ status = await self.session.execute(source, wire, out, srcname=_srcname(cmd),
430
+ breaks=self.breaks)
431
+ # `cwd` rides the done frame, as R's does: a chunk that called
432
+ # os.chdir() has finished, so this is the first moment the new value is
433
+ # certainly current.
434
+ emit({"type": "done", "id": wire, "status": status,
435
+ "message": self.session.failure, "cwd": self.cwd()})
436
+
437
+ @staticmethod
438
+ def progress(wire, source: str) -> None:
439
+ """ONE progress frame per chunk, and the honest reason it is one.
440
+
441
+ R emits one per top-level expression because its worker evaluates them
442
+ one at a time. ipykernel's `execute_request` is atomic — the chunk is
443
+ one unit, and splitting it would change what the notebook shows
444
+ (IPython displays the LAST expression's value, not every one), so a
445
+ per-expression count here would be a number this engine cannot know.
446
+ The frame is still sent so the editor's progress affordance is fed
447
+ rather than silent, and it says what is true: one unit, of one.
448
+ """
449
+ lines = source.count("\n") + 1
450
+ emit({"type": "progress", "id": wire, "index": 1, "total": 1,
451
+ "line": 1, "end": lines})
452
+
453
+ async def exec_document(self, wire, source: str, document, out, srcname=None) -> None:
454
+ """The notebook exec envelope — `document_exec_v1`, as the page sends it.
455
+
456
+ Port of `knitr-run.R`'s `run_document_cell()` minus knitr (see
457
+ document_exec.py for what is kept and what is not). The decisions are
458
+ `self.docs.plan`'s; this runs them: the `setup` frame, the setup chunk
459
+ with its output hidden and its tracebacks shown, the ledger update,
460
+ then the chunk, and ONE terminal `done` carrying `setup`, `cacheHit`,
461
+ `effectiveOptions`, `execution` and `cwd` exactly as R's does.
462
+ """
463
+ done = {"type": "done", "id": wire, "status": "ok", "message": None,
464
+ "setup": None, "cacheHit": False, "effectiveOptions": None}
465
+ try:
466
+ plan = self.docs.plan(source, document)
467
+ except DocumentExecError as err:
468
+ done.update(status="error", message=str(err), cwd=self.cwd())
469
+ return emit(done)
470
+ done["execution"] = plan.execution
471
+ if plan.run_setup:
472
+ # Invalidate BEFORE executing: a failed partial setup must never
473
+ # leave a previous Ready snapshot available to dependent chunks.
474
+ self.docs.setup_started(plan.doc_id)
475
+ report = {"source": plan.setup_source, "srcname": plan.setup_srcname,
476
+ "status": "running"}
477
+ emit({"type": "setup", "id": wire, **report})
478
+ errors: list[dict] = []
479
+
480
+ async def emit_setup(frame):
481
+ # knitr `include = FALSE`: a setup's output is not shown; its
482
+ # failure is, so a failing setup says why. Both halves of a
483
+ # Python error reach the page — the traceback TEXT (`stderr`)
484
+ # and the `traceback` frame that carries its headline.
485
+ kind = frame.get("type")
486
+ if kind == "traceback":
487
+ errors.append(frame)
488
+ await out(frame)
489
+ elif kind == "stderr":
490
+ await out(frame)
491
+
492
+ emit({"type": "progress", "id": wire, "index": 1, "total": 1,
493
+ "line": 1, "end": plan.setup_source.count("\n") + 1,
494
+ "phase": "setup"})
495
+ status = await self.session.execute(plan.setup_source, wire, emit_setup,
496
+ store_history=False)
497
+ why = (errors[-1].get("message") if errors else f"Chunk {status}")
498
+ if status != "ok":
499
+ self.docs.setup_failed(plan.doc_id, plan.setup_source)
500
+ report["status"] = status
501
+ done.update(status=status, setup=report, cwd=self.cwd(),
502
+ message=f"Setup chunk failed; dependent chunk was "
503
+ f"not run. {why}")
504
+ return emit(done)
505
+ self.docs.setup_ok(plan.doc_id, plan.setup_source)
506
+ report["status"] = "ok"
507
+ done["setup"] = report
508
+ elif plan.setup_source is None:
509
+ self.docs.no_setup(plan.doc_id)
510
+ if plan.run_chunk:
511
+ self.progress(wire, source)
512
+ done["status"] = await self.session.execute(source, wire, out,
513
+ srcname=srcname,
514
+ breaks=self.breaks)
515
+ done["message"] = self.session.failure
516
+ done["cwd"] = self.cwd()
517
+ emit(done)
518
+
519
+
520
+ def _srcname(cmd: dict):
521
+ """The chunk's debugging identity, or None. Wire-supplied; anything but a
522
+ plain non-empty string means "no identity", never an error (worker.R)."""
523
+ name = cmd.get("srcname")
524
+ return name if isinstance(name, str) and name else None
525
+
526
+
527
+ def _parse_state(source) -> dict:
528
+ """Is this source a complete Python statement? Drives the console's
529
+ continuation prompt, exactly as `emit_parse` does for R: only the parser
530
+ knows whether more input would help."""
531
+ import codeop
532
+ if not isinstance(source, str):
533
+ source = ""
534
+ try:
535
+ # A trailing newline is what tells codeop a block is finished; without
536
+ # one `if x:\n y` reads as "still open" forever.
537
+ result = codeop.compile_command(source + "\n", "<beatrina>", "exec")
538
+ except SyntaxError as exc:
539
+ return {"complete": True, "message": str(exc)}
540
+ except (ValueError, OverflowError) as exc:
541
+ return {"complete": True, "message": str(exc)}
542
+ return {"complete": result is not None, "message": None}
543
+
544
+
545
+ async def main() -> None:
546
+ worker = Worker()
547
+ loop = asyncio.get_running_loop()
548
+ worker.loop = loop # before the handlers, or an early signal has none
549
+ # add_signal_handler, not signal.signal: the C-level handler runs between
550
+ # bytecodes and cannot touch the loop safely.
551
+ loop.add_signal_handler(signal.SIGINT, worker.on_interrupt)
552
+ loop.add_signal_handler(signal.SIGTERM, worker._eof)
553
+ try:
554
+ await worker.start()
555
+ except Exception as exc:
556
+ emit({"type": "ready-error", "error": f"{type(exc).__name__}: {exc}"})
557
+ sys.stderr.write(f"beatrina python engine failed to start: {exc}\n")
558
+ os._exit(3)
559
+ threading.Thread(target=worker.read_stdin, daemon=True).start()
560
+ await worker.run()
561
+
562
+
563
+ if __name__ == "__main__":
564
+ asyncio.run(main())
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "r",
3
+ "label": "R",
4
+ "language": "r",
5
+ "fences": ["r", "R"],
6
+ "adapter": "host/engine-r.mjs",
7
+ "worker": "spike/worker-boot.R",
8
+ "runtime": {
9
+ "env": "CARMAR_RSCRIPT",
10
+ "ladder": [
11
+ "/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
12
+ "/opt/homebrew/bin/Rscript",
13
+ "/usr/local/bin/Rscript",
14
+ "/opt/local/bin/Rscript",
15
+ "/usr/bin/Rscript",
16
+ "PATH"
17
+ ]
18
+ },
19
+ "controls": {
20
+ "interrupt": "signal:SIGINT:group",
21
+ "console": "stdin:raw-line",
22
+ "commands": "stdin:tagged-json"
23
+ },
24
+ "note": "The worker files stay in spike/ until spike/serve.R retires (host-plan WP7); the manifest points at them so upstream merges stay trivial."
25
+ }