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
+108
View File
@@ -27,6 +27,36 @@ export const AlertsConfigSchema = {
description: 'The whole alerting setup, as stored and as the API sees it.'
} as const;
export const ApplyRequestSchema = {
properties: {
requirements: {
type: 'string',
title: 'Requirements',
default: ''
}
},
type: 'object',
title: 'ApplyRequest',
description: 'A pip manifest, one requirement per line.'
} as const;
export const ApplyResultSchema = {
properties: {
ok: {
type: 'boolean',
title: 'Ok'
},
output: {
type: 'string',
title: 'Output',
default: ''
}
},
type: 'object',
required: ['ok'],
title: 'ApplyResult'
} as const;
export const Body_login_login_access_tokenSchema = {
properties: {
grant_type: {
@@ -890,6 +920,58 @@ export const MessagesPublicSchema = {
title: 'MessagesPublic'
} as const;
export const ModulePackageSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
version: {
type: 'string',
title: 'Version'
}
},
type: 'object',
required: ['name', 'version'],
title: 'ModulePackage',
description: 'One package installed in the venv node code runs on.'
} as const;
export const ModulesInfoSchema = {
properties: {
python_version: {
type: 'string',
title: 'Python Version',
default: ''
},
venv_path: {
type: 'string',
title: 'Venv Path',
default: ''
},
requirements: {
type: 'string',
title: 'Requirements',
default: ''
},
packages: {
items: {
'$ref': '#/components/schemas/ModulePackage'
},
type: 'array',
title: 'Packages'
},
applied: {
type: 'boolean',
title: 'Applied',
default: false
}
},
type: 'object',
title: 'ModulesInfo',
description: 'The venv node code imports from, and the manifest that describes it.'
} as const;
export const NewPasswordSchema = {
properties: {
token: {
@@ -960,6 +1042,19 @@ export const NodeDef_InputSchema = {
}
],
title: 'Source Ref'
},
timeout: {
anyOf: [
{
type: 'number',
exclusiveMinimum: 0
},
{
type: 'null'
}
],
title: 'Timeout',
description: "Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running."
}
},
type: 'object',
@@ -1020,6 +1115,19 @@ export const NodeDef_OutputSchema = {
}
],
title: 'Source Ref'
},
timeout: {
anyOf: [
{
type: 'number',
exclusiveMinimum: 0
},
{
type: 'null'
}
],
title: 'Timeout',
description: "Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running."
}
},
type: 'object',
+65 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
export class AlertsService {
/**
@@ -670,6 +670,32 @@ export class FlowsService {
});
}
/**
* Cancel Node
* Stop a node that is running right now, by killing the worker running it.
*
* Idempotent on purpose: by the time a click reaches here the node may well
* have finished, and that is the outcome that was asked for.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
* @returns Message Successful Response
* @throws ApiError
*/
public static cancelNode(data: FlowsCancelNodeData): CancelablePromise<FlowsCancelNodeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/nodes/{node_id}/cancel',
path: {
name: data.name,
node_id: data.nodeId
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Flow State
* The last value seen on every message of this flow.
@@ -878,6 +904,44 @@ export class MessagesService {
}
}
export class ModulesService {
/**
* Read Modules
* What node code can import, and whether it matches the manifest.
* @returns ModulesInfo Successful Response
* @throws ApiError
*/
public static readModules(): CancelablePromise<ModulesReadModulesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/modules/'
});
}
/**
* Apply Modules
* Install exactly these requirements, then hand them to the workers.
*
* A manifest that does not resolve changes nothing: the venv is left as it
* was and the stored manifest is only written once the install succeeded.
* @param data The data for the request.
* @param data.requestBody
* @returns ApplyResult Successful Response
* @throws ApiError
*/
public static applyModules(data: ModulesApplyModulesData): CancelablePromise<ModulesApplyModulesResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/modules/apply',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
}
export class OauthService {
/**
* Register Client
+54
View File
@@ -43,6 +43,18 @@ export type app__flow__schemas__MessageValue = {
ts?: (number | null);
};
/**
* A pip manifest, one requirement per line.
*/
export type ApplyRequest = {
requirements?: string;
};
export type ApplyResult = {
ok: boolean;
output?: string;
};
export type Body_login_login_access_token = {
grant_type?: (string | null);
username: string;
@@ -308,6 +320,25 @@ export type MessagesPublic = {
count: number;
};
/**
* One package installed in the venv node code runs on.
*/
export type ModulePackage = {
name: string;
version: string;
};
/**
* The venv node code imports from, and the manifest that describes it.
*/
export type ModulesInfo = {
python_version?: string;
venv_path?: string;
requirements?: string;
packages?: Array<ModulePackage>;
applied?: boolean;
};
export type NewPassword = {
token: string;
new_password: string;
@@ -327,6 +358,10 @@ export type NodeDef_Input = {
requires?: Array<MessageSpec>;
provides?: Array<MessageSpec>;
source_ref?: (string | null);
/**
* Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running.
*/
timeout?: (number | null);
};
/**
@@ -343,6 +378,10 @@ export type NodeDef_Output = {
requires?: Array<MessageSpec>;
provides?: Array<MessageSpec>;
source_ref?: (string | null);
/**
* Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running.
*/
timeout?: (number | null);
};
/**
@@ -805,6 +844,13 @@ export type FlowsTriggerNodeData = {
export type FlowsTriggerNodeResponse = (FlowStatePublic);
export type FlowsCancelNodeData = {
name: string;
nodeId: string;
};
export type FlowsCancelNodeResponse = (Message);
export type FlowsReadFlowStateData = {
name: string;
};
@@ -859,6 +905,14 @@ export type MessagesReadMessageHistoryData = {
export type MessagesReadMessageHistoryResponse = (MessagePoints);
export type ModulesReadModulesResponse = (ModulesInfo);
export type ModulesApplyModulesData = {
requestBody: ApplyRequest;
};
export type ModulesApplyModulesResponse = (ApplyResult);
export type OauthRegisterClientData = {
requestBody: OAuthClientRegister;
};
+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" },
]
+21
View File
@@ -20,6 +20,7 @@ import { Route as ViewNameRouteImport } from './routes/view.$name'
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
@@ -80,6 +81,11 @@ const LayoutSecretsRoute = LayoutSecretsRouteImport.update({
path: '/secrets',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutModulesRoute = LayoutModulesRouteImport.update({
id: '/modules',
path: '/modules',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAlertsRoute = LayoutAlertsRouteImport.update({
id: '/alerts',
path: '/alerts',
@@ -119,6 +125,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/alerts': typeof LayoutAlertsRoute
'/modules': typeof LayoutModulesRoute
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
@@ -136,6 +143,7 @@ export interface FileRoutesByTo {
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/alerts': typeof LayoutAlertsRoute
'/modules': typeof LayoutModulesRoute
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
@@ -155,6 +163,7 @@ export interface FileRoutesById {
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
'/_layout/alerts': typeof LayoutAlertsRoute
'/_layout/modules': typeof LayoutModulesRoute
'/_layout/secrets': typeof LayoutSecretsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
| '/signup'
| '/admin'
| '/alerts'
| '/modules'
| '/secrets'
| '/settings'
| '/oauth/authorize'
@@ -192,6 +202,7 @@ export interface FileRouteTypes {
| '/signup'
| '/admin'
| '/alerts'
| '/modules'
| '/secrets'
| '/settings'
| '/oauth/authorize'
@@ -210,6 +221,7 @@ export interface FileRouteTypes {
| '/signup'
| '/_layout/admin'
| '/_layout/alerts'
| '/_layout/modules'
| '/_layout/secrets'
| '/_layout/settings'
| '/oauth/authorize'
@@ -311,6 +323,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutSecretsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/modules': {
id: '/_layout/modules'
path: '/modules'
fullPath: '/modules'
preLoaderRoute: typeof LayoutModulesRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/alerts': {
id: '/_layout/alerts'
path: '/alerts'
@@ -372,6 +391,7 @@ const CanvasRouteWithChildren =
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
LayoutAlertsRoute: typeof LayoutAlertsRoute
LayoutModulesRoute: typeof LayoutModulesRoute
LayoutSecretsRoute: typeof LayoutSecretsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
@@ -382,6 +402,7 @@ interface LayoutRouteChildren {
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
LayoutAlertsRoute: LayoutAlertsRoute,
LayoutModulesRoute: LayoutModulesRoute,
LayoutSecretsRoute: LayoutSecretsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
+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>
)
}