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,577 @@
|
|
|
1
|
+
"""adapter.py — Jupyter messages in, Beatrix frames out.
|
|
2
|
+
|
|
3
|
+
Ported from `../carmar-python/py/carmapy/kernel_adapter.py` (read 2026-09-14).
|
|
4
|
+
It does not evaluate anything itself; `ipykernel` does that, in its own OS
|
|
5
|
+
process, and this translates.
|
|
6
|
+
|
|
7
|
+
THE RULE THAT COST THREE WRONG MEASUREMENTS OVER THERE
|
|
8
|
+
(`../carmar-python/notes/probes/RESULTS.md` P1):
|
|
9
|
+
|
|
10
|
+
A cell is finished when iopub reports `status: idle` for its parent
|
|
11
|
+
msg_id — NEVER when execute_reply arrives.
|
|
12
|
+
|
|
13
|
+
The channels are independent and the reply routinely precedes the output it
|
|
14
|
+
describes. Closing on the reply drops output the user was supposed to see.
|
|
15
|
+
|
|
16
|
+
What is NEW here, against carmapy's version:
|
|
17
|
+
|
|
18
|
+
* `input_request`. The chunk runs with `allow_stdin=True` and the stdin
|
|
19
|
+
channel is drained in the same loop as iopub and shell, so `input()` in a
|
|
20
|
+
Python chunk raises the page's question instead of hanging the kernel.
|
|
21
|
+
carmapy ran every cell with stdin disallowed.
|
|
22
|
+
* The error frame is emitted in worker.R's SHAPE — `stderr` for the
|
|
23
|
+
traceback text plus a `traceback` frame — because `{"type":"error"}` is a
|
|
24
|
+
frame `src/r-kernel.js` does not handle, so carmapy's version would have
|
|
25
|
+
been dropped by the page it was written for. Measured against the R worker
|
|
26
|
+
on 2026-09-14 (`tmp/probe-r-frames.mjs`).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
import queue
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from typing import Awaitable, Callable
|
|
37
|
+
|
|
38
|
+
from jupyter_client.manager import AsyncKernelManager
|
|
39
|
+
|
|
40
|
+
from bootstrap import BOOTSTRAP, CARMAR_MIME, CONTROL_MIME, REPLY_MIME
|
|
41
|
+
|
|
42
|
+
#: debugger.py's PROMPT. Restated, not imported: debugger.py is kernel-side
|
|
43
|
+
#: source and is never imported into this process. test/host-python-debug
|
|
44
|
+
#: pins the two spellings against each other.
|
|
45
|
+
DEBUG_PROMPT = "\x1bcarmar-debug\x1b"
|
|
46
|
+
#: debugger.py's CarmarDebugAbort, as ipykernel names it on the error message.
|
|
47
|
+
DEBUG_ABORT = "CarmarDebugAbort"
|
|
48
|
+
|
|
49
|
+
Emit = Callable[[dict], Awaitable[None]]
|
|
50
|
+
|
|
51
|
+
COMPLETE_TIMEOUT = 1.5 # matches lib/r-complete.js's 1500 ms
|
|
52
|
+
BUSY_BACKOFF = 10.0 # matches BACKOFF_MS; must always heal, never latch
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PythonSession:
|
|
56
|
+
"""One Python session: the notebook's `worker.R` equivalent.
|
|
57
|
+
|
|
58
|
+
Owns exactly one ipykernel process. Not a pool, not a registry — one
|
|
59
|
+
document, one kernel, which is both Jupyter's rule and CarmaR's.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, kernel_name: str = "python3", on_input=None, on_debug=None):
|
|
63
|
+
self.kernel_name = kernel_name
|
|
64
|
+
self.km: AsyncKernelManager | None = None
|
|
65
|
+
self.kc = None
|
|
66
|
+
self.busy: bool = False
|
|
67
|
+
self.busy_since: float | None = None
|
|
68
|
+
self._retry_after: float = 0.0
|
|
69
|
+
self._current: str | None = None # wire id of the running cell
|
|
70
|
+
# The worker's cwd, volunteered by the kernel after every cell (see
|
|
71
|
+
# bootstrap.py). Relative paths resolve against it, so an `os.chdir()`
|
|
72
|
+
# in a chunk is known when that cell ends.
|
|
73
|
+
self.cwd: str = ""
|
|
74
|
+
self._reply: dict | None = None # the viewer answer for the drain
|
|
75
|
+
# `on_input(prompt)` is awaited when the kernel asks a question on the
|
|
76
|
+
# stdin channel. The worker turns it into an `input_request` frame; the
|
|
77
|
+
# answer comes back through `answer_input()` from the command reader,
|
|
78
|
+
# which is a DIFFERENT task — that is the whole reason the drain loop
|
|
79
|
+
# must keep yielding rather than block on the answer.
|
|
80
|
+
self._on_input = on_input
|
|
81
|
+
self._input_open = False
|
|
82
|
+
# The debugger's pause is an `input()` whose prompt starts with
|
|
83
|
+
# DEBUG_PROMPT (engines/python/debugger.py). It is NOT a question for
|
|
84
|
+
# the user: `on_debug(payload_json)` turns it into a `debug` frame, and
|
|
85
|
+
# `answer_debug()` — never `answer_input()` — writes the command back.
|
|
86
|
+
self._on_debug = on_debug
|
|
87
|
+
self._debug_open = False
|
|
88
|
+
# Commands that arrived while the paused chunk was BETWEEN prompts — an
|
|
89
|
+
# `eval` still printing when `where` is pressed. R's Browse prompt reads
|
|
90
|
+
# raw console lines, so they queue on its stdin for free; here they
|
|
91
|
+
# queue in this list and the next prompt takes the first one. Cleared
|
|
92
|
+
# with the run: a step can never leak into the next chunk's pause.
|
|
93
|
+
self._debug_pending: list[dict] = []
|
|
94
|
+
#: Set by `_translate` while a chunk runs, read by the worker when it
|
|
95
|
+
#: builds the `done` frame: was this a Stop, and if it failed, why.
|
|
96
|
+
#: Two fields rather than one status, because "interrupted" and "the
|
|
97
|
+
#: shell reply said error" are different facts that arrive on
|
|
98
|
+
#: different channels.
|
|
99
|
+
self.interrupted = False
|
|
100
|
+
self.failure: str | None = None
|
|
101
|
+
#: `{width, height, res, format}` the chunk asked for, so a figure is
|
|
102
|
+
#: sized by what the page requested rather than by a constant.
|
|
103
|
+
self.dims: dict | None = None
|
|
104
|
+
# ONE READER AT A TIME. `_drain_until_idle` consumes the shell, iopub
|
|
105
|
+
# and stdin channels, so two of them running concurrently would steal
|
|
106
|
+
# each other's messages — a `view` sent during a cell would eat that
|
|
107
|
+
# cell's execute_reply and the cell would never finish. The lock makes
|
|
108
|
+
# `view` QUEUE behind a running cell, which is R's behaviour for the
|
|
109
|
+
# same op (`view` is a worker command there too).
|
|
110
|
+
self._chan = asyncio.Lock()
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- lifecycle
|
|
113
|
+
|
|
114
|
+
async def start(self) -> dict:
|
|
115
|
+
# IPC, not TCP, and it is a boundary decision rather than a preference.
|
|
116
|
+
# host-plan §2: "an engine never opens a port". A TCP-transport
|
|
117
|
+
# ipykernel opens FIVE listening loopback ports that nothing in Beatrix
|
|
118
|
+
# gates, and ipykernel says so itself — it writes a warning to stderr on
|
|
119
|
+
# every start ("running over TCP without encryption… susceptible to
|
|
120
|
+
# eavesdropping"), which the framer then attributes to whatever chunk
|
|
121
|
+
# happens to be running, so the user's first Python cell prints a
|
|
122
|
+
# security warning about a port they did not ask for. Unix domain
|
|
123
|
+
# sockets in the kernel's own runtime directory have no port, no
|
|
124
|
+
# listener, and file permissions for a gate. Windows has no AF_UNIX for
|
|
125
|
+
# ZMQ, so it keeps TCP on loopback.
|
|
126
|
+
transport = "tcp" if sys.platform == "win32" else "ipc"
|
|
127
|
+
self.km = AsyncKernelManager(kernel_name=self.kernel_name, transport=transport)
|
|
128
|
+
await self.km.start_kernel()
|
|
129
|
+
self.kc = self.km.client()
|
|
130
|
+
self.kc.start_channels()
|
|
131
|
+
await self.kc.wait_for_ready(timeout=60)
|
|
132
|
+
await self._bootstrap()
|
|
133
|
+
return {
|
|
134
|
+
"pid": self.km.provisioner.process.pid if self.km.provisioner else None,
|
|
135
|
+
"python": sys.version.split()[0],
|
|
136
|
+
"cwd": await self._cwd(),
|
|
137
|
+
"executable": sys.executable,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async def _bootstrap(self) -> None:
|
|
141
|
+
"""Install the DataFrame formatter. Silent to the USER, never to us.
|
|
142
|
+
|
|
143
|
+
carmapy's first version passed emit=None and discarded the result, so
|
|
144
|
+
when the formatter failed to register (pandas 3.0 moved
|
|
145
|
+
DataFrame.__module__) the kernel came up reporting success and quietly
|
|
146
|
+
rendered every DataFrame as raw HTML. A bootstrap that cannot fail
|
|
147
|
+
loudly is a silent failure by construction.
|
|
148
|
+
"""
|
|
149
|
+
errors: list[str] = []
|
|
150
|
+
|
|
151
|
+
async def capture(frame):
|
|
152
|
+
if frame.get("type") == "traceback":
|
|
153
|
+
errors.append(frame.get("message", ""))
|
|
154
|
+
|
|
155
|
+
msg_id = self.kc.execute(BOOTSTRAP, silent=True, store_history=False)
|
|
156
|
+
status = await self._drain_until_idle(msg_id, capture, None, timeout=60)
|
|
157
|
+
if status != "ok" or errors:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"the Python engine's bootstrap failed; DataFrames would render "
|
|
160
|
+
"as raw HTML.\n" + "\n".join(errors))
|
|
161
|
+
|
|
162
|
+
async def _cwd(self) -> str:
|
|
163
|
+
out: list[str] = []
|
|
164
|
+
|
|
165
|
+
async def cap(frame):
|
|
166
|
+
if frame.get("type") == "stdout":
|
|
167
|
+
out.append(frame["text"])
|
|
168
|
+
|
|
169
|
+
await self.execute("import os as _os; print(_os.getcwd())", "cwd-probe", cap,
|
|
170
|
+
store_history=False, allow_stdin=False)
|
|
171
|
+
self.cwd = "".join(out).strip()
|
|
172
|
+
return self.cwd
|
|
173
|
+
|
|
174
|
+
async def shutdown(self) -> None:
|
|
175
|
+
if self.kc:
|
|
176
|
+
try:
|
|
177
|
+
self.kc.stop_channels()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
if self.km:
|
|
181
|
+
try:
|
|
182
|
+
await self.km.shutdown_kernel(now=True)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
# The IPC transport leaves five socket files behind; the connection
|
|
186
|
+
# file too. Nothing else removes them, and a session per document
|
|
187
|
+
# would litter the runtime directory for as long as the machine is
|
|
188
|
+
# up.
|
|
189
|
+
# AsyncKernelManager's is a COROUTINE (`_async_cleanup_resources`),
|
|
190
|
+
# so calling it and discarding the result deletes nothing and warns
|
|
191
|
+
# "coroutine was never awaited" — measured 2026-09-14. Awaited when
|
|
192
|
+
# it is one, called when it is not.
|
|
193
|
+
try:
|
|
194
|
+
maybe = self.km.cleanup_resources()
|
|
195
|
+
if asyncio.iscoroutine(maybe):
|
|
196
|
+
await maybe
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------- execution
|
|
201
|
+
|
|
202
|
+
async def execute(self, code: str, wire_id: str, emit: Emit | None,
|
|
203
|
+
store_history: bool = True, allow_stdin: bool = True,
|
|
204
|
+
srcname: str | None = None, breaks: dict | None = None) -> str:
|
|
205
|
+
"""Run one chunk. Emits frames as they arrive; returns the final status.
|
|
206
|
+
|
|
207
|
+
`srcname` ("chunk:<stableId>") names the compiled cell, which is the
|
|
208
|
+
address a breakpoint uses; `breaks` is every breakpoint the page has
|
|
209
|
+
set. Both are handed to the kernel in ONE silent call immediately
|
|
210
|
+
before the chunk, under the same channel lock, so no other cell can be
|
|
211
|
+
compiled in between and take the name.
|
|
212
|
+
"""
|
|
213
|
+
async with self._chan:
|
|
214
|
+
self.busy = True
|
|
215
|
+
self.busy_since = time.monotonic()
|
|
216
|
+
self._current = wire_id
|
|
217
|
+
self.interrupted = False
|
|
218
|
+
self.failure = None
|
|
219
|
+
try:
|
|
220
|
+
if srcname:
|
|
221
|
+
spec = json.dumps(json.dumps({"srcname": srcname, "breaks": breaks or {}}))
|
|
222
|
+
prep = self.kc.execute(f"__carmar_debug_prepare({spec})",
|
|
223
|
+
store_history=False, allow_stdin=False)
|
|
224
|
+
await self._drain_until_idle(prep, None, None, timeout=30)
|
|
225
|
+
msg_id = self.kc.execute(code, store_history=store_history,
|
|
226
|
+
allow_stdin=allow_stdin)
|
|
227
|
+
status = await self._drain_until_idle(msg_id, emit, wire_id)
|
|
228
|
+
# The shell reply says "error" for a Stop too, so the two facts
|
|
229
|
+
# are reconciled here and the caller gets R's vocabulary.
|
|
230
|
+
# BOTH conditions: a Stop pressed a millisecond before a chunk
|
|
231
|
+
# would have finished anyway leaves `interrupted` set against a
|
|
232
|
+
# run that succeeded, and R reports that run `ok`.
|
|
233
|
+
if self.interrupted and status != "ok":
|
|
234
|
+
return "interrupted"
|
|
235
|
+
return status
|
|
236
|
+
finally:
|
|
237
|
+
self.busy = False
|
|
238
|
+
self.busy_since = None
|
|
239
|
+
self._current = None
|
|
240
|
+
self._input_open = False
|
|
241
|
+
self._debug_open = False
|
|
242
|
+
self._debug_pending = []
|
|
243
|
+
|
|
244
|
+
async def request(self, op: str, spec: dict) -> dict:
|
|
245
|
+
"""A request answered by the kernel — `view`, `colstats`, `env`, `rm`.
|
|
246
|
+
|
|
247
|
+
It runs in the evaluating session because that is where the objects
|
|
248
|
+
are, which is exactly why R routes `view`/`colstats` to `worker.R`. It
|
|
249
|
+
therefore QUEUES behind a running cell, which is R's behaviour too.
|
|
250
|
+
|
|
251
|
+
The wire never carries code. `spec` is JSON, passed as ONE argument to
|
|
252
|
+
a function the bootstrap installed; the kernel-side half parses it. A
|
|
253
|
+
composed `__carmapy_view(df, sort='...')` string would be the browser
|
|
254
|
+
writing Python, which is the door this design does not have.
|
|
255
|
+
"""
|
|
256
|
+
async with self._chan:
|
|
257
|
+
self._reply = None
|
|
258
|
+
payload = json.dumps(json.dumps(spec, allow_nan=False))
|
|
259
|
+
code = f"__carmapy_reply({op!r}, {payload})"
|
|
260
|
+
msg_id = self.kc.execute(code, store_history=False, allow_stdin=False)
|
|
261
|
+
await self._drain_until_idle(msg_id, None, None, timeout=300)
|
|
262
|
+
reply, self._reply = self._reply, None
|
|
263
|
+
if reply is None:
|
|
264
|
+
return {"error": "the session returned no answer"}
|
|
265
|
+
return reply
|
|
266
|
+
|
|
267
|
+
async def _drain_until_idle(self, msg_id: str, emit: Emit | None,
|
|
268
|
+
wire_id: str | None, timeout: float = 86400) -> str:
|
|
269
|
+
"""Consume iopub until idle for THIS parent, and the shell reply.
|
|
270
|
+
|
|
271
|
+
Both conditions. See the module docstring — this is the P1 rule and
|
|
272
|
+
the single most important line of behaviour in the file.
|
|
273
|
+
"""
|
|
274
|
+
status = "ok"
|
|
275
|
+
idle = False
|
|
276
|
+
reply = False
|
|
277
|
+
deadline = time.monotonic() + timeout
|
|
278
|
+
|
|
279
|
+
while time.monotonic() < deadline and not (idle and reply):
|
|
280
|
+
got = False
|
|
281
|
+
try:
|
|
282
|
+
m = await self.kc.get_iopub_msg(timeout=0.02)
|
|
283
|
+
got = True
|
|
284
|
+
if m.get("parent_header", {}).get("msg_id") == msg_id:
|
|
285
|
+
frames = self._translate(m, wire_id)
|
|
286
|
+
if emit:
|
|
287
|
+
for f in frames:
|
|
288
|
+
await emit(f)
|
|
289
|
+
if (m["header"]["msg_type"] == "status"
|
|
290
|
+
and m["content"]["execution_state"] == "idle"):
|
|
291
|
+
idle = True
|
|
292
|
+
except (queue.Empty, asyncio.TimeoutError):
|
|
293
|
+
pass
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
try:
|
|
297
|
+
r = await self.kc.get_shell_msg(timeout=0.01)
|
|
298
|
+
got = True
|
|
299
|
+
if r.get("parent_header", {}).get("msg_id") == msg_id:
|
|
300
|
+
status = r["content"].get("status", "ok")
|
|
301
|
+
reply = True
|
|
302
|
+
except (queue.Empty, asyncio.TimeoutError):
|
|
303
|
+
pass
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
# The stdin channel, drained in the SAME loop. A question asked
|
|
307
|
+
# while a cell runs is answered by another task (`answer_input`),
|
|
308
|
+
# so this must never block waiting for it.
|
|
309
|
+
if self._on_input is not None and not self._input_open and not self._debug_open:
|
|
310
|
+
try:
|
|
311
|
+
s = await self.kc.get_stdin_msg(timeout=0.01)
|
|
312
|
+
got = True
|
|
313
|
+
asked = s["header"]["msg_type"] == "input_request" \
|
|
314
|
+
and s.get("parent_header", {}).get("msg_id") == msg_id
|
|
315
|
+
prompt = s["content"].get("prompt", "") if asked else ""
|
|
316
|
+
if asked and prompt.startswith(DEBUG_PROMPT) and self._on_debug is not None:
|
|
317
|
+
self._debug_open = True
|
|
318
|
+
await self._on_debug(prompt[len(DEBUG_PROMPT):], wire_id)
|
|
319
|
+
if self._debug_pending:
|
|
320
|
+
self._deliver_debug(self._debug_pending.pop(0))
|
|
321
|
+
elif asked:
|
|
322
|
+
self._input_open = True
|
|
323
|
+
await self._on_input(prompt, wire_id)
|
|
324
|
+
except (queue.Empty, asyncio.TimeoutError):
|
|
325
|
+
pass
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
if not got:
|
|
329
|
+
await asyncio.sleep(0) # yield to the event loop
|
|
330
|
+
return status
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def input_open(self) -> bool:
|
|
334
|
+
return self._input_open
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def current(self) -> str | None:
|
|
338
|
+
"""The wire id of the cell running right now, or None."""
|
|
339
|
+
return self._current
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def debug_open(self) -> bool:
|
|
343
|
+
"""Is the running chunk paused in the debugger right now?"""
|
|
344
|
+
return self._debug_open
|
|
345
|
+
|
|
346
|
+
def answer_debug(self, command: dict) -> bool:
|
|
347
|
+
"""Deliver one debugger command to the paused chunk — now if it is at
|
|
348
|
+
its prompt, at its next prompt if it is between two. False when no
|
|
349
|
+
chunk is running: a step for a run that already finished does nothing.
|
|
350
|
+
"""
|
|
351
|
+
if self.kc is None or self._current is None:
|
|
352
|
+
return False
|
|
353
|
+
if not self._debug_open:
|
|
354
|
+
self._debug_pending.append(command)
|
|
355
|
+
return True
|
|
356
|
+
self._deliver_debug(command)
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
def _deliver_debug(self, command: dict) -> None:
|
|
360
|
+
self.kc.input(json.dumps(command, separators=(",", ":")))
|
|
361
|
+
self._debug_open = False
|
|
362
|
+
|
|
363
|
+
def answer_input(self, value: str) -> bool:
|
|
364
|
+
"""Deliver one line to a waiting `input()`. False when nothing asked."""
|
|
365
|
+
if not self._input_open or self.kc is None:
|
|
366
|
+
return False
|
|
367
|
+
self.kc.input(value)
|
|
368
|
+
self._input_open = False
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
def _translate(self, m: dict, wire_id: str | None) -> list[dict]:
|
|
372
|
+
"""One Jupyter message -> zero or more Beatrix frames."""
|
|
373
|
+
t = m["header"]["msg_type"]
|
|
374
|
+
c = m["content"]
|
|
375
|
+
|
|
376
|
+
if t == "stream":
|
|
377
|
+
kind = "stderr" if c.get("name") == "stderr" else "stdout"
|
|
378
|
+
text = c.get("text", "")
|
|
379
|
+
# ipykernel 7 reports a Stop TWO ways and the choice is not ours:
|
|
380
|
+
# an `error` message with ename KeyboardInterrupt when the signal
|
|
381
|
+
# lands inside the user's frame, and a bare "\nKeyboardInterrupt"
|
|
382
|
+
# written to stderr when it lands while the previous one is still
|
|
383
|
+
# unwinding (measured 2026-09-14 — the interrupt ladder re-sends at
|
|
384
|
+
# +60 ms, so the second shape is the common one). R prints NOTHING
|
|
385
|
+
# when a chunk is interrupted, so neither does this: the word alone,
|
|
386
|
+
# while a Stop this worker sent is outstanding, is the acknowledgement
|
|
387
|
+
# and not output the user wrote.
|
|
388
|
+
if kind == "stderr" and self.interrupted \
|
|
389
|
+
and set(text.split()) <= {"KeyboardInterrupt"}:
|
|
390
|
+
return []
|
|
391
|
+
# The page accumulates and joins on "\n"; keep R's line semantics.
|
|
392
|
+
return [{"type": kind, "id": wire_id, "text": text.rstrip("\n")}]
|
|
393
|
+
|
|
394
|
+
if t == "error":
|
|
395
|
+
ename, evalue = c.get("ename") or "Error", c.get("evalue")
|
|
396
|
+
headline = f"{ename}: {evalue}" if evalue else str(ename)
|
|
397
|
+
# A KeyboardInterrupt is STOP, not a failure. R's worker ends an
|
|
398
|
+
# interrupted chunk with `done {status:"interrupted"}` and prints
|
|
399
|
+
# no traceback (measured against the R worker, 2026-09-14), and a
|
|
400
|
+
# page that showed a stack trace every time someone pressed Stop
|
|
401
|
+
# would be reporting a bug that does not exist.
|
|
402
|
+
if ename == "KeyboardInterrupt":
|
|
403
|
+
self.interrupted = True
|
|
404
|
+
return []
|
|
405
|
+
# The debugger's Stop: R reports it as `interrupted` with the
|
|
406
|
+
# message "Stopped from the debugger" (worker.R carmar_abort_cell),
|
|
407
|
+
# and so does this — no traceback for a button the user pressed.
|
|
408
|
+
if ename == DEBUG_ABORT:
|
|
409
|
+
self.interrupted = True
|
|
410
|
+
self.failure = "Stopped from the debugger"
|
|
411
|
+
return []
|
|
412
|
+
self.failure = headline
|
|
413
|
+
tb = "\n".join(c.get("traceback", []))
|
|
414
|
+
# worker.R's shape, measured: the text of a failure reaches the
|
|
415
|
+
# page as `stderr`, and ONE `traceback` frame carries the headline
|
|
416
|
+
# and the line. `{"type":"error"}` is not in src/r-kernel.js's
|
|
417
|
+
# switch and would be silently dropped.
|
|
418
|
+
return [
|
|
419
|
+
{"type": "stderr", "id": wire_id, "text": tb or headline},
|
|
420
|
+
{"type": "traceback", "id": wire_id, "message": headline,
|
|
421
|
+
"line": _error_line(c), "call": ename, "frames": []},
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
if t in ("execute_result", "display_data"):
|
|
425
|
+
return self._from_bundle(c.get("data", {}) or {}, wire_id,
|
|
426
|
+
c.get("metadata") or {})
|
|
427
|
+
|
|
428
|
+
return []
|
|
429
|
+
|
|
430
|
+
def _from_bundle(self, data: dict, wire_id: str | None, meta: dict | None = None) -> list[dict]:
|
|
431
|
+
"""The MIME bundle, richest representation first.
|
|
432
|
+
|
|
433
|
+
Order matters: a DataFrame also carries text/html and text/plain, and
|
|
434
|
+
emitting all three would paint the same value three times.
|
|
435
|
+
"""
|
|
436
|
+
# The kernel's two private MIMEs are read FIRST and produce no frame.
|
|
437
|
+
# A viewer answer belongs to the caller waiting on it, not to the
|
|
438
|
+
# notebook's output pane, and the cwd is state the worker records.
|
|
439
|
+
if CONTROL_MIME in data:
|
|
440
|
+
payload = data[CONTROL_MIME]
|
|
441
|
+
if isinstance(payload, str):
|
|
442
|
+
payload = json.loads(payload)
|
|
443
|
+
if isinstance(payload.get("cwd"), str):
|
|
444
|
+
self.cwd = payload["cwd"]
|
|
445
|
+
return []
|
|
446
|
+
|
|
447
|
+
if REPLY_MIME in data:
|
|
448
|
+
payload = data[REPLY_MIME]
|
|
449
|
+
self._reply = json.loads(payload) if isinstance(payload, str) else payload
|
|
450
|
+
return []
|
|
451
|
+
|
|
452
|
+
if CARMAR_MIME in data:
|
|
453
|
+
payload = data[CARMAR_MIME]
|
|
454
|
+
if isinstance(payload, str):
|
|
455
|
+
payload = json.loads(payload)
|
|
456
|
+
return [{"type": "dataframe", "id": wire_id, "source": None, **payload}]
|
|
457
|
+
|
|
458
|
+
# The page sizes a figure by pixels ÷ (res ÷ 96), so `res` is not
|
|
459
|
+
# optional — a 1500px plot at 300 dpi is a five-inch figure. Three
|
|
460
|
+
# sources, best first: the message's own metadata, the dims the chunk
|
|
461
|
+
# asked for, then the page's defaults.
|
|
462
|
+
png_meta = (meta or {}).get("image/png") or {}
|
|
463
|
+
dims = self.dims or {}
|
|
464
|
+
|
|
465
|
+
def _size(key, fallback):
|
|
466
|
+
for src in (png_meta, dims):
|
|
467
|
+
v = src.get(key)
|
|
468
|
+
if isinstance(v, (int, float)) and v > 0:
|
|
469
|
+
return v
|
|
470
|
+
return fallback
|
|
471
|
+
|
|
472
|
+
if "image/png" in data:
|
|
473
|
+
return [{"type": "plot", "id": wire_id, "mime": "image/png",
|
|
474
|
+
"width": _size("width", 672), "height": _size("height", 480),
|
|
475
|
+
"res": _size("res", 96),
|
|
476
|
+
"data": data["image/png"].replace("\n", "")}]
|
|
477
|
+
|
|
478
|
+
if "image/svg+xml" in data:
|
|
479
|
+
return [{"type": "plot", "id": wire_id, "mime": "image/svg+xml",
|
|
480
|
+
"width": _size("width", 672), "height": _size("height", 480),
|
|
481
|
+
"res": _size("res", 96),
|
|
482
|
+
"data": data["image/svg+xml"]}]
|
|
483
|
+
|
|
484
|
+
if "text/html" in data:
|
|
485
|
+
return [{"type": "widget", "id": wire_id, "kind": "html",
|
|
486
|
+
"class": "html", "html": data["text/html"]}]
|
|
487
|
+
|
|
488
|
+
if "text/plain" in data:
|
|
489
|
+
return [{"type": "stdout", "id": wire_id, "text": data["text/plain"]}]
|
|
490
|
+
|
|
491
|
+
return []
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------- control
|
|
494
|
+
|
|
495
|
+
async def interrupt(self) -> None:
|
|
496
|
+
"""Stop.
|
|
497
|
+
|
|
498
|
+
`interrupt_mode` is `signal` for the stock python3 kernelspec, so this
|
|
499
|
+
is a SIGINT to the kernel process — which jupyter_client started in its
|
|
500
|
+
own session (measured 2026-09-14: kernel sid == kernel pid), so the
|
|
501
|
+
host's group signal to this worker can never reach it. That is exactly
|
|
502
|
+
why the worker catches SIGINT and calls this.
|
|
503
|
+
"""
|
|
504
|
+
if self.km is not None:
|
|
505
|
+
await self.km.interrupt_kernel()
|
|
506
|
+
|
|
507
|
+
async def force_stop(self) -> None:
|
|
508
|
+
"""The second button. A C extension that neither releases the GIL nor
|
|
509
|
+
checks signals cannot be interrupted by any message — only killed."""
|
|
510
|
+
if self.km is not None:
|
|
511
|
+
await self.km.shutdown_kernel(now=True)
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------- intelligence
|
|
514
|
+
|
|
515
|
+
async def complete(self, line: str, cursor: int) -> dict | None:
|
|
516
|
+
"""LIVE completion — the tier a static analyser cannot serve.
|
|
517
|
+
|
|
518
|
+
Returns None rather than waiting when the kernel is busy. That guard is
|
|
519
|
+
MANDATORY, not an optimisation: carmapy's P3 measured
|
|
520
|
+
`complete_request` getting no answer in 3.0 s while a cell ran, because
|
|
521
|
+
it rides the shell channel and is served in order. Without this, every
|
|
522
|
+
keystroke pause during a long run buys a hang.
|
|
523
|
+
|
|
524
|
+
The backoff always heals on its own and never latches until a reload —
|
|
525
|
+
R learned that the hard way when a four-second timeout marked
|
|
526
|
+
completion unsupported for the rest of the page.
|
|
527
|
+
"""
|
|
528
|
+
# `_chan.locked()` as well as `busy`: a `view` on a million-row frame
|
|
529
|
+
# holds the channel without being a running CELL, and a completion that
|
|
530
|
+
# read the shell channel underneath it would steal the viewer's reply.
|
|
531
|
+
if self.busy or self._chan.locked() or time.monotonic() < self._retry_after:
|
|
532
|
+
return None
|
|
533
|
+
msg_id = self.kc.complete(line, cursor)
|
|
534
|
+
deadline = time.monotonic() + COMPLETE_TIMEOUT
|
|
535
|
+
while time.monotonic() < deadline:
|
|
536
|
+
try:
|
|
537
|
+
r = await self.kc.get_shell_msg(timeout=0.05)
|
|
538
|
+
except (queue.Empty, asyncio.TimeoutError):
|
|
539
|
+
await asyncio.sleep(0)
|
|
540
|
+
continue
|
|
541
|
+
except Exception:
|
|
542
|
+
break
|
|
543
|
+
if r.get("parent_header", {}).get("msg_id") == msg_id:
|
|
544
|
+
c = r["content"]
|
|
545
|
+
self._retry_after = 0.0
|
|
546
|
+
return {"start": c.get("cursor_start", cursor),
|
|
547
|
+
"end": c.get("cursor_end", cursor),
|
|
548
|
+
"matches": c.get("matches", [])}
|
|
549
|
+
self._retry_after = time.monotonic() + BUSY_BACKOFF
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _error_line(content: dict):
|
|
554
|
+
"""The 1-based line inside THE CHUNK THAT RAN, or None.
|
|
555
|
+
|
|
556
|
+
ipykernel's traceback frames for a cell carry the line as
|
|
557
|
+
`Cell In[3], line 4`, or as a bare `----> 4` arrow. Two things the first
|
|
558
|
+
version missed, both measured 2026-09-14 against IPython 9.11:
|
|
559
|
+
|
|
560
|
+
* The text is ANSI-COLOURED — `Cell`, `In[3]` and `, line 4` each sit in
|
|
561
|
+
their own escape sequence — so neither pattern matched anything and
|
|
562
|
+
every Python traceback reached the page with `line: null`. The escapes
|
|
563
|
+
are stripped before reading.
|
|
564
|
+
* The FIRST cell frame is the chunk that ran; later ones are deeper
|
|
565
|
+
calls, possibly into a function another chunk defined. R's `line` is
|
|
566
|
+
the line within the running chunk (worker.R `at_line`), so it is the
|
|
567
|
+
first match, not the last.
|
|
568
|
+
"""
|
|
569
|
+
import re
|
|
570
|
+
text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", "\n".join(content.get("traceback", [])))
|
|
571
|
+
hits = re.findall(r"[Cc]ell In\[\d+\],\s*line\s*(\d+)", text)
|
|
572
|
+
if not hits:
|
|
573
|
+
hits = re.findall(r"-+>\s*(\d+)", text)
|
|
574
|
+
try:
|
|
575
|
+
return int(hits[0]) if hits else None
|
|
576
|
+
except Exception:
|
|
577
|
+
return None
|