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)
This commit is contained in:
2026-05-28 12:08:37 +02:00
parent b55300ff6f
commit 0b35ae3bf0
4 changed files with 822 additions and 70 deletions
+515 -70
View File
@@ -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(