diff --git a/agentwatch/api/server.py b/agentwatch/api/server.py
index cb729010..1af03ea3 100644
--- a/agentwatch/api/server.py
+++ b/agentwatch/api/server.py
@@ -1380,6 +1380,324 @@ async def seed_demo(_auth: None = Depends(_require_api_key)) -> dict[str, Any]:
return {"status": "seeded"}
+# ─────────────────────────────────────────────
+# Visual Workflow Builder Endpoints (#447)
+# ─────────────────────────────────────────────
+
+class WorkflowNode(BaseModel):
+ id: str
+ type: str
+ position: dict[str, float]
+ data: dict[str, Any]
+
+class WorkflowEdge(BaseModel):
+ id: str
+ source: str
+ target: str
+ source_handle: str | None = None
+ target_handle: str | None = None
+
+class Workflow(BaseModel):
+ id: str
+ name: str
+ description: str | None = None
+ nodes: list[WorkflowNode]
+ edges: list[WorkflowEdge]
+ created_at: str | None = None
+ updated_at: str | None = None
+
+DEFAULT_TEMPLATES = [
+ {
+ "id": "tpl-code-review",
+ "name": "Multi-Agent Code Review Pipeline",
+ "description": "Orchestrates LLM nodes to review pull requests, check security policies, and post summaries.",
+ "nodes": [
+ {"id": "node-1", "type": "scheduler", "position": {"x": 100, "y": 200}, "data": {"name": "PR Webhook Trigger", "cron": "*/5 * * * *", "triggerEvent": "pull_request"}},
+ {"id": "node-2", "type": "custom-tool", "position": {"x": 300, "y": 200}, "data": {"name": "Fetch PR Diff", "toolName": "github_pr_fetcher", "arguments": "{\n \"pr_id\": \"{{trigger.pr_id}}\"\n}"}},
+ {"id": "node-3", "type": "llm", "position": {"x": 550, "y": 100}, "data": {"name": "Security Analyzer", "model": "claude-3-5-sonnet", "temperature": 0.2, "systemPrompt": "Analyze the git diff for security vulnerabilities."}},
+ {"id": "node-4", "type": "llm", "position": {"x": 550, "y": 300}, "data": {"name": "Style Guide Checker", "model": "gpt-4o", "temperature": 0.5, "systemPrompt": "Check the code for style violations."}},
+ {"id": "node-5", "type": "conditional", "position": {"x": 800, "y": 200}, "data": {"name": "Vulnerabilities Found?", "property": "security_issues", "operator": "gt", "value": "0"}},
+ {"id": "node-6", "type": "agent", "position": {"x": 1020, "y": 100}, "data": {"name": "Senior Reviewer Agent", "framework": "crewai", "agentName": "Reviewer", "systemPrompt": "Draft a detailed PR rejection comment detailing safety and style issues."}},
+ {"id": "node-7", "type": "http", "position": {"x": 1020, "y": 300}, "data": {"name": "Post Approval Webhook", "url": "https://api.github.com/repos/owner/repo/pulls/comments", "method": "POST"}},
+ ],
+ "edges": [
+ {"id": "edge-1", "source": "node-1", "target": "node-2"},
+ {"id": "edge-2", "source": "node-2", "target": "node-3"},
+ {"id": "edge-3", "source": "node-2", "target": "node-4"},
+ {"id": "edge-4", "source": "node-3", "target": "node-5"},
+ {"id": "edge-5", "source": "node-4", "target": "node-5"},
+ {"id": "edge-6", "source": "node-5", "target": "node-6", "source_handle": "true"},
+ {"id": "edge-7", "source": "node-5", "target": "node-7", "source_handle": "false"},
+ ]
+ },
+ {
+ "id": "tpl-rag",
+ "name": "Retrieval-Augmented LLM Search",
+ "description": "Retrieves local memory context, processes it with LLM, and writes back findings to memory.",
+ "nodes": [
+ {"id": "node-1", "type": "http", "position": {"x": 100, "y": 200}, "data": {"name": "Search Query Input", "url": "https://api.agentwatch.com/search-intent", "method": "GET"}},
+ {"id": "node-2", "type": "memory", "position": {"x": 350, "y": 200}, "data": {"name": "Semantic Vector Search", "memoryKey": "knowledge_base", "mode": "read", "storageType": "semantic"}},
+ {"id": "node-3", "type": "llm", "position": {"x": 600, "y": 200}, "data": {"name": "Synthesis Model", "model": "claude-3-5-sonnet", "temperature": 0.3, "systemPrompt": "Answer user query using the retrieved context."}},
+ {"id": "node-4", "type": "memory", "position": {"x": 850, "y": 200}, "data": {"name": "Cache Result", "memoryKey": "search_history", "mode": "write", "storageType": "episodic"}},
+ ],
+ "edges": [
+ {"id": "edge-1", "source": "node-1", "target": "node-2"},
+ {"id": "edge-2", "source": "node-2", "target": "node-3"},
+ {"id": "edge-3", "source": "node-3", "target": "node-4"},
+ ]
+ }
+]
+
+import json
+WORKFLOWS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "workflows.json")
+
+def load_workflows_from_disk() -> list[dict]:
+ try:
+ if os.path.exists(WORKFLOWS_FILE):
+ with open(WORKFLOWS_FILE, "r") as f:
+ data = json.load(f)
+ if isinstance(data, list):
+ # Ensure defaults exist
+ existing_ids = {w["id"] for w in data}
+ for default in DEFAULT_TEMPLATES:
+ if default["id"] not in existing_ids:
+ data.append(default)
+ return data
+ except Exception as e:
+ logger.warning(f"Failed to load workflows: {e}")
+ return list(DEFAULT_TEMPLATES)
+
+def save_workflows_to_disk(workflows: list[dict]) -> None:
+ try:
+ with open(WORKFLOWS_FILE, "w") as f:
+ json.dump(workflows, f, indent=2)
+ except Exception as e:
+ logger.warning(f"Failed to save workflows: {e}")
+
+@app.get("/api/workflows")
+@app.get("/api/v1/workflows")
+async def list_workflows(request: Request, _auth: None = Depends(_require_api_key)) -> list[dict]:
+ _limiter.check(_rate_limit_key(request, "r"), RATE_READ, request)
+ return load_workflows_from_disk()
+
+@app.get("/api/workflows/{workflow_id}")
+@app.get("/api/v1/workflows/{workflow_id}")
+async def get_workflow(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict:
+ _limiter.check(_rate_limit_key(request, "r"), RATE_READ, request)
+ workflows = load_workflows_from_disk()
+ for w in workflows:
+ if w["id"] == workflow_id:
+ return w
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+@app.post("/api/workflows")
+@app.post("/api/v1/workflows")
+async def save_workflow(request: Request, workflow: Workflow, _auth: None = Depends(_require_api_key)) -> dict:
+ _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request)
+ workflows = load_workflows_from_disk()
+ workflow_dict = workflow.model_dump(mode="json")
+ workflow_dict["updated_at"] = datetime.now(UTC).isoformat()
+
+ updated = False
+ for i, w in enumerate(workflows):
+ if w["id"] == workflow.id:
+ workflow_dict["created_at"] = w.get("created_at") or workflow_dict["updated_at"]
+ workflows[i] = workflow_dict
+ updated = True
+ break
+
+ if not updated:
+ workflow_dict["created_at"] = workflow_dict["updated_at"]
+ workflows.append(workflow_dict)
+
+ save_workflows_to_disk(workflows)
+ return {"status": "saved", "workflow": workflow_dict}
+
+@app.delete("/api/workflows/{workflow_id}")
+@app.delete("/api/v1/workflows/{workflow_id}")
+async def delete_workflow(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict:
+ _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request)
+ workflows = load_workflows_from_disk()
+ new_workflows = [w for w in workflows if w["id"] != workflow_id]
+ if len(new_workflows) == len(workflows):
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ save_workflows_to_disk(new_workflows)
+ return {"status": "deleted"}
+
+@app.post("/api/workflows/{workflow_id}/run")
+@app.post("/api/v1/workflows/{workflow_id}/run")
+async def run_workflow_simulation(request: Request, workflow_id: str, _auth: None = Depends(_require_api_key)) -> dict:
+ _limiter.check(_rate_limit_key(request, "w"), RATE_WRITE, request)
+ workflows = load_workflows_from_disk()
+ workflow = None
+ for w in workflows:
+ if w["id"] == workflow_id:
+ workflow = w
+ break
+
+ if not workflow:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ nodes = workflow.get("nodes", [])
+ edges = workflow.get("edges", [])
+
+ # Simple topological sorting helper / step generator
+ steps = []
+ executed_nodes = set()
+
+ # We will simulate standard step-by-step executions
+ # Map nodes by id for quick lookup
+ node_map = {n["id"]: n for n in nodes}
+
+ # Let's find start nodes (nodes with no incoming edges)
+ incoming_counts = {n["id"]: 0 for n in nodes}
+ outgoing_edges = {n["id"]: [] for n in nodes}
+ for e in edges:
+ target = e.get("target")
+ source = e.get("source")
+ if target in incoming_counts:
+ incoming_counts[target] += 1
+ if source in outgoing_edges:
+ outgoing_edges[source].append(e)
+
+ # Queue for nodes to execute
+ queue = [nid for nid, count in incoming_counts.items() if count == 0]
+ if not queue and nodes:
+ # fallback if circular reference or no clear start
+ queue = [nodes[0]["id"]]
+
+ step_index = 1
+ while queue:
+ current_id = queue.pop(0)
+ if current_id in executed_nodes:
+ continue
+ executed_nodes.add(current_id)
+ node = node_map.get(current_id)
+ if not node:
+ continue
+
+ node_type = node.get("type", "unknown")
+ node_name = node.get("data", {}).get("name", f"Node {current_id}")
+
+ # Determine simulated outputs & logs based on type
+ outputs = {}
+ errors = []
+ log_msgs = []
+ status = "success"
+
+ if node_type == "agent":
+ framework = node.get("data", {}).get("framework", "custom")
+ agent_name = node.get("data", {}).get("agentName", "Agent")
+ log_msgs = [
+ f"Initializing {framework} Agent: '{agent_name}'...",
+ "Running Planner reasoning cycle...",
+ f"Agent task completed successfully."
+ ]
+ outputs = {"agent_status": "idle", "last_message": "Task processed successfully."}
+ elif node_type == "llm":
+ model = node.get("data", {}).get("model", "gpt-4o")
+ log_msgs = [
+ f"Preparing prompt context for Model: {model}...",
+ f"Calling LLM API ({model})...",
+ "Received completion: 240 prompt tokens, 120 completion tokens."
+ ]
+ outputs = {"completion": "Simulated LLM response details", "tokens_used": 360}
+ elif node_type == "memory":
+ mode = node.get("data", {}).get("mode", "read")
+ key = node.get("data", {}).get("memoryKey", "default")
+ storage = node.get("data", {}).get("storageType", "episodic")
+ log_msgs = [
+ f"Connecting to {storage} memory store...",
+ f"Performing memory {mode} for key: '{key}'...",
+ f"Memory operation completed successfully."
+ ]
+ outputs = {"retrieved_context": "Found 3 matching items in memory." if mode == "read" else "Wrote context."}
+ elif node_type == "http":
+ url = node.get("data", {}).get("url", "https://api.example.com")
+ method = node.get("data", {}).get("method", "GET")
+ log_msgs = [
+ f"Dispatching HTTP {method} request to {url}...",
+ "Waiting for server response...",
+ "Response HTTP 200 OK received."
+ ]
+ outputs = {"response_body": {"status": "ok", "items": []}, "status_code": 200}
+ elif node_type == "file":
+ path = node.get("data", {}).get("filePath", "/workspace/data.json")
+ action = node.get("data", {}).get("action", "read")
+ log_msgs = [
+ f"Opening file pointer at: {path}...",
+ f"Performing action '{action}' on filesystem...",
+ "Operation success."
+ ]
+ outputs = {"file_size_bytes": 1024, "status": "completed"}
+ # Let's add a warning if deleting or accessing sensitive files
+ if action == "delete" or "/etc" in path or "/" == path:
+ errors.append(f"High risk filesystem operation detected at: {path}")
+ elif node_type == "scheduler":
+ cron = node.get("data", {}).get("cron", "* * * * *")
+ log_msgs = [
+ f"Evaluating cron schedule pattern: '{cron}'...",
+ "Next execution interval computed successfully."
+ ]
+ outputs = {"next_run": "2026-06-22T14:00:00Z"}
+ elif node_type == "conditional":
+ prop = node.get("data", {}).get("property", "score")
+ operator = node.get("data", {}).get("operator", "eq")
+ val = node.get("data", {}).get("value", "true")
+ log_msgs = [
+ f"Evaluating branch condition: '{prop}' {operator} '{val}'...",
+ "Condition resolved to TRUE."
+ ]
+ outputs = {"branch_taken": "true"}
+ elif node_type == "custom-tool":
+ tool = node.get("data", {}).get("toolName", "custom_script")
+ log_msgs = [
+ f"Resolving custom tool binding: '{tool}'...",
+ "Running sandbox simulation...",
+ "Tool finished with exit code 0."
+ ]
+ outputs = {"result": "success"}
+ else:
+ log_msgs = ["Executing generic workflow step..."]
+
+ # Introduce a random failure scenario or warning for demonstrating errors
+ if current_id == "node-failed-test":
+ status = "failure"
+ errors.append("Simulation execution failed: Connection Timeout")
+ log_msgs.append("CRITICAL: Connection timed out after 5000ms.")
+
+ steps.append({
+ "step_index": step_index,
+ "node_id": current_id,
+ "node_name": node_name,
+ "node_type": node_type,
+ "status": status,
+ "logs": log_msgs,
+ "outputs": outputs,
+ "errors": errors,
+ "duration_ms": 150 + (step_index * 45)
+ })
+ step_index += 1
+
+ # Add next nodes to queue
+ for edge in outgoing_edges[current_id]:
+ target = edge.get("target")
+ if target not in executed_nodes:
+ queue.append(target)
+
+ # Return overall simulation summary and step traces
+ return {
+ "workflow_id": workflow_id,
+ "name": workflow.get("name"),
+ "status": "failure" if any(s["status"] == "failure" for s in steps) else "success",
+ "steps": steps,
+ "total_duration_ms": sum(s["duration_ms"] for s in steps),
+ "executed_nodes_count": len(executed_nodes)
+ }
+
+
+
+
def _sanitize_event(event_dict: dict[str, Any]) -> dict[str, Any]:
"""Escape HTML tags in user-facing preview strings to prevent XSS in the dashboard.
diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json
new file mode 100644
index 00000000..085d8c8c
--- /dev/null
+++ b/frontend/.eslintrc.json
@@ -0,0 +1,8 @@
+{
+ "extends": "next/core-web-vitals",
+ "rules": {
+ "@next/next/no-html-link-for-pages": "off",
+ "react/no-unescaped-entities": "off",
+ "react-hooks/exhaustive-deps": "off"
+ }
+}
diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx
index 019018a5..73926d73 100644
--- a/frontend/pages/index.tsx
+++ b/frontend/pages/index.tsx
@@ -315,10 +315,16 @@ export default function DashboardPage() {
AgentWatch
Reliability, safety, and observability
-
+
+
+
+ Workflow Builder
+
+
+
diff --git a/frontend/pages/workflow-builder.tsx b/frontend/pages/workflow-builder.tsx
new file mode 100644
index 00000000..ae23a387
--- /dev/null
+++ b/frontend/pages/workflow-builder.tsx
@@ -0,0 +1,1264 @@
+import React, { useState, useEffect, useRef } from 'react'
+import useSWR from 'swr'
+import {
+ Cpu,
+ Layers,
+ Link as LinkIcon,
+ FileText,
+ Calendar,
+ GitBranch,
+ Wrench,
+ Play,
+ Pause,
+ SkipForward,
+ SkipBack,
+ RotateCcw,
+ ZoomIn,
+ ZoomOut,
+ Move,
+ Map,
+ Save,
+ FolderOpen,
+ AlertTriangle,
+ Trash2,
+ Plus,
+ X,
+ CheckCircle,
+ HelpCircle,
+ Clock,
+ Compass,
+ Check
+} from 'lucide-react'
+
+// Constants
+const API_BASE = process.env.NEXT_PUBLIC_API_HOST
+ ? `https://${process.env.NEXT_PUBLIC_API_HOST}/api/v1`
+ : '/api/v1'
+
+const fetcher = (url: string) => fetch(url).then((r) => (r.ok ? r.json() : null))
+
+// Types
+interface NodeData {
+ name: string
+ [key: string]: any
+}
+
+interface WorkflowNode {
+ id: string
+ type: string
+ position: { x: number; y: number }
+ data: NodeData
+}
+
+interface WorkflowEdge {
+ id: string
+ source: string
+ target: string
+ source_handle?: string | null
+ target_handle?: string | null
+}
+
+interface Workflow {
+ id: string
+ name: string
+ description?: string
+ nodes: WorkflowNode[]
+ edges: WorkflowEdge[]
+ created_at?: string
+ updated_at?: string
+}
+
+interface SimulationStep {
+ step_index: number
+ node_id: string
+ node_name: string
+ node_type: string
+ status: 'success' | 'failure' | 'running' | 'pending'
+ logs: string[]
+ outputs: any
+ errors: string[]
+ duration_ms: number
+}
+
+// Available node type configurations
+const NODE_TYPES = [
+ { type: 'agent', label: 'Agent Node', icon: Cpu, color: '#3b82f6', defaultData: { name: 'New Agent', framework: 'crewai', agentName: 'Researcher', systemPrompt: 'You are a helpful assistant.' } },
+ { type: 'llm', label: 'LLM Node', icon: Compass, color: '#a855f7', defaultData: { name: 'LLM Synthesizer', model: 'claude-3-5-sonnet', temperature: 0.3, systemPrompt: 'Summarize the input data.' } },
+ { type: 'memory', label: 'Memory Node', icon: Layers, color: '#10b981', defaultData: { name: 'Memory Store', memoryKey: 'chat_history', mode: 'read', storageType: 'semantic' } },
+ { type: 'http', label: 'HTTP Request', icon: LinkIcon, color: '#f59e0b', defaultData: { name: 'API Call', url: 'https://api.example.com/v1/data', method: 'GET', headers: '{}', body: '' } },
+ { type: 'file', label: 'File Processing', icon: FileText, color: '#6366f1', defaultData: { name: 'File Operation', filePath: '/workspace/input.txt', action: 'read' } },
+ { type: 'scheduler', label: 'Scheduler', icon: Calendar, color: '#ec4899', defaultData: { name: 'Cron Trigger', cron: '0 9 * * 1-5', triggerEvent: 'scheduled_report' } },
+ { type: 'conditional', label: 'Conditional Logic', icon: GitBranch, color: '#f97316', defaultData: { name: 'If/Else Route', property: 'status', operator: 'eq', value: 'success' } },
+ { type: 'custom-tool', label: 'Custom Tool', icon: Wrench, color: '#06b6d4', defaultData: { name: 'Custom Sandbox Script', toolName: 'custom_executor', arguments: '{}' } }
+]
+
+export default function WorkflowBuilder() {
+ // SWR for loading workflows from backend
+ const { data: workflowsList, mutate: mutateWorkflows } = useSWR(`${API_BASE}/workflows`, fetcher)
+
+ // Canvas State
+ const [nodes, setNodes] = useState([])
+ const [edges, setEdges] = useState([])
+ const [workflowName, setWorkflowName] = useState('My Automation Pipeline')
+ const [workflowDesc, setWorkflowDesc] = useState('Describe your pipeline goals and structure...')
+ const [selectedWorkflowId, setSelectedWorkflowId] = useState(null)
+
+ // Selection & UI State
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+ const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
+ const [saveSuccess, setSaveSuccess] = useState(false)
+
+ // Zoom & Pan
+ const [zoom, setZoom] = useState(1.0)
+ const [pan, setPan] = useState({ x: 0, y: 0 })
+ const [isPanning, setIsPanning] = useState(false)
+ const [panStart, setPanStart] = useState({ x: 0, y: 0 })
+
+ // Drag node state (from palette to canvas)
+ const [draggedNodeType, setDraggedNodeType] = useState(null)
+ const [isDraggingNodeOnCanvas, setIsDraggingNodeOnCanvas] = useState(null)
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+
+ // Connection line dragging state
+ const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; handle: string } | null>(null)
+ const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 })
+
+ // Simulation State
+ const [simulationSteps, setSimulationSteps] = useState([])
+ const [simulationStatus, setSimulationStatus] = useState<'idle' | 'running' | 'paused' | 'success' | 'failure'>('idle')
+ const [currentStepIndex, setCurrentStepIndex] = useState(-1)
+ const [simLogs, setSimLogs] = useState([])
+
+ // Refs
+ const canvasRef = useRef(null)
+
+ // Load first workflow or template on mount
+ useEffect(() => {
+ if (workflowsList && workflowsList.length > 0 && !selectedWorkflowId) {
+ loadWorkflow(workflowsList[0])
+ }
+ }, [workflowsList])
+
+ // Real-time Validation Warning checks
+ const getValidationWarnings = () => {
+ const warnings: string[] = []
+
+ // Check for disconnected nodes (no edges attached)
+ nodes.forEach(node => {
+ const isConnected = edges.some(e => e.source === node.id || e.target === node.id)
+ if (!isConnected && nodes.length > 1) {
+ warnings.push(`Node "${node.data.name}" is isolated and not connected to any other node.`)
+ }
+
+ // Node specific field validation warnings
+ if (node.type === 'http' && !node.data.url) {
+ warnings.push(`Node "${node.data.name}" has an empty HTTP URL.`)
+ }
+ if (node.type === 'file' && !node.data.filePath) {
+ warnings.push(`Node "${node.data.name}" has an empty File Path.`)
+ }
+ })
+ return warnings
+ }
+
+ // Load a workflow template or saved workflow
+ const loadWorkflow = (wf: Workflow) => {
+ setNodes(wf.nodes || [])
+ setEdges(wf.edges || [])
+ setWorkflowName(wf.name)
+ setWorkflowDesc(wf.description || '')
+ setSelectedWorkflowId(wf.id)
+ setSelectedNodeId(null)
+ setSimulationStatus('idle')
+ setSimulationSteps([])
+ setSimLogs(['System: Workflow loaded successfully. Ready to simulate.'])
+ setCurrentStepIndex(-1)
+ }
+
+ // Create a new blank workflow
+ const handleNewWorkflow = () => {
+ const newId = `wf-${Math.random().toString(36).substr(2, 9)}`
+ setNodes([
+ { id: 'node-1', type: 'scheduler', position: { x: 150, y: 220 }, data: { name: 'Schedule Trigger', cron: '0 9 * * 1-5' } }
+ ])
+ setEdges([])
+ setWorkflowName('Untitled Workflow')
+ setWorkflowDesc('A brand new automation pipeline')
+ setSelectedWorkflowId(newId)
+ setSelectedNodeId('node-1')
+ setSimulationStatus('idle')
+ setSimulationSteps([])
+ setSimLogs(['System: New workflow created.'])
+ setCurrentStepIndex(-1)
+ }
+
+ // Save workflow to backend
+ const handleSaveWorkflow = async () => {
+ const id = selectedWorkflowId || `wf-${Math.random().toString(36).substr(2, 9)}`
+ if (!selectedWorkflowId) {
+ setSelectedWorkflowId(id)
+ }
+
+ const payload: Workflow = {
+ id,
+ name: workflowName,
+ description: workflowDesc,
+ nodes,
+ edges
+ }
+
+ try {
+ const res = await fetch(`${API_BASE}/workflows`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ })
+ if (res.ok) {
+ setSaveSuccess(true)
+ setTimeout(() => setSaveSuccess(false), 3000)
+ if (mutateWorkflows) mutateWorkflows()
+ }
+ } catch (err) {
+ console.error('Failed to save workflow', err)
+ }
+ }
+
+ // Delete a workflow
+ const handleDeleteWorkflow = async (id: string) => {
+ if (confirm('Are you sure you want to delete this workflow?')) {
+ try {
+ const res = await fetch(`${API_BASE}/workflows/${id}`, {
+ method: 'DELETE'
+ })
+ if (res.ok) {
+ if (mutateWorkflows) mutateWorkflows()
+ if (selectedWorkflowId === id) {
+ setSelectedWorkflowId(null)
+ }
+ }
+ } catch (err) {
+ console.error('Failed to delete workflow', err)
+ }
+ }
+ }
+
+ // Zoom controls
+ const zoomIn = () => setZoom(prev => Math.min(prev + 0.1, 1.8))
+ const zoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.5))
+ const zoomReset = () => {
+ setZoom(1.0)
+ setPan({ x: 0, y: 0 })
+ }
+
+ // Pan controls via mouse drag on grid background
+ const handleMouseDownBg = (e: React.MouseEvent) => {
+ if (e.button !== 0) return // Left click only
+ setIsPanning(true)
+ setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y })
+ }
+
+ const handleMouseMoveCanvas = (e: React.MouseEvent) => {
+ if (isPanning) {
+ setPan({
+ x: e.clientX - panStart.x,
+ y: e.clientY - panStart.y
+ })
+ } else if (isDraggingNodeOnCanvas && dragOffset) {
+ // Calculate scaled coords
+ const rect = canvasRef.current?.getBoundingClientRect()
+ if (rect) {
+ const clientX = e.clientX - rect.left - pan.x
+ const clientY = e.clientY - rect.top - pan.y
+ setNodes(prev =>
+ prev.map(n =>
+ n.id === isDraggingNodeOnCanvas
+ ? { ...n, position: { x: Math.round(clientX / zoom - dragOffset.x), y: Math.round(clientY / zoom - dragOffset.y) } }
+ : n
+ )
+ )
+ }
+ } else if (connectingFrom) {
+ const rect = canvasRef.current?.getBoundingClientRect()
+ if (rect) {
+ setCursorPos({
+ x: (e.clientX - rect.left - pan.x) / zoom,
+ y: (e.clientY - rect.top - pan.y) / zoom
+ })
+ }
+ }
+ }
+
+ const handleMouseUpCanvas = () => {
+ setIsPanning(false)
+ setIsDraggingNodeOnCanvas(null)
+ if (connectingFrom) {
+ setConnectingFrom(null)
+ }
+ }
+
+ // Node Dragging Start
+ const handleMouseDownNode = (e: React.MouseEvent, node: WorkflowNode) => {
+ e.stopPropagation()
+ setSelectedNodeId(node.id)
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
+ // Capture click offset inside the node itself to prevent jumping
+ setDragOffset({
+ x: (e.clientX - rect.left) / zoom,
+ y: (e.clientY - rect.top) / zoom
+ })
+ setIsDraggingNodeOnCanvas(node.id)
+ }
+
+ // Drag and Drop from library palette
+ const handleDragStartFromPalette = (type: string) => {
+ setDraggedNodeType(type)
+ }
+
+ const handleDropOnCanvas = (e: React.DragEvent) => {
+ e.preventDefault()
+ if (!draggedNodeType) return
+ const rect = canvasRef.current?.getBoundingClientRect()
+ if (rect) {
+ const canvasX = (e.clientX - rect.left - pan.x) / zoom
+ const canvasY = (e.clientY - rect.top - pan.y) / zoom
+
+ const config = NODE_TYPES.find(t => t.type === draggedNodeType)
+ const newNode: WorkflowNode = {
+ id: `node-${Math.random().toString(36).substr(2, 9)}`,
+ type: draggedNodeType,
+ position: { x: Math.round(canvasX - 100), y: Math.round(canvasY - 40) },
+ data: JSON.parse(JSON.stringify(config?.defaultData || { name: 'New Node' }))
+ }
+ setNodes(prev => [...prev, newNode])
+ setSelectedNodeId(newNode.id)
+ }
+ setDraggedNodeType(null)
+ }
+
+ // Connection handling
+ const handleStartConnection = (e: React.MouseEvent, nodeId: string, handle: string) => {
+ e.stopPropagation()
+ const rect = canvasRef.current?.getBoundingClientRect()
+ if (rect) {
+ setConnectingFrom({ nodeId, handle })
+ setCursorPos({
+ x: (e.clientX - rect.left - pan.x) / zoom,
+ y: (e.clientY - rect.top - pan.y) / zoom
+ })
+ }
+ }
+
+ const handleEndConnection = (e: React.MouseEvent, targetNodeId: string) => {
+ e.stopPropagation()
+ if (connectingFrom && connectingFrom.nodeId !== targetNodeId) {
+ // Create new edge
+ const edgeId = `edge-${Math.random().toString(36).substr(2, 9)}`
+ const newEdge: WorkflowEdge = {
+ id: edgeId,
+ source: connectingFrom.nodeId,
+ target: targetNodeId,
+ source_handle: connectingFrom.handle,
+ target_handle: 'input'
+ }
+ // Check if connection already exists to prevent duplicates
+ const exists = edges.some(edge => edge.source === newEdge.source && edge.target === newEdge.target)
+ if (!exists) {
+ setEdges(prev => [...prev, newEdge])
+ }
+ }
+ setConnectingFrom(null)
+ }
+
+ // Node Deletion
+ const deleteNode = (nodeId: string) => {
+ setNodes(prev => prev.filter(n => n.id !== nodeId))
+ setEdges(prev => prev.filter(e => e.source !== nodeId && e.target !== nodeId))
+ if (selectedNodeId === nodeId) setSelectedNodeId(null)
+ }
+
+ // Config Update
+ const updateNodeData = (nodeId: string, key: string, value: any) => {
+ setNodes(prev =>
+ prev.map(n =>
+ n.id === nodeId
+ ? { ...n, data: { ...n.data, [key]: value } }
+ : n
+ )
+ )
+ }
+
+ // Compute coordinate centers for drawing SVGs
+ const getNodeCoordinates = (nodeId: string, handleType: string) => {
+ const node = nodes.find(n => n.id === nodeId)
+ if (!node) return { x: 0, y: 0 }
+
+ // Width and height approximation for node positions
+ const nodeWidth = 220
+ const nodeHeight = 80
+
+ if (handleType === 'input') {
+ return { x: node.position.x, y: node.position.y + nodeHeight / 2 }
+ } else if (handleType === 'true') {
+ return { x: node.position.x + nodeWidth, y: node.position.y + nodeHeight / 3 }
+ } else if (handleType === 'false') {
+ return { x: node.position.x + nodeWidth, y: node.position.y + (nodeHeight / 3) * 2 }
+ } else {
+ return { x: node.position.x + nodeWidth, y: node.position.y + nodeHeight / 2 }
+ }
+ }
+
+ // Simulation Runner Controllers
+ const triggerSimulation = async () => {
+ if (!selectedWorkflowId) return
+ setSimulationStatus('running')
+ setSimLogs(prev => [...prev, 'System: Launching pipeline execution simulation...'])
+
+ try {
+ const res = await fetch(`${API_BASE}/workflows/${selectedWorkflowId}/run`, {
+ method: 'POST'
+ })
+ if (res.ok) {
+ const simResult = await res.json()
+ setSimulationSteps(simResult.steps || [])
+ if (simResult.steps && simResult.steps.length > 0) {
+ setCurrentStepIndex(0)
+ setSimLogs(prev => [
+ ...prev,
+ ...simResult.steps[0].logs.map((l: string) => `[Node: ${simResult.steps[0].node_name}] ${l}`)
+ ])
+ } else {
+ setSimulationStatus('success')
+ }
+ } else {
+ setSimulationStatus('failure')
+ setSimLogs(prev => [...prev, 'CRITICAL: Simulation failed to bootstrap on the server backend.'])
+ }
+ } catch (e) {
+ setSimulationStatus('failure')
+ setSimLogs(prev => [...prev, 'CRITICAL: Offline mode — simulation error.'])
+ }
+ }
+
+ // Step controls
+ const stepForward = () => {
+ if (currentStepIndex < simulationSteps.length - 1) {
+ const nextIndex = currentStepIndex + 1
+ setCurrentStepIndex(nextIndex)
+ const nextStep = simulationSteps[nextIndex]
+ setSimLogs(prev => [
+ ...prev,
+ ...nextStep.logs.map((l: string) => `[Node: ${nextStep.node_name}] ${l}`)
+ ])
+ if (nextStep.status === 'failure') {
+ setSimulationStatus('failure')
+ } else if (nextIndex === simulationSteps.length - 1) {
+ setSimulationStatus('success')
+ }
+ }
+ }
+
+ const stepBackward = () => {
+ if (currentStepIndex > 0) {
+ setCurrentStepIndex(prev => prev - 1)
+ setSimLogs(prev => [...prev, 'System: Reverting execution step back.'])
+ setSimulationStatus('running')
+ }
+ }
+
+ const resetSimulation = () => {
+ setSimulationStatus('idle')
+ setCurrentStepIndex(-1)
+ setSimulationSteps([])
+ setSimLogs(['System: Simulation reset. Ready to launch.'])
+ }
+
+ // Current active node in simulation
+ const activeNodeId = currentStepIndex >= 0 && currentStepIndex < simulationSteps.length
+ ? simulationSteps[currentStepIndex].node_id
+ : null
+
+ // Check state of nodes in simulation
+ const getNodeSimulationStatus = (nodeId: string) => {
+ if (simulationStatus === 'idle') return null
+
+ // Find if node has completed or is active
+ const step = simulationSteps.find(s => s.node_id === nodeId)
+ if (!step) return 'pending'
+
+ const stepIdx = simulationSteps.indexOf(step)
+ if (stepIdx === currentStepIndex) return 'running'
+ if (stepIdx < currentStepIndex) return step.status // success or failure
+ return 'pending'
+ }
+
+ return (
+
+ {/* Top Header Navigation */}
+
+
+
+ {/* Action Controls */}
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+ {/* Main Builder Pane */}
+
+ {/* Templates Slide Panel Overlay */}
+ {isTemplatesOpen && (
+
+
+
Pipeline Templates
+
+
+
+
+
+
+
+
+ {workflowsList?.map(wf => (
+
+
+
{wf.name}
+
+
+
{wf.description}
+
+
+ ))}
+
+
+ )}
+
+ {/* Left Toolbar: Node Palette & Templates list */}
+
+
+ {/* Center: Dynamic Workflow Canvas Grid */}
+
e.preventDefault()}
+ onDrop={handleDropOnCanvas}
+ onMouseMove={handleMouseMoveCanvas}
+ onMouseUp={handleMouseUpCanvas}
+ onMouseDown={handleMouseDownBg}
+ className="flex-1 h-full relative overflow-hidden bg-[#070913]"
+ style={{ cursor: isPanning ? 'grabbing' : connectingFrom ? 'crosshair' : 'default' }}
+ >
+ {/* Subtle Canvas SVG Grid lines */}
+
+
+ {/* SVG Connection Layer */}
+
+
+ {/* HTML Nodes Layer */}
+
+ {nodes.map(node => {
+ const config = NODE_TYPES.find(t => t.type === node.type)
+ const Icon = config?.icon || HelpCircle
+ const isSelected = selectedNodeId === node.id
+ const simStatus = getNodeSimulationStatus(node.id)
+
+ // Get style variables based on simulation status
+ let borderColor = 'border-white/10'
+ let shadowStyle = ''
+ if (isSelected) {
+ borderColor = 'border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.3)]'
+ } else if (simStatus === 'running') {
+ borderColor = 'border-amber-400'
+ shadowStyle = 'shadow-[0_0_20px_rgba(245,158,11,0.5)] animate-pulse'
+ } else if (simStatus === 'success') {
+ borderColor = 'border-emerald-500'
+ shadowStyle = 'shadow-[0_0_12px_rgba(16,185,129,0.2)]'
+ } else if (simStatus === 'failure') {
+ borderColor = 'border-red-500'
+ shadowStyle = 'shadow-[0_0_20px_rgba(239,68,68,0.5)]'
+ }
+
+ return (
+
handleMouseDownNode(e, node)}
+ onMouseUp={(e) => handleEndConnection(e, node.id)}
+ style={{ left: node.position.x, top: node.position.y }}
+ className={`absolute w-56 bg-zinc-900/90 backdrop-blur rounded-xl border ${borderColor} ${shadowStyle} select-none transition-shadow p-3.5 flex flex-col justify-between cursor-move hover:bg-zinc-900 group`}
+ >
+ {/* Top line details */}
+
+
+
+
+
+
+
{node.data.name}
+
{node.type}
+
+
+ {/* Node status indicators */}
+
+ {simStatus === 'success' && }
+ {simStatus === 'running' && }
+ {simStatus === 'failure' && }
+
+
+
+
+ {/* Handles */}
+ {/* Input handle (left side) */}
+
+
+ {/* Output handles (right side) */}
+ {node.type === 'conditional' ? (
+ <>
+ {/* True handle */}
+
handleStartConnection(e, node.id, 'true')}
+ className="absolute -right-1.5 top-1/3 -translate-y-1/2 w-3 h-3 rounded-full bg-emerald-500 border-2 border-zinc-950 hover:bg-emerald-400 transition cursor-crosshair z-30"
+ title="True Branch"
+ />
+ {/* False handle */}
+
handleStartConnection(e, node.id, 'false')}
+ className="absolute -right-1.5 top-2/3 -translate-y-1/2 w-3 h-3 rounded-full bg-red-500 border-2 border-zinc-950 hover:bg-red-400 transition cursor-crosshair z-30"
+ title="False Branch"
+ />
+ >
+ ) : (
+
handleStartConnection(e, node.id, 'output')}
+ className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-zinc-950 border-2 border-white/20 hover:border-blue-500 transition cursor-crosshair z-30"
+ title="Output Port"
+ />
+ )}
+
+ )
+ })}
+
+
+ {/* Minimap Overlay (bottom-right of canvas) */}
+
+
+ Minimap
+
+
+ {/* Scale mini-nodes into preview box */}
+ {nodes.map(n => (
+
+ ))}
+
+
+
+
+ {/* Right Sidebar: Selected Node Editor Form */}
+
+
+
+ {/* Bottom Panel: Interactive Simulation logs & controls */}
+
+
+ )
+}
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
new file mode 100644
index 00000000..1b1c341a
--- /dev/null
+++ b/tests/test_workflows.py
@@ -0,0 +1,63 @@
+"""Unit tests for Visual Workflow Builder endpoints."""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from agentwatch.api.server import app
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+def test_workflows_lifecycle(client):
+ # Test GET list
+ resp = client.get("/api/workflows")
+ assert resp.status_code == 200
+ initial_workflows = resp.json()
+ assert isinstance(initial_workflows, list)
+
+ # Test POST
+ new_workflow = {
+ "id": "wf-test-1234",
+ "name": "Integration Test Workflow",
+ "description": "Created during testing",
+ "nodes": [
+ {
+ "id": "node-1",
+ "type": "agent",
+ "position": {"x": 100.0, "y": 200.0},
+ "data": {"name": "Test Agent"}
+ }
+ ],
+ "edges": []
+ }
+ resp = client.post("/api/workflows", json=new_workflow)
+ assert resp.status_code == 200
+ saved = resp.json()
+ assert saved["status"] == "saved"
+ assert saved["workflow"]["id"] == "wf-test-1234"
+
+ # Test GET by ID
+ resp = client.get("/api/workflows/wf-test-1234")
+ assert resp.status_code == 200
+ retrieved = resp.json()
+ assert retrieved["id"] == "wf-test-1234"
+ assert retrieved["name"] == "Integration Test Workflow"
+
+ # Test GET list again to verify it is present
+ resp = client.get("/api/workflows")
+ assert resp.status_code == 200
+ workflows = resp.json()
+ assert any(w["id"] == "wf-test-1234" for w in workflows)
+
+ # Test DELETE
+ resp = client.delete("/api/workflows/wf-test-1234")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "deleted"
+
+ # Test GET by ID after delete (should be 404)
+ resp = client.get("/api/workflows/wf-test-1234")
+ assert resp.status_code == 404