feat: enhance agent execution flow with multi-execution view and improved error handling

- Added multi-execution view to display multiple execution statuses.
- Improved error handling in agent triggering to surface failures.
- Updated execution view to support live updates via SSE and added a log panel for node logs.
- Enhanced pipeline canvas to display node statuses during execution.
- Introduced new fields in the build node form for Docker configurations.
- Updated API to support execution streaming and enhanced node catalog descriptions.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 0a1874e736
commit 0284ff768a
27 changed files with 2598 additions and 203 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { api, type ExecutionStatus, type Run } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export default function MultiExecutionPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading</div>}>
<MultiExecutionView />
</Suspense>
);
}
function MultiExecutionView() {
const params = useSearchParams();
const idsParam = params.get("ids") ?? "";
const ids = idsParam
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" asChild>
<Link href="/agents">
<ArrowLeft />
Back to agents
</Link>
</Button>
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{ids.length} runs dispatched
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Each card streams its own status. Click into one for the live canvas.
</p>
</div>
{ids.length === 0 ? (
<p className="text-sm text-muted-foreground">No execution IDs provided.</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{ids.map((id) => (
<RunCard key={id} id={id} />
))}
</div>
)}
</div>
);
}
function statusVariant(s?: string) {
switch ((s || "").toLowerCase()) {
case "success":
case "completed":
return "success" as const;
case "running":
case "pending":
return "secondary" as const;
case "failed":
case "error":
return "destructive" as const;
case "waiting":
case "paused":
return "warning" as const;
default:
return "outline" as const;
}
}
function RunCard({ id }: { id: string }) {
const [status, setStatus] = useState<ExecutionStatus | null>(null);
const [run, setRun] = useState<Run | null>(null);
async function refresh() {
try {
const [s, runs] = await Promise.all([
api.getExecution(id).catch(() => null),
api.listRuns({ limit: 100 }).catch(() => [] as Run[]),
]);
if (s) setStatus(s);
const r = runs.find((x) => x.id === id) ?? null;
setRun(r);
} catch {
// ignore
}
}
useEffect(() => {
refresh();
const t = setInterval(refresh, 3000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const s = (status?.status as string) || run?.status || "running";
return (
<Card className="transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">
{run?.pipelineName || "Pipeline"}
</CardTitle>
<CardDescription className="mt-0.5 truncate font-mono text-[10px]">
{id}
</CardDescription>
</div>
<Badge variant={statusVariant(s)}>{s}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
{run?.startedAt ? <>started {formatDate(run.startedAt)}</> : "—"}
</div>
<div className="flex items-center justify-between">
<Button size="sm" variant="ghost" onClick={refresh}>
<RefreshCw />
Refresh
</Button>
<Button size="sm" asChild>
<Link href={`/executions/view/?id=${encodeURIComponent(id)}`}>
Open
<ArrowRight />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
+401 -97
View File
@@ -1,8 +1,9 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { RefreshCw, Send } from "lucide-react";
import { ArrowLeft, RefreshCw, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -15,11 +16,32 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api, type ExecutionStatus } from "@/lib/api";
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
import { api, type ExecutionStatus, type Run } from "@/lib/api";
import type { PipelineDefinition } from "@/lib/pipeline-graph";
type NodeStatus = "pending" | "running" | "success" | "failed" | "paused";
type NodeStatuses = Record<string, NodeStatus>;
interface ExecutionEvent {
type: string;
node?: string;
node_type?: string;
status?: string;
content?: string; // for node_log events
outputs?: Record<string, unknown>;
error?: string;
duration_ms?: number;
}
type NodeLogLine = { ts: number; line: string };
type NodeLogs = Record<string, NodeLogLine[]>;
export default function ExecutionPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<Suspense
fallback={<div className="p-6 text-sm text-muted-foreground">Loading</div>}
>
<ExecutionView />
</Suspense>
);
@@ -49,34 +71,125 @@ function ExecutionView() {
const id = params.get("id") ?? "";
const [status, setStatus] = useState<ExecutionStatus | null>(null);
const [run, setRun] = useState<Run | null>(null);
const [pipelineDef, setPipelineDef] = useState<PipelineDefinition | null>(null);
const [nodeStatuses, setNodeStatuses] = useState<NodeStatuses>({});
const [events, setEvents] = useState<ExecutionEvent[]>([]);
const [nodeLogs, setNodeLogs] = useState<NodeLogs>({});
const [activeLogNode, setActiveLogNode] = useState<string | null>(null);
const [streamConnected, setStreamConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState(true);
const [tab, setTab] = useState<"canvas" | "json">("canvas");
// resume form
// Resume form
const [awakeable, setAwakeable] = useState("");
const [data, setData] = useState(`{"approved": true}`);
const [resuming, setResuming] = useState(false);
async function refresh() {
if (!id) return;
try {
const s = await api.getExecution(id);
setStatus(s);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
}
const eventSourceRef = useRef<EventSource | null>(null);
// Initial load: status + run record + pipeline def.
useEffect(() => {
refresh();
if (!polling) return;
if (!id) return;
(async () => {
try {
const [statusRes, runs] = await Promise.all([
api.getExecution(id).catch(() => null),
api.listRuns({ limit: 100 }).catch(() => [] as Run[]),
]);
if (statusRes) setStatus(statusRes);
const r = runs.find((x) => x.id === id) ?? null;
setRun(r);
if (r?.pipelineId) {
try {
const f = await api.getFlow(r.pipelineId);
setPipelineDef((f.definition as PipelineDefinition | null) ?? null);
} catch {
// Pipeline may have been deleted; render JSON view only.
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
})();
}, [id]);
// SSE subscription. On open, mark all known nodes as pending. Each
// node_started/completed/error event flips that node's status.
useEffect(() => {
if (!id) return;
const url = api.executionStreamURL(id);
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => setStreamConnected(true);
es.onerror = () => {
setStreamConnected(false);
// EventSource auto-reconnects on transient errors; only close on
// permanent ones. We log but don't bail.
};
es.onmessage = (msg) => {
try {
const ev: ExecutionEvent = JSON.parse(msg.data);
// Log lines are high-frequency — keep them out of the generic
// events array (used for the JSON debug view) and route into
// their own state instead.
if (ev.type === "node_log" && ev.node && typeof ev.content === "string") {
const node = ev.node;
setNodeLogs((prev) => {
const cur = prev[node] ?? [];
const next = cur.length >= 2000 ? cur.slice(-1999) : cur;
return {
...prev,
[node]: [...next, { ts: Date.now(), line: ev.content! }],
};
});
setActiveLogNode((prev) => prev ?? node);
return;
}
setEvents((prev) => [...prev.slice(-99), ev]);
if (ev.node) {
setNodeStatuses((prev) => {
const next: NodeStatus =
ev.type === "node_started"
? "running"
: ev.type === "node_completed"
? "success"
: ev.type === "node_error"
? "failed"
: (prev[ev.node!] ?? "pending");
return { ...prev, [ev.node!]: next };
});
if (ev.type === "node_started") {
setActiveLogNode(ev.node);
}
}
if (ev.type === "done") {
api.getExecution(id).then(setStatus).catch(() => {});
es.close();
setStreamConnected(false);
}
} catch {
// Ignore malformed messages.
}
};
return () => {
es.close();
eventSourceRef.current = null;
setStreamConnected(false);
};
}, [id]);
// Fallback polling for terminal status — useful if SSE failed to connect.
useEffect(() => {
if (!id || streamConnected) return;
const t = setInterval(() => {
refresh();
}, 2000);
api.getExecution(id).then(setStatus).catch(() => {});
}, 3000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, polling]);
}, [id, streamConnected]);
async function onResume() {
setResuming(true);
@@ -85,7 +198,8 @@ function ExecutionView() {
let payload: unknown = {};
if (data.trim()) payload = JSON.parse(data);
await api.resumeExecution(id, { awakeable_id: awakeable, data: payload });
await refresh();
const s = await api.getExecution(id).catch(() => null);
if (s) setStatus(s);
} catch (e) {
setError(e instanceof Error ? e.message : "resume failed");
} finally {
@@ -93,102 +207,292 @@ function ExecutionView() {
}
}
const overallStatus = useMemo(() => {
return (status?.status as string) || run?.status || "running";
}, [status, run]);
if (!id) {
return (
<div className="text-sm text-muted-foreground">
<div className="p-6 text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
const s = (status?.status as string) || "unknown";
const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase());
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-end gap-2">
<div className="flex h-screen flex-col">
{/* Toolbar */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
<Button variant="ghost" size="icon" asChild aria-label="Back">
<Link href="/">
<ArrowLeft className="size-4" />
</Link>
</Button>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Execution
</div>
<span className="font-mono text-xs">{id}</span>
<Badge variant={statusVariant(overallStatus)}>{overallStatus}</Badge>
{streamConnected ? (
<Badge variant="success" className="text-[10px]">
live
</Badge>
) : (
<Badge variant="outline" className="text-[10px]">
polling
</Badge>
)}
<div className="flex-1" />
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setTab("canvas")}
className={
"rounded-sm px-3 py-1 text-xs " +
(tab === "canvas"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
Canvas
</button>
<button
type="button"
onClick={() => setTab("json")}
className={
"rounded-sm px-3 py-1 text-xs " +
(tab === "json"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
JSON
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPolling((p) => !p)}
onClick={() => api.getExecution(id).then(setStatus).catch(() => {})}
>
{polling ? "Stop polling" : "Resume polling"}
</Button>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw />
Refresh
</Button>
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-semibold tracking-tight">Execution</h1>
<Badge variant={statusVariant(s)}>{s}</Badge>
{!terminal && polling && (
<span className="text-xs text-muted-foreground">polling every 2s</span>
)}
</div>
<p className="mt-1 font-mono text-xs text-muted-foreground">{id}</p>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Live snapshot from the orchestrator</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted/30 p-4 text-xs">
{status ? JSON.stringify(status, null, 2) : "Loading…"}
</pre>
</CardContent>
</Card>
<div className="relative min-h-0 flex-1">
{tab === "canvas" ? (
<div className="flex h-full flex-col">
<div className="relative min-h-0 flex-1">
{pipelineDef ? (
<PipelineCanvas
initialValue={pipelineDef}
pipelineId={`exec-${id}`}
readOnly
fullBleed
nodeStatuses={nodeStatuses}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{run?.pipelineId
? "Loading pipeline canvas…"
: "No pipeline definition available for this run."}
</div>
)}
</div>
<LogPanel
nodeLogs={nodeLogs}
activeNode={activeLogNode}
onActiveNodeChange={setActiveLogNode}
nodeStatuses={nodeStatuses}
streaming={streamConnected}
/>
</div>
) : (
<div className="grid h-full grid-cols-1 gap-4 overflow-auto p-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Latest snapshot from the orchestrator</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs">
{status ? JSON.stringify(status, null, 2) : "Loading…"}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Event log</CardTitle>
<CardDescription>Streamed via SSE</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-[11px]">
{events.length === 0
? "(no events yet)"
: events
.map((e) => JSON.stringify(e))
.join("\n")}
</pre>
</CardContent>
</Card>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Resume</CardTitle>
<CardDescription>
Resolve a Restate awakeable to continue a paused workflow.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="awakeable">Awakeable ID</Label>
<Input
id="awakeable"
value={awakeable}
onChange={(e) => setAwakeable(e.target.value)}
placeholder="awk_…"
className="font-mono text-xs"
/>
{/* Resume panel — overlaid only when paused */}
{(overallStatus === "paused" || overallStatus === "waiting") && (
<Card className="absolute right-4 top-4 z-20 w-80 shadow-xl">
<CardHeader>
<CardTitle>Resume</CardTitle>
<CardDescription>
Resolve the Restate awakeable to continue.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="awakeable">Awakeable ID</Label>
<Input
id="awakeable"
value={awakeable}
onChange={(e) => setAwakeable(e.target.value)}
placeholder="awk_…"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="data">Resolution data (JSON)</Label>
<Textarea
id="data"
rows={4}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<Button
className="w-full"
onClick={onResume}
disabled={resuming || !awakeable}
>
<Send />
{resuming ? "Sending…" : "Resume"}
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// LogPanel renders the per-node live log output below the canvas. Tabs at
// the top let the user pick which node's stream they're watching; the
// active tab follows the most-recently-started node by default.
function LogPanel(props: {
nodeLogs: NodeLogs;
activeNode: string | null;
onActiveNodeChange: (node: string | null) => void;
nodeStatuses: NodeStatuses;
streaming: boolean;
}) {
const { nodeLogs, activeNode, onActiveNodeChange, nodeStatuses, streaming } = props;
const nodes = Object.keys(nodeLogs);
const lines = activeNode ? (nodeLogs[activeNode] ?? []) : [];
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new lines unless the user has scrolled up.
const stickRef = useRef(true);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (stickRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [lines.length, activeNode]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
stickRef.current = atBottom;
}
return (
<div className="flex h-64 shrink-0 flex-col border-t bg-background">
<div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1">
<span className="px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Logs
</span>
{nodes.length === 0 ? (
<span className="px-2 text-xs text-muted-foreground">
{streaming ? "waiting for output…" : "no logs yet"}
</span>
) : (
nodes.map((n) => {
const status = nodeStatuses[n];
const isActive = activeNode === n;
return (
<button
key={n}
onClick={() => onActiveNodeChange(n)}
className={
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors " +
(isActive
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/60")
}
>
<span
className={
"size-1.5 rounded-full " +
(status === "running"
? "bg-sky-500 animate-pulse"
: status === "success"
? "bg-emerald-500"
: status === "failed"
? "bg-rose-500"
: "bg-muted-foreground/60")
}
/>
<span className="truncate max-w-[160px]">{n}</span>
<span className="text-[10px] text-muted-foreground">
{nodeLogs[n].length}
</span>
</button>
);
})
)}
<div className="flex-1" />
{streaming && (
<span className="px-2 text-[10px] text-emerald-600 dark:text-emerald-500">
live
</span>
)}
</div>
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-auto bg-zinc-950 px-3 py-2 font-mono text-[11px] leading-[1.4] text-zinc-200"
>
{lines.length === 0 ? (
<div className="text-zinc-500">
{activeNode
? "waiting for output…"
: "select a node tab above"}
</div>
) : (
lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l.line}
</div>
<div className="space-y-2">
<Label htmlFor="data">Resolution data (JSON)</Label>
<Textarea
id="data"
rows={6}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<Button
className="w-full"
onClick={onResume}
disabled={resuming || !awakeable}
>
<Send />
{resuming ? "Sending…" : "Resume"}
</Button>
</CardContent>
</Card>
))
)}
</div>
</div>
);