diff --git a/backend/sandbox/api.py b/backend/sandbox/api.py index 5e654a4c..f937361d 100644 --- a/backend/sandbox/api.py +++ b/backend/sandbox/api.py @@ -6,7 +6,7 @@ from fastapi import FastAPI, UploadFile, File, HTTPException, APIRouter, Form, D from fastapi.responses import Response from pydantic import BaseModel -from sandbox.sandbox import get_or_start_sandbox +from sandbox.sandbox import get_or_start_sandbox, delete_sandbox from utils.logger import logger from utils.auth_utils import get_optional_user_id from services.supabase import DBConnection @@ -305,6 +305,28 @@ async def delete_file( logger.error(f"Error deleting file in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) +@router.delete("/sandboxes/{sandbox_id}") +async def delete_sandbox_route( + sandbox_id: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """Delete an entire sandbox""" + logger.info(f"Received sandbox delete request for sandbox {sandbox_id}, user_id: {user_id}") + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Delete the sandbox using the sandbox module function + await delete_sandbox(sandbox_id) + + return {"status": "success", "deleted": True, "sandbox_id": sandbox_id} + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + # Should happen on server-side fully @router.post("/project/{project_id}/sandbox/ensure-active") async def ensure_project_sandbox_active( diff --git a/backend/sandbox/sandbox.py b/backend/sandbox/sandbox.py index 5e7bc9b2..6c9691ea 100644 --- a/backend/sandbox/sandbox.py +++ b/backend/sandbox/sandbox.py @@ -125,3 +125,20 @@ def create_sandbox(password: str, project_id: str = None): logger.debug(f"Sandbox environment successfully initialized") return sandbox +async def delete_sandbox(sandbox_id: str): + """Delete a sandbox by its ID.""" + logger.info(f"Deleting sandbox with ID: {sandbox_id}") + + try: + # Get the sandbox + sandbox = daytona.get_current_sandbox(sandbox_id) + + # Delete the sandbox + daytona.remove(sandbox) + + logger.info(f"Successfully deleted sandbox {sandbox_id}") + return True + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") + raise e + diff --git a/frontend/src/components/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx index 6011f43f..3c0b25e4 100644 --- a/frontend/src/components/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -225,10 +225,16 @@ export function NavAgents() { // Store threadToDelete in a local variable since it might be cleared const deletedThread = { ...threadToDelete }; + // Get sandbox ID from projects data + const thread = combinedThreads.find(t => t.threadId === threadId); + const project = projects.find(p => p.id === thread?.projectId); + const sandboxId = project?.sandbox?.id; + // Log operation start console.log('DELETION - Starting thread deletion process', { threadId: deletedThread.id, isCurrentThread: isActive, + sandboxId }); // Use the centralized deletion system with completion callback @@ -236,9 +242,9 @@ export function NavAgents() { threadId, isActive, async () => { - // Delete the thread using the mutation + // Delete the thread using the mutation with sandbox ID deleteThreadMutation( - { threadId }, + { threadId, sandboxId }, { onSuccess: () => { // Invalidate queries to refresh the list @@ -282,6 +288,13 @@ export function NavAgents() { deleteMultipleThreadsMutation( { threadIds: threadIdsToDelete, + threadSandboxMap: Object.fromEntries( + threadIdsToDelete.map(threadId => { + const thread = combinedThreads.find(t => t.threadId === threadId); + const project = projects.find(p => p.id === thread?.projectId); + return [threadId, project?.sandbox?.id || '']; + }).filter(([, sandboxId]) => sandboxId) + ), onProgress: handleDeletionProgress }, { diff --git a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts index 5eec10e1..19c12739 100644 --- a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts +++ b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts @@ -33,12 +33,13 @@ export const useThreads = createQueryHook( interface DeleteThreadVariables { threadId: string; + sandboxId?: string; isNavigateAway?: boolean; } export const useDeleteThread = createMutationHook( - async ({ threadId }: DeleteThreadVariables) => { - return await deleteThread(threadId); + async ({ threadId, sandboxId }: DeleteThreadVariables) => { + return await deleteThread(threadId, sandboxId); }, { onSuccess: () => { @@ -48,16 +49,18 @@ export const useDeleteThread = createMutationHook( interface DeleteMultipleThreadsVariables { threadIds: string[]; + threadSandboxMap?: Record; onProgress?: (completed: number, total: number) => void; } export const useDeleteMultipleThreads = createMutationHook( - async ({ threadIds, onProgress }: DeleteMultipleThreadsVariables) => { + async ({ threadIds, threadSandboxMap, onProgress }: DeleteMultipleThreadsVariables) => { let completedCount = 0; const results = await Promise.all( threadIds.map(async (threadId) => { try { - const result = await deleteThread(threadId); + const sandboxId = threadSandboxMap?.[threadId]; + const result = await deleteThread(threadId, sandboxId); completedCount++; onProgress?.(completedCount, threadIds.length); return { success: true, threadId }; diff --git a/frontend/src/hooks/react-query/threads/utils.ts b/frontend/src/hooks/react-query/threads/utils.ts index 9bbc7d38..47bb49d6 100644 --- a/frontend/src/hooks/react-query/threads/utils.ts +++ b/frontend/src/hooks/react-query/threads/utils.ts @@ -74,41 +74,99 @@ export const toggleThreadPublicStatus = async ( return updateThread(threadId, { is_public: isPublic }); }; -export const deleteThread = async (threadId: string): Promise => { +const deleteSandbox = async (sandboxId: string): Promise => { + try { + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + const response = await fetch(`${API_URL}/sandboxes/${sandboxId}`, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + console.warn('Failed to delete sandbox, continuing with thread deletion'); + } + } catch (error) { + console.warn('Error deleting sandbox, continuing with thread deletion:', error); + } +}; + +export const deleteThread = async (threadId: string, sandboxId?: string): Promise => { try { const supabase = createClient(); - + + // If sandbox ID is provided, delete it directly + if (sandboxId) { + await deleteSandbox(sandboxId); + } else { + // Otherwise, get the thread to find its project and sandbox + const { data: thread, error: threadError } = await supabase + .from('threads') + .select('project_id') + .eq('thread_id', threadId) + .single(); + + if (threadError) { + console.error('Error fetching thread:', threadError); + throw new Error(`Error fetching thread: ${threadError.message}`); + } + + // If thread has a project, get sandbox ID and delete it + if (thread?.project_id) { + const { data: project } = await supabase + .from('projects') + .select('sandbox') + .eq('project_id', thread.project_id) + .single(); + + if (project?.sandbox?.id) { + await deleteSandbox(project.sandbox.id); + } + } + } + console.log(`Deleting all agent runs for thread ${threadId}`); const { error: agentRunsError } = await supabase .from('agent_runs') .delete() .eq('thread_id', threadId); - + if (agentRunsError) { console.error('Error deleting agent runs:', agentRunsError); throw new Error(`Error deleting agent runs: ${agentRunsError.message}`); } - + console.log(`Deleting all messages for thread ${threadId}`); const { error: messagesError } = await supabase .from('messages') .delete() .eq('thread_id', threadId); - + if (messagesError) { console.error('Error deleting messages:', messagesError); throw new Error(`Error deleting messages: ${messagesError.message}`); } console.log(`Deleting thread ${threadId}`); - const { error: threadError } = await supabase + const { error: threadError2 } = await supabase .from('threads') .delete() .eq('thread_id', threadId); - if (threadError) { - console.error('Error deleting thread:', threadError); - throw new Error(`Error deleting thread: ${threadError.message}`); + if (threadError2) { + console.error('Error deleting thread:', threadError2); + throw new Error(`Error deleting thread: ${threadError2.message}`); } console.log(