From 5ff2ade57909d423422de2ed70397725c731228b Mon Sep 17 00:00:00 2001 From: bboxwtf Date: Sun, 22 Mar 2026 00:10:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflows):=20add=20full=20Workflow=20sect?= =?UTF-8?q?ion=20=E2=80=94=20visual=20constructor,=20dashboard=20&=20execu?= =?UTF-8?q?tion=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New Feature: Workflow Builder & Execution Engine ### Database Schema (4 new tables) - workflows: pipeline definitions with status (draft/active/paused/archived), tags, canvas metadata - workflowNodes: agent/container/trigger/condition/output blocks with canvas positions - workflowEdges: directional connections between nodes (source→target) - workflowRuns: execution history with per-node status tracking & timing ### Backend (server/workflows.ts + 13 tRPC endpoints in routers.ts) - Full CRUD for workflows, nodes, edges - Atomic canvas save (nodes + edges in one mutation) - BFS graph execution engine: walks from trigger nodes, executes agents/containers in order - Single-node test execution for individual block testing - Run management: start, cancel, poll status, list history - Aggregated workflow stats (success rate, avg duration, run counts) ### Frontend — Visual Constructor - WorkflowCanvas: interactive drag-and-drop builder with: - Node palette sidebar (trigger/agent/container/condition/output types) - Agent list for quick drag-to-canvas agent nodes - Edge drawing between output→input ports with bezier curves - Pan/zoom controls + grid background - Keyboard shortcuts (Delete, Ctrl+S) - Real-time run status overlays (running/success/failed per node) - WorkflowNodeBlock: kind-aware visual cards with status indicators & connection ports - WorkflowNodeEditModal: per-kind configuration (agent selector, Docker image/env, condition expressions, cron/webhook triggers) - WorkflowCreateModal: create new workflows with name, description, tags - WorkflowDashboard: monitoring panel with stats cards, run history timeline, per-node progress bars - Workflows page: unified list/canvas/dashboard views with tabs ### Navigation & Routing - Added Workflows nav item (GitBranch icon) in sidebar between Agents and Tools - Routes: /workflows (list), /workflows/:id (dashboard+canvas) ### Also includes - fix(nodes): keep AddNodeDialog open after join + canJoin guard --- client/src/App.tsx | 3 + client/src/components/DashboardLayout.tsx | 2 + client/src/components/WorkflowCanvas.tsx | 554 ++++++++++++++++++ client/src/components/WorkflowCreateModal.tsx | 144 +++++ client/src/components/WorkflowDashboard.tsx | 233 ++++++++ client/src/components/WorkflowNodeBlock.tsx | 251 ++++++++ .../src/components/WorkflowNodeEditModal.tsx | 253 ++++++++ client/src/pages/Nodes.tsx | 8 +- client/src/pages/Workflows.tsx | 410 +++++++++++++ drizzle/0007_workflows.sql | 77 +++ drizzle/schema.ts | 125 ++++ gateway/internal/api/handlers.go | 23 +- server/routers.ts | 138 +++++ server/workflows.ts | 418 +++++++++++++ 14 files changed, 2635 insertions(+), 4 deletions(-) create mode 100644 client/src/components/WorkflowCanvas.tsx create mode 100644 client/src/components/WorkflowCreateModal.tsx create mode 100644 client/src/components/WorkflowDashboard.tsx create mode 100644 client/src/components/WorkflowNodeBlock.tsx create mode 100644 client/src/components/WorkflowNodeEditModal.tsx create mode 100644 client/src/pages/Workflows.tsx create mode 100644 drizzle/0007_workflows.sql create mode 100644 server/workflows.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 9a835da..64627cd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,7 @@ import Settings from "./pages/Settings"; import Nodes from "./pages/Nodes"; import Tools from "./pages/Tools"; import Skills from "./pages/Skills"; +import Workflows from "./pages/Workflows"; function Router() { // make sure to consider if you need authentication for certain routes @@ -26,6 +27,8 @@ function Router() { + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 9ff3a8a..29e9af8 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -20,6 +20,7 @@ import { Wifi, Wrench, Zap, + GitBranch, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { motion, AnimatePresence } from "framer-motion"; @@ -28,6 +29,7 @@ import { trpc } from "@/lib/trpc"; const NAV_ITEMS = [ { path: "/", icon: LayoutDashboard, label: "Дашборд" }, { path: "/agents", icon: Bot, label: "Агенты" }, + { path: "/workflows", icon: GitBranch, label: "Воркфлоу" }, { path: "/tools", icon: Wrench, label: "Инструменты" }, { path: "/skills", icon: Zap, label: "Скилы" }, { path: "/nodes", icon: Server, label: "Ноды" }, diff --git a/client/src/components/WorkflowCanvas.tsx b/client/src/components/WorkflowCanvas.tsx new file mode 100644 index 0000000..34e89e9 --- /dev/null +++ b/client/src/components/WorkflowCanvas.tsx @@ -0,0 +1,554 @@ +/** + * WorkflowCanvas — interactive visual constructor for building workflows. + * Supports: + * - Drag-and-drop nodes from palette + * - Drawing edges between ports + * - Selecting/deleting nodes and edges + * - Panning/zooming the canvas + * - Save to server via tRPC + * - Real-time run status overlays + */ +import { useState, useRef, useCallback, useEffect } from "react"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Save, + Play, + Trash2, + ZoomIn, + ZoomOut, + Maximize2, + Loader2, + X, + AlertCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { nanoid } from "nanoid"; +import { WorkflowNodeBlock, WorkflowNodePaletteItem, type WFNodeData, type NodeKind } from "./WorkflowNodeBlock"; +import { WorkflowNodeEditModal } from "./WorkflowNodeEditModal"; + +export interface WFEdgeData { + edgeKey: string; + sourceNodeKey: string; + targetNodeKey: string; + sourceHandle?: string; + targetHandle?: string; + label?: string; + meta?: Record; +} + +interface WorkflowCanvasProps { + workflowId: number; + workflowName: string; + initialNodes?: WFNodeData[]; + initialEdges?: WFEdgeData[]; + /** Run results overlay: nodeKey → status */ + runResults?: Record; + onBack: () => void; +} + +export default function WorkflowCanvas({ + workflowId, + workflowName, + initialNodes = [], + initialEdges = [], + runResults, + onBack, +}: WorkflowCanvasProps) { + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + const [selectedNodeKey, setSelectedNodeKey] = useState(null); + const [selectedEdgeKey, setSelectedEdgeKey] = useState(null); + const [editingNode, setEditingNode] = useState(null); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + + // Dragging state + const [dragging, setDragging] = useState<{ nodeKey: string; offsetX: number; offsetY: number } | null>(null); + + // Edge drawing state + const [edgeDrawing, setEdgeDrawing] = useState<{ + sourceKey: string; + sourcePortType: "input" | "output"; + mouseX: number; + mouseY: number; + } | null>(null); + + const canvasRef = useRef(null); + + // Fetch agents for the node editor + const { data: agents = [] } = trpc.agents.list.useQuery(); + + // Save canvas mutation + const saveMutation = trpc.workflows.saveCanvas.useMutation({ + onSuccess: () => toast.success("Canvas saved"), + onError: (e) => toast.error(`Save failed: ${e.message}`), + }); + + // Execute workflow mutation + const executeMutation = trpc.workflows.execute.useMutation({ + onSuccess: (run) => { + toast.success(`Workflow started: ${run?.runKey ?? "?"}`); + }, + onError: (e) => toast.error(`Execution failed: ${e.message}`), + }); + + // Test single node + const testNodeMutation = trpc.workflows.executeNode.useMutation({ + onSuccess: (result) => { + if (result.success) toast.success(`Node executed in ${result.durationMs}ms`); + else toast.error(`Node failed: ${result.error}`); + }, + }); + + // Apply run results as status overlays + const nodesWithStatus: WFNodeData[] = nodes.map((n) => { + const rr = runResults?.[n.nodeKey]; + if (!rr) return n; + return { ...n, runStatus: rr.status, runDurationMs: rr.durationMs, runError: rr.error }; + }); + + // ─── Canvas event handlers ─────────────────────────────────────────────── + + const handleCanvasMouseDown = (e: React.MouseEvent) => { + if (e.target === canvasRef.current || (e.target as HTMLElement).dataset.canvas) { + setSelectedNodeKey(null); + setSelectedEdgeKey(null); + setIsPanning(true); + setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); + } + }; + + const handleCanvasMouseMove = (e: React.MouseEvent) => { + // Panning + if (isPanning) { + setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); + } + + // Node dragging + if (dragging && canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const newX = (e.clientX - rect.left - pan.x) / zoom - dragging.offsetX; + const newY = (e.clientY - rect.top - pan.y) / zoom - dragging.offsetY; + setNodes((prev) => + prev.map((n) => + n.nodeKey === dragging.nodeKey + ? { ...n, posX: Math.round(newX), posY: Math.round(newY) } + : n + ) + ); + } + + // Edge drawing + if (edgeDrawing && canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + setEdgeDrawing({ + ...edgeDrawing, + mouseX: (e.clientX - rect.left - pan.x) / zoom, + mouseY: (e.clientY - rect.top - pan.y) / zoom, + }); + } + }; + + const handleCanvasMouseUp = () => { + setIsPanning(false); + setDragging(null); + setEdgeDrawing(null); + }; + + const handleNodeDragStart = (nodeKey: string, e: React.MouseEvent) => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const node = nodes.find((n) => n.nodeKey === nodeKey); + if (!node) return; + const offsetX = (e.clientX - rect.left - pan.x) / zoom - node.posX; + const offsetY = (e.clientY - rect.top - pan.y) / zoom - node.posY; + setDragging({ nodeKey, offsetX, offsetY }); + } + }; + + // Port connection + const handlePortMouseDown = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + setEdgeDrawing({ + sourceKey: nodeKey, + sourcePortType: portType, + mouseX: (e.clientX - rect.left - pan.x) / zoom, + mouseY: (e.clientY - rect.top - pan.y) / zoom, + }); + } + }; + + // When mouse up on a port — create an edge + const handlePortMouseUp = useCallback((nodeKey: string, portType: "input" | "output") => { + if (!edgeDrawing) return; + // Ensure we're connecting output→input (or input→output) + if (edgeDrawing.sourceKey === nodeKey) return; + const sourceKey = edgeDrawing.sourcePortType === "output" ? edgeDrawing.sourceKey : nodeKey; + const targetKey = edgeDrawing.sourcePortType === "output" ? nodeKey : edgeDrawing.sourceKey; + + // Prevent duplicates + const exists = edges.some((e) => e.sourceNodeKey === sourceKey && e.targetNodeKey === targetKey); + if (exists) return; + + setEdges((prev) => [ + ...prev, + { + edgeKey: `edge_${nanoid(8)}`, + sourceNodeKey: sourceKey, + targetNodeKey: targetKey, + }, + ]); + setEdgeDrawing(null); + }, [edgeDrawing, edges]); + + // Override port mouse-down to also listen for mouse-up (connection target) + const handlePortInteraction = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => { + if (edgeDrawing) { + handlePortMouseUp(nodeKey, portType); + } else { + handlePortMouseDown(nodeKey, portType, e); + } + }; + + // ─── Palette drop ───────────────────────────────────────────────────────── + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const kind = e.dataTransfer.getData("nodeKind") as NodeKind; + if (!kind || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110); + const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30); + + const newNode: WFNodeData = { + nodeKey: `node_${nanoid(8)}`, + label: `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`, + kind, + posX, + posY, + }; + setNodes((prev) => [...prev, newNode]); + }; + + const handleDragOver = (e: React.DragEvent) => e.preventDefault(); + + // ─── Actions ────────────────────────────────────────────────────────────── + + const handleDeleteNode = (nodeKey: string) => { + setNodes((prev) => prev.filter((n) => n.nodeKey !== nodeKey)); + setEdges((prev) => prev.filter((e) => e.sourceNodeKey !== nodeKey && e.targetNodeKey !== nodeKey)); + setSelectedNodeKey(null); + }; + + const handleDeleteEdge = (edgeKey: string) => { + setEdges((prev) => prev.filter((e) => e.edgeKey !== edgeKey)); + setSelectedEdgeKey(null); + }; + + const handleSave = () => { + saveMutation.mutate({ + workflowId, + nodes: nodes.map((n) => ({ + nodeKey: n.nodeKey, + label: n.label, + kind: n.kind, + agentId: n.agentId ?? null, + containerConfig: n.containerConfig ?? {}, + conditionExpr: n.conditionExpr, + triggerConfig: n.triggerConfig ?? {}, + posX: n.posX, + posY: n.posY, + meta: n.meta ?? {}, + })), + edges: edges.map((e) => ({ + edgeKey: e.edgeKey, + sourceNodeKey: e.sourceNodeKey, + targetNodeKey: e.targetNodeKey, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + label: e.label, + meta: e.meta ?? {}, + })), + canvasMeta: { zoom, viewportX: pan.x, viewportY: pan.y }, + }); + }; + + const handleExecute = () => { + executeMutation.mutate({ workflowId, input: "" }); + }; + + const handleNodeSave = (updated: WFNodeData) => { + setNodes((prev) => prev.map((n) => (n.nodeKey === updated.nodeKey ? updated : n))); + setEditingNode(null); + }; + + // ─── Edge rendering helpers ─────────────────────────────────────────────── + + const getNodeCenter = (nodeKey: string, portType: "top" | "bottom") => { + const node = nodes.find((n) => n.nodeKey === nodeKey); + if (!node) return { x: 0, y: 0 }; + return { + x: node.posX + 110, // half of 220px width + y: portType === "top" ? node.posY : node.posY + 80, // approximate height + }; + }; + + const buildEdgePath = (sx: number, sy: number, tx: number, ty: number) => { + const midY = (sy + ty) / 2; + return `M ${sx} ${sy} C ${sx} ${midY}, ${tx} ${midY}, ${tx} ${ty}`; + }; + + // ─── Keyboard shortcuts ─────────────────────────────────────────────────── + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Delete" || e.key === "Backspace") { + if (selectedNodeKey) handleDeleteNode(selectedNodeKey); + if (selectedEdgeKey) handleDeleteEdge(selectedEdgeKey); + } + if (e.key === "s" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSave(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [selectedNodeKey, selectedEdgeKey, nodes, edges]); + + return ( +
+ {/* Toolbar */} +
+
+ +
+ {workflowName} + + {nodes.length} nodes · {edges.length} edges + +
+
+ + {Math.round(zoom * 100)}% + + +
+ + +
+
+ +
+ {/* Sidebar palette */} +
+
Node Palette
+ {(["trigger", "agent", "container", "condition", "output"] as NodeKind[]).map((kind) => ( +
e.dataTransfer.setData("nodeKind", kind)} + > + {}} /> +
+ ))} + + {/* Quick agent list */} + {agents.length > 0 && ( + <> +
Available Agents
+ {agents.slice(0, 10).map((agent: any) => ( +
{ + e.dataTransfer.setData("nodeKind", "agent"); + e.dataTransfer.setData("agentId", String(agent.id)); + e.dataTransfer.setData("agentName", agent.name); + }} + > +
+ {agent.name} +
+ ))} + + )} +
+ + {/* Canvas area */} +
{ + e.preventDefault(); + const kind = e.dataTransfer.getData("nodeKind") as NodeKind; + if (!kind || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110); + const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30); + + const agentIdStr = e.dataTransfer.getData("agentId"); + const agentName = e.dataTransfer.getData("agentName"); + + const newNode: WFNodeData = { + nodeKey: `node_${nanoid(8)}`, + label: agentName || `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`, + kind, + agentId: agentIdStr ? Number(agentIdStr) : undefined, + agentName: agentName || undefined, + posX, + posY, + }; + setNodes((prev) => [...prev, newNode]); + }} + onDragOver={handleDragOver} + > + {/* Grid pattern */} + + + + + + + + + + {/* Transform container */} +
+ {/* Edges SVG */} + + {edges.map((edge) => { + const src = getNodeCenter(edge.sourceNodeKey, "bottom"); + const tgt = getNodeCenter(edge.targetNodeKey, "top"); + const isSelected = selectedEdgeKey === edge.edgeKey; + return ( + + {/* Hit area (wider invisible stroke for clicking) */} + { e.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }} + /> + + {/* Arrow marker */} + + + ); + })} + + {/* Edge being drawn */} + {edgeDrawing && (() => { + const srcNode = nodes.find((n) => n.nodeKey === edgeDrawing.sourceKey); + if (!srcNode) return null; + const sx = srcNode.posX + 110; + const sy = edgeDrawing.sourcePortType === "output" ? srcNode.posY + 80 : srcNode.posY; + return ( + + ); + })()} + + + {/* Nodes */} + {nodesWithStatus.map((node) => ( + { setSelectedNodeKey(node.nodeKey); setSelectedEdgeKey(null); }} + onDelete={() => handleDeleteNode(node.nodeKey)} + onEdit={() => setEditingNode(node)} + onDragStart={(e) => handleNodeDragStart(node.nodeKey, e)} + onPortMouseDown={handlePortInteraction} + /> + ))} +
+ + {/* Empty state */} + {nodes.length === 0 && ( +
+
+ +

+ Drag nodes from the palette to start building your workflow +

+
+
+ )} +
+
+ + {/* Node edit modal */} + {editingNode && ( + { if (!open) setEditingNode(null); }} + onSave={handleNodeSave} + /> + )} +
+ ); +} diff --git a/client/src/components/WorkflowCreateModal.tsx b/client/src/components/WorkflowCreateModal.tsx new file mode 100644 index 0000000..79b4fc4 --- /dev/null +++ b/client/src/components/WorkflowCreateModal.tsx @@ -0,0 +1,144 @@ +/** + * WorkflowCreateModal — create a new workflow (name + description + tags). + */ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Plus, GitBranch, Loader2, X } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +interface WorkflowCreateModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (workflow: any) => void; +} + +export function WorkflowCreateModal({ open, onOpenChange, onSuccess }: WorkflowCreateModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState([]); + + const createMutation = trpc.workflows.create.useMutation({ + onSuccess: (wf) => { + toast.success(`Workflow "${wf?.name}" created`); + onSuccess(wf); + handleReset(); + onOpenChange(false); + }, + onError: (err) => { + toast.error(`Failed: ${err.message}`); + }, + }); + + const handleReset = () => { + setName(""); + setDescription(""); + setTags([]); + setTagInput(""); + }; + + const handleAddTag = () => { + const trimmed = tagInput.trim(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + setTagInput(""); + } + }; + + const handleRemoveTag = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }; + + const handleCreate = () => { + if (!name.trim()) { + toast.error("Workflow name is required"); + return; + } + createMutation.mutate({ name: name.trim(), description: description.trim() || undefined, tags }); + }; + + return ( + { if (!v) handleReset(); onOpenChange(v); }}> + + + + + New Workflow + + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Content Pipeline" + className="mt-1" + autoFocus + /> +
+ +
+ +