"use client"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Check, ChevronDown, ChevronRight, Pause, RefreshCw, Send, X } 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { PipelineCanvas } from "@/components/canvas/pipeline-canvas"; import { api, type ExecutionStatus, type Run } from "@/lib/api"; import type { PipelineDefinition, PipelineNode } from "@/lib/pipeline-graph"; import { lookup as lookupNode } from "@/lib/node-catalog"; import { formatDate } from "@/lib/utils"; type NodeStatus = "pending" | "running" | "success" | "failed" | "paused"; type NodeStatuses = Record; type NodeLogs = Record; type NodeDurations = Record; interface ExecutionEvent { type: string; node?: string; node_type?: string; status?: string; content?: string; outputs?: Record; error?: string; duration_ms?: number; } export default function ExecutionPage() { return ( Loading…} > ); } 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 ExecutionView() { const params = useSearchParams(); const id = params.get("id") ?? ""; const [status, setStatus] = useState(null); const [run, setRun] = useState(null); const [pipelineDef, setPipelineDef] = useState(null); const [nodeStatuses, setNodeStatuses] = useState({}); // Per-node tick when we first marked it running. Used to enforce a // minimum visible "running" duration so the user always sees the // spinner — even for instantaneous nodes (Trigger, NoOp). Without // this, fast nodes flicker pending → success in one render batch and // the running state is invisible. const runningSinceRef = useRef>({}); const [nodeLogs, setNodeLogs] = useState({}); const [nodeDurations, setNodeDurations] = useState({}); const [streamConnected, setStreamConnected] = useState(false); const [error, setError] = useState(null); // Resume form const [awakeable, setAwakeable] = useState(""); const [data, setData] = useState(`{"approved": true}`); const [resuming, setResuming] = useState(false); const [approvalReason, setApprovalReason] = useState(""); // Initial load useEffect(() => { 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 */ } } } catch (e) { setError(e instanceof Error ? e.message : "load failed"); } })(); }, [id]); // SSE useEffect(() => { if (!id) return; const es = new EventSource(api.executionStreamURL(id)); es.onopen = () => setStreamConnected(true); es.onerror = () => setStreamConnected(false); es.onmessage = (msg) => { try { const ev: ExecutionEvent = JSON.parse(msg.data); 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, ev.content!] }; }); return; } if (ev.node) { const node = ev.node; const minVisibleMs = 400; if (ev.type === "node_started") { runningSinceRef.current[node] = Date.now(); setNodeStatuses((prev) => ({ ...prev, [node]: "running" })); } else if (ev.type === "node_completed" || ev.type === "node_error") { const final: NodeStatus = ev.type === "node_completed" ? "success" : "failed"; const startedAt = runningSinceRef.current[node]; const elapsed = startedAt ? Date.now() - startedAt : Infinity; // Make sure the user actually sees a "running" frame. If we // never recorded a start (subscriber arrived after the start // event flushed) we apply the terminal state immediately. if (startedAt === undefined || elapsed >= minVisibleMs) { setNodeStatuses((prev) => ({ ...prev, [node]: final })); } else { // Briefly show "running" first if we missed it, then flip. setNodeStatuses((prev) => ({ ...prev, [node]: prev[node] === "running" ? "running" : "running", })); setTimeout(() => { setNodeStatuses((prev) => ({ ...prev, [node]: final })); }, minVisibleMs - elapsed); } delete runningSinceRef.current[node]; if (typeof ev.duration_ms === "number") { setNodeDurations((prev) => ({ ...prev, [node]: ev.duration_ms!, })); } } } if (ev.type === "done") { api.getExecution(id).then(setStatus).catch(() => {}); es.close(); setStreamConnected(false); } } catch { /* ignore */ } }; return () => { es.close(); setStreamConnected(false); }; }, [id]); // Fallback polling when SSE drops useEffect(() => { if (!id || streamConnected) return; const t = setInterval(() => { api.getExecution(id).then(setStatus).catch(() => {}); }, 4000); return () => clearInterval(t); }, [id, streamConnected]); // Poll the orchestrator REST endpoint independently of the SSE feed so // we pick up `pending_approval` as soon as the Approval node parks the // workflow. SSE only carries the engine's lifecycle events; pending- // approval state is set by the executor as Restate KV and surfaced via // GetPendingApproval. Don't poll once we've reached terminal state. // 4s interval matches Restate's recommended minimum to avoid the // shared-handler racing the workflow's own goroutine. useEffect(() => { if (!id) return; const isTerm = (s: string | undefined) => ["success", "completed", "failed", "error", "partial_error"].includes( (s || "").toLowerCase() ); if (isTerm(status?.status)) return; const t = setInterval(() => { api.getExecution(id).then(setStatus).catch(() => {}); }, 4000); return () => clearInterval(t); }, [id, status?.status]); // Hydrate canvas node statuses from the terminal payload whenever we // have status + pipeline def. This handles the "joined after the run // finished" case — without it, the canvas stays at PENDING forever // because SSE has nothing left to replay. SSE-derived statuses are not // overwritten so an in-flight run is still authoritative. useEffect(() => { if (!status || !pipelineDef) return; const overall = String(status.status || "").toLowerCase(); const isTerm = ["success", "completed", "failed", "error", "partial_error"].includes(overall); if (!isTerm) return; setNodeStatuses((prev) => { const next: NodeStatuses = { ...prev }; for (const n of pipelineDef.nodes ?? []) { if (next[n.name]) continue; // SSE wins next[n.name] = inferStatus(status, n.name); } return next; }); }, [status, pipelineDef]); async function onResume() { setResuming(true); setError(null); try { let payload: unknown = {}; if (data.trim()) payload = JSON.parse(data); await api.resumeExecution(id, { awakeable_id: awakeable, data: payload }); const s = await api.getExecution(id).catch(() => null); if (s) setStatus(s); } catch (e) { setError(e instanceof Error ? e.message : "resume failed"); } finally { setResuming(false); } } // approveOrReject is the one-click path: reads the awakeable id from // pending_approval (no manual copy) and resolves with the standard // {approved: bool, reason} body the ApprovalExecutor unwraps. async function approveOrReject(approved: boolean, reason?: string) { const pending = (status as { pending_approval?: { awakeable_id?: string } } | null) ?.pending_approval; if (!pending?.awakeable_id) { setError("no pending approval on this run"); return; } setResuming(true); setError(null); try { await api.resumeExecution(id, { awakeable_id: pending.awakeable_id, data: { approved, reason }, }); const s = await api.getExecution(id).catch(() => null); if (s) setStatus(s); } catch (e) { setError(e instanceof Error ? e.message : "resume failed"); } finally { setResuming(false); } } const pendingApproval = useMemo(() => { const pa = (status as { pending_approval?: { node?: string; awakeable_id?: string; context?: Record } } | null) ?.pending_approval; return pa && pa.awakeable_id ? pa : null; }, [status]); const overallStatus = useMemo( () => pendingApproval ? "paused" : (status?.status as string) || run?.status || "running", [status, run, pendingApproval] ); const isTerminal = ["success", "completed", "failed", "error", "partial_error"] .includes(overallStatus.toLowerCase()); const nodes: PipelineNode[] = pipelineDef?.nodes ?? []; if (!id) { return (
Missing id query param.
); } return (
{/* Header */}

Run

{id}
{run?.pipelineId && ( pipeline {run.pipelineId.slice(0, 8)} )}
{overallStatus}
{error && ( {error} )} {/* Pipeline canvas card */} Pipeline {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
{pipelineDef ? ( ) : (
{run?.pipelineId ? "Loading pipeline canvas…" : "No pipeline definition for this run."}
)}
{/* Nodes panel */} Nodes Per-node detail. Build logs stream live and archive to S3 when the node finishes. {nodes.length === 0 ? (

No nodes loaded.

) : ( nodes.map((n) => ( | undefined} /> )) )}
{/* Pending approval overlay — surfaces the awakeable, exposes one-click Approve / Reject so the user never has to copy IDs. */} {pendingApproval && (
Approval needed {pendingApproval.node}
{pendingReason(pendingApproval) ?? "This run is waiting for a human decision."}