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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- 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
|
+
}
|