Skip to content

Commit 4a42a20

Browse files
author
Agent-Planner
committed
feat: Add comprehensive project deletion cleanup across all systems
- Enhanced delete_project endpoint to perform complete cleanup: * Disconnects all WebSocket connections * Stops and removes agent process managers * Stops dev servers * Deletes database files (features.db, assistant.db) * Unregisters from registry * Optionally deletes entire project directory - Added cleanup_manager function in process_manager to remove specific project managers - Added disconnect_all_for_project method in ConnectionManager - Updated ImportProjectModal to detect project name conflicts - Added Delete Existing Project button with confirmation dialog - Fixed 'deleteProject assigned but never used' error This ensures projects are cleanly removed from registry, websockets, API, database, agents, and dev servers, preventing conflicts when reimporting.
1 parent aca42f5 commit 4a42a20

4 files changed

Lines changed: 194 additions & 6 deletions

File tree

server/routers/projects.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,18 @@ async def get_project(name: str):
331331
@router.delete("/{name}")
332332
async def delete_project(name: str, delete_files: bool = False):
333333
"""
334-
Delete a project from the registry.
334+
Delete a project from the registry and perform comprehensive cleanup.
335+
336+
This removes the project from:
337+
- Registry (project registration)
338+
- Database (features.db file)
339+
- WebSocket connections (all active connections)
340+
- Agent processes (stop and cleanup)
341+
- Dev servers (stop if running)
335342
336343
Args:
337344
name: Project name to delete
338-
delete_files: If True, also delete the project directory and files
345+
delete_files: If True, also delete the project directory and all files
339346
"""
340347
_init_imports()
341348
_, unregister_project, get_project_path, _, _ = _get_registry_functions()
@@ -354,19 +361,63 @@ async def delete_project(name: str, delete_files: bool = False):
354361
detail="Cannot delete project while agent is running. Stop the agent first."
355362
)
356363

357-
# Optionally delete files
364+
# Step 1: Disconnect all WebSocket connections for this project
365+
from .websocket import manager as websocket_manager
366+
try:
367+
disconnected = await websocket_manager.disconnect_all_for_project(name)
368+
logger.info(f"Disconnected {disconnected} WebSocket connection(s) for project '{name}'")
369+
except Exception as e:
370+
logger.warning(f"Error disconnecting WebSocket connections for project '{name}': {e}")
371+
372+
# Step 2: Stop agent process manager for this project
373+
from .services.process_manager import cleanup_manager as cleanup_process_manager
374+
from .services.dev_server_manager import get_devserver_manager
375+
try:
376+
await cleanup_process_manager(name, project_dir)
377+
logger.info(f"Stopped agent process manager for project '{name}'")
378+
except Exception as e:
379+
logger.warning(f"Error stopping agent process manager for project '{name}': {e}")
380+
381+
# Step 3: Stop dev server if running for this project
382+
try:
383+
devserver_mgr = get_devserver_manager()
384+
await devserver_mgr.stop_server(name)
385+
logger.info(f"Stopped dev server for project '{name}'")
386+
except Exception as e:
387+
logger.warning(f"Error stopping dev server for project '{name}': {e}")
388+
389+
# Step 4: Delete database files (features.db, assistant.db)
390+
db_files = ["features.db", "assistant.db"]
391+
deleted_dbs = []
392+
for db_file in db_files:
393+
db_path = project_dir / db_file
394+
if db_path.exists():
395+
try:
396+
db_path.unlink()
397+
deleted_dbs.append(db_file)
398+
logger.info(f"Deleted {db_file} for project '{name}'")
399+
except Exception as e:
400+
logger.warning(f"Error deleting {db_file} for project '{name}': {e}")
401+
402+
# Step 5: Optionally delete the entire project directory
358403
if delete_files and project_dir.exists():
359404
try:
360405
shutil.rmtree(project_dir)
406+
logger.info(f"Deleted project directory for '{name}'")
361407
except Exception as e:
362408
raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}")
363409

