Files
GoClaw/client/src/components/WorkflowNodeBlock.tsx
bboxwtf 5ff2ade579 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
2026-03-22 00:10:53 +00:00

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>
);
}