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
+146
View File
@@ -0,0 +1,146 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Package } from "lucide-react"
import { useState } from "react"
import { type ApiError, ModulesService } from "@/client"
import { Button } from "@/components/ui/button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/modules")({
component: Modules,
head: () => ({
meta: [
{
title: "Modules - Fluksio",
},
],
}),
})
const modulesKey = ["modules"]
const SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
function Modules() {
const { data } = useQuery({
queryKey: modulesKey,
queryFn: () => ModulesService.readModules(),
})
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
// Null until the field is touched, so what is stored shows through until
// someone actually starts editing it.
const [draft, setDraft] = useState<string | null>(null)
const [output, setOutput] = useState("")
const apply = useMutation({
mutationFn: (requirements: string) =>
ModulesService.applyModules({ requestBody: { requirements } }),
onSuccess: (result) => {
showSuccessToast("Modules installed")
setOutput(result.output ?? "")
setDraft(null)
queryClient.invalidateQueries({ queryKey: modulesKey })
},
onError: (error: ApiError) => {
// The 400 detail is uv's own output — several lines of resolver
// reasoning, which belongs in the pane rather than in a toast.
const detail = (error.body as { detail?: string } | undefined)?.detail
if (detail) {
setOutput(detail)
showErrorToast("Those requirements could not be installed")
} else {
handleError.call(showErrorToast, error)
}
},
})
const stored = data?.requirements ?? ""
const requirements = draft ?? stored
const packages = data?.packages ?? []
const dirty = requirements !== stored
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Modules</h1>
<p className="text-sm text-muted-foreground">
The Python packages your function nodes can import. They are installed
into an environment of their own, separate from the engine's, so a
version you pin here is the one your code gets. The list is kept with
your flows, so a rebuilt deployment installs the same set again.
</p>
</div>
<section className="grid gap-3">
<h2 className={SECTION}>Requirements</h2>
<textarea
className="min-h-[160px] w-full rounded-md border border-border bg-card p-3 font-mono text-sm shadow-e1 outline-none focus-visible:border-primary"
spellCheck={false}
aria-label="Requirements"
data-testid="requirements"
placeholder={"requests==2.32.3\npandas>=2.2"}
value={requirements}
onChange={(event) => setDraft(event.target.value)}
/>
<div className="flex items-center gap-3">
<Button
disabled={apply.isPending}
data-testid="apply-modules"
onClick={() => apply.mutate(requirements)}
>
<Package />
{apply.isPending ? "Installing" : "Apply"}
</Button>
<span className="text-sm text-muted-foreground">
{dirty
? "Not applied yet."
: data?.applied
? "Installed and in step."
: "What is installed does not match this list."}
</span>
</div>
{output ? (
<pre
className="max-h-64 overflow-auto rounded-md border border-border bg-card p-3 font-mono text-xs shadow-e1"
data-testid="modules-output"
>
{output}
</pre>
) : null}
</section>
<section className="grid gap-3">
<h2 className={SECTION}>Installed</h2>
<p className="text-sm text-muted-foreground">
{data
? `Python ${data.python_version || "unknown"} at ${data.venv_path}`
: "Reading the environment…"}
</p>
{packages.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing installed yet node code has the standard library.
</p>
) : (
<div className="grid gap-2">
{packages.map((entry) => (
<div
key={entry.name}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
data-testid="module-row"
>
<span className="font-mono text-sm">{entry.name}</span>
<span className="font-mono text-sm text-muted-foreground">
{entry.version}
</span>
</div>
))}
</div>
)}
</section>
</div>
)
}