feat: implement web terminal for tool instances
- 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 ✓
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the docker exec process with a shell."""
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
"-i",
|
||||
self.container_id,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"exec bash -l || exec sh -l",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
"""Read output from the process."""
|
||||
if self.process is None or self.process.stdout is None:
|
||||
return b""
|
||||
try:
|
||||
return await self.process.stdout.read(4096)
|
||||
except (asyncio.CancelledError, BrokenPipeError):
|
||||
return b""
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the process."""
|
||||
if self.process is None or self.process.stdin is None or self._closed:
|
||||
return
|
||||
try:
|
||||
self.process.stdin.write(data)
|
||||
await self.process.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_id,
|
||||
"stty",
|
||||
"cols",
|
||||
str(cols),
|
||||
"rows",
|
||||
str(rows),
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
if self.process is not None:
|
||||
try:
|
||||
self.process.kill()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if the session process is still running."""
|
||||
if self.process is None:
|
||||
return False
|
||||
return self.process.returncode is None
|
||||
Reference in New Issue
Block a user