364-
# Unregister from registry
410+
# Step 6: Unregister from registry (do this last)
365411
unregister_project(name)
412+
logger.info(f"Unregistered project '{name}' from registry")
366413

367414
return {
368415
"success": True,
369-
"message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)")
416+
"message": f"Project '{name}' deleted completely" + (" (including files)" if delete_files else " (files preserved)"),
417+
"details": {
418+
"databases_deleted": deleted_dbs,
419+
"files_deleted": delete_files
420+
}
370421
}
371422

372423

server/services/process_manager.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,27 @@ async def cleanup_all_managers() -> None:
633633
_managers.clear()
634634

635635

636+
async def cleanup_manager(project_name: str, project_dir: Path) -> None:
637+
"""Stop and remove a specific project's agent process manager.
638+
639+
Args:
640+
project_name: Name of the project
641+
project_dir: Absolute path to the project directory
642+
"""
643+
with _managers_lock:
644+
# Use composite key to match get_manager
645+
key = (project_name, str(project_dir.resolve()))
646+
manager = _managers.pop(key, None)
647+
648+
if manager:
649+
try:
650+
if manager.status != "stopped":
651+
await manager.stop()
652+
logger.info(f"Cleaned up agent process manager for project: {project_name}")
653+
except Exception as e:
654+
logger.warning(f"Error stopping manager for {project_name}: {e}")
655+
656+
636657
def cleanup_orphaned_locks() -> int:
637658
"""
638659
Clean up orphaned lock files from previous server runs.

server/websocket.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,31 @@ def get_connection_count(self, project_name: str) -> int:
618618
"""Get number of active connections for a project."""
619619
return len(self.active_connections.get(project_name, set()))
620620

621+
async def disconnect_all_for_project(self, project_name: str) -> int:
622+
"""Disconnect all WebSocket connections for a specific project.
623+
624+
Args:
625+
project_name: Name of the project
626+
627+
Returns:
628+
Number of connections that were disconnected
629+
"""
630+
async with self._lock:
631+
connections = list(self.active_connections.get(project_name, set()))
632+
if project_name in self.active_connections:
633+
del self.active_connections[project_name]
634+
635+
# Close connections outside the lock to avoid deadlock
636+
closed_count = 0
637+
for connection in connections:
638+
try:
639+
await connection.close(code=1000, reason="Project deleted")
640+
closed_count += 1
641+
except Exception as e:
642+
logger.warning(f"Error closing WebSocket connection for project {project_name}: {e}")
643+
644+
return closed_count
645+
621646

622647
# Global connection manager
623648
manager = ConnectionManager()

ui/src/components/ImportProjectModal.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import {
2828
Square,
2929
ChevronDown,
3030
ChevronRight,
31+
Trash2,
3132
} from 'lucide-react'
3233
import { useImportProject } from '../hooks/useImportProject'
33-
import { useCreateProject, useDeleteProject } from '../hooks/useProjects'
34+
import { useCreateProject, useDeleteProject, useProjects } from '../hooks/useProjects'
3435
import { FolderBrowser } from './FolderBrowser'
36+
import { ConfirmDialog } from './ConfirmDialog'
3537

3638
type Step = 'folder' | 'analyzing' | 'detected' | 'features' | 'register' | 'complete'
3739

@@ -50,6 +52,11 @@ export function ImportProjectModal({
5052
const [projectName, setProjectName] = useState('')
5153
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
5254
const [registerError, setRegisterError] = useState<string | null>(null)
55+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
56+
const [projectToDelete, setProjectToDelete] = useState<string | null>(null)
57+
58+
// Fetch existing projects to check for conflicts
59+
const { data: existingProjects } = useProjects()
5360

5461
const {
5562
state,
@@ -150,6 +157,27 @@ export function ImportProjectModal({
150157
onClose()
151158
}
152159

160+
const handleDeleteExistingProject = async () => {
161+
if (!projectToDelete) return
162+
163+
164+
try {
165+
await deleteProject.mutateAsync(projectToDelete)
166+
setShowDeleteConfirm(false)
167+
setProjectToDelete(null)
168+
169+
// Refresh the import step to reflect the deletion
170+
if (step === 'register') {
171+
// Stay on register step so user can now create the project with same name
172+
}
173+
} catch (error) {
174+
const errorMessage = error instanceof Error ? error.message : 'Failed to delete project'
175+
setRegisterError(`Delete failed: ${errorMessage}`)
176+
setShowDeleteConfirm(false)
177+
setProjectToDelete(null)
178+
}
179+
}
180+
153181
const handleBack = () => {
154182
if (step === 'detected') {
155183
setStep('folder')
@@ -529,6 +557,11 @@ export function ImportProjectModal({
529557

530558
// Register project step
531559
if (step === 'register') {
560+
// Check if project name already exists
561+
const existingProject = existingProjects?.find(p => p.name === projectName)
562+
const nameConflict = !!existingProject
563+
564+
532565
return (
533566
<div className="neo-modal-backdrop" onClick={handleClose}>
534567
<div
@@ -557,10 +590,41 @@ export function ImportProjectModal({
557590
className="neo-input"
558591
pattern="^[a-zA-Z0-9_-]+$"
559592
autoFocus
593+
disabled={createProject.isPending}
560594
/>
561595
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-2">
562596
Use letters, numbers, hyphens, and underscores only.
563597
</p>
598+
{nameConflict && (
599+
<div className="mt-3 p-3 bg-[var(--color-neo-error-bg)] border-3 border-[var(--color-neo-error-border)] rounded">
600+
<p className="text-sm text-[var(--color-neo-error-text)] font-bold mb-1">
601+
Project name already exists!
602+
</p>
603+
<p className="text-xs text-[var(--color-neo-error-text)] mb-2">
604+
A project named "{projectName}" is already registered.
605+
</p>
606+
<button
607+
onClick={() => {
608+
setProjectToDelete(projectName)
609+
setShowDeleteConfirm(true)
610+
}}
611+
className="neo-btn neo-btn-destructive text-sm"
612+
disabled={deleteProject.isPending}
613+
>
614+
{deleteProject.isPending ? (
615+
<>
616+
<Loader2 size={14} className="animate-spin mr-1" />
617+
Deleting...
618+
</>
619+
) : (
620+
<>
621+
<Trash2 size={14} className="mr-1" />
622+
Delete Existing Project
623+
</>
624+
)}
625+
</button>
626+
</div>
627+
)}
564628
</div>
565629

566630
<div className="mb-6 p-4 bg-[var(--color-neo-bg-secondary)] border-3 border-[var(--color-neo-border)]">
@@ -648,5 +712,32 @@ export function ImportProjectModal({
648712
)
649713
}
650714

715+
// Delete confirmation dialog
716+
if (showDeleteConfirm) {
717+
return (
718+
<ConfirmDialog
719+
isOpen={showDeleteConfirm}
720+
title="Delete Existing Project"
721+
message={`Are you sure you want to delete "${projectToDelete}"? This will:
722+
723+
• Remove the project from the registry
724+
• Disconnect all WebSocket connections
725+
• Stop any running agent processes
726+
• Delete all database files (features.db, assistant.db)
727+
• Stop any dev servers
728+
• Preserve the project files on disk`}
729+
confirmLabel="Delete Project"
730+
cancelLabel="Cancel"
731+
variant="danger"
732+
isLoading={deleteProject.isPending}
733+
onConfirm={handleDeleteExistingProject}
734+
onCancel={() => {
735+
setShowDeleteConfirm(false)
736+
setProjectToDelete(null)
737+
}}
738+
/>
739+
)
740+
}
741+
651742
return null
652743
}

0 commit comments

Comments
 (0)