diff --git a/packages/cli-workflow/src/commands/serve/routes-workflow.ts b/packages/cli-workflow/src/commands/serve/routes-workflow.ts
index d06a39d..34c91b2 100644
--- a/packages/cli-workflow/src/commands/serve/routes-workflow.ts
+++ b/packages/cli-workflow/src/commands/serve/routes-workflow.ts
@@ -1,9 +1,14 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
+ validateWorkflowDescriptor,
} from "@uncaged/workflow-register";
import { Hono } from "hono";
+import { parse as parseYaml } from "yaml";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
- return c.json({ name, ...entry });
+ let descriptor: WorkflowDescriptor | null = null;
+ try {
+ const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
+ const yamlText = await readFile(yamlPath, "utf8");
+ const parsed: unknown = parseYaml(yamlText);
+ const validated = validateWorkflowDescriptor(parsed);
+ descriptor = validated.ok ? validated.value : null;
+ } catch {
+ descriptor = null;
+ }
+ return c.json({ name, ...entry, descriptor });
});
app.get("/:name/history", async (c) => {
diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json
index 4a1d32a..75cba22 100644
--- a/packages/workflow-dashboard/package.json
+++ b/packages/workflow-dashboard/package.json
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dagrejs/dagre": "^3.0.0",
+ "@xyflow/react": "^12.10.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts
index d3b1714..4cdab22 100644
--- a/packages/workflow-dashboard/src/api.ts
+++ b/packages/workflow-dashboard/src/api.ts
@@ -104,6 +104,36 @@ export type WorkflowResultRecord = {
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
+export type WorkflowGraphEdge = {
+ from: string;
+ to: string;
+ condition: string;
+ conditionDescription: string | null;
+};
+
+export type WorkflowGraph = {
+ edges: readonly WorkflowGraphEdge[];
+};
+
+export type WorkflowRoleDescriptor = {
+ description: string;
+ schema: Record;
+};
+
+export type WorkflowDescriptor = {
+ description: string;
+ roles: Record;
+ graph: WorkflowGraph;
+};
+
+export type WorkflowDetail = {
+ name: string;
+ hash: string;
+ timestamp: number;
+ history: unknown[];
+ descriptor: WorkflowDescriptor | null;
+};
+
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise {
@@ -117,6 +147,15 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
return fetchJson(agentBase(agent), "/workflows");
}
+export function getWorkflowDescriptor(
+ agent: string,
+ name: string,
+): Promise {
+ return fetchJson(agentBase(agent), `/workflows/${encodeURIComponent(name)}`).then(
+ (res) => res.descriptor,
+ );
+}
+
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
}
diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx
index 4127759..3e4b822 100644
--- a/packages/workflow-dashboard/src/components/thread-detail.tsx
+++ b/packages/workflow-dashboard/src/components/thread-detail.tsx
@@ -1,8 +1,17 @@
-import { useEffect, useRef, useState } from "react";
-import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ getThread,
+ getWorkflowDescriptor,
+ killThread,
+ pauseThread,
+ resumeThread,
+ type ThreadRecord,
+ type WorkflowDescriptor,
+} from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
+import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
@@ -10,6 +19,84 @@ type Props = {
onBack: () => void;
};
+function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
+ for (const r of records) {
+ if (r.type === "thread-start") return r.workflow;
+ }
+ return null;
+}
+
+type GraphPanelProps = {
+ descriptor: WorkflowDescriptor;
+ workflowName: string | null;
+ nodeStates: Map;
+};
+
+function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
+ const [open, setOpen] = useState(true);
+ const edgeCount = descriptor.graph.edges.length;
+ return (
+
+
+ {open && (
+
+
+
+ )}
+
+ );
+}
+
+function computeNodeStates(records: readonly ThreadRecord[]): Map {
+ const states = new Map();
+ const roleRecords = records.filter(
+ (r): r is Extract => r.type === "role",
+ );
+ const hasResult = records.some((r) => r.type === "workflow-result");
+
+ for (let i = 0; i < roleRecords.length; i++) {
+ const role = roleRecords[i].role;
+ const isLast = i === roleRecords.length - 1;
+ states.set(role, !hasResult && isLast ? "active" : "completed");
+ }
+
+ if (roleRecords.length > 0) {
+ states.set("__start__", "completed");
+ }
+ if (hasResult) {
+ states.set("__end__", "completed");
+ for (const [k, v] of states) {
+ if (v === "active") states.set(k, "completed");
+ }
+ }
+
+ return states;
+}
+
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
@@ -23,6 +110,17 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
? data.records
: ([] as typeof sse.records);
+ const workflowName = useMemo(() => extractWorkflowName(records), [records]);
+
+ const descriptorFetch = useFetch(
+ () =>
+ workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
+ [agent, workflowName],
+ );
+
+ const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
+ const nodeStates = useMemo(() => computeNodeStates(records), [records]);
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -95,6 +193,10 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
)}
+ {descriptor !== null && descriptor.graph.edges.length > 0 && (
+
+ )}
+
{status === "loading" && !liveActive && records.length === 0 && (
Loading...
)}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx
new file mode 100644
index 0000000..28fb962
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx
@@ -0,0 +1,76 @@
+import {
+ BaseEdge,
+ EdgeLabelRenderer,
+ type EdgeProps,
+ getBezierPath,
+ getSmoothStepPath,
+} from "@xyflow/react";
+import type { ConditionEdgeData } from "./types.ts";
+
+export function ConditionEdge(props: EdgeProps) {
+ const {
+ id,
+ source,
+ target,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ data,
+ markerEnd,
+ } = props;
+ const edgeData = data as ConditionEdgeData | undefined;
+ const isFallback = edgeData?.isFallback ?? false;
+ const isSelfLoop = source === target;
+
+ const [path, labelX, labelY] = isSelfLoop
+ ? getSmoothStepPath({
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ borderRadius: 20,
+ })
+ : getBezierPath({
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ });
+
+ const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
+ const strokeDasharray = isFallback ? "5 4" : undefined;
+
+ return (
+ <>
+
+ {edgeData && !isFallback && edgeData.condition !== "" && (
+
+
+ {edgeData.condition}
+
+
+ )}
+ >
+ );
+}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/index.ts b/packages/workflow-dashboard/src/components/workflow-graph/index.ts
new file mode 100644
index 0000000..797015e
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/index.ts
@@ -0,0 +1,2 @@
+export type { NodeState } from "./types.ts";
+export { WorkflowGraph } from "./workflow-graph.tsx";
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx
new file mode 100644
index 0000000..ae73cde
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx
@@ -0,0 +1,69 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react";
+import type { RoleNodeData } from "./types.ts";
+
+function borderColor(state: RoleNodeData["state"]): string {
+ switch (state) {
+ case "completed":
+ return "var(--color-success)";
+ case "active":
+ return "var(--color-accent)";
+ default:
+ return "var(--color-border)";
+ }
+}
+
+function stateIcon(state: RoleNodeData["state"]): string | null {
+ if (state === "completed") return "✓";
+ if (state === "active") return "●";
+ return null;
+}
+
+export function RoleNode(props: NodeProps) {
+ const data = props.data as RoleNodeData;
+ const icon = stateIcon(data.state);
+ const isActive = data.state === "active";
+ const handleStyle = {
+ background: "var(--color-text-muted)",
+ width: 6,
+ height: 6,
+ border: "none",
+ } as const;
+
+ return (
+
+
+
+ {icon !== null && (
+
+ {icon}
+
+ )}
+ {data.label}
+
+ {data.description !== "" && (
+
+ {data.description}
+
+ )}
+
+
+ );
+}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx
new file mode 100644
index 0000000..4adea67
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx
@@ -0,0 +1,57 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react";
+import type { TerminalNodeData } from "./types.ts";
+
+function borderColor(state: TerminalNodeData["state"]): string {
+ switch (state) {
+ case "completed":
+ return "var(--color-success)";
+ case "active":
+ return "var(--color-accent)";
+ default:
+ return "var(--color-border)";
+ }
+}
+
+function bgColor(state: TerminalNodeData["state"]): string {
+ if (state === "completed") return "var(--color-success)";
+ if (state === "active") return "var(--color-accent)";
+ return "var(--color-surface)";
+}
+
+export function TerminalNode(props: NodeProps) {
+ const data = props.data as TerminalNodeData;
+ const isStart = data.kind === "start";
+ const isActive = data.state === "active";
+ const handleStyle = {
+ background: "var(--color-text-muted)",
+ width: 6,
+ height: 6,
+ border: "none",
+ } as const;
+
+ return (
+
+ {isStart ? (
+
+ ) : (
+
+ )}
+ {isStart ? "▶" : "■"}
+
+ );
+}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/types.ts b/packages/workflow-dashboard/src/components/workflow-graph/types.ts
new file mode 100644
index 0000000..0c509ce
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/types.ts
@@ -0,0 +1,29 @@
+import type { WorkflowGraphEdge } from "../../api.ts";
+
+export type NodeState = "default" | "completed" | "active";
+
+export type TerminalKind = "start" | "end";
+
+export type RoleNodeData = {
+ label: string;
+ description: string;
+ state: NodeState;
+ [key: string]: unknown;
+};
+
+export type TerminalNodeData = {
+ kind: TerminalKind;
+ state: NodeState;
+ [key: string]: unknown;
+};
+
+export type ConditionEdgeData = {
+ condition: string;
+ conditionDescription: string | null;
+ isFallback: boolean;
+ [key: string]: unknown;
+};
+
+export type GraphInput = {
+ edges: readonly WorkflowGraphEdge[];
+};
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts
new file mode 100644
index 0000000..5dd64fd
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts
@@ -0,0 +1,127 @@
+import Dagre from "@dagrejs/dagre";
+import type { Edge, Node } from "@xyflow/react";
+import { useMemo } from "react";
+import type { WorkflowGraphEdge } from "../../api.ts";
+import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
+
+const START_ID = "__start__";
+const END_ID = "__end__";
+const ROLE_NODE_WIDTH = 180;
+const ROLE_NODE_HEIGHT = 60;
+const TERMINAL_NODE_SIZE = 40;
+
+type LayoutInput = {
+ edges: readonly WorkflowGraphEdge[];
+ roles: Record;
+ nodeStates: Map;
+};
+
+type LayoutResult = {
+ nodes: Node[];
+ edges: Edge[];
+};
+
+function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set {
+ const ids = new Set();
+ for (const e of edges) {
+ ids.add(e.from);
+ ids.add(e.to);
+ }
+ return ids;
+}
+
+function nodeSize(id: string): { width: number; height: number } {
+ if (id === START_ID || id === END_ID) {
+ return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
+ }
+ return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
+}
+
+function buildRoleNode(
+ id: string,
+ pos: { x: number; y: number },
+ roles: Record,
+ state: NodeState,
+): Node {
+ const description = roles[id]?.description ?? "";
+ return {
+ id,
+ type: "role",
+ position: pos,
+ data: { label: id, description, state },
+ draggable: false,
+ };
+}
+
+function buildTerminalNode(
+ id: string,
+ pos: { x: number; y: number },
+ state: NodeState,
+): Node {
+ return {
+ id,
+ type: "terminal",
+ position: pos,
+ data: { kind: id === START_ID ? "start" : "end", state },
+ draggable: false,
+ selectable: false,
+ };
+}
+
+function edgeKey(e: WorkflowGraphEdge): string {
+ return `${e.from}->${e.to}::${e.condition}`;
+}
+
+function buildEdge(e: WorkflowGraphEdge): Edge {
+ const isFallback = e.condition === "FALLBACK";
+ return {
+ id: edgeKey(e),
+ source: e.from,
+ target: e.to,
+ type: "condition",
+ data: {
+ condition: e.condition,
+ conditionDescription: e.conditionDescription,
+ isFallback,
+ },
+ };
+}
+
+export function useLayout(input: LayoutInput): LayoutResult {
+ return useMemo(() => {
+ const ids = collectNodeIds(input.edges);
+
+ const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
+ g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
+
+ for (const id of ids) {
+ const size = nodeSize(id);
+ g.setNode(id, { width: size.width, height: size.height });
+ }
+ for (const e of input.edges) {
+ if (e.from === e.to) {
+ continue;
+ }
+ g.setEdge(e.from, e.to, {}, edgeKey(e));
+ }
+
+ Dagre.layout(g);
+
+ const nodes: Node[] = [];
+ for (const id of ids) {
+ const dagNode = g.node(id);
+ const size = nodeSize(id);
+ const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
+ const state = input.nodeStates.get(id) ?? "default";
+ if (id === START_ID || id === END_ID) {
+ nodes.push(buildTerminalNode(id, pos, state));
+ } else {
+ nodes.push(buildRoleNode(id, pos, input.roles, state));
+ }
+ }
+
+ const edges: Edge[] = input.edges.map(buildEdge);
+
+ return { nodes, edges };
+ }, [input.edges, input.roles, input.nodeStates]);
+}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx
new file mode 100644
index 0000000..132de10
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx
@@ -0,0 +1,61 @@
+import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { useMemo } from "react";
+import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
+import { ConditionEdge } from "./condition-edge.tsx";
+import { RoleNode } from "./role-node.tsx";
+import { TerminalNode } from "./terminal-node.tsx";
+import type { NodeState } from "./types.ts";
+import { useLayout } from "./use-layout.ts";
+
+type Props = {
+ graph: WorkflowGraphData;
+ roles: Record;
+ nodeStates: Map;
+};
+
+const nodeTypes: NodeTypes = {
+ role: RoleNode,
+ terminal: TerminalNode,
+};
+
+const edgeTypes: EdgeTypes = {
+ condition: ConditionEdge,
+};
+
+export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
+ const layout = useLayout({ edges: graph.edges, roles, nodeStates });
+
+ const styledEdges = useMemo(
+ () =>
+ layout.edges.map((e) => ({
+ ...e,
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ width: 14,
+ height: 14,
+ color: "var(--color-text)",
+ },
+ })),
+ [layout.edges],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/workflow-dashboard/src/index.css b/packages/workflow-dashboard/src/index.css
index 537e9ea..7c44386 100644
--- a/packages/workflow-dashboard/src/index.css
+++ b/packages/workflow-dashboard/src/index.css
@@ -19,3 +19,17 @@ body {
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
+
+@keyframes wf-node-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
+ }
+ 50% {
+ box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
+ }
+}
+
+.wf-node-pulse {
+ animation: wf-node-pulse 1.6s ease-in-out infinite;
+}