e344e961d6
- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)
Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
"""Terminal session manager for WebSocket connections."""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from fastapi import WebSocket
|
|
|
|
from src.services.terminal_session import TerminalSession
|
|
|
|
|
|
class TerminalManager:
|
|
"""Manages active terminal sessions."""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, TerminalSession] = {}
|
|
|
|
async def create_session(
|
|
self,
|
|
instance_id: uuid.UUID,
|
|
container_id: str,
|
|
websocket: WebSocket,
|
|
) -> TerminalSession:
|
|
"""Create a new terminal session."""
|
|
session_id = str(uuid.uuid4())
|
|
session = TerminalSession(session_id, instance_id, container_id)
|
|
await session.start()
|
|
self._sessions[session_id] = session
|
|
|
|
# Start background tasks for I/O streaming
|
|
asyncio.create_task(self._read_loop(session, websocket))
|
|
asyncio.create_task(self._write_loop(session, websocket))
|
|
|
|
return session
|
|
|
|
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
|
"""Read output from the container and send to WebSocket."""
|
|
try:
|
|
while session.is_alive() and not session._closed:
|
|
data = await session.read_output()
|
|
if data:
|
|
await websocket.send_bytes(data)
|
|
else:
|
|
await asyncio.sleep(0.01)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
await self._cleanup_session(session)
|
|
|
|
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
|
"""Read input from WebSocket and send to container."""
|
|
try:
|
|
while session.is_alive() and not session._closed:
|
|
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)
|
|
import json
|
|
try:
|
|
ctrl = json.loads(text)
|
|
if ctrl.get("type") == "resize":
|
|
await session.resize(
|
|
ctrl.get("cols", 80),
|
|
ctrl.get("rows", 24),
|
|
)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
else:
|
|
await session.write_input(text.encode("utf-8"))
|
|
elif message["type"] == "websocket.disconnect":
|
|
break
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
await self._cleanup_session(session)
|
|
|
|
async def _cleanup_session(self, session: TerminalSession) -> None:
|
|
"""Clean up a session."""
|
|
if session.session_id in self._sessions:
|
|
del self._sessions[session.session_id]
|
|
await session.close()
|
|
|
|
async def close_all(self) -> None:
|
|
"""Close all active sessions."""
|
|
sessions = list(self._sessions.values())
|
|
self._sessions.clear()
|
|
for session in sessions:
|
|
await session.close()
|
|
|
|
|
|
# Global terminal manager instance
|
|
terminal_manager = TerminalManager()
|