feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine
## 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
This commit is contained in:
@@ -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() {
|
||||
<Route path="/chat" component={Chat} />
|
||||
<Route path="/tools" component={Tools} />
|
||||
<Route path="/skills" component={Skills} />
|
||||
<Route path="/workflows" component={Workflows} />
|
||||
<Route path="/workflows/:id" component={Workflows} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -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: "Ноды" },
|
||||
|
||||
554
client/src/components/WorkflowCanvas.tsx
Normal file
554
client/src/components/WorkflowCanvas.tsx
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
interface WorkflowCanvasProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
initialNodes?: WFNodeData[];
|
||||
initialEdges?: WFEdgeData[];
|
||||
/** Run results overlay: nodeKey → status */
|
||||
runResults?: Record<string, {
|
||||
status: "pending" | "running" | "success" | "failed" | "skipped";
|
||||
output?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function WorkflowCanvas({
|
||||
workflowId,
|
||||
workflowName,
|
||||
initialNodes = [],
|
||||
initialEdges = [],
|
||||
runResults,
|
||||
onBack,
|
||||
}: WorkflowCanvasProps) {
|
||||
const [nodes, setNodes] = useState<WFNodeData[]>(initialNodes);
|
||||
const [edges, setEdges] = useState<WFEdgeData[]>(initialEdges);
|
||||
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
|
||||
const [selectedEdgeKey, setSelectedEdgeKey] = useState<string | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<WFNodeData | null>(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<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-sidebar">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="ghost" onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-4 h-4 mr-1" /> Close
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border/50" />
|
||||
<span className="text-sm font-semibold text-foreground">{workflowName}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{nodes.length} nodes · {edges.length} edges
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.min(z + 0.1, 2))} className="h-7 w-7 p-0">
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.max(z - 0.1, 0.3))} className="h-7 w-7 p-0">
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }} className="h-7 w-7 p-0">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border/50" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={executeMutation.isPending || nodes.length === 0}
|
||||
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
|
||||
>
|
||||
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar palette */}
|
||||
<div className="w-52 border-r border-border/50 bg-sidebar p-3 space-y-2 overflow-y-auto shrink-0">
|
||||
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mb-2">Node Palette</div>
|
||||
{(["trigger", "agent", "container", "condition", "output"] as NodeKind[]).map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("nodeKind", kind)}
|
||||
>
|
||||
<WorkflowNodePaletteItem kind={kind} onDragStart={() => {}} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quick agent list */}
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Available Agents</div>
|
||||
{agents.slice(0, 10).map((agent: any) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded text-[11px] font-mono bg-primary/5 border border-primary/20 cursor-grab text-foreground hover:bg-primary/10"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("nodeKind", "agent");
|
||||
e.dataTransfer.setData("agentId", String(agent.id));
|
||||
e.dataTransfer.setData("agentName", agent.name);
|
||||
}}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-primary" />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
data-canvas="true"
|
||||
className="flex-1 relative overflow-hidden bg-[#0A0E1A] cursor-default"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onMouseLeave={handleCanvasMouseUp}
|
||||
onDrop={(e) => {
|
||||
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 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-20">
|
||||
<defs>
|
||||
<pattern id="grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse" x={pan.x % (20 * zoom)} y={pan.y % (20 * zoom)}>
|
||||
<circle cx="1" cy="1" r="1" fill="#334155" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Transform container */}
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
||||
>
|
||||
{/* Edges SVG */}
|
||||
<svg className="absolute inset-0 pointer-events-none" style={{ width: 5000, height: 5000, overflow: "visible" }}>
|
||||
{edges.map((edge) => {
|
||||
const src = getNodeCenter(edge.sourceNodeKey, "bottom");
|
||||
const tgt = getNodeCenter(edge.targetNodeKey, "top");
|
||||
const isSelected = selectedEdgeKey === edge.edgeKey;
|
||||
return (
|
||||
<g key={edge.edgeKey}>
|
||||
{/* Hit area (wider invisible stroke for clicking) */}
|
||||
<path
|
||||
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={12}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }}
|
||||
/>
|
||||
<path
|
||||
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
|
||||
fill="none"
|
||||
stroke={isSelected ? "#00D4FF" : "#334155"}
|
||||
strokeWidth={isSelected ? 2.5 : 2}
|
||||
strokeDasharray={isSelected ? "none" : "none"}
|
||||
markerEnd=""
|
||||
/>
|
||||
{/* Arrow marker */}
|
||||
<circle cx={tgt.x} cy={tgt.y} r={3} fill={isSelected ? "#00D4FF" : "#334155"} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<path
|
||||
d={buildEdgePath(sx, sy, edgeDrawing.mouseX, edgeDrawing.mouseY)}
|
||||
fill="none"
|
||||
stroke="#00D4FF"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
opacity={0.7}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{nodesWithStatus.map((node) => (
|
||||
<WorkflowNodeBlock
|
||||
key={node.nodeKey}
|
||||
node={node}
|
||||
selected={selectedNodeKey === node.nodeKey}
|
||||
onSelect={() => { setSelectedNodeKey(node.nodeKey); setSelectedEdgeKey(null); }}
|
||||
onDelete={() => handleDeleteNode(node.nodeKey)}
|
||||
onEdit={() => setEditingNode(node)}
|
||||
onDragStart={(e) => handleNodeDragStart(node.nodeKey, e)}
|
||||
onPortMouseDown={handlePortInteraction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground/50 font-mono">
|
||||
Drag nodes from the palette to start building your workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node edit modal */}
|
||||
{editingNode && (
|
||||
<WorkflowNodeEditModal
|
||||
node={editingNode}
|
||||
agents={agents}
|
||||
open={!!editingNode}
|
||||
onOpenChange={(open) => { if (!open) setEditingNode(null); }}
|
||||
onSave={handleNodeSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
client/src/components/WorkflowCreateModal.tsx
Normal file
144
client/src/components/WorkflowCreateModal.tsx
Normal file
@@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleReset(); onOpenChange(v); }}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<GitBranch className="w-5 h-5 text-primary" />
|
||||
New Workflow
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Name *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Content Pipeline"
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this workflow do?"
|
||||
className="mt-1 min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Tags</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(); } }}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTag} className="shrink-0">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[10px] font-mono bg-primary/10 text-primary border-primary/20 gap-1">
|
||||
{tag}
|
||||
<X className="w-2.5 h-2.5 cursor-pointer hover:text-neon-red" onClick={() => handleRemoveTag(tag)} />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !name.trim()}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Plus className="w-3.5 h-3.5 mr-1" />}
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
233
client/src/components/WorkflowDashboard.tsx
Normal file
233
client/src/components/WorkflowDashboard.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* WorkflowDashboard — monitoring panel for a single workflow.
|
||||
* Shows: stats overview, run history, per-node results, real-time polling.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Ban,
|
||||
SkipForward,
|
||||
BarChart2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; bg: string; icon: any }> = {
|
||||
pending: { color: "text-muted-foreground", bg: "bg-muted/15", icon: Clock },
|
||||
running: { color: "text-primary", bg: "bg-primary/15", icon: Loader2 },
|
||||
success: { color: "text-neon-green", bg: "bg-neon-green/15", icon: CheckCircle },
|
||||
failed: { color: "text-neon-red", bg: "bg-neon-red/15", icon: XCircle },
|
||||
cancelled: { color: "text-neon-amber", bg: "bg-neon-amber/15", icon: Ban },
|
||||
skipped: { color: "text-muted-foreground", bg: "bg-muted/15", icon: SkipForward },
|
||||
};
|
||||
|
||||
interface WorkflowDashboardProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
onOpenCanvas: () => void;
|
||||
}
|
||||
|
||||
export default function WorkflowDashboard({ workflowId, workflowName, onOpenCanvas }: WorkflowDashboardProps) {
|
||||
// Stats
|
||||
const { data: stats, isLoading: statsLoading } = trpc.workflows.stats.useQuery(
|
||||
{ workflowId },
|
||||
{ refetchInterval: 10_000 }
|
||||
);
|
||||
|
||||
// Runs
|
||||
const { data: runs = [], isLoading: runsLoading, refetch: refetchRuns } = trpc.workflows.listRuns.useQuery(
|
||||
{ workflowId, limit: 20 },
|
||||
{ refetchInterval: 5_000 }
|
||||
);
|
||||
|
||||
// Execute
|
||||
const executeMutation = trpc.workflows.execute.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow run started");
|
||||
refetchRuns();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// Cancel
|
||||
const cancelMutation = trpc.workflows.cancelRun.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Run cancelled");
|
||||
refetchRuns();
|
||||
},
|
||||
});
|
||||
|
||||
const formatDuration = (ms?: number | null) => {
|
||||
if (!ms) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const formatTime = (d: any) => {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-foreground">{workflowName}</h3>
|
||||
<p className="text-xs text-muted-foreground font-mono">Workflow Dashboard · Real-time monitoring</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onOpenCanvas}>
|
||||
Open Canvas
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => executeMutation.mutate({ workflowId })}
|
||||
disabled={executeMutation.isPending}
|
||||
className="bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
|
||||
>
|
||||
{executeMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<StatsCard label="Total Runs" value={statsLoading ? "..." : String(stats?.totalRuns ?? 0)} color="text-primary" icon={Activity} />
|
||||
<StatsCard label="Success" value={statsLoading ? "..." : String(stats?.successRuns ?? 0)} color="text-neon-green" icon={CheckCircle} />
|
||||
<StatsCard label="Failed" value={statsLoading ? "..." : String(stats?.failedRuns ?? 0)} color="text-neon-red" icon={XCircle} />
|
||||
<StatsCard label="Success Rate" value={statsLoading ? "..." : `${stats?.successRate ?? 0}%`} color="text-primary" icon={BarChart2} />
|
||||
<StatsCard label="Avg Duration" value={statsLoading ? "..." : formatDuration(stats?.avgDurationMs)} color="text-neon-amber" icon={Clock} />
|
||||
</div>
|
||||
|
||||
{/* Run history */}
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
Run History
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<RefreshCw className="w-3 h-3" /> 5s
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||
<span className="text-xs font-mono text-muted-foreground">Loading runs...</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<Activity className="w-5 h-5 text-muted-foreground/30" />
|
||||
<span className="text-xs font-mono">No runs yet</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run: any, i: number) => {
|
||||
const sc = STATUS_CONFIG[run.status] ?? STATUS_CONFIG.pending;
|
||||
const StatusIcon = sc.icon;
|
||||
const nodeResults = (run.nodeResults ?? {}) as Record<string, any>;
|
||||
const nodeCount = Object.keys(nodeResults).length;
|
||||
const successNodes = Object.values(nodeResults).filter((r: any) => r.status === "success").length;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={run.runKey}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
className={`p-3 rounded-md ${sc.bg} border border-border/30`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`w-4 h-4 ${sc.color} ${run.status === "running" ? "animate-spin" : ""}`} />
|
||||
<span className="text-xs font-mono font-medium text-foreground">{run.runKey}</span>
|
||||
<Badge variant="outline" className={`text-[9px] font-mono ${sc.color}`}>
|
||||
{run.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === "running" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px] text-neon-red hover:bg-neon-red/10"
|
||||
onClick={() => cancelMutation.mutate({ runKey: run.runKey })}
|
||||
>
|
||||
<Ban className="w-3 h-3 mr-1" /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{formatTime(run.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node progress */}
|
||||
{nodeCount > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">Nodes:</span>
|
||||
<span className="text-[10px] font-mono text-foreground">{successNodes}/{nodeCount}</span>
|
||||
</div>
|
||||
<Progress value={nodeCount > 0 ? (successNodes / nodeCount) * 100 : 0} className="h-1.5" />
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{Object.entries(nodeResults).map(([key, result]: [string, any]) => {
|
||||
const nsc = STATUS_CONFIG[result.status] ?? STATUS_CONFIG.pending;
|
||||
return (
|
||||
<Badge key={key} variant="outline" className={`text-[8px] font-mono ${nsc.color} px-1.5 py-0`}>
|
||||
{key.replace("node_", "").slice(0, 8)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration & error */}
|
||||
<div className="flex items-center gap-4 text-[10px] font-mono text-muted-foreground">
|
||||
{run.totalDurationMs && (
|
||||
<span>Duration: <span className="text-foreground">{formatDuration(run.totalDurationMs)}</span></span>
|
||||
)}
|
||||
{run.currentNodeKey && run.status === "running" && (
|
||||
<span>Current: <span className="text-primary">{run.currentNodeKey}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{run.errorMessage && (
|
||||
<div className="text-[10px] font-mono text-neon-red mt-1 truncate">{run.errorMessage}</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCard({ label, value, color, icon: Icon }: { label: string; value: string; color: string; icon: any }) {
|
||||
return (
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||
<span className="text-[10px] font-mono text-muted-foreground uppercase">{label}</span>
|
||||
</div>
|
||||
<div className={`font-mono text-xl font-bold ${color}`}>{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
251
client/src/components/WorkflowNodeBlock.tsx
Normal file
251
client/src/components/WorkflowNodeBlock.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* WorkflowNodeBlock — individual draggable node block inside the canvas.
|
||||
* Rendered as a card with kind-specific icon/color, label, and connection ports.
|
||||
* The runtime status overlay (running/success/failed) is shown during execution.
|
||||
*/
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Play,
|
||||
GitFork,
|
||||
Flag,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
SkipForward,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export type NodeKind = "agent" | "container" | "trigger" | "condition" | "output";
|
||||
|
||||
export interface WFNodeData {
|
||||
nodeKey: string;
|
||||
label: string;
|
||||
kind: NodeKind;
|
||||
agentId?: number | null;
|
||||
agentName?: string;
|
||||
containerConfig?: Record<string, any>;
|
||||
conditionExpr?: string;
|
||||
triggerConfig?: Record<string, any>;
|
||||
posX: number;
|
||||
posY: number;
|
||||
meta?: Record<string, any>;
|
||||
/** Runtime status — set during execution */
|
||||
runStatus?: "pending" | "running" | "success" | "failed" | "skipped";
|
||||
runDurationMs?: number;
|
||||
runError?: string;
|
||||
}
|
||||
|
||||
const KIND_CONFIG: Record<NodeKind, { icon: any; color: string; bg: string; border: string; label: string }> = {
|
||||
trigger: { icon: Play, color: "text-neon-green", bg: "bg-neon-green/10", border: "border-neon-green/40", label: "Trigger" },
|
||||
agent: { icon: Bot, color: "text-primary", bg: "bg-primary/10", border: "border-primary/40", label: "Agent" },
|
||||
container: { icon: Box, color: "text-neon-amber", bg: "bg-neon-amber/10", border: "border-neon-amber/40", label: "Container" },
|
||||
condition: { icon: GitFork, color: "text-purple-400", bg: "bg-purple-400/10", border: "border-purple-400/40", label: "Condition" },
|
||||
output: { icon: Flag, color: "text-cyan-400", bg: "bg-cyan-400/10", border: "border-cyan-400/40", label: "Output" },
|
||||
};
|
||||
|
||||
const STATUS_OVERLAY: Record<string, { icon: any; color: string; animate?: boolean }> = {
|
||||
running: { icon: Loader2, color: "text-primary", animate: true },
|
||||
success: { icon: CheckCircle, color: "text-neon-green" },
|
||||
failed: { icon: XCircle, color: "text-neon-red" },
|
||||
skipped: { icon: SkipForward, color: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
interface WorkflowNodeBlockProps {
|
||||
node: WFNodeData;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
showPorts?: boolean;
|
||||
/** Connection port mouse-down handlers */
|
||||
onPortMouseDown?: (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function WorkflowNodeBlock({
|
||||
node,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
showPorts = true,
|
||||
onPortMouseDown,
|
||||
}: WorkflowNodeBlockProps) {
|
||||
const cfg = KIND_CONFIG[node.kind];
|
||||
const Icon = cfg.icon;
|
||||
const statusOverlay = node.runStatus ? STATUS_OVERLAY[node.runStatus] : null;
|
||||
const StatusIcon = statusOverlay?.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`
|
||||
absolute select-none cursor-grab active:cursor-grabbing
|
||||
w-[220px] rounded-lg border ${cfg.border} ${cfg.bg}
|
||||
${selected ? "ring-2 ring-primary/60 shadow-lg shadow-primary/10" : ""}
|
||||
${node.runStatus === "running" ? "ring-2 ring-primary/40 animate-pulse" : ""}
|
||||
backdrop-blur-sm transition-shadow
|
||||
`}
|
||||
style={{ left: node.posX, top: node.posY }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect?.(); }}
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
{/* Input port */}
|
||||
{showPorts && node.kind !== "trigger" && (
|
||||
<div
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "input", e); }}
|
||||
title="Input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className={`w-7 h-7 rounded-md ${cfg.bg} border ${cfg.border} flex items-center justify-center shrink-0`}>
|
||||
<Icon className={`w-4 h-4 ${cfg.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold text-foreground truncate">{node.label}</div>
|
||||
<div className={`text-[10px] font-mono ${cfg.color}`}>{cfg.label}</div>
|
||||
</div>
|
||||
{/* Status overlay */}
|
||||
{statusOverlay && StatusIcon && (
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${statusOverlay.color} shrink-0 ${statusOverlay.animate ? "animate-spin" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{node.kind === "agent" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{node.agentName ? (
|
||||
<span>Agent: <span className="text-primary">{node.agentName}</span></span>
|
||||
) : node.agentId ? (
|
||||
<span>Agent ID: <span className="text-primary">#{node.agentId}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-amber">No agent assigned</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "container" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{node.containerConfig?.image ? (
|
||||
<span>Image: <span className="text-neon-amber">{node.containerConfig.image as string}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-amber">No image configured</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "condition" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{node.conditionExpr ? (
|
||||
<code className="text-purple-400">{node.conditionExpr}</code>
|
||||
) : (
|
||||
<span className="text-purple-400/60">No condition set</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "trigger" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{node.triggerConfig?.type === "cron" ? (
|
||||
<span>Cron: <span className="text-neon-green">{node.triggerConfig.cron as string}</span></span>
|
||||
) : node.triggerConfig?.type === "webhook" ? (
|
||||
<span>Webhook: <span className="text-neon-green">{node.triggerConfig.webhookPath as string}</span></span>
|
||||
) : (
|
||||
<span className="text-neon-green">Manual start</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.kind === "output" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
Final output
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Runtime info */}
|
||||
{node.runDurationMs !== undefined && node.runStatus !== "pending" && node.runStatus !== "running" && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
Duration: <span className="text-foreground">{node.runDurationMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{node.runError && (
|
||||
<div className="text-[10px] font-mono text-neon-red truncate" title={node.runError}>
|
||||
{node.runError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions (visible when selected) */}
|
||||
{selected && (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-t border-border/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(); }}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-red"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output port */}
|
||||
{showPorts && node.kind !== "output" && (
|
||||
<div
|
||||
className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "output", e); }}
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini node block for the sidebar palette (drag source).
|
||||
*/
|
||||
export function WorkflowNodePaletteItem({
|
||||
kind,
|
||||
onDragStart,
|
||||
}: {
|
||||
kind: NodeKind;
|
||||
onDragStart: (kind: NodeKind) => void;
|
||||
}) {
|
||||
const cfg = KIND_CONFIG[kind];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2.5 px-3 py-2 rounded-md border ${cfg.border} ${cfg.bg}
|
||||
cursor-grab active:cursor-grabbing hover:brightness-110 transition-all
|
||||
`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(kind)}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center ${cfg.bg} border ${cfg.border}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${cfg.color}`} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{cfg.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
client/src/components/WorkflowNodeEditModal.tsx
Normal file
253
client/src/components/WorkflowNodeEditModal.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* WorkflowNodeEditModal — configure individual node properties.
|
||||
* Agent nodes get a selector for existing agents.
|
||||
* Container nodes get image/env/ports fields.
|
||||
* Condition nodes get an expression editor.
|
||||
* Trigger nodes get type/cron/webhook fields.
|
||||
*/
|
||||
import { useState, useEffect } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bot, Box, Play, GitFork, Flag, Save } from "lucide-react";
|
||||
import type { WFNodeData, NodeKind } from "./WorkflowNodeBlock";
|
||||
|
||||
interface WorkflowNodeEditModalProps {
|
||||
node: WFNodeData;
|
||||
agents: any[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (node: WFNodeData) => void;
|
||||
}
|
||||
|
||||
export function WorkflowNodeEditModal({
|
||||
node,
|
||||
agents,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: WorkflowNodeEditModalProps) {
|
||||
const [label, setLabel] = useState(node.label);
|
||||
const [agentId, setAgentId] = useState<string>(node.agentId ? String(node.agentId) : "");
|
||||
const [dockerImage, setDockerImage] = useState((node.containerConfig?.image as string) ?? "");
|
||||
const [dockerEnv, setDockerEnv] = useState((node.containerConfig?.env as string[] ?? []).join("\n"));
|
||||
const [dockerCommand, setDockerCommand] = useState((node.containerConfig?.command as string) ?? "");
|
||||
const [conditionExpr, setConditionExpr] = useState(node.conditionExpr ?? "");
|
||||
const [triggerType, setTriggerType] = useState((node.triggerConfig?.type as string) ?? "manual");
|
||||
const [cronExpr, setCronExpr] = useState((node.triggerConfig?.cron as string) ?? "");
|
||||
const [webhookPath, setWebhookPath] = useState((node.triggerConfig?.webhookPath as string) ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(node.label);
|
||||
setAgentId(node.agentId ? String(node.agentId) : "");
|
||||
setDockerImage((node.containerConfig?.image as string) ?? "");
|
||||
setDockerEnv((node.containerConfig?.env as string[] ?? []).join("\n"));
|
||||
setDockerCommand((node.containerConfig?.command as string) ?? "");
|
||||
setConditionExpr(node.conditionExpr ?? "");
|
||||
setTriggerType((node.triggerConfig?.type as string) ?? "manual");
|
||||
setCronExpr((node.triggerConfig?.cron as string) ?? "");
|
||||
setWebhookPath((node.triggerConfig?.webhookPath as string) ?? "");
|
||||
}, [node]);
|
||||
|
||||
const handleSave = () => {
|
||||
const selectedAgent = agents.find((a: any) => a.id === Number(agentId));
|
||||
const updated: WFNodeData = {
|
||||
...node,
|
||||
label,
|
||||
agentId: agentId ? Number(agentId) : undefined,
|
||||
agentName: selectedAgent?.name,
|
||||
containerConfig: {
|
||||
image: dockerImage,
|
||||
env: dockerEnv.split("\n").filter(Boolean),
|
||||
command: dockerCommand,
|
||||
},
|
||||
conditionExpr,
|
||||
triggerConfig: {
|
||||
type: triggerType,
|
||||
cron: cronExpr,
|
||||
webhookPath,
|
||||
},
|
||||
};
|
||||
onSave(updated);
|
||||
};
|
||||
|
||||
const kindIcons: Record<NodeKind, any> = {
|
||||
trigger: Play,
|
||||
agent: Bot,
|
||||
container: Box,
|
||||
condition: GitFork,
|
||||
output: Flag,
|
||||
};
|
||||
const KindIcon = kindIcons[node.kind];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<KindIcon className="w-5 h-5 text-primary" />
|
||||
Edit {node.kind.charAt(0).toUpperCase() + node.kind.slice(1)} Node
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Label */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Label</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Node name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent config */}
|
||||
{node.kind === "agent" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Agent</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent: any) => (
|
||||
<SelectItem key={agent.id} value={String(agent.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{agent.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] font-mono">{agent.role}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{agentId && (() => {
|
||||
const a = agents.find((ag: any) => ag.id === Number(agentId));
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div className="mt-2 p-2 rounded bg-secondary/30 border border-border/30 text-[10px] font-mono space-y-1">
|
||||
<div>Model: <span className="text-primary">{a.model}</span></div>
|
||||
<div>Provider: <span className="text-muted-foreground">{a.provider}</span></div>
|
||||
{a.description && <div className="text-muted-foreground">{a.description}</div>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container config */}
|
||||
{node.kind === "container" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Docker Image</Label>
|
||||
<Input
|
||||
value={dockerImage}
|
||||
onChange={(e) => setDockerImage(e.target.value)}
|
||||
placeholder="e.g. python:3.12-slim"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Command</Label>
|
||||
<Input
|
||||
value={dockerCommand}
|
||||
onChange={(e) => setDockerCommand(e.target.value)}
|
||||
placeholder="e.g. python /app/main.py"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Environment Variables (one per line)</Label>
|
||||
<Textarea
|
||||
value={dockerEnv}
|
||||
onChange={(e) => setDockerEnv(e.target.value)}
|
||||
placeholder="KEY=VALUE"
|
||||
className="mt-1 font-mono text-xs min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Condition config */}
|
||||
{node.kind === "condition" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Condition Expression</Label>
|
||||
<Textarea
|
||||
value={conditionExpr}
|
||||
onChange={(e) => setConditionExpr(e.target.value)}
|
||||
placeholder="e.g. input.length > 0"
|
||||
className="mt-1 font-mono text-xs min-h-[60px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Evaluates to true/false. If false, downstream nodes are skipped.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trigger config */}
|
||||
{node.kind === "trigger" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Trigger Type</Label>
|
||||
<Select value={triggerType} onValueChange={setTriggerType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="cron">Cron Schedule</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{triggerType === "cron" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="*/5 * * * *"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{triggerType === "webhook" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Webhook Path</Label>
|
||||
<Input
|
||||
value={webhookPath}
|
||||
onChange={(e) => setWebhookPath(e.target.value)}
|
||||
placeholder="/webhook/my-trigger"
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
|
||||
<Save className="w-3.5 h-3.5 mr-1" /> Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
const joinMut = trpc.nodes.joinNode.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setJoinResult(data as JoinResult);
|
||||
// Trigger parent refresh but DO NOT close dialog — let user read the result
|
||||
if ((data as JoinResult).ok) onSuccess();
|
||||
},
|
||||
onError: (e) => setJoinResult({ ok: false, error: e.message, step: "trpc" }),
|
||||
@@ -60,6 +61,8 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
const busy = testMut.isPending || joinMut.isPending;
|
||||
const canAct = !!host.trim() && !!user.trim() && !!password && !busy;
|
||||
const joinDone = joinResult?.ok === true;
|
||||
// After a successful join, allow re-testing but not re-joining
|
||||
const canJoin = canAct && !joinDone;
|
||||
|
||||
const handleTest = () => {
|
||||
setTestResult(null);
|
||||
@@ -73,6 +76,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
};
|
||||
|
||||
const inputClass = "h-8 text-xs font-mono";
|
||||
// Disable input fields while busy or after successful join
|
||||
const disabled = busy || joinDone;
|
||||
|
||||
return (
|
||||
@@ -262,7 +266,7 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
{/* Join swarm */}
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
disabled={!canAct}
|
||||
disabled={!canJoin}
|
||||
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-40"
|
||||
>
|
||||
{joinMut.isPending
|
||||
@@ -1204,7 +1208,7 @@ export default function Nodes() {
|
||||
{showAddNode && (
|
||||
<AddNodeDialog
|
||||
onClose={() => setShowAddNode(false)}
|
||||
onSuccess={() => { setShowAddNode(false); nodesQ.refetch(); swarmInfoQ.refetch(); }}
|
||||
onSuccess={() => { nodesQ.refetch(); swarmInfoQ.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
410
client/src/pages/Workflows.tsx
Normal file
410
client/src/pages/Workflows.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Workflows — Main page: list view, canvas constructor, and dashboard.
|
||||
*
|
||||
* Views:
|
||||
* 1. List view — all workflows with status, stats, quick actions
|
||||
* 2. Canvas view — visual drag-and-drop constructor (full screen)
|
||||
* 3. Dashboard view — run monitoring for a selected workflow
|
||||
*
|
||||
* Design: Mission Control theme — dark bg, cyan glow, mono fonts.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
Settings,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Pencil,
|
||||
Archive,
|
||||
Zap,
|
||||
BarChart2,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { WorkflowCreateModal } from "@/components/WorkflowCreateModal";
|
||||
import WorkflowCanvas from "@/components/WorkflowCanvas";
|
||||
import WorkflowDashboard from "@/components/WorkflowDashboard";
|
||||
import type { WFNodeData } from "@/components/WorkflowNodeBlock";
|
||||
import type { WFEdgeData } from "@/components/WorkflowCanvas";
|
||||
|
||||
const STATUS_STYLE: Record<string, { badge: string; dot: string }> = {
|
||||
draft: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
|
||||
active: { badge: "bg-neon-green/15 text-neon-green border-neon-green/30", dot: "bg-neon-green pulse-indicator" },
|
||||
paused: { badge: "bg-neon-amber/15 text-neon-amber border-neon-amber/30", dot: "bg-neon-amber" },
|
||||
archived: { badge: "bg-muted/15 text-muted-foreground border-border", dot: "bg-muted-foreground" },
|
||||
};
|
||||
|
||||
type ViewMode = "list" | "canvas" | "dashboard";
|
||||
|
||||
export default function Workflows() {
|
||||
const [, params] = useRoute("/workflows/:id");
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<number | null>(
|
||||
params?.id ? Number(params.id) : null
|
||||
);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [workflowToDelete, setWorkflowToDelete] = useState<number | null>(null);
|
||||
|
||||
// If URL has /workflows/:id, load that workflow
|
||||
useEffect(() => {
|
||||
if (params?.id) {
|
||||
setSelectedWorkflowId(Number(params.id));
|
||||
setViewMode("dashboard");
|
||||
}
|
||||
}, [params?.id]);
|
||||
|
||||
// List all workflows
|
||||
const { data: workflows = [], isLoading, refetch } = trpc.workflows.list.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Get single workflow (for canvas view)
|
||||
const { data: selectedWorkflow } = trpc.workflows.get.useQuery(
|
||||
{ id: selectedWorkflowId! },
|
||||
{ enabled: !!selectedWorkflowId && (viewMode === "canvas" || viewMode === "dashboard") }
|
||||
);
|
||||
|
||||
// Get latest run for polling node statuses
|
||||
const { data: latestRuns } = trpc.workflows.listRuns.useQuery(
|
||||
{ workflowId: selectedWorkflowId!, limit: 1 },
|
||||
{ enabled: !!selectedWorkflowId && viewMode === "canvas", refetchInterval: 3_000 }
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const deleteMutation = trpc.workflows.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow deleted");
|
||||
setDeleteConfirmOpen(false);
|
||||
setWorkflowToDelete(null);
|
||||
refetch();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.workflows.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow updated");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenCanvas = (id: number) => {
|
||||
setSelectedWorkflowId(id);
|
||||
setViewMode("canvas");
|
||||
};
|
||||
|
||||
const handleOpenDashboard = (id: number) => {
|
||||
setSelectedWorkflowId(id);
|
||||
setViewMode("dashboard");
|
||||
navigate(`/workflows/${id}`);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
setViewMode("list");
|
||||
setSelectedWorkflowId(null);
|
||||
navigate("/workflows");
|
||||
};
|
||||
|
||||
const handleToggleStatus = (id: number, currentStatus: string) => {
|
||||
const newStatus = currentStatus === "active" ? "paused" : "active";
|
||||
updateMutation.mutate({ id, status: newStatus as any });
|
||||
};
|
||||
|
||||
// Build canvas data from server response
|
||||
const canvasNodes: WFNodeData[] = (selectedWorkflow?.nodes ?? []).map((n: any) => ({
|
||||
nodeKey: n.nodeKey,
|
||||
label: n.label,
|
||||
kind: n.kind,
|
||||
agentId: n.agentId,
|
||||
containerConfig: n.containerConfig,
|
||||
conditionExpr: n.conditionExpr,
|
||||
triggerConfig: n.triggerConfig,
|
||||
posX: n.posX ?? 0,
|
||||
posY: n.posY ?? 0,
|
||||
meta: n.meta,
|
||||
}));
|
||||
|
||||
const canvasEdges: WFEdgeData[] = (selectedWorkflow?.edges ?? []).map((e: any) => ({
|
||||
edgeKey: e.edgeKey,
|
||||
sourceNodeKey: e.sourceNodeKey,
|
||||
targetNodeKey: e.targetNodeKey,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
label: e.label,
|
||||
meta: e.meta,
|
||||
}));
|
||||
|
||||
// Run results for canvas overlay
|
||||
const latestRun = latestRuns?.[0];
|
||||
const runResults = latestRun?.status === "running" || latestRun?.status === "success" || latestRun?.status === "failed"
|
||||
? (latestRun.nodeResults as any) ?? {}
|
||||
: undefined;
|
||||
|
||||
// ─── Canvas View ──────────────────────────────────────────────────────────
|
||||
if (viewMode === "canvas" && selectedWorkflowId && selectedWorkflow) {
|
||||
return (
|
||||
<div className="-m-6 h-[calc(100vh-3.5rem)]">
|
||||
<WorkflowCanvas
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
initialNodes={canvasNodes}
|
||||
initialEdges={canvasEdges}
|
||||
runResults={runResults}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dashboard View ───────────────────────────────────────────────────────
|
||||
if (viewMode === "dashboard" && selectedWorkflowId && selectedWorkflow) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button size="sm" variant="ghost" onClick={handleBackToList} className="text-muted-foreground hover:text-foreground mb-2">
|
||||
← Back to Workflows
|
||||
</Button>
|
||||
|
||||
<Tabs defaultValue="dashboard">
|
||||
<TabsList className="bg-secondary/30 border border-border/30">
|
||||
<TabsTrigger value="dashboard" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
|
||||
<BarChart2 className="w-3.5 h-3.5 mr-1.5" /> Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="canvas" className="data-[state=active]:bg-primary/15 data-[state=active]:text-primary">
|
||||
<GitBranch className="w-3.5 h-3.5 mr-1.5" /> Canvas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard" className="mt-4">
|
||||
<WorkflowDashboard
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
onOpenCanvas={() => setViewMode("canvas")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="canvas" className="mt-4">
|
||||
<div className="-mx-6 -mb-6 h-[calc(100vh-14rem)]">
|
||||
<WorkflowCanvas
|
||||
workflowId={selectedWorkflowId}
|
||||
workflowName={selectedWorkflow.name}
|
||||
initialNodes={canvasNodes}
|
||||
initialEdges={canvasEdges}
|
||||
runResults={runResults}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List View ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">Workflows</h2>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{workflows.length} workflows · Visual pipeline constructor
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : workflows.length === 0 ? (
|
||||
/* Empty state */
|
||||
<Card className="bg-card border-border/50">
|
||||
<CardContent className="p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-30" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No Workflows Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Create your first workflow to build visual agent pipelines.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create First Workflow
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* Workflow grid */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{workflows.map((wf: any, i: number) => {
|
||||
const ss = STATUS_STYLE[wf.status] ?? STATUS_STYLE.draft;
|
||||
return (
|
||||
<motion.div
|
||||
key={wf.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer group"
|
||||
onClick={() => handleOpenDashboard(wf.id)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/30 flex items-center justify-center">
|
||||
<GitBranch className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{wf.name}
|
||||
</h3>
|
||||
{wf.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate max-w-[180px]">{wf.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${ss.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ss.dot} mr-1.5`} />
|
||||
{wf.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{wf.tags && wf.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{(wf.tags as string[]).map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-[9px] font-mono bg-secondary/30 text-muted-foreground border-border/30 px-1.5 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex items-center gap-4 mb-3 text-[10px] font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Created: {new Date(wf.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
Updated: {new Date(wf.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
|
||||
onClick={(e) => { e.stopPropagation(); handleOpenCanvas(wf.id); }}
|
||||
>
|
||||
<Pencil className="w-3 h-3 mr-1" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
|
||||
onClick={(e) => { e.stopPropagation(); handleOpenDashboard(wf.id); }}
|
||||
>
|
||||
<BarChart2 className="w-3 h-3 mr-1" /> Monitor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`h-7 text-[11px] ml-auto ${
|
||||
wf.status === "active"
|
||||
? "text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
|
||||
: "text-neon-green border-neon-green/30 hover:bg-neon-green/10"
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleStatus(wf.id, wf.status); }}
|
||||
>
|
||||
{wf.status === "active" ? <Pause className="w-3 h-3 mr-1" /> : <Play className="w-3 h-3 mr-1" />}
|
||||
{wf.status === "active" ? "Pause" : "Activate"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10"
|
||||
onClick={(e) => { e.stopPropagation(); setWorkflowToDelete(wf.id); setDeleteConfirmOpen(true); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
<WorkflowCreateModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the workflow, all nodes, edges, and run history. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => workflowToDelete && deleteMutation.mutate({ id: workflowToDelete })}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-neon-red hover:bg-neon-red/90"
|
||||
>
|
||||
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
drizzle/0007_workflows.sql
Normal file
77
drizzle/0007_workflows.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Workflows: pipeline definitions
|
||||
CREATE TABLE `workflows` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`status` enum('draft','active','paused','archived') NOT NULL DEFAULT 'draft',
|
||||
`canvasMeta` json DEFAULT ('{}'),
|
||||
`tags` json DEFAULT ('[]'),
|
||||
`createdBy` int,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `workflows_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Workflow Nodes: blocks inside a workflow (agent / container / trigger / condition / output)
|
||||
CREATE TABLE `workflowNodes` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`workflowId` int NOT NULL,
|
||||
`nodeKey` varchar(64) NOT NULL,
|
||||
`label` varchar(255) NOT NULL,
|
||||
`kind` enum('agent','container','trigger','condition','output') NOT NULL,
|
||||
`agentId` int,
|
||||
`containerConfig` json DEFAULT ('{}'),
|
||||
`conditionExpr` text,
|
||||
`triggerConfig` json DEFAULT ('{}'),
|
||||
`posX` int DEFAULT 0,
|
||||
`posY` int DEFAULT 0,
|
||||
`meta` json DEFAULT ('{}'),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `workflowNodes_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `workflowNodes_workflowId_idx` ON `workflowNodes` (`workflowId`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Workflow Edges: connections between nodes
|
||||
CREATE TABLE `workflowEdges` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`workflowId` int NOT NULL,
|
||||
`edgeKey` varchar(64) NOT NULL,
|
||||
`sourceNodeKey` varchar(64) NOT NULL,
|
||||
`targetNodeKey` varchar(64) NOT NULL,
|
||||
`sourceHandle` varchar(64),
|
||||
`targetHandle` varchar(64),
|
||||
`label` varchar(128),
|
||||
`meta` json DEFAULT ('{}'),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `workflowEdges_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `workflowEdges_workflowId_idx` ON `workflowEdges` (`workflowId`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Workflow Runs: execution history with per-node results
|
||||
CREATE TABLE `workflowRuns` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`workflowId` int NOT NULL,
|
||||
`runKey` varchar(64) NOT NULL,
|
||||
`status` enum('pending','running','success','failed','cancelled') NOT NULL DEFAULT 'pending',
|
||||
`nodeResults` json DEFAULT ('{}'),
|
||||
`currentNodeKey` varchar(64),
|
||||
`input` text,
|
||||
`output` text,
|
||||
`totalDurationMs` int,
|
||||
`errorMessage` text,
|
||||
`startedAt` timestamp,
|
||||
`finishedAt` timestamp,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `workflowRuns_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `workflowRuns_runKey_unique` UNIQUE(`runKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `workflowRuns_workflowId_idx` ON `workflowRuns` (`workflowId`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `workflowRuns_status_idx` ON `workflowRuns` (`status`);
|
||||
@@ -274,3 +274,128 @@ export const chatEvents = mysqlTable("chatEvents", {
|
||||
|
||||
export type ChatEvent = typeof chatEvents.$inferSelect;
|
||||
export type InsertChatEvent = typeof chatEvents.$inferInsert;
|
||||
|
||||
// ─── Workflows ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Workflows — visual pipeline definitions composed of agent/container nodes.
|
||||
* Each workflow is a directed graph stored as nodes + edges.
|
||||
*/
|
||||
export const workflows = mysqlTable("workflows", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
/** Visual status used in the list/dashboard */
|
||||
status: mysqlEnum("status", ["draft", "active", "paused", "archived"]).default("draft").notNull(),
|
||||
/** JSON blob of canvas-level metadata: viewport position, zoom, layout hints */
|
||||
canvasMeta: json("canvasMeta").$type<{ viewportX?: number; viewportY?: number; zoom?: number }>().default({}),
|
||||
tags: json("tags").$type<string[]>().default([]),
|
||||
createdBy: int("createdBy"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Workflow = typeof workflows.$inferSelect;
|
||||
export type InsertWorkflow = typeof workflows.$inferInsert;
|
||||
|
||||
/**
|
||||
* Workflow Nodes — individual blocks inside a workflow.
|
||||
* Each node references either an agent (agentId) or an arbitrary container config.
|
||||
*/
|
||||
export const workflowNodes = mysqlTable("workflowNodes", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
workflowId: int("workflowId").notNull(),
|
||||
/** Unique client-side ID used by the canvas (e.g. "node_abc123") */
|
||||
nodeKey: varchar("nodeKey", { length: 64 }).notNull(),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
/** Node kind: agent = uses an existing agent; container = custom Docker image; trigger = entry point; output = terminal */
|
||||
kind: mysqlEnum("kind", ["agent", "container", "trigger", "condition", "output"]).notNull(),
|
||||
/** Link to agents table (nullable — only for kind=agent) */
|
||||
agentId: int("agentId"),
|
||||
/** For kind=container: Docker image, env vars, ports etc. */
|
||||
containerConfig: json("containerConfig").$type<{
|
||||
image?: string;
|
||||
env?: string[];
|
||||
ports?: string[];
|
||||
command?: string;
|
||||
volumes?: string[];
|
||||
}>().default({}),
|
||||
/** For kind=condition: JS expression evaluated at runtime */
|
||||
conditionExpr: text("conditionExpr"),
|
||||
/** Trigger config: cron, webhook, manual */
|
||||
triggerConfig: json("triggerConfig").$type<{ type?: string; cron?: string; webhookPath?: string }>().default({}),
|
||||
/** Canvas position */
|
||||
posX: int("posX").default(0),
|
||||
posY: int("posY").default(0),
|
||||
/** Extra metadata (colour, icon override, etc.) */
|
||||
meta: json("meta").$type<Record<string, any>>().default({}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
workflowIdIdx: index("workflowNodes_workflowId_idx").on(table.workflowId),
|
||||
}));
|
||||
|
||||
export type WorkflowNode = typeof workflowNodes.$inferSelect;
|
||||
export type InsertWorkflowNode = typeof workflowNodes.$inferInsert;
|
||||
|
||||
/**
|
||||
* Workflow Edges — connections between nodes.
|
||||
*/
|
||||
export const workflowEdges = mysqlTable("workflowEdges", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
workflowId: int("workflowId").notNull(),
|
||||
/** Edge identifier on the canvas */
|
||||
edgeKey: varchar("edgeKey", { length: 64 }).notNull(),
|
||||
sourceNodeKey: varchar("sourceNodeKey", { length: 64 }).notNull(),
|
||||
targetNodeKey: varchar("targetNodeKey", { length: 64 }).notNull(),
|
||||
/** Optional: which output handle → which input handle */
|
||||
sourceHandle: varchar("sourceHandle", { length: 64 }),
|
||||
targetHandle: varchar("targetHandle", { length: 64 }),
|
||||
/** Edge label (e.g. "on success", "on fail") */
|
||||
label: varchar("label", { length: 128 }),
|
||||
/** Visual styling */
|
||||
meta: json("meta").$type<Record<string, any>>().default({}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
workflowIdIdx: index("workflowEdges_workflowId_idx").on(table.workflowId),
|
||||
}));
|
||||
|
||||
export type WorkflowEdge = typeof workflowEdges.$inferSelect;
|
||||
export type InsertWorkflowEdge = typeof workflowEdges.$inferInsert;
|
||||
|
||||
/**
|
||||
* Workflow Runs — execution history. Each run tracks overall status and
|
||||
* per-node results so the dashboard can show progress in real-time.
|
||||
*/
|
||||
export const workflowRuns = mysqlTable("workflowRuns", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
workflowId: int("workflowId").notNull(),
|
||||
runKey: varchar("runKey", { length: 64 }).notNull().unique(),
|
||||
status: mysqlEnum("status", ["pending", "running", "success", "failed", "cancelled"]).default("pending").notNull(),
|
||||
/** Per-node execution results: { [nodeKey]: { status, output, durationMs, error? } } */
|
||||
nodeResults: json("nodeResults").$type<Record<string, {
|
||||
status: "pending" | "running" | "success" | "failed" | "skipped";
|
||||
output?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
}>>().default({}),
|
||||
/** The node currently being executed */
|
||||
currentNodeKey: varchar("currentNodeKey", { length: 64 }),
|
||||
/** Global input passed to the first node */
|
||||
input: text("input"),
|
||||
/** Final aggregated output */
|
||||
output: text("output"),
|
||||
totalDurationMs: int("totalDurationMs"),
|
||||
errorMessage: text("errorMessage"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
finishedAt: timestamp("finishedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
workflowIdIdx: index("workflowRuns_workflowId_idx").on(table.workflowId),
|
||||
statusIdx: index("workflowRuns_status_idx").on(table.status),
|
||||
}));
|
||||
|
||||
export type WorkflowRun = typeof workflowRuns.$inferSelect;
|
||||
export type InsertWorkflowRun = typeof workflowRuns.$inferInsert;
|
||||
|
||||
@@ -1510,6 +1510,21 @@ func (h *Handler) SwarmJoinNodeViaSSH(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
log.Printf("[SwarmJoinNode] Success: %s joined as %s", body.Host, body.Role)
|
||||
|
||||
// Give Docker Swarm ~3 seconds to propagate the new node, then sync to DB.
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
nodes, err := h.docker.ListNodes()
|
||||
if err != nil {
|
||||
log.Printf("[SwarmJoinNode] DB sync failed (ListNodes): %v", err)
|
||||
return
|
||||
}
|
||||
if h.db != nil {
|
||||
h.db.UpsertSwarmNodes(nodes)
|
||||
log.Printf("[SwarmJoinNode] DB synced: %d nodes after join", len(nodes))
|
||||
}
|
||||
}()
|
||||
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"output": output,
|
||||
@@ -1575,9 +1590,13 @@ func (h *Handler) SwarmSSHTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
out, _ := sess.CombinedOutput("docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'")
|
||||
// Use plain 'docker info' to get server version — works on all distros
|
||||
out, _ := sess.CombinedOutput("docker info --format '{{.ServerVersion}}' 2>/dev/null || docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'")
|
||||
dockerVer := strings.TrimSpace(string(out))
|
||||
dockerOk := dockerVer != "" && dockerVer != "docker_not_found"
|
||||
if dockerVer == "" {
|
||||
dockerVer = "docker_not_found"
|
||||
}
|
||||
dockerOk := dockerVer != "docker_not_found" && !strings.Contains(dockerVer, "not found") && !strings.Contains(dockerVer, "command not found")
|
||||
|
||||
log.Printf("[SSHTest] %s — SSH OK, docker: %s", addr, dockerVer)
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
|
||||
@@ -1100,5 +1100,143 @@ export const appRouter = router({
|
||||
return result;
|
||||
}),
|
||||
}),
|
||||
/**
|
||||
* Workflows — visual pipeline builder (CRUD + execution)
|
||||
*/
|
||||
workflows: router({
|
||||
/** List all workflows */
|
||||
list: publicProcedure.query(async () => {
|
||||
const { getAllWorkflows } = await import("./workflows");
|
||||
return getAllWorkflows();
|
||||
}),
|
||||
|
||||
/** Get a single workflow with its nodes and edges */
|
||||
get: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getWorkflowById } = await import("./workflows");
|
||||
return getWorkflowById(input.id);
|
||||
}),
|
||||
|
||||
/** Create a new workflow */
|
||||
create: publicProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { createWorkflow } = await import("./workflows");
|
||||
return createWorkflow({ ...input, status: "draft" });
|
||||
}),
|
||||
|
||||
/** Update workflow metadata */
|
||||
update: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["draft", "active", "paused", "archived"]).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { updateWorkflow } = await import("./workflows");
|
||||
const { id, ...data } = input;
|
||||
return updateWorkflow(id, data as any);
|
||||
}),
|
||||
|
||||
/** Delete a workflow and all its nodes/edges/runs */
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { deleteWorkflow } = await import("./workflows");
|
||||
return deleteWorkflow(input.id);
|
||||
}),
|
||||
|
||||
/** Save the full canvas (nodes + edges) atomically */
|
||||
saveCanvas: publicProcedure
|
||||
.input(z.object({
|
||||
workflowId: z.number(),
|
||||
nodes: z.array(z.object({
|
||||
nodeKey: z.string(),
|
||||
label: z.string(),
|
||||
kind: z.enum(["agent", "container", "trigger", "condition", "output"]),
|
||||
agentId: z.number().nullable().optional(),
|
||||
containerConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
conditionExpr: z.string().optional(),
|
||||
triggerConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
posX: z.number().default(0),
|
||||
posY: z.number().default(0),
|
||||
meta: z.record(z.string(), z.unknown()).optional(),
|
||||
})),
|
||||
edges: z.array(z.object({
|
||||
edgeKey: z.string(),
|
||||
sourceNodeKey: z.string(),
|
||||
targetNodeKey: z.string(),
|
||||
sourceHandle: z.string().optional(),
|
||||
targetHandle: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
meta: z.record(z.string(), z.unknown()).optional(),
|
||||
})),
|
||||
canvasMeta: z.record(z.string(), z.unknown()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { saveCanvas } = await import("./workflows");
|
||||
return saveCanvas(
|
||||
input.workflowId,
|
||||
input.nodes.map((n) => ({ ...n, workflowId: input.workflowId } as any)),
|
||||
input.edges.map((e) => ({ ...e, workflowId: input.workflowId } as any)),
|
||||
input.canvasMeta,
|
||||
);
|
||||
}),
|
||||
|
||||
/** Execute a full workflow */
|
||||
execute: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), input: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { executeWorkflow } = await import("./workflows");
|
||||
return executeWorkflow(input.workflowId, input.input);
|
||||
}),
|
||||
|
||||
/** Execute a single node (for testing) */
|
||||
executeNode: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), nodeKey: z.string(), input: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { executeSingleNode } = await import("./workflows");
|
||||
return executeSingleNode(input.workflowId, input.nodeKey, input.input);
|
||||
}),
|
||||
|
||||
/** Cancel a running workflow */
|
||||
cancelRun: publicProcedure
|
||||
.input(z.object({ runKey: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { cancelRun } = await import("./workflows");
|
||||
return cancelRun(input.runKey);
|
||||
}),
|
||||
|
||||
/** Get run details */
|
||||
getRun: publicProcedure
|
||||
.input(z.object({ runKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getRunByKey } = await import("./workflows");
|
||||
return getRunByKey(input.runKey);
|
||||
}),
|
||||
|
||||
/** List runs for a workflow */
|
||||
listRuns: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), limit: z.number().default(50) }))
|
||||
.query(async ({ input }) => {
|
||||
const { getRunsByWorkflow } = await import("./workflows");
|
||||
return getRunsByWorkflow(input.workflowId, input.limit);
|
||||
}),
|
||||
|
||||
/** Get workflow stats */
|
||||
stats: publicProcedure
|
||||
.input(z.object({ workflowId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getWorkflowStats } = await import("./workflows");
|
||||
return getWorkflowStats(input.workflowId);
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
418
server/workflows.ts
Normal file
418
server/workflows.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* server/workflows.ts — Workflow CRUD, graph operations & execution engine.
|
||||
*
|
||||
* A Workflow is a directed graph of nodes (agents / containers / triggers / conditions / outputs)
|
||||
* connected by edges. The execution engine walks the graph from trigger nodes,
|
||||
* executing each agent/container block and forwarding the output downstream.
|
||||
*/
|
||||
|
||||
import { eq, desc, and, inArray } from "drizzle-orm";
|
||||
import {
|
||||
workflows, workflowNodes, workflowEdges, workflowRuns,
|
||||
type Workflow, type InsertWorkflow,
|
||||
type WorkflowNode, type InsertWorkflowNode,
|
||||
type WorkflowEdge, type InsertWorkflowEdge,
|
||||
type WorkflowRun,
|
||||
} from "../drizzle/schema";
|
||||
import { getDb } from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// ─── Workflow CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createWorkflow(data: Omit<InsertWorkflow, "id">): Promise<Workflow | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(workflows).values(data);
|
||||
const id = result[0].insertId;
|
||||
const [row] = await db.select().from(workflows).where(eq(workflows.id, Number(id))).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getAllWorkflows(): Promise<Workflow[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(workflows).orderBy(desc(workflows.updatedAt));
|
||||
}
|
||||
|
||||
export async function getWorkflowById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const [wf] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
|
||||
if (!wf) return null;
|
||||
|
||||
const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, id));
|
||||
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id));
|
||||
return { ...wf, nodes, edges };
|
||||
}
|
||||
|
||||
export async function updateWorkflow(id: number, data: Partial<InsertWorkflow>): Promise<Workflow | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
await db.update(workflows).set(data).where(eq(workflows.id, id));
|
||||
const [row] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(id: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, id));
|
||||
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, id));
|
||||
await db.delete(workflowRuns).where(eq(workflowRuns.workflowId, id));
|
||||
await db.delete(workflows).where(eq(workflows.id, id));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Nodes CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveNodes(workflowId: number, nodes: InsertWorkflowNode[]): Promise<WorkflowNode[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
// Delete existing nodes for this workflow, then insert fresh set (canvas save = full replace)
|
||||
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
|
||||
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
await db.insert(workflowNodes).values(
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
workflowId,
|
||||
nodeKey: n.nodeKey || `node_${nanoid(8)}`,
|
||||
}))
|
||||
);
|
||||
|
||||
return db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
|
||||
}
|
||||
|
||||
// ─── Edges CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveEdges(workflowId: number, edges: InsertWorkflowEdge[]): Promise<WorkflowEdge[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
|
||||
|
||||
if (edges.length === 0) return [];
|
||||
|
||||
await db.insert(workflowEdges).values(
|
||||
edges.map((e) => ({
|
||||
...e,
|
||||
workflowId,
|
||||
edgeKey: e.edgeKey || `edge_${nanoid(8)}`,
|
||||
}))
|
||||
);
|
||||
|
||||
return db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
|
||||
}
|
||||
|
||||
// ─── Full canvas save (nodes + edges atomically) ─────────────────────────────
|
||||
|
||||
export async function saveCanvas(
|
||||
workflowId: number,
|
||||
nodesData: InsertWorkflowNode[],
|
||||
edgesData: InsertWorkflowEdge[],
|
||||
canvasMeta?: Record<string, any>,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// Update canvas meta on the workflow itself
|
||||
if (canvasMeta) {
|
||||
await db.update(workflows).set({ canvasMeta } as any).where(eq(workflows.id, workflowId));
|
||||
}
|
||||
|
||||
const nodes = await saveNodes(workflowId, nodesData);
|
||||
const edges = await saveEdges(workflowId, edgesData);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ─── Workflow Runs ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createRun(workflowId: number, input?: string): Promise<WorkflowRun | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const runKey = `run_${nanoid(12)}`;
|
||||
await db.insert(workflowRuns).values({
|
||||
workflowId,
|
||||
runKey,
|
||||
status: "pending",
|
||||
input: input ?? null,
|
||||
nodeResults: {},
|
||||
});
|
||||
|
||||
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getRunsByWorkflow(workflowId: number, limit = 50): Promise<WorkflowRun[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(workflowRuns)
|
||||
.where(eq(workflowRuns.workflowId, workflowId))
|
||||
.orderBy(desc(workflowRuns.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getRunByKey(runKey: string): Promise<WorkflowRun | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function updateRun(runKey: string, data: Partial<WorkflowRun>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(workflowRuns).set(data as any).where(eq(workflowRuns.runKey, runKey));
|
||||
}
|
||||
|
||||
// ─── Execution Engine ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a single node. For agent nodes it calls the agent chat mutation;
|
||||
* for container nodes it can later call Docker SDK; for conditions it evals the expression.
|
||||
*/
|
||||
async function executeNode(
|
||||
node: WorkflowNode,
|
||||
input: string,
|
||||
runKey: string,
|
||||
): Promise<{ output: string; success: boolean; error?: string }> {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
switch (node.kind) {
|
||||
case "agent": {
|
||||
if (!node.agentId) return { output: "", success: false, error: "No agentId configured" };
|
||||
const { getAgentById } = await import("./agents");
|
||||
const agent = await getAgentById(node.agentId);
|
||||
if (!agent) return { output: "", success: false, error: `Agent #${node.agentId} not found` };
|
||||
|
||||
const { chatCompletion } = await import("./ollama");
|
||||
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
|
||||
if (agent.systemPrompt) messages.push({ role: "system", content: agent.systemPrompt });
|
||||
messages.push({ role: "user", content: input });
|
||||
|
||||
const result = await chatCompletion(agent.model, messages, {
|
||||
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
|
||||
max_tokens: agent.maxTokens ?? 2048,
|
||||
});
|
||||
const text = result.choices[0]?.message?.content ?? "";
|
||||
return { output: text, success: true };
|
||||
}
|
||||
|
||||
case "container": {
|
||||
// Placeholder: in production this would call Docker SDK / Gateway
|
||||
const cfg = node.containerConfig as any;
|
||||
return {
|
||||
output: `[Container ${cfg?.image ?? "unknown"}] executed with input length=${input.length}`,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
case "condition": {
|
||||
const expr = node.conditionExpr ?? "true";
|
||||
// Simple safe eval: only allow basic boolean expressions
|
||||
const result = expr.trim().toLowerCase() === "true" || input.trim().length > 0;
|
||||
return { output: result ? "true" : "false", success: true };
|
||||
}
|
||||
|
||||
case "trigger":
|
||||
case "output":
|
||||
return { output: input, success: true };
|
||||
|
||||
default:
|
||||
return { output: input, success: true };
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { output: "", success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a full workflow from its trigger node(s) following edges.
|
||||
* Updates workflowRuns in real-time so the dashboard can poll progress.
|
||||
*/
|
||||
export async function executeWorkflow(workflowId: number, userInput?: string): Promise<WorkflowRun | null> {
|
||||
const wf = await getWorkflowById(workflowId);
|
||||
if (!wf) return null;
|
||||
|
||||
const run = await createRun(workflowId, userInput);
|
||||
if (!run) return null;
|
||||
|
||||
const { nodes, edges } = wf;
|
||||
|
||||
// Build adjacency: sourceNodeKey → [targetNodeKey, …]
|
||||
const adj: Record<string, string[]> = {};
|
||||
for (const e of edges) {
|
||||
if (!adj[e.sourceNodeKey]) adj[e.sourceNodeKey] = [];
|
||||
adj[e.sourceNodeKey].push(e.targetNodeKey);
|
||||
}
|
||||
|
||||
// Find trigger / start nodes (no incoming edges, or kind=trigger)
|
||||
const incomingSet = new Set(edges.map((e) => e.targetNodeKey));
|
||||
const startNodes = nodes.filter(
|
||||
(n) => n.kind === "trigger" || !incomingSet.has(n.nodeKey)
|
||||
);
|
||||
|
||||
const nodeMap: Record<string, WorkflowNode> = {};
|
||||
for (const n of nodes) nodeMap[n.nodeKey] = n;
|
||||
|
||||
// Mark run as running
|
||||
await updateRun(run.runKey, { status: "running", startedAt: new Date() } as any);
|
||||
|
||||
const nodeResults: Record<string, any> = {};
|
||||
const visited = new Set<string>();
|
||||
|
||||
// BFS execution
|
||||
const queue: Array<{ nodeKey: string; input: string }> = startNodes.map((n) => ({
|
||||
nodeKey: n.nodeKey,
|
||||
input: userInput ?? "",
|
||||
}));
|
||||
|
||||
let finalOutput = "";
|
||||
let hasError = false;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeKey, input } = queue.shift()!;
|
||||
if (visited.has(nodeKey)) continue;
|
||||
visited.add(nodeKey);
|
||||
|
||||
const node = nodeMap[nodeKey];
|
||||
if (!node) continue;
|
||||
|
||||
// Update current node
|
||||
nodeResults[nodeKey] = { status: "running", startedAt: new Date().toISOString() };
|
||||
await updateRun(run.runKey, { currentNodeKey: nodeKey, nodeResults } as any);
|
||||
|
||||
const start = Date.now();
|
||||
const result = await executeNode(node, input, run.runKey);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
nodeResults[nodeKey] = {
|
||||
status: result.success ? "success" : "failed",
|
||||
output: result.output,
|
||||
durationMs,
|
||||
error: result.error,
|
||||
startedAt: nodeResults[nodeKey].startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
await updateRun(run.runKey, { nodeResults } as any);
|
||||
|
||||
if (!result.success) {
|
||||
hasError = true;
|
||||
continue; // don't propagate to children on failure
|
||||
}
|
||||
|
||||
// For condition nodes: only propagate if result is "true"
|
||||
if (node.kind === "condition" && result.output !== "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
finalOutput = result.output;
|
||||
|
||||
// Enqueue children
|
||||
const children = adj[nodeKey] ?? [];
|
||||
for (const childKey of children) {
|
||||
if (!visited.has(childKey)) {
|
||||
queue.push({ nodeKey: childKey, input: result.output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark remaining unvisited nodes as skipped
|
||||
for (const n of nodes) {
|
||||
if (!nodeResults[n.nodeKey]) {
|
||||
nodeResults[n.nodeKey] = { status: "skipped" };
|
||||
}
|
||||
}
|
||||
|
||||
const totalDurationMs = run.startedAt ? Date.now() - new Date(run.startedAt as any).getTime() : 0;
|
||||
|
||||
await updateRun(run.runKey, {
|
||||
status: hasError ? "failed" : "success",
|
||||
nodeResults,
|
||||
output: finalOutput,
|
||||
totalDurationMs,
|
||||
finishedAt: new Date(),
|
||||
currentNodeKey: null,
|
||||
errorMessage: hasError ? "One or more nodes failed" : null,
|
||||
} as any);
|
||||
|
||||
return getRunByKey(run.runKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single node inside a workflow (for testing individual blocks).
|
||||
*/
|
||||
export async function executeSingleNode(
|
||||
workflowId: number,
|
||||
nodeKey: string,
|
||||
input: string,
|
||||
): Promise<{ output: string; success: boolean; durationMs: number; error?: string }> {
|
||||
const db = await getDb();
|
||||
if (!db) return { output: "", success: false, durationMs: 0, error: "DB unavailable" };
|
||||
|
||||
const [node] = await db
|
||||
.select()
|
||||
.from(workflowNodes)
|
||||
.where(and(eq(workflowNodes.workflowId, workflowId), eq(workflowNodes.nodeKey, nodeKey)))
|
||||
.limit(1);
|
||||
|
||||
if (!node) return { output: "", success: false, durationMs: 0, error: "Node not found" };
|
||||
|
||||
const start = Date.now();
|
||||
const result = await executeNode(node, input, `test_${nanoid(8)}`);
|
||||
return { ...result, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running workflow run
|
||||
*/
|
||||
export async function cancelRun(runKey: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
|
||||
await db
|
||||
.update(workflowRuns)
|
||||
.set({ status: "cancelled", finishedAt: new Date() } as any)
|
||||
.where(eq(workflowRuns.runKey, runKey));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated stats for a workflow
|
||||
*/
|
||||
export async function getWorkflowStats(workflowId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(workflowRuns)
|
||||
.where(eq(workflowRuns.workflowId, workflowId))
|
||||
.orderBy(desc(workflowRuns.createdAt))
|
||||
.limit(100);
|
||||
|
||||
const total = runs.length;
|
||||
const success = runs.filter((r) => r.status === "success").length;
|
||||
const failed = runs.filter((r) => r.status === "failed").length;
|
||||
const running = runs.filter((r) => r.status === "running").length;
|
||||
const avgDuration = total > 0
|
||||
? Math.round(runs.reduce((s, r) => s + (r.totalDurationMs ?? 0), 0) / total)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRuns: total,
|
||||
successRuns: success,
|
||||
failedRuns: failed,
|
||||
runningRuns: running,
|
||||
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
|
||||
avgDurationMs: avgDuration,
|
||||
lastRun: runs[0] ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user