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..777b7e5 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'; @@ -22,6 +23,61 @@ workflowRoutes.get('/', async (_req, res, next) => { } }); +// GET /api/workflows/templates - Alias for listing workflows +workflowRoutes.get('/templates', async (_req, res, next) => { + try { + const workflows = getWorkflows(); + sendSuccess(res, workflows); + } catch (err) { + next(err); + } +}); + +// POST /api/workflows/execute - Validate and execute a workflow +workflowRoutes.post('/execute', async (req, res, next) => { + try { + const { workflowId, workflow } = req.body; + + // Must provide either workflowId or workflow + if (!workflowId && !workflow) { + return sendError(res, 'workflowId or workflow required', 400); + } + + let resolvedWorkflow; + + if (workflowId) { + // Look up template workflow by ID + const workflows = getWorkflows(); + resolvedWorkflow = workflows.find(w => w.workflow_id === workflowId); + if (!resolvedWorkflow) { + return sendError(res, `Workflow not found: ${workflowId}`, 404); + } + } else { + // Use provided dynamic workflow + resolvedWorkflow = workflow; + } + + // Validate workflow has at least one step + if (!resolvedWorkflow.steps || resolvedWorkflow.steps.length === 0) { + return sendError(res, 'Workflow must have at least one step', 400); + } + + // Validate agent steps have agent type + for (const step of resolvedWorkflow.steps) { + if (step.type === 'agent' && !step.agent) { + return sendError(res, `Step "${step.name}" is type "agent" but missing agent type`, 400); + } + } + + sendSuccess(res, { + workflow_id: resolvedWorkflow.workflow_id, + steps_count: resolvedWorkflow.steps.length, + }); + } catch (err) { + next(err); + } +}); + // GET /api/missions - List all missions missionRoutes.get('/', async (_req, res, next) => { try { @@ -121,7 +177,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 +201,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/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/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 new file mode 100644 index 0000000..60cbfef --- /dev/null +++ b/packages/frontend/src/api/workflowApi.ts @@ -0,0 +1,14 @@ +import type { Workflow } from '@haflow/shared'; +import { api } from './client'; + +// Re-export workflow-related functions from main client +export const getWorkflows = api.getWorkflows; + +// 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 { 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 */}
-
+
+ +
+ ))} +
+ )} +
+ 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..65839b9 --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/HumanGateConfig.tsx @@ -0,0 +1,54 @@ +import type { 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..7c16064 --- /dev/null +++ b/packages/frontend/src/components/workflow/panels/NodeConfigPanel.tsx @@ -0,0 +1,87 @@ +import type { 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..f0013fe --- /dev/null +++ b/packages/frontend/src/components/workflow/toolbar/NodePalette.tsx @@ -0,0 +1,67 @@ +import type { DragEvent } from 'react'; +import type { 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..0149669 --- /dev/null +++ b/packages/frontend/src/utils/connectionValidation.ts @@ -0,0 +1,79 @@ +import type { Connection } from 'reactflow'; +import type { 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..e4d290e --- /dev/null +++ b/packages/frontend/src/utils/workflowConverter.ts @@ -0,0 +1,126 @@ +import type { + 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..a765ac0 --- /dev/null +++ b/packages/frontend/src/utils/workflowValidation.ts @@ -0,0 +1,88 @@ +import type { 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f27bcb7..2ec502e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.9)(react@19.2.3) + reactflow: + specifier: ^11.11.4 + version: 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -1399,6 +1402,42 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reactflow/background@11.3.14': + resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/controls@11.2.14': + resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/core@11.11.4': + resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/minimap@11.7.14': + resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/node-resizer@2.2.14': + resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/node-toolbar@1.3.14': + resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -1690,6 +1729,99 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1709,6 +1841,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2073,6 +2208,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -2189,6 +2327,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3439,6 +3615,12 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + reactflow@11.11.4: + resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3914,6 +4096,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4165,6 +4352,21 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5099,6 +5301,84 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reactflow/background@11.3.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/controls@11.2.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/core@11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@types/d3': 7.4.3 + '@types/d3-drag': 3.0.7 + '@types/d3-selection': 3.0.11 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/minimap@11.7.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/d3-selection': 3.0.11 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/node-resizer@2.2.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/node-toolbar@1.3.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.9)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/pluginutils@5.3.0(rollup@4.55.1)': @@ -5343,6 +5623,123 @@ snapshots: dependencies: '@types/node': 25.0.8 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5371,6 +5768,8 @@ snapshots: '@types/qs': 6.14.0 '@types/serve-static': 1.15.10 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5908,6 +6307,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + cli-boxes@3.0.0: {} clsx@2.1.1: {} @@ -6006,6 +6407,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + debug@2.6.9: dependencies: ms: 2.0.0 @@ -7553,6 +7990,20 @@ snapshots: react@19.2.3: {} + reactflow@11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@reactflow/background': 11.3.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@reactflow/controls': 11.2.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@reactflow/core': 11.11.4(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@reactflow/minimap': 11.7.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@reactflow/node-resizer': 2.2.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@reactflow/node-toolbar': 1.3.14(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -8104,6 +8555,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.9 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -8358,4 +8813,11 @@ snapshots: zod@3.25.76: {} + zustand@4.5.7(@types/react@19.2.9)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.9 + react: 19.2.3 + zwitch@2.0.4: {}