Files
app/backend/app/flow/worker_main.py
T
stroblmeandClaude Fable 5 f300c43f3a Run python nodes out of process, with modules of their own
User code no longer execs in the engine. A pool of persistent worker
subprocesses speaks one JSON object per line; the controller installs a
proxy as the node's function, so every execution path funnels through it
and the pipeline is untouched. A crash costs one subprocess, a per-node
timeout is a kill, and cancelling from the canvas is that same kill.

The workers run a venv of the user's own on the data volume, filled from
a pip manifest versioned beside the flows. Applying it retires the
workers and rebuilds, so a package lands without restarting the engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 21:43:36 +02:00

167 lines
5.6 KiB
Python

"""One user-code process: compiles a node's source once, then runs it on demand.
This runs under the *user* venv's interpreter, so a node's imports resolve
against what the Modules page installed rather than against the engine's own
packages. Nothing of the app is importable here — the file is handed to the
interpreter by path and is deliberately standard library only.
The protocol is one JSON object per line: a request arrives on stdin, the reply
goes out on a private duplicate of fd 1 taken before anything else can write to
it. fd 1 itself is pointed at stderr, so a stray ``write(1, ...)`` — from a
native library, or a node printing during an import — lands in the server log
instead of corrupting the reply stream.
"""
from __future__ import annotations
import contextlib
import hashlib
import io
import json
import os
import sys
import traceback
from collections.abc import Callable
from types import ModuleType
from typing import Any, cast
#: Enough of a print to debug with. A node printing in a loop must not fill the
#: pipe or the reply.
MAX_LOG = 16 * 1024
def load_function(flow: str, node_id: str, code: str) -> Callable[..., Any]:
"""Compile a node's source and return the function to run.
A node file defines ``process(...)``; if it defines exactly one public
function under another name, that one is used.
"""
digest = hashlib.md5(code.encode()).hexdigest()[:8]
module_name = f"_fluksio_node_{flow}_{node_id}_{digest}"
module = ModuleType(module_name)
module.__dict__["__name__"] = module_name
sys.modules[module_name] = module
exec(compile(code, f"<node {flow}.{node_id}>", "exec"), module.__dict__)
if callable(getattr(module, "process", None)):
return cast(Callable[..., Any], module.process)
functions = [
value
for name, value in vars(module).items()
if callable(value)
and not name.startswith("_")
and getattr(value, "__module__", None) == module_name
]
if len(functions) == 1:
return cast(Callable[..., Any], functions[0])
raise ValueError(
"Define a function named 'process' — this file has "
f"{len(functions)} functions to choose from."
)
class _Capped(io.StringIO):
"""Keeps the first ``MAX_LOG`` characters and quietly drops the rest."""
def write(self, text: str) -> int:
room = MAX_LOG - self.tell()
if room > 0:
super().write(text[:room])
return len(text)
def _short_error(exc: BaseException) -> str:
"""One line a node author can act on — the engine's rule, applied here."""
if isinstance(exc, SyntaxError):
# Its own message already names the compiled file, which is noise here.
return f"{type(exc).__name__}: {exc.msg} (line {exc.lineno})"
frames = [
frame
for frame in traceback.extract_tb(exc.__traceback__)
if frame.filename.startswith("<node ")
]
where = f" (line {frames[-1].lineno})" if frames else ""
return f"{type(exc).__name__}: {exc}{where}"
def _node_traceback(exc: BaseException) -> str:
"""The traceback from the node's own code onward; the rest is this loop."""
frames = traceback.extract_tb(exc.__traceback__)
start = next(
(i for i, frame in enumerate(frames) if frame.filename.startswith("<node ")),
0,
)
return "".join(
["Traceback (most recent call last):\n"]
+ traceback.format_list(frames[start:])
+ traceback.format_exception_only(type(exc), exc)
)
def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
"""Compile if needed, then run — the part that may raise the user's error."""
flow, node = request["flow"], request["node"]
source = request.get("source") or ""
digest = hashlib.md5(source.encode()).hexdigest()
cached = cache.get((flow, node))
if cached is None or cached[0] != digest:
cache[(flow, node)] = (digest, load_function(flow, node, source))
function = cache[(flow, node)][1]
if request["op"] == "compile":
return None
result = function(
**(request.get("kwargs") or {}), params=request.get("params") or {}
)
try:
json.dumps(result)
except (TypeError, ValueError):
# The typed-message contract only carries JSON, and a node running out
# of process is where that stops being a formality.
raise ValueError(
f"returned {type(result).__name__}, which cannot be sent back as "
"JSON — return numbers, strings, booleans, lists or dicts."
) from None
return result
def main() -> None:
rpc = os.fdopen(os.dup(1), "w")
# Everything the node writes to the real stdout now goes to the server log.
os.dup2(2, 1)
cache: dict[tuple[str, str], Any] = {}
for line in sys.stdin:
if not line.strip():
continue
request = json.loads(line)
captured = _Capped()
response: dict[str, Any] = {"id": request.get("id")}
try:
with (
contextlib.redirect_stdout(captured),
contextlib.redirect_stderr(captured),
):
response["result"] = _handle(request, cache)
response["ok"] = True
except Exception as exc:
response["ok"] = False
response["error"] = {
"type": type(exc).__name__,
"message": str(exc),
"short": _short_error(exc),
"traceback": _node_traceback(exc),
}
response["logs"] = captured.getvalue()
rpc.write(json.dumps(response) + "\n")
rpc.flush()
if __name__ == "__main__":
main()