from __future__ import annotations import base64 from pathlib import Path from typing import Any from uuid import uuid4 from device.manager import DeviceManager from driver.registry import build_driver_factory from pydantic import BaseModel, Field from runtime.task import TaskRunner from storage.device_config import DeviceConfigStore from storage.task_metadata import TaskMetadataStore from storage.timeline import Timeline class RegisterDeviceRequest(BaseModel): driver_type: str connection_info: dict[str, Any] = Field(default_factory=dict) name: str | None = None class RuntimeConfigRequest(BaseModel): max_steps: int def create_console_router( *, device_manager: DeviceManager, metadata_store: TaskMetadataStore, timeline: Timeline, config_store: DeviceConfigStore, task_runner: TaskRunner, ) -> Any: from fastapi import APIRouter, HTTPException, Response, status router = APIRouter(prefix="/console", tags=["console"]) @router.get("/devices") def devices() -> list[dict[str, Any]]: return [device.to_dict() for device in device_manager.list_devices()] @router.post("/devices", status_code=status.HTTP_201_CREATED) def register_device(request: RegisterDeviceRequest) -> dict[str, Any]: try: driver_factory = build_driver_factory( request.driver_type, request.connection_info, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc device_id = uuid4().hex device = device_manager.register_device( device_id, driver_factory, name=request.name, driver_type=request.driver_type, connection_info=request.connection_info, ) config_store.add( device_id=device_id, name=request.name, driver_type=request.driver_type, connection_info=request.connection_info, ) return device.to_dict() @router.delete( "/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, ) def unregister_device(device_id: str) -> Response: if not _has_device(device_manager, device_id): raise HTTPException(status_code=404, detail="device not found") device_manager.unregister_device(device_id) config_store.remove(device_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/tasks") def tasks( device_id: str | None = None, status: str | None = None, ) -> list[dict[str, Any]]: rows = metadata_store.list_tasks() if device_id is not None: rows = [row for row in rows if row["device_id"] == device_id] if status is not None: rows = [row for row in rows if row["status"] == status] return rows @router.get("/tasks/{task_id}") def task_detail(task_id: str) -> dict[str, Any]: task = metadata_store.get_task(task_id) if task is None: raise HTTPException(status_code=404, detail="task not found") return task @router.get("/tasks/{task_id}/timeline") def task_timeline(task_id: str) -> list[dict[str, Any]]: if metadata_store.get_task(task_id) is None: raise HTTPException(status_code=404, detail="task not found") return [_inline_screenshot(record) for record in timeline.read(task_id)] @router.get("/config") def runtime_config() -> dict[str, int]: return {"max_steps": _runner_max_steps(task_runner)} @router.put("/config") def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]: if request.max_steps <= 0: raise HTTPException(status_code=400, detail="max_steps must be positive") _set_runner_max_steps(task_runner, request.max_steps) config_store.set_setting("max_steps", request.max_steps) return {"max_steps": request.max_steps} return router def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]: payload = dict(record) screenshot_path = payload.get("screenshot_path") if screenshot_path: path = Path(str(screenshot_path)) if path.exists(): payload["image_base64"] = base64.b64encode(path.read_bytes()).decode( "ascii" ) return payload def _has_device(device_manager: DeviceManager, device_id: str) -> bool: return any(device.id == device_id for device in device_manager.list_devices()) def _runner_max_steps(task_runner: TaskRunner) -> int: config = getattr(task_runner, "config", None) if config is None or not hasattr(config, "max_steps"): raise RuntimeError("task runner config unavailable") return int(config.max_steps) def _set_runner_max_steps(task_runner: TaskRunner, max_steps: int) -> None: config = getattr(task_runner, "config", None) if config is None or not hasattr(config, "max_steps"): raise RuntimeError("task runner config unavailable") config.max_steps = max_steps