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:
+501
-56
@@ -1,16 +1,20 @@
|
|||||||
"""WebSocket terminal endpoint for tool instances."""
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,32 +23,58 @@ logger = logging.getLogger(__name__)
|
|||||||
class SessionRef:
|
class SessionRef:
|
||||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
"""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.session = session
|
||||||
|
self.slot_session_id = slot_session_id or session.session_id
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
)
|
)
|
||||||
async def terminal_websocket(
|
async def terminal_websocket_default(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> 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.
|
Backward-compatible route that maps to the default session.
|
||||||
Sessions persist across WebSocket disconnections.
|
"""
|
||||||
|
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:
|
Args:
|
||||||
websocket: The WebSocket connection.
|
websocket: The WebSocket connection.
|
||||||
instance_id: UUID string of the tool instance.
|
instance_id: UUID string of the tool instance.
|
||||||
|
target_session_id: Specific session ID (slot key). None means default session.
|
||||||
db_session: Database 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()
|
await websocket.accept()
|
||||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
@@ -59,7 +89,9 @@ async def terminal_websocket(
|
|||||||
# Authenticate user from session cookie
|
# Authenticate user from session cookie
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
if user_id is None:
|
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")
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -71,31 +103,76 @@ async def terminal_websocket(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if instance.owner_id != user_id:
|
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")
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.status != "running" or not instance.container_id:
|
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")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
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
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
if startup_command:
|
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
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
|
if target_session_id is None:
|
||||||
|
# Default session alias
|
||||||
session = await terminal_manager.get_or_create_session(
|
session = await terminal_manager.get_or_create_session(
|
||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
)
|
)
|
||||||
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
# Attach WebSocket to session
|
# Attach WebSocket to session
|
||||||
await terminal_manager.attach_websocket(session, websocket)
|
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)
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Use mutable session reference so loops can survive reset
|
# 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
|
# Start I/O loops and heartbeat
|
||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
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))
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
logger.debug("Started terminal loops for instance %s", instance_id)
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
@@ -120,23 +199,32 @@ async def terminal_websocket(
|
|||||||
return_when=asyncio.FIRST_COMPLETED,
|
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
|
# Cancel remaining tasks
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
except Exception as exc:
|
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}")
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
# Detach WebSocket, don't kill session
|
# Detach WebSocket, don't kill session
|
||||||
try:
|
with suppress(Exception):
|
||||||
if 'session' in locals():
|
if session is not None:
|
||||||
await terminal_manager.detach_websocket(session, websocket)
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
logger.debug(
|
||||||
except Exception:
|
"WebSocket detached from session for instance %s", instance_id
|
||||||
pass
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||||
@@ -175,7 +263,6 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
text = message["text"]
|
text = message["text"]
|
||||||
if text.startswith("{"):
|
if text.startswith("{"):
|
||||||
# Control message (JSON)
|
# Control message (JSON)
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
ctrl = json.loads(text)
|
ctrl = json.loads(text)
|
||||||
msg_type = ctrl.get("type")
|
msg_type = ctrl.get("type")
|
||||||
@@ -183,26 +270,43 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
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)
|
await session.resize(cols, rows)
|
||||||
elif msg_type == "reset":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session
|
# Reset terminal session (scoped to current slot)
|
||||||
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
logger.debug(
|
||||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
"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
|
# Reset the session scoped to its slot
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
session.instance_id,
|
session.instance_id,
|
||||||
session.container_id,
|
session.container_id,
|
||||||
startup_command=session.startup_command,
|
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
|
session_ref.session = new_session
|
||||||
|
|
||||||
# Attach to new session
|
# Attach to new session
|
||||||
await terminal_manager.attach_websocket(new_session, websocket)
|
await terminal_manager.attach_websocket(
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
new_session, websocket
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
)
|
||||||
|
|
||||||
# Continue the loop with the new session
|
# Continue the loop with the new session
|
||||||
continue
|
continue
|
||||||
@@ -232,55 +336,391 @@ async def _heartbeat_loop(websocket: WebSocket) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
async def _get_terminal_instance(
|
||||||
"/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(
|
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
user_id: uuid.UUID,
|
||||||
) -> dict:
|
db_session: AsyncSession,
|
||||||
"""Reset the terminal session for an instance.
|
) -> ToolInstance:
|
||||||
|
"""Fetch instance and validate auth, ownership, and running status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project.
|
project_id: UUID of the project.
|
||||||
repo_id: UUID of the repository.
|
repo_id: UUID of the repository.
|
||||||
instance_id: UUID of the tool instance.
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
db_session: Database session.
|
db_session: Database session.
|
||||||
|
|
||||||
Returns:
|
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)
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
if instance is None:
|
if instance is None or instance.repository_id != repo_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND, detail="Instance 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:
|
if instance.status != "running" or not instance.container_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||||
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
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
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:
|
try:
|
||||||
# Reset the session
|
# Reset the default session
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -289,11 +729,16 @@ async def reset_terminal_session(
|
|||||||
"session_id": new_session.session_id,
|
"session_id": new_session.session_id,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
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(
|
async def _get_user_from_websocket(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<TerminalSession[]> {
|
||||||
|
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<TerminalSessionCreateResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<TerminalSession | null>;
|
||||||
|
closeSession: (sessionId: string) => Promise<void>;
|
||||||
|
renameSession: (sessionId: string, name: string) => Promise<void>;
|
||||||
|
resetSession: (sessionId: string) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTerminalSessions(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): UseTerminalSessionsResult {
|
||||||
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user