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
This commit is contained in:
2026-08-16 21:43:36 +02:00
co-authored by Claude Fable 5
parent 979c9d3c1f
commit f300c43f3a
27 changed files with 1536 additions and 46 deletions
+28 -1
View File
@@ -15,12 +15,13 @@ import {
Radio,
Shuffle,
Split,
Square,
Terminal,
Timer,
} from "lucide-react"
import { memo } from "react"
import type { MessageSpec, NodeDef_Input } from "@/client"
import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Tooltip,
@@ -157,6 +158,32 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
{typeLabel}
</span>
</span>
{status === "running" ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Stop this node"
data-testid="node-cancel"
onClick={(event) => {
event.stopPropagation()
// Best effort by nature: it may well have finished between
// the render and the click, which is the outcome asked for.
FlowsService.cancelNode({
name: flow,
nodeId: definition.id,
}).catch(() => {})
}}
>
<Square />
</Button>
</TooltipTrigger>
<TooltipContent>Stop this node</TooltipContent>
</Tooltip>
) : null}
{status === "error" && onShowLogs ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -934,6 +934,32 @@ function PanelBody({
onChange={(params) => onChange({ ...node, params })}
/>
) : null}
{hasSource ? (
<div className="grid gap-1.5">
<Label htmlFor="node-timeout" className={SECTION}>
Timeout
</Label>
<Input
id="node-timeout"
type="number"
min={0}
step="any"
className="h-8 text-sm"
placeholder="30 (default)"
value={node.timeout ? String(node.timeout) : ""}
onChange={(event) =>
onChange({
...node,
timeout: Number(event.target.value) || null,
})
}
/>
<p className="text-xs text-muted-foreground">
Seconds this code may run before it is stopped. Above 60 the
engine may deliver the same work again while it is still running.
</p>
</div>
) : null}
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}
@@ -26,6 +26,7 @@ type FlowEvent =
ts: number
source?: ValueSource
}
| { type: "node_started"; node: string }
| { type: "node_executed"; node: string; outputs: number }
| { type: "node_error"; node: string; error: string }
| { type: "node_status"; node: string; status: string; error?: string | null }
@@ -97,6 +98,9 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
source: message.source,
})
break
case "node_started":
liveStore.setStatus(message.node, { status: "running" })
break
case "node_executed":
liveStore.setStatus(message.node, { status: "success" })
if (message.outputs > 0) liveStore.recordEmit(message.node)
@@ -4,6 +4,7 @@ import {
KeyRound,
LayoutDashboard,
LogOut,
Package,
Settings,
Users,
Workflow,
@@ -28,6 +29,7 @@ const baseItems: Item[] = [
// Both are engine-wide operator settings rather than personal ones, so they
// sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
{ icon: Package, title: "Modules", path: "/modules" },
{ icon: Bell, title: "Alerts", path: "/alerts" },
]