From d0260fc33d86c5d4996cb45b741e3a352d41a75e Mon Sep 17 00:00:00 2001 From: oxedom Date: Sun, 25 Jan 2026 23:32:31 +0200 Subject: [PATCH 1/3] feat: workflow --- .../integration/routes/workflows.test.ts | 239 ++++++++++++++++++ packages/frontend/src/api/workflowApi.ts | 26 ++ .../components/workflow/ValidationErrors.tsx | 39 +++ .../components/workflow/WorkflowBuilder.tsx | 209 +++++++++++++++ .../components/workflow/WorkflowViewer.tsx | 68 +++++ .../src/components/workflow/config.ts | 32 +++ .../workflow/hooks/useWorkflowState.ts | 185 ++++++++++++++ .../frontend/src/components/workflow/index.ts | 6 + .../components/workflow/nodes/AgentNode.tsx | 44 ++++ .../components/workflow/nodes/BaseNode.tsx | 75 ++++++ .../workflow/nodes/CodeReviewNode.tsx | 24 ++ .../workflow/nodes/HumanGateNode.tsx | 28 ++ .../src/components/workflow/nodes/index.ts | 12 + .../workflow/panels/AgentConfig.tsx | 97 +++++++ .../workflow/panels/CodeReviewConfig.tsx | 110 ++++++++ .../workflow/panels/HumanGateConfig.tsx | 54 ++++ .../workflow/panels/NodeConfigPanel.tsx | 87 +++++++ .../workflow/toolbar/NodePalette.tsx | 67 +++++ .../workflow/toolbar/WorkflowToolbar.tsx | 77 ++++++ packages/frontend/src/types/workflow.ts | 70 +++++ .../src/utils/connectionValidation.ts | 79 ++++++ .../frontend/src/utils/workflowConverter.ts | 126 +++++++++ .../frontend/src/utils/workflowValidation.ts | 88 +++++++ 23 files changed, 1842 insertions(+) create mode 100644 packages/backend/tests/integration/routes/workflows.test.ts create mode 100644 packages/frontend/src/api/workflowApi.ts create mode 100644 packages/frontend/src/components/workflow/ValidationErrors.tsx create mode 100644 packages/frontend/src/components/workflow/WorkflowBuilder.tsx create mode 100644 packages/frontend/src/components/workflow/WorkflowViewer.tsx create mode 100644 packages/frontend/src/components/workflow/config.ts create mode 100644 packages/frontend/src/components/workflow/hooks/useWorkflowState.ts create mode 100644 packages/frontend/src/components/workflow/index.ts create mode 100644 packages/frontend/src/components/workflow/nodes/AgentNode.tsx create mode 100644 packages/frontend/src/components/workflow/nodes/BaseNode.tsx create mode 100644 packages/frontend/src/components/workflow/nodes/CodeReviewNode.tsx create mode 100644 packages/frontend/src/components/workflow/nodes/HumanGateNode.tsx create mode 100644 packages/frontend/src/components/workflow/nodes/index.ts create mode 100644 packages/frontend/src/components/workflow/panels/AgentConfig.tsx create mode 100644 packages/frontend/src/components/workflow/panels/CodeReviewConfig.tsx create mode 100644 packages/frontend/src/components/workflow/panels/HumanGateConfig.tsx create mode 100644 packages/frontend/src/components/workflow/panels/NodeConfigPanel.tsx create mode 100644 packages/frontend/src/components/workflow/toolbar/NodePalette.tsx create mode 100644 packages/frontend/src/components/workflow/toolbar/WorkflowToolbar.tsx create mode 100644 packages/frontend/src/types/workflow.ts create mode 100644 packages/frontend/src/utils/connectionValidation.ts create mode 100644 packages/frontend/src/utils/workflowConverter.ts create mode 100644 packages/frontend/src/utils/workflowValidation.ts diff --git a/packages/backend/tests/integration/routes/workflows.test.ts b/packages/backend/tests/integration/routes/workflows.test.ts new file mode 100644 index 0000000..9fb0918 --- /dev/null +++ b/packages/backend/tests/integration/routes/workflows.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import type { Workflow } from '@haflow/shared'; + +const BASE_URL = 'http://localhost:4001'; + +describe('workflow routes', () => { + describe('GET /api/workflows', () => { + it('returns 200 with workflow list', async () => { + const res = await request(BASE_URL).get('/api/workflows'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('includes raw-research-plan-implement workflow', async () => { + const res = await request(BASE_URL).get('/api/workflows'); + + const workflow = res.body.data.find((w: Workflow) => w.workflow_id === 'raw-research-plan-implement'); + expect(workflow).toBeDefined(); + expect(workflow.name).toBe('Raw Research Plan Implement'); + expect(workflow.steps.length).toBeGreaterThan(0); + }); + + it('includes oneshot workflow', async () => { + const res = await request(BASE_URL).get('/api/workflows'); + + const workflow = res.body.data.find((w: Workflow) => w.workflow_id === 'oneshot'); + expect(workflow).toBeDefined(); + expect(workflow.name).toBe('Oneshot'); + }); + }); + + describe('GET /api/workflows/templates', () => { + it('returns 200 with templates list', async () => { + const res = await request(BASE_URL).get('/api/workflows/templates'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('returns same data as /api/workflows', async () => { + const workflowsRes = await request(BASE_URL).get('/api/workflows'); + const templatesRes = await request(BASE_URL).get('/api/workflows/templates'); + + expect(templatesRes.body.data.length).toBe(workflowsRes.body.data.length); + }); + }); + + describe('POST /api/workflows/execute', () => { + it('returns 400 when neither workflowId nor workflow provided', async () => { + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('workflowId or workflow required'); + }); + + it('returns 404 for non-existent template workflow', async () => { + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflowId: 'non-existent-workflow' }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('not found'); + }); + + it('validates and returns success for template workflow', async () => { + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflowId: 'raw-research-plan-implement' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.workflow_id).toBe('raw-research-plan-implement'); + expect(res.body.data.steps_count).toBe(8); + }); + + it('validates valid dynamic workflow', async () => { + const workflow = { + workflow_id: 'test-workflow', + name: 'Test Workflow', + steps: [ + { + step_id: 'step1', + name: 'Cleanup', + type: 'agent', + agent: 'cleanup-agent', + inputArtifact: 'raw-input.md', + outputArtifact: 'structured.md', + workspaceMode: 'document', + }, + ], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.workflow_id).toBe('test-workflow'); + expect(res.body.data.steps_count).toBe(1); + }); + + it('rejects workflow with empty steps', async () => { + const workflow = { + workflow_id: 'test-workflow', + name: 'Test Workflow', + steps: [], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('at least one step'); + }); + + it('rejects agent node without agent type', async () => { + const workflow = { + workflow_id: 'test-workflow', + name: 'Test Workflow', + steps: [ + { + step_id: 'step1', + name: 'Agent Step', + type: 'agent', + // Missing agent type + workspaceMode: 'document', + }, + ], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('agent type'); + }); + + it('accepts human-gate without agent type', async () => { + const workflow = { + workflow_id: 'test-workflow', + name: 'Test Workflow', + steps: [ + { + step_id: 'step1', + name: 'Human Gate', + type: 'human-gate', + reviewArtifact: 'output.md', + workspaceMode: 'document', + }, + ], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('accepts code-review without agent type', async () => { + const workflow = { + workflow_id: 'test-workflow', + name: 'Test Workflow', + steps: [ + { + step_id: 'step1', + name: 'Code Review', + type: 'code-review', + workspaceMode: 'codegen', + quickCommands: ['npm test'], + }, + ], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('validates multi-step workflow', async () => { + const workflow = { + workflow_id: 'multi-step', + name: 'Multi Step', + steps: [ + { + step_id: 'cleanup', + name: 'Cleanup', + type: 'agent', + agent: 'cleanup-agent', + inputArtifact: 'raw-input.md', + outputArtifact: 'structured.md', + workspaceMode: 'document', + }, + { + step_id: 'review', + name: 'Review', + type: 'human-gate', + reviewArtifact: 'structured.md', + workspaceMode: 'document', + }, + { + step_id: 'impl', + name: 'Implementation', + type: 'agent', + agent: 'impl-agent', + inputArtifact: 'structured.md', + outputArtifact: 'result.json', + workspaceMode: 'codegen', + }, + ], + }; + + const res = await request(BASE_URL) + .post('/api/workflows/execute') + .send({ workflow }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.steps_count).toBe(3); + }); + }); +}); diff --git a/packages/frontend/src/api/workflowApi.ts b/packages/frontend/src/api/workflowApi.ts new file mode 100644 index 0000000..b4fdff5 --- /dev/null +++ b/packages/frontend/src/api/workflowApi.ts @@ -0,0 +1,26 @@ +import type { Workflow } from '@haflow/shared'; +import { api } from './client'; + +// Re-export workflow-related functions from main client +export const getWorkflowTemplates = api.getWorkflowTemplates; +export const executeWorkflow = api.executeWorkflow; +export const executeWorkflowTemplate = api.executeWorkflowTemplate; + +// Additional workflow-specific utilities +export async function saveWorkflow(workflow: Workflow): Promise { + // For now, workflows are not persisted - they're executed directly + // This is a placeholder for future persistence functionality + console.log('Workflow save not yet implemented', workflow); +} + +export async function validateAndExecuteWorkflow(workflow: Workflow): Promise<{ + workflow_id: string; + name: string; + steps_count: number; + message: string; +}> { + // Execute workflow (backend will validate) + return executeWorkflow(workflow); +} + +export { api }; diff --git a/packages/frontend/src/components/workflow/ValidationErrors.tsx b/packages/frontend/src/components/workflow/ValidationErrors.tsx new file mode 100644 index 0000000..fba0b91 --- /dev/null +++ b/packages/frontend/src/components/workflow/ValidationErrors.tsx @@ -0,0 +1,39 @@ +import { ValidationError } from '@/types/workflow'; +import { AlertTriangle } from 'lucide-react'; + +interface ValidationErrorsProps { + errors: ValidationError[]; + onErrorClick?: (nodeId: string) => void; +} + +export function ValidationErrors({ errors, onErrorClick }: ValidationErrorsProps) { + if (errors.length === 0) return null; + + return ( +
+
+ +

Validation Errors

+
+
    + {errors.map((error, index) => ( +
  • error.nodeId && onErrorClick?.(error.nodeId)} + className={`text-sm text-red-600 ${ + error.nodeId + ? 'cursor-pointer hover:text-red-800 hover:underline' + : '' + }`} + data-testid={`validation-error-${index}`} + > + {error.field && ( + {error.field}: + )} + {error.message} +
  • + ))} +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/WorkflowBuilder.tsx b/packages/frontend/src/components/workflow/WorkflowBuilder.tsx new file mode 100644 index 0000000..49d6f38 --- /dev/null +++ b/packages/frontend/src/components/workflow/WorkflowBuilder.tsx @@ -0,0 +1,209 @@ +import { useCallback, useRef, DragEvent, useState } from 'react'; +import ReactFlow, { + Controls, + Background, + MiniMap, + ReactFlowProvider, + BackgroundVariant, +} from 'reactflow'; +import { nodeTypes } from './nodes'; +import { useWorkflowState } from './hooks/useWorkflowState'; +import { NodePalette } from './toolbar/NodePalette'; +import { WorkflowToolbar } from './toolbar/WorkflowToolbar'; +import { NodeConfigPanel } from './panels/NodeConfigPanel'; +import { ValidationErrors } from './ValidationErrors'; +import { REACT_FLOW_CONFIG, NODE_DIMENSIONS } from './config'; +import { NodeType, WorkflowNode, ValidationError } from '@/types/workflow'; +import type { Workflow } from '@haflow/shared'; +import { validateWorkflowForExecution } from '@/utils/workflowValidation'; +import 'reactflow/dist/style.css'; + +interface WorkflowBuilderProps { + initialWorkflow?: Workflow; + onSave: (workflow: Workflow) => Promise; + onExecute: (workflow: Workflow) => Promise; +} + +function WorkflowBuilderInner({ + initialWorkflow, + onSave, + onExecute, +}: WorkflowBuilderProps) { + const reactFlowWrapper = useRef(null); + const [validationErrors, setValidationErrors] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + + const { + nodes, + edges, + selectedNodeId, + isDirty, + workflowName, + onNodesChange, + onEdgesChange, + onConnect, + addNode, + updateNodeData, + removeNode, + setSelectedNodeId, + clearWorkflow, + updateWorkflowName, + toWorkflow, + markClean, + } = useWorkflowState(initialWorkflow); + + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + const type = event.dataTransfer.getData('application/reactflow') as NodeType; + if (!type || !reactFlowWrapper.current) return; + + const bounds = reactFlowWrapper.current.getBoundingClientRect(); + const position = { + x: event.clientX - bounds.left - NODE_DIMENSIONS.width / 2, + y: event.clientY - bounds.top - NODE_DIMENSIONS.height / 2, + }; + + addNode(type, position); + }, + [addNode] + ); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave(toWorkflow()); + markClean(); + setValidationErrors([]); + } catch (error) { + console.error('Failed to save workflow:', error); + } finally { + setIsSaving(false); + } + }; + + const handleExecute = async () => { + // Validate before execution + const validation = validateWorkflowForExecution( + nodes as WorkflowNode[], + edges + ); + + if (!validation.valid) { + setValidationErrors(validation.errors); + return; + } + + setValidationErrors([]); + setIsExecuting(true); + try { + await onExecute(toWorkflow()); + } catch (error) { + console.error('Failed to execute workflow:', error); + } finally { + setIsExecuting(false); + } + }; + + const handleClear = () => { + if (isDirty) { + const confirmed = window.confirm( + 'You have unsaved changes. Are you sure you want to clear the workflow?' + ); + if (!confirmed) return; + } + clearWorkflow(); + setValidationErrors([]); + }; + + const handleErrorClick = (nodeId: string) => { + setSelectedNodeId(nodeId); + }; + + const selectedNode = nodes.find((n) => n.id === selectedNodeId); + + return ( +
+ + + {validationErrors.length > 0 && ( + + )} + +
+ + +
+ setSelectedNodeId(node.id)} + onPaneClick={() => setSelectedNodeId(null)} + fitView + {...REACT_FLOW_CONFIG} + > + + + + +
+ + + selectedNodeId && updateNodeData(selectedNodeId, data) + } + onDelete={() => selectedNodeId && removeNode(selectedNodeId)} + onClose={() => setSelectedNodeId(null)} + /> +
+
+ ); +} + +export default function WorkflowBuilder(props: WorkflowBuilderProps) { + return ( + + + + ); +} + +export { WorkflowBuilder }; diff --git a/packages/frontend/src/components/workflow/WorkflowViewer.tsx b/packages/frontend/src/components/workflow/WorkflowViewer.tsx new file mode 100644 index 0000000..bb69248 --- /dev/null +++ b/packages/frontend/src/components/workflow/WorkflowViewer.tsx @@ -0,0 +1,68 @@ +import ReactFlow, { + Controls, + Background, + MiniMap, + ReactFlowProvider, + BackgroundVariant, +} from 'reactflow'; +import { nodeTypes } from './nodes'; +import { convertWorkflowToNodes } from '@/utils/workflowConverter'; +import type { Workflow } from '@haflow/shared'; +import { REACT_FLOW_CONFIG } from './config'; +import { WorkflowNode, ExecutionStatus } from '@/types/workflow'; +import 'reactflow/dist/style.css'; + +interface WorkflowViewerProps { + workflow: Workflow; + executionStatus?: Record; +} + +function WorkflowViewerInner({ workflow, executionStatus }: WorkflowViewerProps) { + const { nodes, edges } = convertWorkflowToNodes(workflow); + + // Add execution status to node data + const nodesWithStatus: WorkflowNode[] = nodes.map((node) => ({ + ...node, + data: { + ...node.data, + executionStatus: executionStatus?.[node.id], + }, + })); + + return ( +
+
+

{workflow.name}

+
+ +
+ + + + + +
+
+ ); +} + +export default function WorkflowViewer(props: WorkflowViewerProps) { + return ( + + + + ); +} + +export { WorkflowViewer }; diff --git a/packages/frontend/src/components/workflow/config.ts b/packages/frontend/src/components/workflow/config.ts new file mode 100644 index 0000000..8703780 --- /dev/null +++ b/packages/frontend/src/components/workflow/config.ts @@ -0,0 +1,32 @@ +import { ConnectionLineType } from 'reactflow'; + +export const REACT_FLOW_CONFIG = { + defaultViewport: { x: 0, y: 0, zoom: 1 }, + minZoom: 0.25, + maxZoom: 2, + snapToGrid: true, + snapGrid: [16, 16] as [number, number], + connectionLineType: ConnectionLineType.SmoothStep, + deleteKeyCode: ['Backspace', 'Delete'], +}; + +export const NODE_DIMENSIONS = { + width: 280, + height: 120, + spacing: { x: 320, y: 160 }, +}; + +// Agent color mapping +export const AGENT_COLORS: Record = { + 'cleanup-agent': '#3B82F6', // Blue + 'research-agent': '#8B5CF6', // Purple + 'planning-agent': '#F59E0B', // Amber + 'impl-agent': '#10B981', // Green +}; + +// Node type colors +export const NODE_TYPE_COLORS: Record = { + 'agent': '#6B7280', // Gray (default, overridden by agent color) + 'human-gate': '#EF4444', // Red + 'code-review': '#6366F1', // Indigo +}; diff --git a/packages/frontend/src/components/workflow/hooks/useWorkflowState.ts b/packages/frontend/src/components/workflow/hooks/useWorkflowState.ts new file mode 100644 index 0000000..2b2bcfe --- /dev/null +++ b/packages/frontend/src/components/workflow/hooks/useWorkflowState.ts @@ -0,0 +1,185 @@ +import { useState, useCallback } from 'react'; +import { + useNodesState, + useEdgesState, + addEdge, + Connection, + Node, + Edge, + NodeChange, + EdgeChange, +} from 'reactflow'; +import { + WorkflowNode, + WorkflowEdge, + WorkflowStepWithStatus, + NodeType, +} from '@/types/workflow'; +import type { Workflow } from '@haflow/shared'; +import { + generateNodeId, + generateEdgeId, + createEmptyWorkflow, + createDefaultNodeData, + convertNodesToWorkflow, + convertWorkflowToNodes, +} from '@/utils/workflowConverter'; +import { validateConnection } from '@/utils/connectionValidation'; + +export function useWorkflowState(initialWorkflow?: Workflow) { + // Initialize from workflow if provided + const initialState = initialWorkflow + ? convertWorkflowToNodes(initialWorkflow) + : { nodes: [], edges: [] }; + + const [workflowMeta, setWorkflowMeta] = useState({ + workflowId: initialWorkflow?.workflow_id || generateNodeId(), + name: initialWorkflow?.name || 'New Workflow', + }); + + const [nodes, setNodes, onNodesChange] = useNodesState( + initialState.nodes as Node[] + ); + const [edges, setEdges, onEdgesChange] = useEdgesState( + initialState.edges as Edge[] + ); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [isDirty, setIsDirty] = useState(false); + + const handleNodesChange = useCallback( + (changes: NodeChange>[]) => { + onNodesChange(changes); + // Mark dirty on any node change except selection + const isOnlySelection = changes.every((c) => c.type === 'select'); + if (!isOnlySelection) { + setIsDirty(true); + } + }, + [onNodesChange] + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + onEdgesChange(changes); + // Mark dirty on any edge change except selection + const isOnlySelection = changes.every((c) => c.type === 'select'); + if (!isOnlySelection) { + setIsDirty(true); + } + }, + [onEdgesChange] + ); + + const addNode = useCallback( + (type: NodeType, position: { x: number; y: number }) => { + const newNode: WorkflowNode = { + id: generateNodeId(), + type, + position, + data: createDefaultNodeData(type), + }; + setNodes((nds) => [...nds, newNode as Node]); + setIsDirty(true); + return newNode.id; + }, + [setNodes] + ); + + const updateNodeData = useCallback( + (nodeId: string, data: Partial) => { + setNodes((nds) => + nds.map((node) => + node.id === nodeId + ? { ...node, data: { ...node.data, ...data } } + : node + ) + ); + setIsDirty(true); + }, + [setNodes] + ); + + const removeNode = useCallback( + (nodeId: string) => { + setNodes((nds) => nds.filter((node) => node.id !== nodeId)); + setEdges((eds) => + eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId) + ); + if (selectedNodeId === nodeId) { + setSelectedNodeId(null); + } + setIsDirty(true); + }, + [setNodes, setEdges, selectedNodeId] + ); + + const onConnect = useCallback( + (connection: Connection) => { + const workflowNodes = nodes as WorkflowNode[]; + const workflowEdges = edges as WorkflowEdge[]; + const validation = validateConnection(connection, workflowNodes, workflowEdges); + + if (validation.valid) { + setEdges((eds) => + addEdge({ ...connection, id: generateEdgeId() }, eds) + ); + setIsDirty(true); + } + }, + [setEdges, nodes, edges] + ); + + const clearWorkflow = useCallback(() => { + setNodes([]); + setEdges([]); + setSelectedNodeId(null); + setWorkflowMeta({ + workflowId: generateNodeId(), + name: 'New Workflow', + }); + setIsDirty(false); + }, [setNodes, setEdges]); + + const updateWorkflowName = useCallback((name: string) => { + setWorkflowMeta((prev) => ({ ...prev, name })); + setIsDirty(true); + }, []); + + const toWorkflow = useCallback((): Workflow => { + return convertNodesToWorkflow( + workflowMeta.workflowId, + workflowMeta.name, + nodes as WorkflowNode[], + edges as WorkflowEdge[] + ); + }, [workflowMeta, nodes, edges]); + + const markClean = useCallback(() => { + setIsDirty(false); + }, []); + + return { + // State + nodes: nodes as WorkflowNode[], + edges: edges as WorkflowEdge[], + selectedNodeId, + isDirty, + workflowName: workflowMeta.name, + workflowId: workflowMeta.workflowId, + + // React Flow handlers + onNodesChange: handleNodesChange, + onEdgesChange: handleEdgesChange, + onConnect, + + // Actions + addNode, + updateNodeData, + removeNode, + setSelectedNodeId, + clearWorkflow, + updateWorkflowName, + toWorkflow, + markClean, + }; +} diff --git a/packages/frontend/src/components/workflow/index.ts b/packages/frontend/src/components/workflow/index.ts new file mode 100644 index 0000000..6410c09 --- /dev/null +++ b/packages/frontend/src/components/workflow/index.ts @@ -0,0 +1,6 @@ +export { default as WorkflowBuilder, WorkflowBuilder as WorkflowBuilderComponent } from './WorkflowBuilder'; +export { default as WorkflowViewer, WorkflowViewer as WorkflowViewerComponent } from './WorkflowViewer'; +export { ValidationErrors } from './ValidationErrors'; +export { nodeTypes } from './nodes'; +export { useWorkflowState } from './hooks/useWorkflowState'; +export * from './config'; diff --git a/packages/frontend/src/components/workflow/nodes/AgentNode.tsx b/packages/frontend/src/components/workflow/nodes/AgentNode.tsx new file mode 100644 index 0000000..3b3e116 --- /dev/null +++ b/packages/frontend/src/components/workflow/nodes/AgentNode.tsx @@ -0,0 +1,44 @@ +import { NodeProps } from 'reactflow'; +import { BaseNode } from './BaseNode'; +import { WorkflowStepWithStatus } from '@/types/workflow'; +import { Bot } from 'lucide-react'; +import { AGENT_COLORS } from '../config'; + +const AGENT_LABELS: Record = { + 'cleanup-agent': 'Cleanup Agent', + 'research-agent': 'Research Agent', + 'planning-agent': 'Planning Agent', + 'impl-agent': 'Implementation Agent', +}; + +export function AgentNode(props: NodeProps) { + const { data } = props; + const agentColor = data.agent ? AGENT_COLORS[data.agent] || '#6B7280' : '#6B7280'; + + return ( + } color={agentColor}> +
+ Agent: + + {data.agent ? AGENT_LABELS[data.agent] || data.agent : 'Not set'} + +
+ {data.inputArtifact && ( +
+ Input: + {data.inputArtifact} +
+ )} + {data.outputArtifact && ( +
+ Output: + {data.outputArtifact} +
+ )} +
+ Mode: + {data.workspaceMode} +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/nodes/BaseNode.tsx b/packages/frontend/src/components/workflow/nodes/BaseNode.tsx new file mode 100644 index 0000000..10696ea --- /dev/null +++ b/packages/frontend/src/components/workflow/nodes/BaseNode.tsx @@ -0,0 +1,75 @@ +import { Handle, Position, NodeProps } from 'reactflow'; +import { WorkflowStepWithStatus, ExecutionStatus } from '@/types/workflow'; +import { cn } from '@/lib/utils'; +import { Check, X, Loader2 } from 'lucide-react'; + +interface BaseNodeProps extends NodeProps { + icon: React.ReactNode; + color: string; + children: React.ReactNode; +} + +const STATUS_STYLES: Record = { + pending: 'status-pending', + running: 'status-running', + completed: 'status-completed', + failed: 'status-failed', +}; + +export function BaseNode({ data, selected, icon, color, children }: BaseNodeProps) { + const statusClass = data.executionStatus ? STATUS_STYLES[data.executionStatus] : ''; + + return ( +
+ + + {data.executionStatus && ( +
+ {data.executionStatus === 'running' && ( + + )} + {data.executionStatus === 'completed' && ( + + )} + {data.executionStatus === 'failed' && ( + + )} +
+ )} + +
+ {icon} + {data.name} +
+ +
+ {children} +
+ + +
+ ); +} diff --git a/packages/frontend/src/components/workflow/nodes/CodeReviewNode.tsx b/packages/frontend/src/components/workflow/nodes/CodeReviewNode.tsx new file mode 100644 index 0000000..484da99 --- /dev/null +++ b/packages/frontend/src/components/workflow/nodes/CodeReviewNode.tsx @@ -0,0 +1,24 @@ +import { NodeProps } from 'reactflow'; +import { BaseNode } from './BaseNode'; +import { WorkflowStepWithStatus } from '@/types/workflow'; +import { Code2 } from 'lucide-react'; +import { NODE_TYPE_COLORS } from '../config'; + +export function CodeReviewNode(props: NodeProps) { + const { data } = props; + + return ( + } color={NODE_TYPE_COLORS['code-review']}> +
+ Mode: + {data.workspaceMode} +
+ {data.quickCommands && data.quickCommands.length > 0 && ( +
+ Commands: + {data.quickCommands.length} available +
+ )} +
+ ); +} diff --git a/packages/frontend/src/components/workflow/nodes/HumanGateNode.tsx b/packages/frontend/src/components/workflow/nodes/HumanGateNode.tsx new file mode 100644 index 0000000..89694d2 --- /dev/null +++ b/packages/frontend/src/components/workflow/nodes/HumanGateNode.tsx @@ -0,0 +1,28 @@ +import { NodeProps } from 'reactflow'; +import { BaseNode } from './BaseNode'; +import { WorkflowStepWithStatus } from '@/types/workflow'; +import { User } from 'lucide-react'; +import { NODE_TYPE_COLORS } from '../config'; +import { Badge } from '@/components/ui/badge'; + +export function HumanGateNode(props: NodeProps) { + const { data } = props; + + return ( + } color={NODE_TYPE_COLORS['human-gate']}> +
+ Review: + {data.reviewArtifact || 'Not set'} +
+
+ Mode: + {data.workspaceMode} +
+
+ + Requires Human Approval + +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/nodes/index.ts b/packages/frontend/src/components/workflow/nodes/index.ts new file mode 100644 index 0000000..1e275ed --- /dev/null +++ b/packages/frontend/src/components/workflow/nodes/index.ts @@ -0,0 +1,12 @@ +import { AgentNode } from './AgentNode'; +import { HumanGateNode } from './HumanGateNode'; +import { CodeReviewNode } from './CodeReviewNode'; + +export const nodeTypes = { + 'agent': AgentNode, + 'human-gate': HumanGateNode, + 'code-review': CodeReviewNode, +}; + +export { AgentNode, HumanGateNode, CodeReviewNode }; +export { BaseNode } from './BaseNode'; diff --git a/packages/frontend/src/components/workflow/panels/AgentConfig.tsx b/packages/frontend/src/components/workflow/panels/AgentConfig.tsx new file mode 100644 index 0000000..52438e4 --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/AgentConfig.tsx @@ -0,0 +1,97 @@ +import { WorkflowStepWithStatus, AgentType } from '@/types/workflow'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const AGENTS: { value: AgentType; label: string }[] = [ + { value: 'cleanup-agent', label: 'Cleanup Agent' }, + { value: 'research-agent', label: 'Research Agent' }, + { value: 'planning-agent', label: 'Planning Agent' }, + { value: 'impl-agent', label: 'Implementation Agent' }, +]; + +const WORKSPACE_MODES: { value: 'document' | 'codegen'; label: string }[] = [ + { value: 'document', label: 'Document (Markdown)' }, + { value: 'codegen', label: 'Code Generation' }, +]; + +interface AgentConfigProps { + node: WorkflowStepWithStatus; + onUpdate: (data: Partial) => void; +} + +export function AgentConfig({ node, onUpdate }: AgentConfigProps) { + return ( +
+
+ + +
+ +
+ + onUpdate({ inputArtifact: e.target.value })} + placeholder="e.g., raw-input.md" + data-testid="input-artifact-input" + /> +
+ +
+ + onUpdate({ outputArtifact: e.target.value })} + placeholder="e.g., research-output.md" + data-testid="output-artifact-input" + /> +
+ +
+ + +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/panels/CodeReviewConfig.tsx b/packages/frontend/src/components/workflow/panels/CodeReviewConfig.tsx new file mode 100644 index 0000000..ba25764 --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/CodeReviewConfig.tsx @@ -0,0 +1,110 @@ +import { useState, KeyboardEvent } from 'react'; +import { WorkflowStepWithStatus } from '@/types/workflow'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { X, Plus } from 'lucide-react'; + +interface CodeReviewConfigProps { + node: WorkflowStepWithStatus; + onUpdate: (data: Partial) => void; +} + +export function CodeReviewConfig({ node, onUpdate }: CodeReviewConfigProps) { + const [newCommand, setNewCommand] = useState(''); + const commands = node.quickCommands || []; + + const addCommand = () => { + if (newCommand.trim()) { + onUpdate({ quickCommands: [...commands, newCommand.trim()] }); + setNewCommand(''); + } + }; + + const removeCommand = (index: number) => { + const updated = commands.filter((_, i) => i !== index); + onUpdate({ quickCommands: updated }); + }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCommand(); + } + }; + + return ( +
+
+ + +
+ +
+ + {commands.length > 0 && ( +
+ {commands.map((cmd, index) => ( +
+ + {cmd} + + +
+ ))} +
+ )} +
+ setNewCommand(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="npm run test" + className="flex-1" + data-testid="new-command-input" + /> + +
+
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/panels/HumanGateConfig.tsx b/packages/frontend/src/components/workflow/panels/HumanGateConfig.tsx new file mode 100644 index 0000000..7dd2fdc --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/HumanGateConfig.tsx @@ -0,0 +1,54 @@ +import { WorkflowStepWithStatus } from '@/types/workflow'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface HumanGateConfigProps { + node: WorkflowStepWithStatus; + onUpdate: (data: Partial) => void; +} + +export function HumanGateConfig({ node, onUpdate }: HumanGateConfigProps) { + return ( +
+
+ + onUpdate({ reviewArtifact: e.target.value })} + placeholder="e.g., implementation-plan.md" + data-testid="review-artifact-input" + /> +

+ The artifact that requires human review +

+
+ +
+ + +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/panels/NodeConfigPanel.tsx b/packages/frontend/src/components/workflow/panels/NodeConfigPanel.tsx new file mode 100644 index 0000000..cd6f771 --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/NodeConfigPanel.tsx @@ -0,0 +1,87 @@ +import { WorkflowStepWithStatus, NodeType } from '@/types/workflow'; +import { AgentConfig } from './AgentConfig'; +import { HumanGateConfig } from './HumanGateConfig'; +import { CodeReviewConfig } from './CodeReviewConfig'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { X, Trash2 } from 'lucide-react'; + +interface NodeConfigPanelProps { + node: WorkflowStepWithStatus | null; + nodeType: NodeType | null; + onUpdate: (data: Partial) => void; + onDelete: () => void; + onClose: () => void; +} + +export function NodeConfigPanel({ + node, + nodeType, + onUpdate, + onDelete, + onClose, +}: NodeConfigPanelProps) { + if (!node || !nodeType) { + return ( +
+

+ Select a node to configure +

+
+ ); + } + + const ConfigComponent = { + agent: AgentConfig, + 'human-gate': HumanGateConfig, + 'code-review': CodeReviewConfig, + }[nodeType]; + + return ( +
+
+

+ Configure {nodeType.replace('-', ' ')} +

+ +
+ +
+
+ + onUpdate({ name: e.target.value })} + data-testid="step-name-input" + /> +
+ + {ConfigComponent && } +
+ +
+ +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/toolbar/NodePalette.tsx b/packages/frontend/src/components/workflow/toolbar/NodePalette.tsx new file mode 100644 index 0000000..6daa519 --- /dev/null +++ b/packages/frontend/src/components/workflow/toolbar/NodePalette.tsx @@ -0,0 +1,67 @@ +import { DragEvent } from 'react'; +import { NodeType } from '@/types/workflow'; +import { Bot, User, Code2 } from 'lucide-react'; + +interface NodeTypeInfo { + type: NodeType; + label: string; + icon: React.ReactNode; + description: string; +} + +const NODE_TYPES: NodeTypeInfo[] = [ + { + type: 'agent', + label: 'Agent', + icon: , + description: 'Execute an AI agent step', + }, + { + type: 'human-gate', + label: 'Human Gate', + icon: , + description: 'Pause for human review', + }, + { + type: 'code-review', + label: 'Code Review', + icon: , + description: 'Review with commands', + }, +]; + +export function NodePalette() { + const onDragStart = (event: DragEvent, nodeType: NodeType) => { + event.dataTransfer.setData('application/reactflow', nodeType); + event.dataTransfer.effectAllowed = 'move'; + }; + + return ( +
+

+ Add Node +

+
+ {NODE_TYPES.map(({ type, label, icon, description }) => ( +
onDragStart(e, type)} + data-testid={`palette-item-${type}`} + > + {icon} +
+ + {label} + + + {description} + +
+
+ ))} +
+
+ ); +} diff --git a/packages/frontend/src/components/workflow/toolbar/WorkflowToolbar.tsx b/packages/frontend/src/components/workflow/toolbar/WorkflowToolbar.tsx new file mode 100644 index 0000000..0e29711 --- /dev/null +++ b/packages/frontend/src/components/workflow/toolbar/WorkflowToolbar.tsx @@ -0,0 +1,77 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Trash2, Play, Save } from 'lucide-react'; + +interface WorkflowToolbarProps { + workflowName: string; + isDirty: boolean; + onSave: () => void; + onExecute: () => void; + onClear: () => void; + onNameChange: (name: string) => void; + isSaving?: boolean; + isExecuting?: boolean; +} + +export function WorkflowToolbar({ + workflowName, + isDirty, + onSave, + onExecute, + onClear, + onNameChange, + isSaving = false, + isExecuting = false, +}: WorkflowToolbarProps) { + return ( +
+
+ onNameChange(e.target.value)} + placeholder="Workflow name" + data-testid="workflow-name-input" + /> + {isDirty && ( + + Unsaved changes + + )} +
+
+ + + +
+
+ ); +} diff --git a/packages/frontend/src/types/workflow.ts b/packages/frontend/src/types/workflow.ts new file mode 100644 index 0000000..2abd8cc --- /dev/null +++ b/packages/frontend/src/types/workflow.ts @@ -0,0 +1,70 @@ +// Re-export shared types for convenience +export type { + Workflow, + WorkflowStep, + WorkspaceMode, + StepType, +} from '@haflow/shared'; + +// Agent types for workflow builder +export type AgentType = 'cleanup-agent' | 'research-agent' | 'planning-agent' | 'impl-agent'; + +// Node type for the workflow builder (subset of StepType) +export type NodeType = 'agent' | 'human-gate' | 'code-review'; + +// Extended WorkflowStep with execution status for viewer +export interface WorkflowStepWithStatus { + step_id: string; + name: string; + type: NodeType; + agent?: AgentType; + inputArtifact?: string; + outputArtifact?: string; + reviewArtifact?: string; + workspaceMode: 'document' | 'codegen'; + quickCommands?: string[]; + executionStatus?: ExecutionStatus; +} + +// Execution status for workflow viewer +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed'; + +// React Flow specific types +export interface WorkflowNode { + id: string; + type: NodeType; + position: { x: number; y: number }; + data: WorkflowStepWithStatus; +} + +export interface WorkflowEdge { + id: string; + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; +} + +// State management types +export interface WorkflowBuilderState { + workflowId: string; + name: string; + description?: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + selectedNodeId: string | null; + isDirty: boolean; +} + +// Validation error type +export interface ValidationError { + nodeId?: string; + field?: string; + message: string; +} + +// Validation result +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} diff --git a/packages/frontend/src/utils/connectionValidation.ts b/packages/frontend/src/utils/connectionValidation.ts new file mode 100644 index 0000000..59d71e7 --- /dev/null +++ b/packages/frontend/src/utils/connectionValidation.ts @@ -0,0 +1,79 @@ +import { Connection } from 'reactflow'; +import { WorkflowNode, WorkflowEdge, NodeType } from '@/types/workflow'; + +interface ConnectionRules { + canConnectTo: NodeType[]; + maxOutputs: number; + maxInputs: number; +} + +const CONNECTION_RULES: Record = { + 'agent': { + canConnectTo: ['agent', 'human-gate', 'code-review'], + maxOutputs: 1, + maxInputs: 1, + }, + 'human-gate': { + canConnectTo: ['agent', 'code-review'], + maxOutputs: 1, + maxInputs: 1, + }, + 'code-review': { + canConnectTo: ['agent'], + maxOutputs: 1, + maxInputs: 1, + }, +}; + +export interface ConnectionValidationResult { + valid: boolean; + reason?: string; +} + +export function validateConnection( + connection: Connection, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ConnectionValidationResult { + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + + if (!sourceNode || !targetNode) { + return { valid: false, reason: 'Invalid node reference' }; + } + + const sourceType = sourceNode.type as NodeType; + const targetType = targetNode.type as NodeType; + + // Check if connection type is allowed + const rules = CONNECTION_RULES[sourceType]; + if (!rules) { + return { valid: false, reason: `Unknown source node type: ${sourceType}` }; + } + + if (!rules.canConnectTo.includes(targetType)) { + return { valid: false, reason: `${sourceType} cannot connect to ${targetType}` }; + } + + // Check max outputs from source + const sourceOutputs = edges.filter((e) => e.source === connection.source).length; + if (sourceOutputs >= rules.maxOutputs) { + return { valid: false, reason: `${sourceType} can only have ${rules.maxOutputs} output(s)` }; + } + + // Check max inputs to target + const targetRules = CONNECTION_RULES[targetType]; + if (targetRules) { + const targetInputs = edges.filter((e) => e.target === connection.target).length; + if (targetInputs >= targetRules.maxInputs) { + return { valid: false, reason: `${targetType} can only have ${targetRules.maxInputs} input(s)` }; + } + } + + // Prevent self-connections + if (connection.source === connection.target) { + return { valid: false, reason: 'Cannot connect node to itself' }; + } + + return { valid: true }; +} diff --git a/packages/frontend/src/utils/workflowConverter.ts b/packages/frontend/src/utils/workflowConverter.ts new file mode 100644 index 0000000..10b6430 --- /dev/null +++ b/packages/frontend/src/utils/workflowConverter.ts @@ -0,0 +1,126 @@ +import { + WorkflowNode, + WorkflowEdge, + NodeType, + WorkflowStepWithStatus, +} from '@/types/workflow'; +import type { Workflow, WorkflowStep } from '@haflow/shared'; + +export function generateNodeId(): string { + return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +export function generateEdgeId(): string { + return `edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +export function createEmptyWorkflow(): Workflow { + return { + workflow_id: generateNodeId(), + name: 'New Workflow', + steps: [], + }; +} + +export function createDefaultNodeData(type: NodeType): WorkflowStepWithStatus { + const baseData: WorkflowStepWithStatus = { + step_id: generateNodeId(), + name: `New ${type} step`, + type, + workspaceMode: 'document', + }; + + if (type === 'agent') { + return { ...baseData, agent: undefined, inputArtifact: '', outputArtifact: '' }; + } + if (type === 'human-gate') { + return { ...baseData, reviewArtifact: '' }; + } + if (type === 'code-review') { + return { ...baseData, quickCommands: [] }; + } + + return baseData; +} + +export function convertNodesToWorkflow( + workflowId: string, + name: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): Workflow { + // Build adjacency list from edges + const adjacency = new Map(); + edges.forEach((edge) => { + adjacency.set(edge.source, edge.target); + }); + + // Find start node (no incoming edges) + const targetNodes = new Set(edges.map((e) => e.target)); + const startNode = nodes.find((n) => !targetNodes.has(n.id)); + + if (!startNode) { + // If no clear start node, just return steps in node order + const steps: WorkflowStep[] = nodes.map((n) => ({ + step_id: n.data.step_id, + name: n.data.name, + type: n.data.type, + agent: n.data.agent, + inputArtifact: n.data.inputArtifact, + outputArtifact: n.data.outputArtifact, + reviewArtifact: n.data.reviewArtifact, + workspaceMode: n.data.workspaceMode, + quickCommands: n.data.quickCommands, + })); + return { workflow_id: workflowId, name, steps }; + } + + // Traverse in order + const orderedSteps: WorkflowStep[] = []; + let currentId: string | undefined = startNode.id; + + while (currentId) { + const node = nodes.find((n) => n.id === currentId); + if (node) { + orderedSteps.push({ + step_id: node.data.step_id, + name: node.data.name, + type: node.data.type, + agent: node.data.agent, + inputArtifact: node.data.inputArtifact, + outputArtifact: node.data.outputArtifact, + reviewArtifact: node.data.reviewArtifact, + workspaceMode: node.data.workspaceMode, + quickCommands: node.data.quickCommands, + }); + } + currentId = adjacency.get(currentId); + } + + return { workflow_id: workflowId, name, steps: orderedSteps }; +} + +export function convertWorkflowToNodes( + workflow: Workflow +): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } { + const nodes: WorkflowNode[] = workflow.steps.map((step, index) => ({ + id: step.step_id, + type: step.type as NodeType, + position: { x: 100 + index * 350, y: 100 }, + data: { + ...step, + type: step.type as NodeType, + } as WorkflowStepWithStatus, + })); + + const edges: WorkflowEdge[] = []; + for (let i = 0; i < workflow.steps.length - 1; i++) { + edges.push({ + id: generateEdgeId(), + source: workflow.steps[i].step_id, + target: workflow.steps[i + 1].step_id, + }); + } + + return { nodes, edges }; +} diff --git a/packages/frontend/src/utils/workflowValidation.ts b/packages/frontend/src/utils/workflowValidation.ts new file mode 100644 index 0000000..7855ed7 --- /dev/null +++ b/packages/frontend/src/utils/workflowValidation.ts @@ -0,0 +1,88 @@ +import { WorkflowNode, WorkflowEdge, ValidationError, ValidationResult } from '@/types/workflow'; + +export function validateWorkflowForExecution( + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ValidationResult { + const errors: ValidationError[] = []; + + // Check for at least one node + if (nodes.length === 0) { + errors.push({ message: 'Workflow must have at least one node' }); + return { valid: false, errors }; + } + + // Check all agent nodes have agent type + nodes.forEach((node) => { + if (node.type === 'agent' && !node.data.agent) { + errors.push({ + nodeId: node.id, + field: 'agent', + message: `Agent node "${node.data.name}" must have an agent type selected`, + }); + } + }); + + // Check for disconnected nodes (except when there's only one node) + if (nodes.length > 1) { + const connectedNodes = new Set(); + edges.forEach((edge) => { + connectedNodes.add(edge.source); + connectedNodes.add(edge.target); + }); + + nodes.forEach((node) => { + if (!connectedNodes.has(node.id)) { + errors.push({ + nodeId: node.id, + message: `Node "${node.data.name}" is not connected to the workflow`, + }); + } + }); + } + + // Check for cycles + if (hasCycle(nodes, edges)) { + errors.push({ message: 'Workflow contains a cycle, which is not allowed' }); + } + + return { valid: errors.length === 0, errors }; +} + +function hasCycle(nodes: WorkflowNode[], edges: WorkflowEdge[]): boolean { + const adjacency = new Map(); + nodes.forEach((n) => adjacency.set(n.id, [])); + edges.forEach((e) => { + const neighbors = adjacency.get(e.source); + if (neighbors) { + neighbors.push(e.target); + } + }); + + const visited = new Set(); + const recStack = new Set(); + + function dfs(nodeId: string): boolean { + visited.add(nodeId); + recStack.add(nodeId); + + for (const neighbor of adjacency.get(nodeId) || []) { + if (!visited.has(neighbor)) { + if (dfs(neighbor)) return true; + } else if (recStack.has(neighbor)) { + return true; + } + } + + recStack.delete(nodeId); + return false; + } + + for (const node of nodes) { + if (!visited.has(node.id)) { + if (dfs(node.id)) return true; + } + } + + return false; +} From 44ec565b3abedd307800b603680f378336bad0ae Mon Sep 17 00:00:00 2001 From: oxedom Date: Sun, 25 Jan 2026 23:52:50 +0200 Subject: [PATCH 2/3] fix: workflow --- ...elete_mission_enhancement_fccda2ee.plan.md | 104 ++++ packages/backend/src/routes/missions.ts | 21 +- packages/backend/src/services/docker.ts | 17 + .../backend/src/services/mission-store.ts | 16 + packages/backend/src/services/sandbox.ts | 6 + packages/frontend/index.html | 2 + packages/frontend/package.json | 1 + packages/frontend/public/manifest.json | 26 + packages/frontend/src/App.tsx | 75 ++- packages/frontend/src/api/client.ts | 11 + packages/frontend/src/api/workflowApi.ts | 14 +- .../frontend/src/components/MissionDetail.tsx | 70 ++- packages/frontend/src/components/Sidebar.tsx | 7 +- .../components/workflow/ValidationErrors.tsx | 2 +- .../components/workflow/WorkflowBuilder.tsx | 4 +- .../components/workflow/WorkflowViewer.tsx | 2 +- .../workflow/hooks/useWorkflowState.ts | 15 +- .../components/workflow/nodes/AgentNode.tsx | 4 +- .../components/workflow/nodes/BaseNode.tsx | 4 +- .../workflow/nodes/CodeReviewNode.tsx | 4 +- .../workflow/nodes/HumanGateNode.tsx | 4 +- .../workflow/panels/AgentConfig.tsx | 2 +- .../workflow/panels/CodeReviewConfig.tsx | 4 +- .../workflow/panels/HumanGateConfig.tsx | 2 +- .../workflow/panels/NodeConfigPanel.tsx | 2 +- .../workflow/toolbar/NodePalette.tsx | 4 +- .../src/utils/connectionValidation.ts | 4 +- .../frontend/src/utils/workflowConverter.ts | 2 +- .../frontend/src/utils/workflowValidation.ts | 2 +- pnpm-lock.yaml | 462 ++++++++++++++++++ 30 files changed, 836 insertions(+), 57 deletions(-) create mode 100644 .cursor/plans/delete_mission_enhancement_fccda2ee.plan.md create mode 100644 packages/frontend/public/manifest.json diff --git a/.cursor/plans/delete_mission_enhancement_fccda2ee.plan.md b/.cursor/plans/delete_mission_enhancement_fccda2ee.plan.md new file mode 100644 index 0000000..b752694 --- /dev/null +++ b/.cursor/plans/delete_mission_enhancement_fccda2ee.plan.md @@ -0,0 +1,104 @@ +--- +name: Delete Mission Enhancement +overview: Add delete functionality for individual missions (with container cleanup) and a "Delete All Missions" button that wipes ~/.haflow/missions/*. +todos: + - id: backend-docker + content: Add removeByMissionId() to docker.ts + status: completed + - id: backend-store + content: Add deleteAllMissions() to mission-store.ts + status: completed + - id: backend-routes + content: Update DELETE endpoint and add DELETE /api/missions + status: completed + - id: frontend-api + content: Add deleteMission and deleteAllMissions to API client + status: completed + - id: frontend-detail + content: Add delete button with confirmation to MissionDetail + status: completed + - id: frontend-app + content: Add mutations and Delete All Missions button to App.tsx + status: completed +--- + +# Delete Mission Enhancement + +## Overview + +Add two delete features: (a) delete individual missions with their associated Docker containers, and (b) delete all missions to reset ~/.haflow/missions/*. + +## Implementation + +### Phase 1: Backend - Enhanced Delete Endpoints + +**File**: [`packages/backend/src/services/docker.ts`](packages/backend/src/services/docker.ts) + +- Add `removeByMissionId(missionId: string)` function to find and remove containers with label `haflow.mission_id={missionId}` + +**File**: [`packages/backend/src/routes/missions.ts`](packages/backend/src/routes/missions.ts) + +- Modify `DELETE /api/missions/:missionId` to also cleanup associated containers before deleting mission directory +- Add `DELETE /api/missions` endpoint to delete ALL missions (wipes ~/.haflow/missions/*) + +**File**: [`packages/backend/src/services/mission-store.ts`](packages/backend/src/services/mission-store.ts) + +- Add `deleteAllMissions()` function that removes everything in missions directory + +### Phase 2: Frontend - API Client + +**File**: [`packages/frontend/src/api/client.ts`](packages/frontend/src/api/client.ts) + +- Add `deleteMission(missionId: string)` method +- Add `deleteAllMissions()` method + +### Phase 3: Frontend - Delete Mission Button + +**File**: [`packages/frontend/src/components/MissionDetail.tsx`](packages/frontend/src/components/MissionDetail.tsx) + +- Add delete button in the header area (near mission title/status) +- Add confirmation dialog before deletion +- Pass `onDelete` callback prop + +**File**: [`packages/frontend/src/App.tsx`](packages/frontend/src/App.tsx) + +- Add `deleteMissionMutation` using TanStack Query +- Handle `onDelete` - clear selection after successful delete +- Add `deleteAllMissionsMutation` +- Add "Delete All Missions" button near existing "Cleanup Containers" button with confirmation dialog + +## Key Code Changes + +### Docker - Find containers by mission: + +```typescript +async function removeByMissionId(missionId: string): Promise { + const { stdout } = await execAsync( + `docker ps -aq --filter="label=${LABEL_PREFIX}.mission_id=${missionId}"` + ); + const ids = stdout.trim().split('\n').filter(Boolean); + for (const id of ids) await remove(id); + return ids.length; +} +``` + +### Delete mission route enhancement: + +```typescript +// Delete mission AND its containers +await dockerProvider.removeByMissionId(missionId); +await missionStore.deleteMission(missionId); +``` + +### Delete all missions: + +```typescript +async function deleteAllMissions(): Promise { + const { rm, readdir } = await import('fs/promises'); + const dir = missionsDir(); + const entries = await readdir(dir); + for (const entry of entries) { + await rm(join(dir, entry), { recursive: true, force: true }); + } +} +``` \ No newline at end of file diff --git a/packages/backend/src/routes/missions.ts b/packages/backend/src/routes/missions.ts index 500c156..fb3ca5a 100644 --- a/packages/backend/src/routes/missions.ts +++ b/packages/backend/src/routes/missions.ts @@ -4,6 +4,7 @@ import { existsSync } from 'fs'; import { CreateMissionRequestSchema, SaveArtifactRequestSchema } from '@haflow/shared'; import { missionStore } from '../services/mission-store.js'; import { missionEngine } from '../services/mission-engine.js'; +import { dockerProvider } from '../services/docker.js'; import { getWorkflows } from '../services/workflow.js'; import { sendSuccess, sendError } from '../utils/response.js'; import { config, execAsync, getProjectGitStatus, getFileDiff } from '../utils/config.js'; @@ -121,7 +122,21 @@ missionRoutes.post('/:missionId/mark-completed', async (req, res, next) => { } }); -// DELETE /api/missions/:missionId - Delete mission +// DELETE /api/missions - Delete ALL missions +missionRoutes.delete('/', async (_req, res, next) => { + try { + // First cleanup all haflow containers + await dockerProvider.cleanupOrphaned(); + + // Then delete all mission directories + const deletedCount = await missionStore.deleteAllMissions(); + sendSuccess(res, { deleted: deletedCount, message: `Deleted ${deletedCount} mission(s)` }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/missions/:missionId - Delete mission and its containers missionRoutes.delete('/:missionId', async (req, res, next) => { try { const { missionId } = req.params; @@ -131,6 +146,10 @@ missionRoutes.delete('/:missionId', async (req, res, next) => { return sendError(res, `Mission not found: ${missionId}`, 404); } + // First cleanup containers associated with this mission + await dockerProvider.removeByMissionId(missionId); + + // Then delete the mission directory await missionStore.deleteMission(missionId); sendSuccess(res, null); } catch (err) { diff --git a/packages/backend/src/services/docker.ts b/packages/backend/src/services/docker.ts index 4d922f6..bfc983b 100644 --- a/packages/backend/src/services/docker.ts +++ b/packages/backend/src/services/docker.ts @@ -179,6 +179,22 @@ async function cleanupOrphaned(): Promise { } } +async function removeByMissionId(missionId: string): Promise { + try { + const { stdout } = await execAsync( + `docker ps -aq --filter="label=${LABEL_PREFIX}.mission_id=${missionId}"` + ); + const ids = stdout.trim().split('\n').filter(Boolean); + for (const id of ids) { + await remove(id); + } + return ids.length; + } catch { + // Ignore errors - containers may not exist + return 0; + } +} + // COMPLETE marker for Ralph loop detection const COMPLETE_MARKER = 'COMPLETE'; @@ -485,5 +501,6 @@ export const dockerProvider: SandboxProvider = { remove, isAvailable, cleanupOrphaned, + removeByMissionId, startClaudeStreaming, }; diff --git a/packages/backend/src/services/mission-store.ts b/packages/backend/src/services/mission-store.ts index 9ebcff7..0e42b60 100644 --- a/packages/backend/src/services/mission-store.ts +++ b/packages/backend/src/services/mission-store.ts @@ -139,6 +139,21 @@ async function deleteMission(missionId: string): Promise { await rm(dir, { recursive: true, force: true }); } +async function deleteAllMissions(): Promise { + const dir = missionsDir(); + if (!existsSync(dir)) { + return 0; + } + const entries = await readdir(dir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()); + + const { rm } = await import('fs/promises'); + for (const entry of dirs) { + await rm(join(dir, entry.name), { recursive: true, force: true }); + } + return dirs.length; +} + // --- Update --- async function updateMeta(missionId: string, updates: Partial): Promise { const meta = await getMeta(missionId); @@ -286,6 +301,7 @@ export const missionStore = { getDetail, listMissions, deleteMission, + deleteAllMissions, updateMeta, loadArtifacts, getArtifact, diff --git a/packages/backend/src/services/sandbox.ts b/packages/backend/src/services/sandbox.ts index a166596..afda534 100644 --- a/packages/backend/src/services/sandbox.ts +++ b/packages/backend/src/services/sandbox.ts @@ -76,6 +76,12 @@ export interface SandboxProvider { */ cleanupOrphaned(): Promise; + /** + * Remove all containers associated with a specific mission + * Returns the number of containers removed + */ + removeByMissionId(missionId: string): Promise; + /** * Start Claude sandbox with streaming output * Returns an async generator that yields StreamEvents diff --git a/packages/frontend/index.html b/packages/frontend/index.html index 1b7ecb6..963e10b 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -6,6 +6,8 @@ + + diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 37f7b47..169d360 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -33,6 +33,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", + "reactflow": "^11.11.4", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0" }, diff --git a/packages/frontend/public/manifest.json b/packages/frontend/public/manifest.json new file mode 100644 index 0000000..f91681c --- /dev/null +++ b/packages/frontend/public/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Haflow", + "short_name": "Haflow", + "description": "Local-first AI mission orchestrator with human gates and ephemeral sandboxes", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0b", + "theme_color": "#0a0a0b", + "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 44dee09..87aee15 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Menu, Headphones, Trash2 } from 'lucide-react' +import { Menu, Headphones, Trash2, FolderX } from 'lucide-react' import { Sidebar } from '@/components/Sidebar' import { MissionDetail as MissionDetailView } from '@/components/MissionDetail' import { NewMissionModal } from '@/components/NewMissionModal' @@ -33,6 +33,7 @@ function AppContent() { const [isSidebarOpen, setIsSidebarOpen] = useState(false) const [showVoiceChat, setShowVoiceChat] = useState(false) const [isCleanupDialogOpen, setIsCleanupDialogOpen] = useState(false) + const [isDeleteAllDialogOpen, setIsDeleteAllDialogOpen] = useState(false) // Query: Fetch missions list with polling const { data: missions = [], isLoading: isLoadingMissions } = useQuery({ @@ -121,6 +122,34 @@ function AppContent() { }, }) + // Mutation: Delete single mission + const deleteMissionMutation = useMutation({ + mutationFn: async (missionId: string) => { + return api.deleteMission(missionId) + }, + onSuccess: () => { + setSelectedMissionId(null) + queryClient.invalidateQueries({ queryKey: ['missions'] }) + }, + onError: (error) => { + alert(`Failed to delete mission: ${error instanceof Error ? error.message : 'Unknown error'}`) + }, + }) + + // Mutation: Delete all missions + const deleteAllMissionsMutation = useMutation({ + mutationFn: api.deleteAllMissions, + onSuccess: (data) => { + setIsDeleteAllDialogOpen(false) + setSelectedMissionId(null) + queryClient.invalidateQueries({ queryKey: ['missions'] }) + alert(data.message) + }, + onError: (error) => { + alert(`Failed to delete missions: ${error instanceof Error ? error.message : 'Unknown error'}`) + }, + }) + // Handlers const handleSelectMission = (id: string) => { setSelectedMissionId(id) @@ -153,6 +182,11 @@ function AppContent() { await markCompletedMutation.mutateAsync() } + const handleDeleteMission = async () => { + if (!selectedMissionId) return + await deleteMissionMutation.mutateAsync(selectedMissionId) + } + if (isLoadingMissions) { return (
@@ -200,6 +234,15 @@ function AppContent() {
{/* Desktop Header with Voice Chat toggle */}
+ + + + +
) } diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index 64e5592..6696547 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -86,6 +86,17 @@ export const api = { return res.data.data!; }, + deleteMission: async (missionId: string): Promise => { + const res = await client.delete>(`/missions/${missionId}`); + if (!res.data.success) throw new Error(res.data.error || 'Failed to delete mission'); + }, + + deleteAllMissions: async (): Promise<{ deleted: number; message: string }> => { + const res = await client.delete>('/missions'); + if (!res.data.success) throw new Error(res.data.error || 'Failed to delete all missions'); + return res.data.data!; + }, + // Code review step APIs runCommand: async ( missionId: string, diff --git a/packages/frontend/src/api/workflowApi.ts b/packages/frontend/src/api/workflowApi.ts index b4fdff5..60cbfef 100644 --- a/packages/frontend/src/api/workflowApi.ts +++ b/packages/frontend/src/api/workflowApi.ts @@ -2,9 +2,7 @@ import type { Workflow } from '@haflow/shared'; import { api } from './client'; // Re-export workflow-related functions from main client -export const getWorkflowTemplates = api.getWorkflowTemplates; -export const executeWorkflow = api.executeWorkflow; -export const executeWorkflowTemplate = api.executeWorkflowTemplate; +export const getWorkflows = api.getWorkflows; // Additional workflow-specific utilities export async function saveWorkflow(workflow: Workflow): Promise { @@ -13,14 +11,4 @@ export async function saveWorkflow(workflow: Workflow): Promise { console.log('Workflow save not yet implemented', workflow); } -export async function validateAndExecuteWorkflow(workflow: Workflow): Promise<{ - workflow_id: string; - name: string; - steps_count: number; - message: string; -}> { - // Execute workflow (backend will validate) - return executeWorkflow(workflow); -} - export { api }; diff --git a/packages/frontend/src/components/MissionDetail.tsx b/packages/frontend/src/components/MissionDetail.tsx index cf738c3..9f58b26 100644 --- a/packages/frontend/src/components/MissionDetail.tsx +++ b/packages/frontend/src/components/MissionDetail.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react' import type { MissionDetail as MissionDetailType, MissionStatus, StepRun, StepType } from '@haflow/shared' -import { Check, ChevronDown, ChevronUp, ArrowRight, Play } from 'lucide-react' +import { Check, ChevronDown, ChevronUp, ArrowRight, Play, Trash2 } from 'lucide-react' import * as Diff from 'diff' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -8,6 +8,14 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { StepHistoryModal } from './StepHistoryModal' import { CodeReviewStep } from './CodeReviewStep' @@ -17,6 +25,8 @@ interface MissionDetailProps { onSaveArtifact: (filename: string, content: string) => void onContinue: () => void onMarkCompleted: () => void + onDelete: () => void + isDeleting?: boolean } const statusConfig: Record = { @@ -218,12 +228,13 @@ function DiffViewer({ original, modified }: DiffViewerProps) { ) } -export function MissionDetail({ mission, onSaveArtifact, onContinue, onMarkCompleted }: MissionDetailProps) { +export function MissionDetail({ mission, onSaveArtifact, onContinue, onMarkCompleted, onDelete, isDeleting }: MissionDetailProps) { const [viewMode, setViewMode] = useState<'editor' | 'diff' | 'preview'>('editor') const [editorContent, setEditorContent] = useState('') const [originalContent, setOriginalContent] = useState('') const [hasChanges, setHasChanges] = useState(false) const [selectedHistoryStepIndex, setSelectedHistoryStepIndex] = useState(null) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const handleHistoryStepClick = (stepIndex: number) => { setSelectedHistoryStepIndex(stepIndex) @@ -272,13 +283,24 @@ export function MissionDetail({ mission, onSaveArtifact, onContinue, onMarkCompl
{/* Header */}
-
-

- Mission: {mission.title} -

- - {statusInfo.label} - +
+
+

+ Mission: {mission.title} +

+ + {statusInfo.label} + +
+
@@ -462,6 +484,36 @@ export function MissionDetail({ mission, onSaveArtifact, onContinue, onMarkCompl artifacts={mission.artifacts} runs={mission.runs} /> + + {/* Delete Confirmation Dialog */} + + + + Delete Mission + + Are you sure you want to delete "{mission.title}"? This will also remove any associated Docker containers. This action cannot be undone. + + + + + + + +
) } diff --git a/packages/frontend/src/components/Sidebar.tsx b/packages/frontend/src/components/Sidebar.tsx index e7323b1..a569270 100644 --- a/packages/frontend/src/components/Sidebar.tsx +++ b/packages/frontend/src/components/Sidebar.tsx @@ -68,14 +68,17 @@ export function Sidebar({ missions, selectedMissionId, onSelectMission, onNewMis > {/* Header */}
-
+
+