mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-31 06:42:06 +00:00
feat: add interactive terminal and dev server management
Add new features for interactive terminal sessions and dev server control: Terminal Component: - New Terminal.tsx component using xterm.js for full terminal emulation - WebSocket-based PTY communication with bidirectional I/O - Cross-platform support (Windows via winpty, Unix via built-in pty) - Auto-reconnection with exponential backoff - Fix duplicate WebSocket connection bug by checking CONNECTING state - Add manual close flag to prevent auto-reconnect race conditions - Add project tracking to avoid duplicate connects on initial activation Dev Server Management: - New DevServerControl.tsx for starting/stopping dev servers - DevServerManager service for subprocess management - WebSocket streaming of dev server output - Project configuration service for reading package.json scripts Backend Infrastructure: - Terminal router with WebSocket endpoint for PTY I/O - DevServer router for server lifecycle management - Terminal session manager with callback-based output streaming - Enhanced WebSocket schemas for terminal and dev server messages UI Integration: - New Terminal and Dev Server tabs in the main application - Updated DebugLogViewer with improved UI and functionality - Extended useWebSocket hook for terminal message handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,20 +7,24 @@ FastAPI routers for different API endpoints.
|
||||
|
||||
from .agent import router as agent_router
|
||||
from .assistant_chat import router as assistant_chat_router
|
||||
from .devserver import router as devserver_router
|
||||
from .expand_project import router as expand_project_router
|
||||
from .features import router as features_router
|
||||
from .filesystem import router as filesystem_router
|
||||
from .projects import router as projects_router
|
||||
from .settings import router as settings_router
|
||||
from .spec_creation import router as spec_creation_router
|
||||
from .terminal import router as terminal_router
|
||||
|
||||
__all__ = [
|
||||
"projects_router",
|
||||
"features_router",
|
||||
"agent_router",
|
||||
"devserver_router",
|
||||
"spec_creation_router",
|
||||
"expand_project_router",
|
||||
"filesystem_router",
|
||||
"assistant_chat_router",
|
||||
"settings_router",
|
||||
"terminal_router",
|
||||
]
|
||||
|
||||
280
server/routers/devserver.py
Normal file
280
server/routers/devserver.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Dev Server Router
|
||||
=================
|
||||
|
||||
API endpoints for dev server control (start/stop) and configuration.
|
||||
Uses project registry for path lookups and project_config for command detection.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import (
|
||||
DevServerActionResponse,
|
||||
DevServerConfigResponse,
|
||||
DevServerConfigUpdate,
|
||||
DevServerStartRequest,
|
||||
DevServerStatus,
|
||||
)
|
||||
from ..services.dev_server_manager import get_devserver_manager
|
||||
from ..services.project_config import (
|
||||
clear_dev_command,
|
||||
get_dev_command,
|
||||
get_project_config,
|
||||
set_dev_command,
|
||||
)
|
||||
|
||||
# Add root to path for registry import
|
||||
_root = Path(__file__).parent.parent.parent
|
||||
if str(_root) not in sys.path:
|
||||
sys.path.insert(0, str(_root))
|
||||
|
||||
from registry import get_project_path as registry_get_project_path
|
||||
|
||||
|
||||
def _get_project_path(project_name: str) -> Path | None:
|
||||
"""Get project path from registry."""
|
||||
return registry_get_project_path(project_name)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> str:
|
||||
"""Validate and sanitize project name to prevent path traversal."""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid project name"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def get_project_dir(project_name: str) -> Path:
|
||||
"""
|
||||
Get the validated project directory for a project name.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
Path to the project directory
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found or directory does not exist
|
||||
"""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_project_path(project_name)
|
||||
|
||||
if not project_dir:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Project '{project_name}' not found in registry"
|
||||
)
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Project directory not found: {project_dir}"
|
||||
)
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
def get_project_devserver_manager(project_name: str):
|
||||
"""
|
||||
Get the dev server process manager for a project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
DevServerProcessManager instance for the project
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found or directory does not exist
|
||||
"""
|
||||
project_dir = get_project_dir(project_name)
|
||||
return get_devserver_manager(project_name, project_dir)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/status", response_model=DevServerStatus)
|
||||
async def get_devserver_status(project_name: str) -> DevServerStatus:
|
||||
"""
|
||||
Get the current status of the dev server for a project.
|
||||
|
||||
Returns information about whether the dev server is running,
|
||||
its process ID, detected URL, and the command used to start it.
|
||||
"""
|
||||
manager = get_project_devserver_manager(project_name)
|
||||
|
||||
# Run healthcheck to detect crashed processes
|
||||
await manager.healthcheck()
|
||||
|
||||
return DevServerStatus(
|
||||
status=manager.status,
|
||||
pid=manager.pid,
|
||||
url=manager.detected_url,
|
||||
command=manager._command,
|
||||
started_at=manager.started_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start", response_model=DevServerActionResponse)
|
||||
async def start_devserver(
|
||||
project_name: str,
|
||||
request: DevServerStartRequest = DevServerStartRequest(),
|
||||
) -> DevServerActionResponse:
|
||||
"""
|
||||
Start the dev server for a project.
|
||||
|
||||
If a custom command is provided in the request, it will be used.
|
||||
Otherwise, the effective command from the project configuration is used.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
request: Optional start request with custom command
|
||||
|
||||
Returns:
|
||||
Response indicating success/failure and current status
|
||||
"""
|
||||
manager = get_project_devserver_manager(project_name)
|
||||
project_dir = get_project_dir(project_name)
|
||||
|
||||
# Determine which command to use
|
||||
command: str | None
|
||||
if request.command:
|
||||
command = request.command
|
||||
else:
|
||||
command = get_dev_command(project_dir)
|
||||
|
||||
if not command:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No dev command available. Configure a custom command or ensure project type can be detected."
|
||||
)
|
||||
|
||||
# Now command is definitely str
|
||||
success, message = await manager.start(command)
|
||||
|
||||
return DevServerActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop", response_model=DevServerActionResponse)
|
||||
async def stop_devserver(project_name: str) -> DevServerActionResponse:
|
||||
"""
|
||||
Stop the dev server for a project.
|
||||
|
||||
Gracefully terminates the dev server process and all its child processes.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
Response indicating success/failure and current status
|
||||
"""
|
||||
manager = get_project_devserver_manager(project_name)
|
||||
|
||||
success, message = await manager.stop()
|
||||
|
||||
return DevServerActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", response_model=DevServerConfigResponse)
|
||||
async def get_devserver_config(project_name: str) -> DevServerConfigResponse:
|
||||
"""
|
||||
Get the dev server configuration for a project.
|
||||
|
||||
Returns information about:
|
||||
- detected_type: The auto-detected project type (nodejs-vite, python-django, etc.)
|
||||
- detected_command: The default command for the detected type
|
||||
- custom_command: Any user-configured custom command
|
||||
- effective_command: The command that will actually be used (custom or detected)
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
Configuration details for the project's dev server
|
||||
"""
|
||||
project_dir = get_project_dir(project_name)
|
||||
config = get_project_config(project_dir)
|
||||
|
||||
return DevServerConfigResponse(
|
||||
detected_type=config["detected_type"],
|
||||
detected_command=config["detected_command"],
|
||||
custom_command=config["custom_command"],
|
||||
effective_command=config["effective_command"],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=DevServerConfigResponse)
|
||||
async def update_devserver_config(
|
||||
project_name: str,
|
||||
update: DevServerConfigUpdate,
|
||||
) -> DevServerConfigResponse:
|
||||
"""
|
||||
Update the dev server configuration for a project.
|
||||
|
||||
Set custom_command to a string to override the auto-detected command.
|
||||
Set custom_command to null/None to clear the custom command and revert
|
||||
to using the auto-detected command.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
update: Configuration update containing the new custom_command
|
||||
|
||||
Returns:
|
||||
Updated configuration details for the project's dev server
|
||||
"""
|
||||
project_dir = get_project_dir(project_name)
|
||||
|
||||
# Update the custom command
|
||||
if update.custom_command is None:
|
||||
# Clear the custom command
|
||||
try:
|
||||
clear_dev_command(project_dir)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
else:
|
||||
# Set the custom command
|
||||
try:
|
||||
set_dev_command(project_dir, update.custom_command)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save configuration: {e}"
|
||||
)
|
||||
|
||||
# Return updated config
|
||||
config = get_project_config(project_dir)
|
||||
|
||||
return DevServerConfigResponse(
|
||||
detected_type=config["detected_type"],
|
||||
detected_command=config["detected_command"],
|
||||
custom_command=config["custom_command"],
|
||||
effective_command=config["effective_command"],
|
||||
)
|
||||
273
server/routers/terminal.py
Normal file
273
server/routers/terminal.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Terminal Router
|
||||
===============
|
||||
|
||||
WebSocket endpoint for interactive terminal I/O with PTY support.
|
||||
Provides real-time bidirectional communication with terminal sessions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..services.terminal_manager import get_terminal_session
|
||||
|
||||
# Add project root to path for registry import
|
||||
_root = Path(__file__).parent.parent.parent
|
||||
if str(_root) not in sys.path:
|
||||
sys.path.insert(0, str(_root))
|
||||
|
||||
from registry import get_project_path as registry_get_project_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/terminal", tags=["terminal"])
|
||||
|
||||
|
||||
class TerminalCloseCode:
|
||||
"""WebSocket close codes for terminal endpoint."""
|
||||
|
||||
INVALID_PROJECT_NAME = 4000
|
||||
PROJECT_NOT_FOUND = 4004
|
||||
FAILED_TO_START = 4500
|
||||
|
||||
|
||||
def _get_project_path(project_name: str) -> Path | None:
|
||||
"""Get project path from registry."""
|
||||
return registry_get_project_path(project_name)
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> bool:
|
||||
"""
|
||||
Validate project name to prevent path traversal attacks.
|
||||
|
||||
Allows only alphanumeric characters, underscores, and hyphens.
|
||||
Maximum length of 50 characters.
|
||||
|
||||
Args:
|
||||
name: The project name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return bool(re.match(r"^[a-zA-Z0-9_-]{1,50}$", name))
|
||||
|
||||
|
||||
@router.websocket("/ws/{project_name}")
|
||||
async def terminal_websocket(websocket: WebSocket, project_name: str) -> None:
|
||||
"""
|
||||
WebSocket endpoint for interactive terminal I/O.
|
||||
|
||||
Message protocol:
|
||||
|
||||
Client -> Server:
|
||||
- {"type": "input", "data": "<base64-encoded-bytes>"} - Keyboard input
|
||||
- {"type": "resize", "cols": 80, "rows": 24} - Terminal resize
|
||||
- {"type": "ping"} - Keep-alive ping
|
||||
|
||||
Server -> Client:
|
||||
- {"type": "output", "data": "<base64-encoded-bytes>"} - PTY output
|
||||
- {"type": "exit", "code": 0} - Shell process exited
|
||||
- {"type": "pong"} - Keep-alive response
|
||||
- {"type": "error", "message": "..."} - Error message
|
||||
"""
|
||||
# Validate project name
|
||||
if not validate_project_name(project_name):
|
||||
await websocket.close(
|
||||
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
||||
)
|
||||
return
|
||||
|
||||
# Look up project directory from registry
|
||||
project_dir = _get_project_path(project_name)
|
||||
if not project_dir:
|
||||
await websocket.close(
|
||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||
reason="Project not found in registry",
|
||||
)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
await websocket.close(
|
||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||
reason="Project directory not found",
|
||||
)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# Get or create terminal session for this project
|
||||
session = get_terminal_session(project_name, project_dir)
|
||||
|
||||
# Queue for output data to send to client
|
||||
output_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
# Callback to receive terminal output and queue it for sending
|
||||
def on_output(data: bytes) -> None:
|
||||
"""Queue terminal output for async sending to WebSocket."""
|
||||
try:
|
||||
output_queue.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(f"Output queue full for {project_name}, dropping data")
|
||||
|
||||
# Register the output callback
|
||||
session.add_output_callback(on_output)
|
||||
|
||||
# Start the terminal session if not already active
|
||||
if not session.is_active:
|
||||
started = await session.start()
|
||||
if not started:
|
||||
session.remove_output_callback(on_output)
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": "Failed to start terminal session"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.close(
|
||||
code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal"
|
||||
)
|
||||
return
|
||||
|
||||
# Task to send queued output to WebSocket
|
||||
async def send_output_task() -> None:
|
||||
"""Continuously send queued output to the WebSocket client."""
|
||||
try:
|
||||
while True:
|
||||
# Wait for output data
|
||||
data = await output_queue.get()
|
||||
|
||||
# Encode as base64 and send
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
await websocket.send_json({"type": "output", "data": encoded})
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except WebSocketDisconnect:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending output for {project_name}: {e}")
|
||||
raise
|
||||
|
||||
# Task to monitor if the terminal session exits
|
||||
async def monitor_exit_task() -> None:
|
||||
"""Monitor the terminal session and notify client on exit."""
|
||||
try:
|
||||
while session.is_active:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Session ended - send exit message
|
||||
# Note: We don't have access to actual exit code from PTY
|
||||
await websocket.send_json({"type": "exit", "code": 0})
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except WebSocketDisconnect:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in exit monitor for {project_name}: {e}")
|
||||
|
||||
# Start background tasks
|
||||
output_task = asyncio.create_task(send_output_task())
|
||||
exit_task = asyncio.create_task(monitor_exit_task())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
# Receive message from client
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
msg_type = message.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "input":
|
||||
# Decode base64 input and write to PTY
|
||||
encoded_data = message.get("data", "")
|
||||
# Add size limit to prevent DoS
|
||||
if len(encoded_data) > 65536: # 64KB limit for base64 encoded data
|
||||
await websocket.send_json({"type": "error", "message": "Input too large"})
|
||||
continue
|
||||
if encoded_data:
|
||||
try:
|
||||
decoded = base64.b64decode(encoded_data)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Failed to decode base64 input: {e}")
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": "Invalid base64 data"}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
session.write(decoded)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write to terminal: {e}")
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": "Failed to write to terminal"}
|
||||
)
|
||||
|
||||
elif msg_type == "resize":
|
||||
# Resize the terminal
|
||||
cols = message.get("cols", 80)
|
||||
rows = message.get("rows", 24)
|
||||
|
||||
# Validate dimensions
|
||||
if isinstance(cols, int) and isinstance(rows, int):
|
||||
cols = max(10, min(500, cols))
|
||||
rows = max(5, min(200, rows))
|
||||
session.resize(cols, rows)
|
||||
else:
|
||||
await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"})
|
||||
|
||||
else:
|
||||
await websocket.send_json({"type": "error", "message": f"Unknown message type: {msg_type}"})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Terminal WebSocket disconnected for {project_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Terminal WebSocket error for {project_name}")
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
finally:
|
||||
# Cancel background tasks
|
||||
output_task.cancel()
|
||||
exit_task.cancel()
|
||||
|
||||
try:
|
||||
await output_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
try:
|
||||
await exit_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Remove the output callback
|
||||
session.remove_output_callback(on_output)
|
||||
|
||||
# Only stop session if no other clients are connected
|
||||
with session._callbacks_lock:
|
||||
remaining_callbacks = len(session._output_callbacks)
|
||||
|
||||
if remaining_callbacks == 0:
|
||||
await session.stop()
|
||||
logger.info(f"Terminal session stopped for {project_name} (last client disconnected)")
|
||||
else:
|
||||
logger.info(
|
||||
f"Client disconnected from {project_name}, {remaining_callbacks} clients remaining"
|
||||
)
|
||||
Reference in New Issue
Block a user