feat: add workflow console lifecycle explorer

This commit is contained in:
lda
2026-07-02 21:43:04 +07:00 Verified
parent 2cae5b59eb
commit 3cdcf6ff3b
31 changed files with 4457 additions and 12 deletions
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { WorkflowGraph } from "./WorkflowGraph.js";
import type { WorkflowGraphModel } from "./graph-model.js";
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
beforeAll(() => {
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.DOMRect = {
fromRect: () => ({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON() {},
}),
} as unknown as typeof DOMRect;
});
afterAll(() => {
delete (globalThis as Record<string, unknown>).ResizeObserver;
delete (globalThis as Record<string, unknown>).DOMRect;
});
const mockModel: WorkflowGraphModel = {
nodes: [
{
id: "start",
data: {
nodeId: "start",
kind: "use",
label: "Start",
nodeRef: "workflow.start",
raw: {},
},
position: { x: 0, y: 0 },
},
{
id: "review",
data: {
nodeId: "review",
kind: "interrupt",
label: "Review",
nodeRef: null,
raw: {},
},
position: { x: 200, y: 0 },
},
{
id: "end",
data: {
nodeId: "end",
kind: "end",
label: "End",
nodeRef: null,
raw: {},
},
position: { x: 400, y: 0 },
},
],
edges: [
{ id: "e1", source: "start", target: "review", label: "ok" },
{ id: "e2", source: "review", target: "end", label: "submitted" },
],
};
const findNodeById = (container: HTMLElement, nodeId: string): HTMLElement | null =>
container.querySelector(`[data-node-id="${nodeId}"]`);
describe("WorkflowGraph", () => {
it("renders nodes and edges", () => {
const { container } = render(<WorkflowGraph model={mockModel} />);
expect(screen.getByText("Start")).toBeInTheDocument();
expect(screen.getByText("Review")).toBeInTheDocument();
expect(screen.getByText("End")).toBeInTheDocument();
expect(findNodeById(container, "start")).not.toBeNull();
expect(findNodeById(container, "review")).not.toBeNull();
expect(findNodeById(container, "end")).not.toBeNull();
});
it("calls onNodeSelect when node is clicked", () => {
const onSelect = vi.fn();
const { container } = render(<WorkflowGraph model={mockModel} onNodeSelect={onSelect} />);
const reviewNode = findNodeById(container, "review");
fireEvent.click(reviewNode!);
expect(onSelect).toHaveBeenCalledWith("review");
});
it("highlights active node when activeNodeId is provided", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
const reviewNode = findNodeById(container, "review");
expect(reviewNode).toHaveAttribute("data-active", "true");
});
it("does not highlight nodes when activeNodeId is null", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId={null} />);
const reviewNode = findNodeById(container, "review");
expect(reviewNode).toHaveAttribute("data-active", "false");
});
it("shows empty state when no nodes", () => {
const emptyModel: WorkflowGraphModel = { nodes: [], edges: [] };
render(<WorkflowGraph model={emptyModel} />);
expect(screen.getByText(/no nodes/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,120 @@
import { useCallback, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type NodeTypes,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { WorkflowGraphModel, WorkflowGraphNodeData } from "./graph-model.js";
type WorkflowGraphProps = {
readonly model: WorkflowGraphModel;
readonly activeNodeId?: string | null;
readonly onNodeSelect?: (nodeId: string) => void;
};
const nodeColor = (data: WorkflowGraphNodeData): string => {
switch (data.kind) {
case "use":
return "#3b82f6";
case "condition":
return "#f59e0b";
case "interrupt":
return "#ef4444";
case "foreach":
return "#8b5cf6";
case "join":
return "#10b981";
case "end":
return "#6b7280";
default:
return "#94a3b8";
}
};
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive;
return (
<div
role="button"
tabIndex={0}
data-active={isActive}
data-node-id={data.nodeId}
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
style={{ borderColor: nodeColor(data) }}
>
<div className="graph-node__label">{data.label}</div>
{data.nodeRef && (
<div className="graph-node__ref">{data.nodeRef}</div>
)}
</div>
);
};
const nodeTypes: NodeTypes = {
custom: CustomNode,
};
export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: WorkflowGraphProps) => {
const nodes: Node[] = useMemo(
() =>
model.nodes.map((n) => ({
id: n.id,
type: "custom",
position: n.position,
data: { ...n.data, isActive: activeNodeId === n.id },
})),
[model.nodes, activeNodeId],
);
const edges: Edge[] = useMemo(
() =>
model.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "default",
})),
[model.edges],
);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
onNodeSelect?.(node.id);
},
[onNodeSelect],
);
if (model.nodes.length === 0) {
return (
<div className="workflow-graph workflow-graph--empty">
No nodes in this workflow
</div>
);
}
return (
<div className="workflow-graph" data-testid="workflow-graph">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
fitView
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
};
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { buildWorkflowGraph, type WorkflowGraphNodeData } from "./graph-model.js";
const samplePlan = {
nodes: [
{
id: "open",
type: "node",
node: "local.browser_click.open_click_page",
input: [],
output: [],
},
{
id: "wait",
type: "node",
node: "local.browser_click.wait_for_click",
input: [],
output: [],
},
{
id: "check",
type: "condition",
check: { op: "exists", path: "state.clicked" },
},
{
id: "ask",
type: "interrupt",
kind: "approval",
request: [],
resume: [],
outcomes: ["approved", "rejected"],
},
{
id: "__end__",
type: "end",
outcome: "ok",
},
],
edges: [
{ from: "open", outcome: "ok", to: "wait" },
{ from: "wait", outcome: "ok", to: "check" },
{ from: "check", outcome: "true", to: "ask" },
{ from: "check", outcome: "false", to: "__end__" },
{ from: "ask", outcome: "approved", to: "__end__" },
],
};
describe("buildWorkflowGraph", () => {
it("produces stable node ids from plan", () => {
const model = buildWorkflowGraph(samplePlan);
const nodeIds = model.nodes.map((n) => n.id);
expect(nodeIds).toEqual(["__end__", "ask", "check", "open", "wait"]);
});
it("maps node types correctly", () => {
const model = buildWorkflowGraph(samplePlan);
const kinds = model.nodes.map((n) => n.data.kind);
expect(kinds).toEqual(["end", "interrupt", "condition", "use", "use"]);
});
it("preserves node references", () => {
const model = buildWorkflowGraph(samplePlan);
const openNode = model.nodes.find((n) => n.id === "open");
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
});
it("creates edges from plan edges", () => {
const model = buildWorkflowGraph(samplePlan);
expect(model.edges.length).toBe(5);
});
it("labels edges with outcome names", () => {
const model = buildWorkflowGraph(samplePlan);
const okEdge = model.edges.find(
(e) => e.source === "open" && e.target === "wait",
);
expect(okEdge?.label).toBe("ok");
});
it("assigns deterministic coordinates", () => {
const model1 = buildWorkflowGraph(samplePlan);
const model2 = buildWorkflowGraph(structuredClone(samplePlan));
expect(model1.nodes.map((n) => n.position)).toEqual(
model2.nodes.map((n) => n.position),
);
});
it("does not mutate the input plan", () => {
const original = structuredClone(samplePlan);
buildWorkflowGraph(samplePlan);
expect(samplePlan).toEqual(original);
});
it("handles empty plan", () => {
const model = buildWorkflowGraph({ nodes: [], edges: [] });
expect(model.nodes).toEqual([]);
expect(model.edges).toEqual([]);
});
it("includes raw node data", () => {
const model = buildWorkflowGraph(samplePlan);
const openNode = model.nodes.find((n) => n.id === "open");
expect(openNode?.data.raw).toBeDefined();
});
});
+127
View File
@@ -0,0 +1,127 @@
import dagre from "@dagrejs/dagre";
export type WorkflowGraphNodeKind =
| "use"
| "subgraph"
| "condition"
| "interrupt"
| "foreach"
| "join"
| "end";
export type WorkflowGraphNodeData = {
readonly nodeId: string;
readonly kind: WorkflowGraphNodeKind;
readonly label: string;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
};
export type WorkflowGraphNode = {
readonly id: string;
readonly data: WorkflowGraphNodeData;
readonly position: { readonly x: number; readonly y: number };
};
export type WorkflowGraphEdge = {
readonly id: string;
readonly source: string;
readonly target: string;
readonly label: string;
};
export type WorkflowGraphModel = {
readonly nodes: ReadonlyArray<WorkflowGraphNode>;
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
};
const NODE_WIDTH = 180;
const NODE_HEIGHT = 60;
const mapNodeKind = (type: string): WorkflowGraphNodeKind => {
switch (type) {
case "node":
return "use";
case "subgraph":
return "subgraph";
case "condition":
return "condition";
case "interrupt":
return "interrupt";
case "foreach":
return "foreach";
case "join":
return "join";
case "end":
return "end";
default:
return "use";
}
};
const buildLabel = (node: Record<string, unknown>): string => {
const type = node.type as string;
if (type === "end") return (node.outcome as string) ?? "End";
if (type === "condition") return "Condition";
if (type === "interrupt") return (node.kind as string) ?? "Interrupt";
if (type === "foreach") return "For Each";
if (type === "join") return "Join";
const nodeRef = node.node as string | undefined;
if (nodeRef) {
const parts = nodeRef.split(".");
return parts[parts.length - 1] ?? nodeRef;
}
return (node.id as string) ?? "Unknown";
};
export const buildWorkflowGraph = (
plan: {
nodes: ReadonlyArray<Record<string, unknown>>;
edges: ReadonlyArray<Record<string, unknown>>;
},
): WorkflowGraphModel => {
const sortedNodes = [...plan.nodes].sort((a, b) =>
String(a.id).localeCompare(String(b.id)),
);
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
for (const node of sortedNodes) {
g.setNode(String(node.id), { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of plan.edges) {
g.setEdge(String(edge.from), String(edge.to));
}
dagre.layout(g);
const nodes: WorkflowGraphNode[] = sortedNodes.map((node) => {
const id = String(node.id);
const pos = g.node(id);
return {
id,
data: {
nodeId: id,
kind: mapNodeKind(node.type as string),
label: buildLabel(node),
nodeRef: (node.node as string | null) ?? null,
raw: node as Record<string, unknown>,
},
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
};
});
let edgeIndex = 0;
const edges: WorkflowGraphEdge[] = plan.edges.map((edge) => {
const source = String(edge.from);
const target = String(edge.to);
const label = String(edge.outcome ?? "");
const id = `e-${source}-${target}-${edgeIndex++}`;
return { id, source, target, label };
});
return { nodes, edges };
};