## 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
252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|