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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user