37ccaa4fdc
Service organization (19 files moved into 6 subpackages): - services/instance/ — event_bus, health_monitor, lifecycle_hooks - services/config/ — config_profile_resolver - services/git/ — clone, git_operations, git_service - services/build/ — docker_build, manifest_compiler - services/terminal/ — terminal_manager, terminal_session - services/shared/ — correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager API router organization (16 files moved into 6 subpackages): - api/tool/ — tool_instances, tool_types, tool_definitions, tool_types_validation, sessions (extracted from tool_instances) - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy Updated main.py imports and all __init__.py re-exports. Sessions router extracted from tool_instances.py into api/tool/sessions.py. Quality gates: py_compile passed, ruff passed.
750 lines
25 KiB
Python
750 lines
25 KiB
Python
"""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 starlette.websockets import WebSocketDisconnect
|
|
|
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
from src.models import TerminalSessionModel
|
|
from src.models import ToolInstance
|
|
from src.models import ToolType
|
|
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SessionRef:
|
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
|
|
|
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_default(
|
|
websocket: WebSocket,
|
|
instance_id: str,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
) -> None:
|
|
"""WebSocket endpoint for terminal access (default session alias).
|
|
|
|
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.
|
|
"""
|
|
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)
|
|
|
|
try:
|
|
# Parse instance_id
|
|
instance_uuid = uuid.UUID(instance_id)
|
|
except ValueError:
|
|
logger.error("Invalid instance ID: %s", instance_id)
|
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
|
return
|
|
|
|
# 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
|
|
)
|
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
return
|
|
|
|
# Get instance and verify ownership
|
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
|
if instance is None:
|
|
logger.warning("Instance %s not found", instance_id)
|
|
await websocket.close(code=4004, reason="Instance not found")
|
|
return
|
|
|
|
if instance.owner_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,
|
|
)
|
|
await websocket.close(code=4004, reason="Instance not running")
|
|
return
|
|
|
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
|
|
|
# Verify the container actually exists (may have been removed/recreated)
|
|
from src.services.docker import get_container_status
|
|
|
|
container_status = get_container_status(instance.container_id)
|
|
if container_status["status"] == "not_found":
|
|
logger.error(
|
|
"Container %s for instance %s not found (may have been removed)",
|
|
instance.container_id,
|
|
instance_id,
|
|
)
|
|
await websocket.close(
|
|
code=4004, reason="Container not found — restart the tool instance"
|
|
)
|
|
return
|
|
|
|
# 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,
|
|
)
|
|
|
|
session = None
|
|
|
|
# Get or create terminal session
|
|
try:
|
|
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:
|
|
# Session not in memory — may have been lost on server restart.
|
|
# Try to restore from the DB row.
|
|
db_row = await db_session.get(
|
|
TerminalSessionModel, uuid.UUID(target_session_id)
|
|
)
|
|
if (
|
|
db_row is not None
|
|
and db_row.instance_id == instance_uuid
|
|
and db_row.status != "closed"
|
|
):
|
|
logger.info(
|
|
"Restoring terminal session %s for instance %s from DB",
|
|
target_session_id,
|
|
instance_id,
|
|
)
|
|
session = await terminal_manager.create_session(
|
|
instance_uuid,
|
|
instance.container_id,
|
|
startup_command=startup_command,
|
|
name=db_row.name,
|
|
session_id=target_session_id,
|
|
)
|
|
else:
|
|
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,
|
|
)
|
|
|
|
# Attach WebSocket to session
|
|
await terminal_manager.attach_websocket(session, websocket)
|
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
|
|
|
# Send connected status
|
|
await websocket.send_json({"type": "status", "status": "connected"})
|
|
logger.debug("Sent connected status for instance %s", instance_id)
|
|
|
|
# Use mutable session reference so loops can survive reset
|
|
session_ref = SessionRef(session, slot_session_id)
|
|
|
|
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
|
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)
|
|
|
|
# Wait for either task to complete (indicating disconnect or error)
|
|
done, pending = await asyncio.wait(
|
|
[write_task, heartbeat_task],
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
|
|
logger.debug(
|
|
"Terminal loop completed for instance %s, done=%s",
|
|
instance_id,
|
|
len(done),
|
|
)
|
|
|
|
# Cancel remaining tasks
|
|
for task in pending:
|
|
task.cancel()
|
|
|
|
except WebSocketDisconnect:
|
|
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Terminal session error for instance %s: %s",
|
|
instance_id,
|
|
str(exc),
|
|
exc_info=True,
|
|
)
|
|
with suppress(Exception):
|
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
|
finally:
|
|
# Detach WebSocket, don't kill session
|
|
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
|
|
)
|
|
|
|
|
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
|
"""Read input from WebSocket and send to container."""
|
|
try:
|
|
while True:
|
|
session = session_ref.session
|
|
if not session.is_alive() or session._closed:
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
message = await websocket.receive()
|
|
if message["type"] == "websocket.receive":
|
|
if "bytes" in message:
|
|
await session.write_input(message["bytes"])
|
|
elif "text" in message:
|
|
text = message["text"]
|
|
if text.startswith("{"):
|
|
# Control message (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(
|
|
"Received resize message for instance %s: %sx%s",
|
|
instance_id,
|
|
cols,
|
|
rows,
|
|
)
|
|
await session.resize(cols, rows)
|
|
elif msg_type == "ack":
|
|
char_count = ctrl.get("chars", 0)
|
|
if char_count > 0:
|
|
session.acknowledge_data(char_count)
|
|
elif msg_type == "reset":
|
|
# 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
|
|
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"}
|
|
)
|
|
|
|
# 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"))
|
|
else:
|
|
await session.write_input(text.encode("utf-8"))
|
|
elif message["type"] == "websocket.disconnect":
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
|
"""Send periodic ping messages to detect disconnections."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(30) # Ping every 30 seconds
|
|
try:
|
|
await websocket.send_json({"type": "ping"})
|
|
except Exception:
|
|
# WebSocket is closed or broken
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _get_terminal_instance(
|
|
instance_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
db_session: AsyncSession,
|
|
) -> ToolInstance:
|
|
"""Fetch instance and validate auth, ownership, and running status.
|
|
|
|
Args:
|
|
instance_id: UUID of the tool instance.
|
|
user_id: ID of the authenticated user.
|
|
db_session: Database session.
|
|
|
|
Returns:
|
|
The validated ToolInstance.
|
|
|
|
Raises:
|
|
HTTPException: If instance not found, not owned, or not running.
|
|
"""
|
|
instance = await db_session.get(ToolInstance, instance_id)
|
|
if instance is None:
|
|
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"
|
|
)
|
|
|
|
return instance
|
|
|
|
|
|
@router.get(
|
|
"/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(
|
|
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:
|
|
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(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.
|
|
# Include DB rows even without in-memory counterparts (e.g. after
|
|
# server restart) so the frontend can display tabs and reconnect.
|
|
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(
|
|
"/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(
|
|
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:
|
|
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(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(
|
|
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
|
summary="Close terminal session",
|
|
description="Close a specific terminal session.",
|
|
)
|
|
async def close_terminal_session(
|
|
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:
|
|
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(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(
|
|
"/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(
|
|
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:
|
|
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(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(
|
|
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
|
summary="Rename terminal session",
|
|
description="Rename a specific terminal session.",
|
|
)
|
|
async def rename_terminal_session(
|
|
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:
|
|
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(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(
|
|
"/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(
|
|
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:
|
|
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(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 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,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Terminal session reset successfully",
|
|
"instance_id": str(instance_id),
|
|
"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,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to reset terminal session: {exc}",
|
|
) from exc
|
|
|
|
|
|
async def _get_user_from_websocket(
|
|
websocket: WebSocket,
|
|
db_session: AsyncSession,
|
|
) -> uuid.UUID | None:
|
|
"""Extract and validate user ID from session cookie in WebSocket.
|
|
|
|
Args:
|
|
websocket: The WebSocket connection.
|
|
db_session: Database session.
|
|
|
|
Returns:
|
|
The user's UUID if authenticated, None otherwise.
|
|
"""
|
|
from src.auth.session import decode_session_cookie
|
|
from src.config import Settings
|
|
|
|
session_cookie = websocket.cookies.get("session")
|
|
if not session_cookie:
|
|
return None
|
|
|
|
settings = Settings()
|
|
try:
|
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
|
return uuid.UUID(str(payload["user_id"]))
|
|
except (ValueError, KeyError):
|
|
return None
|