Start, stop and pause flows, and show what their nodes print

Flows can now be taken off the engine and put back. Stopped state lives in a
runtime.json beside the flow, not in the flow document: the canvas autosaves
that document, so a stopped flow would otherwise start itself again on the
next edit. A stopped flow gets no subscriptions, schedules or webhooks, its
nodes are skipped by the scheduler, and running it answers 409. Pausing holds
a flow's nodes while its values keep arriving, so the canvas still shows what
is coming in.

Node code is user code and print is how it says things, so stdout is teed
through a contextvar sink active only during a node execution — one event per
execution, capped, so a chatty node cannot outrun the stream. A node that
fails sends its traceback the same way, trimmed to the author's own frames.
The dock gains a logs panel and a pause control; the dashboard replaces its
placeholder with what is running, stopped or failing; the edge inspector can
send the last message again.

Single-stepping is deferred and noted: the scheduler keeps no progress between
calls, so a step button would re-run the same node rather than advance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-15 23:35:08 +02:00
co-authored by Claude Fable 5
parent 606ab3c423
commit 7344eac262
29 changed files with 1410 additions and 48 deletions
+137 -10
View File
@@ -1,6 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link } from "@tanstack/react-router"
import { AlertCircle, Workflow } from "lucide-react"
import { type FlowSummary, FlowsService } from "@/client"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
@@ -13,19 +22,137 @@ export const Route = createFileRoute("/_layout/")({
}),
})
function Dashboard() {
const { user: currentUser } = useAuth()
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
function Tile({ label, value }: { label: string; value: number }) {
return (
<Card className="gap-1 py-4">
<div className="px-5">
<p className="text-2xl font-semibold tabular-nums">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</Card>
)
}
function FlowRow({ flow }: { flow: FlowSummary }) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const enabled = flow.enabled ?? true
const toggle = useMutation({
mutationFn: (next: boolean) =>
next
? FlowsService.startFlow({ name: flow.name })
: FlowsService.stopFlow({ name: flow.name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) })
},
onError: () => showErrorToast("The flow could not be started or stopped."),
})
return (
<div>
<div>
<h1 className="text-2xl truncate max-w-sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋
</h1>
<p className="text-muted-foreground">
Welcome back, nice to see you again!!!
<div
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
data-testid="dashboard-flow-row"
>
<Link
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="min-w-0 flex-1"
>
<p className="truncate font-medium">{flow.title || flow.name}</p>
<p className="truncate text-sm text-muted-foreground">
{flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`}
{flow.has_draft ? " · unpublished changes" : ""}
</p>
</Link>
{(flow.error_count ?? 0) > 0 ? (
<Badge variant="destructive" className="gap-1">
<AlertCircle />
{flow.error_count}
</Badge>
) : null}
<Badge
variant="outline"
className={
enabled
? flow.paused
? "border-transparent bg-primary/15 text-primary"
: "border-transparent bg-status-success/15 text-status-success"
: "text-muted-foreground"
}
>
{enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"}
</Badge>
<Switch
checked={enabled}
disabled={toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-label={`Run ${flow.title || flow.name}`}
data-testid="flow-enabled-switch"
/>
</div>
)
}
function Dashboard() {
const { user: currentUser } = useAuth()
const { data, isPending } = useQuery({
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
const flows = data?.data ?? []
const running = flows.filter((flow) => flow.enabled ?? true).length
const failing = flows.filter((flow) => (flow.error_count ?? 0) > 0).length
return (
<div className="grid gap-6">
<div>
<h1 className="max-w-sm truncate text-2xl">
Hi, {currentUser?.full_name || currentUser?.email} 👋
</h1>
<p className="text-muted-foreground">
{flows.length === 0
? "No flows yet."
: `${running} of ${flows.length} flows are running.`}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Tile label="Flows" value={flows.length} />
<Tile label="Running" value={running} />
<Tile label="With errors" value={failing} />
</div>
<Card className="gap-0 py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
</div>
)
}