"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { ArrowRight, Check, Pause, RefreshCw, 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 { Textarea } from "@/components/ui/textarea"; import { api, type ExecutionStatus, type Run } from "@/lib/api"; import { formatDate } from "@/lib/utils"; interface PendingApproval { awakeable_id: string; node?: string; context?: { reason?: string; node?: string; [k: string]: unknown }; } interface PendingItem { run: Run; approval: PendingApproval; } // /approvals lists every run that is currently parked on a HITL Approval // node. The orchestrator stores `pending_approval` as Restate KV and // surfaces it via GetExecution; we fan out per "running" run, keep only // the ones that have a pending_approval set, and let the user resolve // them in one click. export default function ApprovalsInboxPage() { const [items, setItems] = useState(null); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const loadRef = useRef(0); const load = useCallback(async () => { const tag = ++loadRef.current; try { const runs = await api.listRuns({ limit: 50 }); const candidates = runs.filter((r) => /running|pending|paused|waiting/i.test(r.status)); const checked = await Promise.all( candidates.map(async (r) => { try { const s = (await api.getExecution(r.id)) as ExecutionStatus & { pending_approval?: PendingApproval; }; const pa = s.pending_approval; if (pa && pa.awakeable_id) { return { run: r, approval: pa } as PendingItem; } } catch { /* ignore — run may have advanced */ } return null; }) ); // Only commit if a newer load hasn't started. if (tag !== loadRef.current) return; setItems(checked.filter(Boolean) as PendingItem[]); setError(null); } catch (e) { if (tag !== loadRef.current) return; setError(e instanceof Error ? e.message : "load failed"); } }, []); useEffect(() => { load(); // 6s — same reasoning as executions/view: avoid racing the workflow // SDK's shared-handler with parallel reads. const t = setInterval(load, 6000); // Also reload when ANY new run is created (covers the case where a // freshly-triggered run instantly parks on an Approval node). const es = new EventSource(api.runsStreamURL()); es.onmessage = () => load(); es.onerror = () => {}; return () => { clearInterval(t); es.close(); }; }, [load]); async function decide(it: PendingItem, approved: boolean, reason: string) { setBusyId(it.run.id); try { await api.resumeExecution(it.run.id, { awakeable_id: it.approval.awakeable_id, data: { approved, reason }, }); await load(); } catch (e) { setError(e instanceof Error ? e.message : "resume failed"); } finally { setBusyId(null); } } return (

Approvals

Runs paused on a HITL Approval node, awaiting a decision.

{error && ( {error} )} {items === null ? (

Loading…

) : items.length === 0 ? (
No approvals waiting

When a pipeline hits a Wait-for-approval node it’ll show up here for a one-click decision.

) : (
{items.map((it) => ( ))}
)}
); } function ApprovalCard({ item, busy, onDecide, }: { item: PendingItem; busy: boolean; onDecide: (it: PendingItem, approved: boolean, reason: string) => void; }) { const [reason, setReason] = useState(""); const reasonHint = typeof item.approval.context?.reason === "string" ? (item.approval.context.reason as string) : undefined; const nodeName = item.approval.node || (typeof item.approval.context?.node === "string" ? (item.approval.context.node as string) : "Approval"); return (
{item.run.pipelineName || "Pipeline"} {nodeName} {item.run.id}
{reasonHint && (

{reasonHint}

)}
paused since {formatDate(item.run.startedAt)}