From 0b35ae3bf0f3dbe1ba3d4bb2016ccf79b33abb9a Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 28 May 2026 12:08:37 +0200 Subject: [PATCH] feat: multi-session terminal backend API + frontend client (PR 2) - Add WebSocket route /ws/tool-instances/{instance_id}/terminal/{session_id} - Preserve /terminal as default-session alias for backward compatibility - Extract shared _handle_terminal_websocket handler for both routes - Add REST endpoints: GET list, POST create, DELETE close, POST reset, POST rename - Preserve legacy POST .../terminal/reset as default session alias - Add frontend API client (apps/web/src/api/terminal.ts) - Add useTerminalSessions React hook for session CRUD + state management - Add integration tests for auth requirements on all new endpoints Quality gates: pytest (8 new passed, 182 total passed, 51 pre-existing failures) --- apps/api/src/api/terminal.py | 585 ++++++++++++++++--- apps/api/tests/api/test_terminal_ws_multi.py | 75 +++ apps/web/src/api/terminal.ts | 87 +++ apps/web/src/hooks/use-terminal-sessions.ts | 145 +++++ 4 files changed, 822 insertions(+), 70 deletions(-) create mode 100644 apps/api/tests/api/test_terminal_ws_multi.py create mode 100644 apps/web/src/api/terminal.ts create mode 100644 apps/web/src/hooks/use-terminal-sessions.ts diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index 1da1643..71431e3 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -1,16 +1,20 @@ """WebSocket terminal endpoint for tool instances.""" import asyncio +import json import logging import uuid +from contextlib import suppress from fastapi import APIRouter, Depends, HTTPException, WebSocket, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import get_db_session +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.terminal_session import TerminalSessionModel from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType -from src.services.terminal_manager import terminal_manager +from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager router = APIRouter() logger = logging.getLogger(__name__) @@ -19,32 +23,58 @@ logger = logging.getLogger(__name__) class SessionRef: """Mutable reference to a terminal session, allowing updates during reset.""" - def __init__(self, session): + def __init__(self, session, slot_session_id: str | None = None): self.session = session + self.slot_session_id = slot_session_id or session.session_id @router.websocket( "/ws/tool-instances/{instance_id}/terminal", ) -async def terminal_websocket( +async def terminal_websocket_default( websocket: WebSocket, instance_id: str, db_session: AsyncSession = Depends(get_db_session), ) -> None: - """WebSocket endpoint for terminal access to a tool instance. + """WebSocket endpoint for terminal access (default session alias). - Provides an interactive terminal session inside a running tool instance container. - Sessions persist across WebSocket disconnections. + Backward-compatible route that maps to the default session. + """ + await _handle_terminal_websocket(websocket, instance_id, None, db_session) + + +@router.websocket( + "/ws/tool-instances/{instance_id}/terminal/{session_id}", +) +async def terminal_websocket_specific( + websocket: WebSocket, + instance_id: str, + session_id: str, + db_session: AsyncSession = Depends(get_db_session), +) -> None: + """WebSocket endpoint for a specific terminal session.""" + await _handle_terminal_websocket(websocket, instance_id, session_id, db_session) + + +async def _handle_terminal_websocket( + websocket: WebSocket, + instance_id: str, + target_session_id: str | None, + db_session: AsyncSession, +) -> None: + """Shared WebSocket handler for terminal sessions. Args: websocket: The WebSocket connection. instance_id: UUID string of the tool instance. + target_session_id: Specific session ID (slot key). None means default session. db_session: Database session. - - Returns: - None. Communicates via WebSocket messages. """ - logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id) + logger.debug( + "Terminal WebSocket connection attempt for instance %s (session=%s)", + instance_id, + target_session_id or "default", + ) await websocket.accept() logger.debug("Terminal WebSocket accepted for instance %s", instance_id) @@ -59,7 +89,9 @@ async def terminal_websocket( # Authenticate user from session cookie user_id = await _get_user_from_websocket(websocket, db_session) if user_id is None: - logger.warning("Unauthorized terminal access attempt for instance %s", instance_id) + logger.warning( + "Unauthorized terminal access attempt for instance %s", instance_id + ) await websocket.close(code=4003, reason="Unauthorized") return @@ -71,31 +103,76 @@ async def terminal_websocket( return if instance.owner_id != user_id: - logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id) + logger.warning( + "Forbidden terminal access for instance %s by user %s", + instance_id, + user_id, + ) await websocket.close(code=4003, reason="Forbidden") return if instance.status != "running" or not instance.container_id: - logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id) + logger.warning( + "Instance %s not running (status=%s, container_id=%s)", + instance_id, + instance.status, + instance.container_id, + ) await websocket.close(code=4004, reason="Instance not running") return - logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id) + logger.debug( + "Terminal auth passed for instance %s, user %s", instance_id, user_id + ) # Fetch tool type to get startup_command tool_type = await db_session.get(ToolType, instance.tool_type_id) startup_command = tool_type.startup_command if tool_type else None if startup_command: - logger.debug("Using startup command for instance %s: %s", instance_id, startup_command) + logger.debug( + "Using startup command for instance %s: %s", + instance_id, + startup_command, + ) + + session = None # Get or create terminal session try: - session = await terminal_manager.get_or_create_session( - instance_uuid, - instance.container_id, - startup_command=startup_command, + if target_session_id is None: + # Default session alias + session = await terminal_manager.get_or_create_session( + instance_uuid, + instance.container_id, + startup_command=startup_command, + ) + slot_session_id = "default" + else: + # Specific session + session = terminal_manager.get_session( + instance_id, + target_session_id, + ) + if session is None: + logger.warning( + "Session %s not found for instance %s", + target_session_id, + instance_id, + ) + await websocket.close(code=4004, reason="Session not found") + return + # Determine slot key for reset scoping + key = terminal_manager._find_key_by_internal_id( + instance_id, session.session_id + ) + slot_session_id = key[1] if key else target_session_id + + logger.debug( + "Terminal session ready for instance %s (session_id=%s, slot=%s)", + instance_id, + session.session_id, + slot_session_id, ) - logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id) # Attach WebSocket to session await terminal_manager.attach_websocket(session, websocket) @@ -106,11 +183,13 @@ async def terminal_websocket( logger.debug("Sent connected status for instance %s", instance_id) # Use mutable session reference so loops can survive reset - session_ref = SessionRef(session) + session_ref = SessionRef(session, slot_session_id) # Start I/O loops and heartbeat read_task = asyncio.create_task(_read_loop(session_ref, websocket)) - write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id)) + write_task = asyncio.create_task( + _write_loop(session_ref, websocket, instance_id) + ) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) logger.debug("Started terminal loops for instance %s", instance_id) @@ -119,24 +198,33 @@ async def terminal_websocket( [read_task, write_task, heartbeat_task], return_when=asyncio.FIRST_COMPLETED, ) - - logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done)) - + + logger.debug( + "Terminal loop completed for instance %s, done=%s", + instance_id, + len(done), + ) + # Cancel remaining tasks for task in pending: task.cancel() except Exception as exc: - logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True) + logger.error( + "Terminal session error for instance %s: %s", + instance_id, + str(exc), + exc_info=True, + ) await websocket.close(code=4000, reason=f"Error: {exc}") finally: # Detach WebSocket, don't kill session - try: - if 'session' in locals(): + with suppress(Exception): + if session is not None: await terminal_manager.detach_websocket(session, websocket) - logger.debug("WebSocket detached from session for instance %s", instance_id) - except Exception: - pass + logger.debug( + "WebSocket detached from session for instance %s", instance_id + ) async def _read_loop(session_ref: SessionRef, websocket) -> None: @@ -175,38 +263,54 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N text = message["text"] if text.startswith("{"): # Control message (JSON) - import json try: ctrl = json.loads(text) msg_type = ctrl.get("type") - + if msg_type == "resize": cols = ctrl.get("cols", 80) rows = ctrl.get("rows", 24) - logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}") + logger.debug( + "Received resize message for instance %s: %sx%s", + instance_id, + cols, + rows, + ) await session.resize(cols, rows) elif msg_type == "reset": - # Reset terminal session - logger.debug("Resetting terminal session for instance %s", session.instance_id) - await websocket.send_json({"type": "status", "status": "resetting"}) - - # Reset the session + # Reset terminal session (scoped to current slot) + logger.debug( + "Resetting terminal session for instance %s (slot=%s)", + session.instance_id, + session_ref.slot_session_id, + ) + await websocket.send_json( + {"type": "status", "status": "resetting"} + ) + + # Reset the session scoped to its slot new_session = await terminal_manager.reset_session( session.instance_id, session.container_id, startup_command=session.startup_command, + session_id=session_ref.slot_session_id, + name=session.name, ) - - # Update the mutable session reference so read_loop uses the new session + + # Update the mutable session reference session_ref.session = new_session - + # Attach to new session - await terminal_manager.attach_websocket(new_session, websocket) - await websocket.send_json({"type": "status", "status": "connected"}) - + await terminal_manager.attach_websocket( + new_session, websocket + ) + await websocket.send_json( + {"type": "status", "status": "connected"} + ) + # Continue the loop with the new session continue - + except json.JSONDecodeError: # Not a valid JSON control message, treat as regular input await session.write_input(text.encode("utf-8")) @@ -232,56 +336,392 @@ async def _heartbeat_loop(websocket: WebSocket) -> None: pass -@router.post( - "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset", - summary="Reset terminal session", - description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.", -) -async def reset_terminal_session( +async def _get_terminal_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Reset the terminal session for an instance. + user_id: uuid.UUID, + db_session: AsyncSession, +) -> ToolInstance: + """Fetch instance and validate auth, ownership, and running status. Args: project_id: UUID of the project. repo_id: UUID of the repository. instance_id: UUID of the tool instance. + user_id: ID of the authenticated user. db_session: Database session. Returns: - Dictionary with status message. + The validated ToolInstance. + + Raises: + HTTPException: If instance not found, not owned, or not running. """ - # Get instance and verify it exists and is running instance = await db_session.get(ToolInstance, instance_id) - if instance is None: + if instance is None or instance.repository_id != repo_id: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Instance not found" + status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found" + ) + + if instance.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found" + ) + + if instance.owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to access this instance", ) if instance.status != "running" or not instance.container_id: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Instance is not running" + status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running" + ) + + return instance + + +@router.get( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions", + summary="List terminal sessions", + description="List terminal sessions for a tool instance with live WebSocket state.", +) +async def list_terminal_sessions( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """List terminal sessions for an instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with sessions list. + """ + await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + + # Query active DB rows for this instance + result = await db_session.execute( + select(TerminalSessionModel) + .where(TerminalSessionModel.instance_id == instance_id) + .where(TerminalSessionModel.status != "closed") + .order_by(TerminalSessionModel.created_at.asc()) + ) + db_rows = result.scalars().all() + + # Build response with live has_websockets flag + sessions = [] + for row in db_rows: + live_session = terminal_manager.get_session( + str(instance_id), str(row.id) + ) + sessions.append( + { + "id": str(row.id), + "name": row.name, + "status": row.status, + "has_websockets": live_session.has_websockets() + if live_session + else False, + "created_at": row.created_at.isoformat() if row.created_at else None, + "last_activity_at": row.last_activity_at.isoformat() + if row.last_activity_at + else None, + } + ) + + return {"sessions": sessions} + + +@router.post( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions", + summary="Create terminal session", + description="Create a new terminal session for a running tool instance.", + status_code=status.HTTP_201_CREATED, +) +async def create_terminal_session( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a new terminal session. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + data: Request body with optional name. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with new session details. + + Raises: + HTTPException: 409 if max sessions reached. + """ + instance = await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + assert instance.container_id is not None + + # Fetch tool type to get startup_command + tool_type = await db_session.get(ToolType, instance.tool_type_id) + startup_command = tool_type.startup_command if tool_type else None + + name = data.get("name") + + try: + session = await terminal_manager.create_session( + instance_id, + instance.container_id, + startup_command=startup_command, + name=name, + ) + except MaxSessionsExceededError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Maximum of 5 terminal sessions reached for this instance", + ) from None + + return { + "id": session.session_id, + "name": session.name, + "status": session.status, + "created_at": session.last_activity, + } + + +@router.delete( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions/{session_id}", + summary="Close terminal session", + description="Close a specific terminal session.", +) +async def close_terminal_session( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + session_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """Close a terminal session. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + session_id: ID of the session to close. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with closure status. + """ + await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + + # Find the session by internal ID to determine its slot key + key = terminal_manager._find_key_by_internal_id( + str(instance_id), session_id + ) + if key is None and terminal_manager.get_session(str(instance_id), session_id) is not None: + key = (str(instance_id), session_id) + + if key is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" + ) + + await terminal_manager.close_session(key[0], key[1]) + + return {"status": "closed", "session_id": session_id} + + +@router.post( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions/{session_id}/reset", + summary="Reset terminal session", + description="Reset a specific terminal session, killing the current shell and starting fresh.", +) +async def reset_specific_terminal_session( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + session_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """Reset a specific terminal session. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + session_id: ID of the session to reset. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with reset session details. + """ + instance = await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + assert instance.container_id is not None + + # Determine slot key for reset + key = terminal_manager._find_key_by_internal_id( + str(instance_id), session_id + ) + if key is None and terminal_manager.get_session(str(instance_id), session_id) is not None: + key = (str(instance_id), session_id) + + if key is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" ) # Fetch tool type to get startup_command tool_type = await db_session.get(ToolType, instance.tool_type_id) startup_command = tool_type.startup_command if tool_type else None + # Preserve name if possible + live_session = terminal_manager.get_session(str(instance_id), session_id) + name = live_session.name if live_session else None + + new_session = await terminal_manager.reset_session( + instance_id, + instance.container_id, + startup_command=startup_command, + session_id=key[1], + name=name, + ) + + return { + "id": new_session.session_id, + "name": new_session.name, + "status": new_session.status, + } + + +@router.post( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions/{session_id}/rename", + summary="Rename terminal session", + description="Rename a specific terminal session.", +) +async def rename_terminal_session( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + session_id: str, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """Rename a terminal session. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + session_id: ID of the session to rename. + data: Request body with new name. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with updated session details. + """ + await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + + new_name = data.get("name") + if not new_name or not isinstance(new_name, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required" + ) + + # Update in-memory session name if live + live_session = terminal_manager.get_session(str(instance_id), session_id) + if live_session: + live_session.name = new_name + + # Update DB row + db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id)) + if db_row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" + ) + + db_row.name = new_name + await db_session.commit() + + return {"id": str(db_row.id), "name": new_name} + + +@router.post( + "/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset", + summary="Reset terminal session (legacy alias)", + description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.", +) +async def reset_terminal_session( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + db_session: AsyncSession = Depends(get_db_session), +) -> dict: + """Reset the default terminal session for an instance (legacy alias). + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the tool instance. + user_id: ID of the authenticated user. + db_session: Database session. + + Returns: + Dictionary with status message. + """ + instance = await _get_terminal_instance( + project_id, repo_id, instance_id, user_id, db_session + ) + assert instance.container_id is not None + + # Fetch tool type to get startup_command + tool_type = await db_session.get(ToolType, instance.tool_type_id) + startup_command = tool_type.startup_command if tool_type else None + try: - # Reset the session + # Reset the default session new_session = await terminal_manager.reset_session( instance_id, instance.container_id, startup_command=startup_command, ) - - logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id) - + + logger.info( + "Terminal session reset for instance %s (new session_id=%s)", + instance_id, + new_session.session_id, + ) + return { "status": "success", "message": "Terminal session reset successfully", @@ -289,11 +729,16 @@ async def reset_terminal_session( "session_id": new_session.session_id, } except Exception as exc: - logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True) + logger.error( + "Failed to reset terminal session for instance %s: %s", + instance_id, + str(exc), + exc_info=True, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to reset terminal session: {exc}" - ) + detail=f"Failed to reset terminal session: {exc}", + ) from exc async def _get_user_from_websocket( diff --git a/apps/api/tests/api/test_terminal_ws_multi.py b/apps/api/tests/api/test_terminal_ws_multi.py new file mode 100644 index 0000000..a7f5ad3 --- /dev/null +++ b/apps/api/tests/api/test_terminal_ws_multi.py @@ -0,0 +1,75 @@ +"""Integration tests for multi-session terminal WebSocket and REST API.""" + +import pytest +from fastapi.testclient import TestClient +from src.main import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +class TestTerminalWebSocketMultiSession: + """Tests for multi-session WebSocket routing.""" + + def test_specific_session_websocket_route_exists(self, client): + """The specific session WebSocket route should be registered.""" + # We can't easily test WebSocket without auth, but we can verify + # the route exists by checking for a 403 (no auth cookie) + response = client.get("/ws/tool-instances/test-instance/terminal/test-session") + # WebSocket endpoint returns 403 when accessed via HTTP GET + assert response.status_code in (403, 404) + + def test_default_session_alias_route_exists(self, client): + """The default session alias route should still exist.""" + response = client.get("/ws/tool-instances/test-instance/terminal") + assert response.status_code in (403, 404) + + +class TestTerminalRestApi: + """Tests for REST API endpoints.""" + + def test_list_sessions_requires_auth(self, client): + """List sessions endpoint requires authentication.""" + response = client.get( + "/projects/test/repositories/test/instances/test/terminal/sessions" + ) + assert response.status_code == 401 + + def test_create_session_requires_auth(self, client): + """Create session endpoint requires authentication.""" + response = client.post( + "/projects/test/repositories/test/instances/test/terminal/sessions", + json={}, + ) + assert response.status_code == 401 + + def test_close_session_requires_auth(self, client): + """Close session endpoint requires authentication.""" + response = client.delete( + "/projects/test/repositories/test/instances/test/terminal/sessions/test-session" + ) + assert response.status_code == 401 + + def test_reset_session_requires_auth(self, client): + """Reset session endpoint requires authentication.""" + response = client.post( + "/projects/test/repositories/test/instances/test/terminal/sessions/test-session/reset" + ) + assert response.status_code == 401 + + def test_rename_session_requires_auth(self, client): + """Rename session endpoint requires authentication.""" + response = client.post( + "/projects/test/repositories/test/instances/test/terminal/sessions/test-session/rename", + json={"name": "New Name"}, + ) + assert response.status_code == 401 + + def test_legacy_reset_alias_requires_auth(self, client): + """Legacy reset endpoint still requires auth.""" + response = client.post( + "/projects/test/repositories/test/instances/test/terminal/reset" + ) + assert response.status_code == 401 diff --git a/apps/web/src/api/terminal.ts b/apps/web/src/api/terminal.ts new file mode 100644 index 0000000..864b691 --- /dev/null +++ b/apps/web/src/api/terminal.ts @@ -0,0 +1,87 @@ +import { apiClient } from "./client"; + +export interface TerminalSession { + id: string; + name: string; + status: string; + has_websockets: boolean; + created_at: string; + last_activity_at: string | null; +} + +export interface TerminalSessionListResponse { + sessions: TerminalSession[]; +} + +export interface TerminalSessionCreateRequest { + name?: string; +} + +export interface TerminalSessionCreateResponse { + id: string; + name: string; + status: string; + created_at: string; +} + +export async function listTerminalSessions( + projectId: string, + repoId: string, + instanceId: string +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions` + ); + return response.data.sessions; +} + +export async function createTerminalSession( + projectId: string, + repoId: string, + instanceId: string, + name?: string +): Promise { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`, + { name } + ); + return response.data; +} + +export async function closeTerminalSession( + projectId: string, + repoId: string, + instanceId: string, + sessionId: string +): Promise<{ status: string; session_id: string }> { + const response = await apiClient.delete( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}` + ); + return response.data; +} + +export async function resetTerminalSession( + projectId: string, + repoId: string, + instanceId: string, + sessionId: string +): Promise<{ id: string; name: string; status: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset` + ); + return response.data; +} + +export async function renameTerminalSession( + projectId: string, + repoId: string, + instanceId: string, + sessionId: string, + name: string +): Promise<{ id: string; name: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`, + { name } + ); + return response.data; +} diff --git a/apps/web/src/hooks/use-terminal-sessions.ts b/apps/web/src/hooks/use-terminal-sessions.ts new file mode 100644 index 0000000..eefc8fc --- /dev/null +++ b/apps/web/src/hooks/use-terminal-sessions.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from "react"; +import { + listTerminalSessions, + createTerminalSession, + closeTerminalSession, + resetTerminalSession, + renameTerminalSession, + type TerminalSession, +} from "../api/terminal"; + +export interface UseTerminalSessionsResult { + sessions: TerminalSession[]; + activeSessionId: string | null; + setActiveSessionId: (id: string) => void; + createSession: (name?: string) => Promise; + closeSession: (sessionId: string) => Promise; + renameSession: (sessionId: string, name: string) => Promise; + resetSession: (sessionId: string) => Promise; + loading: boolean; + error: string | null; +} + +export function useTerminalSessions( + projectId: string, + repoId: string, + instanceId: string +): UseTerminalSessionsResult { + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadSessions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const sess = await listTerminalSessions(projectId, repoId, instanceId); + setSessions(sess); + if (sess.length > 0 && !activeSessionId) { + setActiveSessionId(sess[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + setLoading(false); + } + }, [projectId, repoId, instanceId, activeSessionId]); + + const createSession = useCallback( + async (name?: string) => { + setError(null); + try { + const newSession = await createTerminalSession( + projectId, + repoId, + instanceId, + name + ); + const session: TerminalSession = { + id: newSession.id, + name: newSession.name, + status: newSession.status, + has_websockets: false, + created_at: newSession.created_at, + last_activity_at: null, + }; + setSessions((prev) => [...prev, session]); + setActiveSessionId(session.id); + return session; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to create session"; + setError(msg); + return null; + } + }, + [projectId, repoId, instanceId] + ); + + const closeSession = useCallback( + async (sessionId: string) => { + setError(null); + try { + await closeTerminalSession(projectId, repoId, instanceId, sessionId); + setSessions((prev) => { + const filtered = prev.filter((s) => s.id !== sessionId); + if (activeSessionId === sessionId && filtered.length > 0) { + setActiveSessionId(filtered[0].id); + } else if (filtered.length === 0) { + setActiveSessionId(null); + } + return filtered; + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to close session"); + } + }, + [projectId, repoId, instanceId, activeSessionId] + ); + + const renameSession = useCallback( + async (sessionId: string, name: string) => { + setError(null); + try { + await renameTerminalSession(projectId, repoId, instanceId, sessionId, name); + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, name } : s)) + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rename session"); + } + }, + [projectId, repoId, instanceId] + ); + + const resetSession = useCallback( + async (sessionId: string) => { + setError(null); + try { + await resetTerminalSession(projectId, repoId, instanceId, sessionId); + // Refetch to get updated session info + await loadSessions(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to reset session"); + } + }, + [projectId, repoId, instanceId, loadSessions] + ); + + // Initial load + useEffect(() => { + void loadSessions(); + }, [loadSessions]); + + return { + sessions, + activeSessionId, + setActiveSessionId, + createSession, + closeSession, + renameSession, + resetSession, + loading, + error, + }; +}