From e00c50e703deb5deffa24b6cf8ca4e34a8b0f667 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 15 Jul 2026 08:03:13 +0800 Subject: [PATCH] feat(api): server-rendered Jinja2 Runtime console at /ui/ Replaces the separate Vue/Vite `console/` SPA with a same-origin, server-rendered console built on a module-level Jinja2 Environment with select_autoescape(["html","xml"]). - Add api/console_web.py with /ui/ routes (dashboard, tasks, task detail/timeline, config) and a _status_fragment polled every 10s. - Refactor api/console.py into a typed ConsoleService shared by the JSON and HTML routers so validation/persistence cannot drift. - Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard CORS middleware from api/rest.py; GET / now redirects to /ui/. - Delete the top-level console/ project; add jinja2 and python-multipart as direct dependencies and ship templates/CSS/JS via package-data. - Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir and no-CORS regressions, wheel-packaging smoke test). /console/* JSON endpoints remain unchanged. The console keeps the trusted-network-only boundary; auth/CSRF is intentionally deferred. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 1 - README.md | 8 +- api/console.py | 233 ++-- api/console_web.py | 332 +++++ api/rest.py | 73 +- .../static/runtime_console/console.css | 82 +- api/static/runtime_console/dashboard.js | 25 + .../runtime_console/_status_fragment.html | 40 + api/templates/runtime_console/base.html | 46 + api/templates/runtime_console/config.html | 98 ++ api/templates/runtime_console/dashboard.html | 17 + .../runtime_console/task_detail.html | 90 ++ api/templates/runtime_console/tasks.html | 62 + console/.env.example | 1 - console/.gitignore | 4 - console/README.md | 46 - console/index.html | 12 - console/package-lock.json | 1211 ----------------- console/package.json | 22 - console/src/App.vue | 521 ------- console/src/api.ts | 96 -- console/src/main.ts | 5 - console/src/types.ts | 48 - console/src/vite-env.d.ts | 1 - console/tsconfig.json | 20 - console/tsconfig.node.json | 12 - console/vite.config.ts | 7 - docs/CONSTITUTION.md | 7 +- docs/MACOS_IPHONE_SETUP.md | 26 +- .../.openspec.yaml | 2 + .../design.md | 233 ++++ .../proposal.md | 60 + .../spec.md | 113 ++ .../runtime-console-jinja2-templates/tasks.md | 42 + pyproject.toml | 9 + tests/test_console_api.py | 37 +- tests/test_runtime_console_packaging.py | 79 ++ tests/test_runtime_console_web.py | 393 ++++++ uv.lock | 4 + 39 files changed, 1890 insertions(+), 2228 deletions(-) create mode 100644 api/console_web.py rename console/src/style.css => api/static/runtime_console/console.css (88%) create mode 100644 api/static/runtime_console/dashboard.js create mode 100644 api/templates/runtime_console/_status_fragment.html create mode 100644 api/templates/runtime_console/base.html create mode 100644 api/templates/runtime_console/config.html create mode 100644 api/templates/runtime_console/dashboard.html create mode 100644 api/templates/runtime_console/task_detail.html create mode 100644 api/templates/runtime_console/tasks.html delete mode 100644 console/.env.example delete mode 100644 console/.gitignore delete mode 100644 console/README.md delete mode 100644 console/index.html delete mode 100644 console/package-lock.json delete mode 100644 console/package.json delete mode 100644 console/src/App.vue delete mode 100644 console/src/api.ts delete mode 100644 console/src/main.ts delete mode 100644 console/src/types.ts delete mode 100644 console/src/vite-env.d.ts delete mode 100644 console/tsconfig.json delete mode 100644 console/tsconfig.node.json delete mode 100644 console/vite.config.ts create mode 100644 openspec/changes/runtime-console-jinja2-templates/.openspec.yaml create mode 100644 openspec/changes/runtime-console-jinja2-templates/design.md create mode 100644 openspec/changes/runtime-console-jinja2-templates/proposal.md create mode 100644 openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md create mode 100644 openspec/changes/runtime-console-jinja2-templates/tasks.md create mode 100644 tests/test_runtime_console_packaging.py create mode 100644 tests/test_runtime_console_web.py diff --git a/.dockerignore b/.dockerignore index 1557afd..349334b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,5 @@ __pycache__ *.py[cod] *.sqlite3 tasks -console/node_modules cloud-console/node_modules cloud-console/dist diff --git a/README.md b/README.md index 22596ca..fc8537d 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,12 @@ uv build --package device-agent-runtime uv build --package device-cloud-platform ``` -The Vue/Vite application under `console/` remains an independent npm project; -uv does not install or modify its JavaScript dependencies. +The Runtime API ships a same-origin operator console at `/ui/`, rendered through +Jinja2 templates packaged with `device-agent-runtime`. Start the API +(`uvicorn api.rest:create_app --factory`) and open `/` (it redirects to `/ui/`). +The `/console/*` JSON endpoints remain available for programmatic clients. The +console assumes a trusted local network; it has no authentication, authorization, +or CSRF protection. ## Project Direction diff --git a/api/console.py b/api/console.py index d18cbe8..d47a518 100644 --- a/api/console.py +++ b/api/console.py @@ -24,58 +24,160 @@ 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: +class ConsoleService: + """API-local typed operations shared by JSON and HTML console routers. + + Stays in the ``api`` layer: it only depends on the existing injected + Runtime state (device manager, metadata store, timeline, config store, + task runner) and does not introduce HTTP/UI concerns below this layer. + Page and JSON handlers receive the same instance so validation, driver + allow-listing, persistence, and error mapping cannot drift apart. + """ + + def __init__( + self, + *, + device_manager: DeviceManager, + metadata_store: TaskMetadataStore, + timeline: Timeline, + config_store: DeviceConfigStore, + task_runner: TaskRunner, + ) -> None: + self._device_manager = device_manager + self._metadata_store = metadata_store + self._timeline = timeline + self._config_store = config_store + self._task_runner = task_runner + + def list_devices(self) -> list[dict[str, Any]]: + return [device.to_dict() for device in self._device_manager.list_devices()] + + def register_device( + self, + *, + driver_type: str, + connection_info: dict[str, Any], + name: str | None, + ) -> dict[str, Any]: + driver_factory = build_driver_factory(driver_type, connection_info) + device_id = uuid4().hex + device = self._device_manager.register_device( + device_id, + driver_factory, + name=name, + driver_type=driver_type, + connection_info=connection_info, + ) + self._config_store.add( + device_id=device_id, + name=name, + driver_type=driver_type, + connection_info=connection_info, + ) + return device.to_dict() + + def unregister_device(self, device_id: str) -> None: + if not self._has_device(device_id): + raise KeyError("device not found") + self._device_manager.unregister_device(device_id) + self._config_store.remove(device_id) + + def list_tasks( + self, + *, + device_id: str | None = None, + status: str | None = None, + ) -> list[dict[str, Any]]: + rows = self._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 + + def get_task(self, task_id: str) -> dict[str, Any]: + task = self._metadata_store.get_task(task_id) + if task is None: + raise KeyError("task not found") + return task + + def get_task_timeline(self, task_id: str) -> list[dict[str, Any]]: + if self._metadata_store.get_task(task_id) is None: + raise KeyError("task not found") + return [ + self._inline_screenshot(record) for record in self._timeline.read(task_id) + ] + + def get_runtime_config(self) -> dict[str, int]: + return {"max_steps": self._runner_max_steps()} + + def update_runtime_config(self, *, max_steps: int) -> dict[str, int]: + if max_steps <= 0: + raise ValueError("max_steps must be positive") + self._set_runner_max_steps(max_steps) + self._config_store.set_setting("max_steps", max_steps) + return {"max_steps": max_steps} + + def _has_device(self, device_id: str) -> bool: + return any( + device.id == device_id for device in self._device_manager.list_devices() + ) + + @staticmethod + 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 _runner_max_steps(self) -> int: + config = getattr(self._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(self, max_steps: int) -> None: + config = getattr(self._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 + + +def create_console_router(service: ConsoleService) -> 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()] + return service.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, + return service.register_device( + driver_type=request.driver_type, + connection_info=request.connection_info, + name=request.name, ) 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) + try: + service.unregister_device(device_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/tasks") @@ -83,66 +185,33 @@ def create_console_router( 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 + return service.list_tasks(device_id=device_id, status=status) @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 + try: + return service.get_task(task_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @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)] + try: + return service.get_task_timeline(task_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/config") def runtime_config() -> dict[str, int]: - return {"max_steps": _runner_max_steps(task_runner)} + return service.get_runtime_config() @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} + def update_runtime_config( + request: RuntimeConfigRequest, + ) -> dict[str, int]: + try: + return service.update_runtime_config(max_steps=request.max_steps) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc 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 diff --git a/api/console_web.py b/api/console_web.py new file mode 100644 index 0000000..945e483 --- /dev/null +++ b/api/console_web.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from api.console import ConsoleService +from driver.registry import SUPPORTED_DRIVER_TYPES +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_TEMPLATE_DIR = Path(__file__).parent / "templates" / "runtime_console" +_STATIC_DIR = Path(__file__).parent / "static" / "runtime_console" + +_ENV = Environment( + loader=FileSystemLoader(str(_TEMPLATE_DIR)), + autoescape=select_autoescape(["html", "xml"]), + auto_reload=False, +) + +_TASK_STATUSES = ["created", "running", "completed", "failed", "cancelled"] +_DEFAULT_FORM_VALUES: dict[str, str] = { + "name": "", + "driver_type": "", + "server_url": "", + "udid": "", + "wda_local_port": "", +} + + +def _render(request: Request, template_name: str, **context: Any) -> str: + return _ENV.get_template(template_name).render( + url_for=request.url_for, + **context, + ) + + +def _device_names(service: ConsoleService) -> dict[str, str]: + return { + device["id"]: device.get("name") or device["id"] + for device in service.list_devices() + } + + +def _default_driver_type() -> str: + if not _DEFAULT_FORM_VALUES["driver_type"] and SUPPORTED_DRIVER_TYPES: + _DEFAULT_FORM_VALUES["driver_type"] = next(iter(SUPPORTED_DRIVER_TYPES)) + return _DEFAULT_FORM_VALUES["driver_type"] + + +def _config_context( + request: Request, + service: ConsoleService, + *, + form_values: dict[str, str] | None = None, + device_error: str = "", + device_added: str = "", + config_error: str = "", + config_saved: bool = False, + max_steps_override: int | None = None, +) -> str: + form = dict(_DEFAULT_FORM_VALUES) + form["driver_type"] = _default_driver_type() + if form_values: + form.update(form_values) + config = service.get_runtime_config() + return _render( + request, + "config.html", + active_section="config", + devices=service.list_devices(), + max_steps=max_steps_override + if max_steps_override is not None + else config["max_steps"], + supported_driver_types=list(SUPPORTED_DRIVER_TYPES), + form_values=form, + device_error=device_error, + device_added=device_added, + config_error=config_error, + config_saved=config_saved, + ) + + +def create_console_web_router(service: ConsoleService) -> APIRouter: + router = APIRouter(prefix="/ui", tags=["console-ui"]) + + @router.get("/", name="runtime_console_dashboard", include_in_schema=False) + def dashboard(request: Request) -> HTMLResponse: + devices = service.list_devices() + tasks = service.list_tasks() + running = sum(1 for task in tasks if task.get("status") == "running") + failed = sum(1 for task in tasks if task.get("status") == "failed") + return HTMLResponse( + _render( + request, + "dashboard.html", + active_section="dashboard", + devices=devices, + device_count=len(devices), + task_count=len(tasks), + running_tasks=running, + failed_tasks=failed, + ) + ) + + @router.get( + "/_status_fragment", + name="runtime_console_status_fragment", + include_in_schema=False, + ) + def status_fragment(request: Request) -> HTMLResponse: + devices = service.list_devices() + tasks = service.list_tasks() + running = sum(1 for task in tasks if task.get("status") == "running") + failed = sum(1 for task in tasks if task.get("status") == "failed") + return HTMLResponse( + _render( + request, + "_status_fragment.html", + devices=devices, + device_count=len(devices), + running_tasks=running, + failed_tasks=failed, + ) + ) + + @router.get("/tasks", name="runtime_console_tasks", include_in_schema=False) + def tasks( + request: Request, + device_id: str | None = None, + status: str | None = None, + ) -> HTMLResponse: + service_tasks = service.list_tasks(device_id=device_id, status=status) + return HTMLResponse( + _render( + request, + "tasks.html", + active_section="tasks", + tasks=service_tasks, + devices=service.list_devices(), + device_names=_device_names(service), + task_statuses=_TASK_STATUSES, + selected_device_id=device_id or "", + selected_status=status or "", + ) + ) + + @router.get( + "/tasks/{task_id}", + name="runtime_console_task_detail", + include_in_schema=False, + ) + def task_detail( + request: Request, + task_id: str, + step: int | None = None, + ) -> HTMLResponse: + try: + task = service.get_task(task_id) + timeline = service.get_task_timeline(task_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + if timeline: + index = 0 + if step is not None: + index = max(0, min(step, len(timeline) - 1)) + current_step = timeline[index] + else: + index = 0 + current_step = None + device_name = _device_names(service).get( + task.get("device_id", ""), + task.get("device_id", ""), + ) + return HTMLResponse( + _render( + request, + "task_detail.html", + active_section="tasks", + task=task, + device_name=device_name, + timeline=timeline, + current_step=current_step if current_step is not None else {}, + current_step_index=index, + ) + ) + + @router.get("/config", name="runtime_console_config", include_in_schema=False) + def config(request: Request) -> HTMLResponse: + return HTMLResponse(_config_context(request, service)) + + @router.post( + "/config/devices", + name="runtime_console_register_device", + include_in_schema=False, + response_model=None, + ) + async def register_device(request: Request) -> HTMLResponse | RedirectResponse: + form = await request.form() + name = (form.get("name") or "").strip() + driver_type = (form.get("driver_type") or "").strip() + server_url = (form.get("server_url") or "").strip() + udid = (form.get("udid") or "").strip() + wda_local_port_raw = (form.get("wda_local_port") or "").strip() + + connection_info: dict[str, Any] = {} + if server_url: + connection_info["server_url"] = server_url + if udid: + connection_info["udid"] = udid + if wda_local_port_raw: + try: + connection_info["wda_local_port"] = int(wda_local_port_raw) + except ValueError: + return HTMLResponse( + _config_context( + request, + service, + form_values={ + "name": name, + "driver_type": driver_type, + "server_url": server_url, + "udid": udid, + "wda_local_port": wda_local_port_raw, + }, + device_error="wda_local_port must be a number", + ), + status_code=400, + ) + + try: + service.register_device( + driver_type=driver_type, + connection_info=connection_info, + name=name or None, + ) + except ValueError as exc: + return HTMLResponse( + _config_context( + request, + service, + form_values={ + "name": name, + "driver_type": driver_type, + "server_url": server_url, + "udid": udid, + "wda_local_port": wda_local_port_raw, + }, + device_error=str(exc), + ), + status_code=400, + ) + return RedirectResponse( + url=request.url_for("runtime_console_config"), + status_code=303, + ) + + @router.post( + "/config/devices/{device_id}/delete", + name="runtime_console_remove_device", + include_in_schema=False, + response_model=None, + ) + async def remove_device( + request: Request, + device_id: str, + ) -> HTMLResponse | RedirectResponse: + try: + service.unregister_device(device_id) + except KeyError as exc: + return HTMLResponse( + _config_context(request, service, device_error=str(exc)), + status_code=404, + ) + return RedirectResponse( + url=request.url_for("runtime_console_config"), + status_code=303, + ) + + @router.post( + "/config/max-steps", + name="runtime_console_update_max_steps", + include_in_schema=False, + response_model=None, + ) + async def update_max_steps(request: Request) -> HTMLResponse | RedirectResponse: + form = await request.form() + raw = (form.get("max_steps") or "").strip() + try: + max_steps = int(raw) + except ValueError: + return HTMLResponse( + _config_context( + request, + service, + config_error="max_steps must be an integer", + max_steps_override=raw if raw else None, + ), + status_code=400, + ) + try: + service.update_runtime_config(max_steps=max_steps) + except ValueError as exc: + return HTMLResponse( + _config_context( + request, + service, + config_error=str(exc), + max_steps_override=max_steps, + ), + status_code=400, + ) + return RedirectResponse( + url=request.url_for("runtime_console_config"), + status_code=303, + ) + + return router + + +def mount_console_assets(app: Any) -> None: + """Mount the package-owned static assets under ``/ui/assets``. + + Kept as a separate helper so ``api.rest`` can mount assets with a stable + route name that templates reference via ``url_for``. + """ + app.mount( + "/ui/assets", + StaticFiles(directory=str(_STATIC_DIR)), + name="runtime_console_assets", + ) diff --git a/api/rest.py b/api/rest.py index 03dcb30..c9f5ec6 100644 --- a/api/rest.py +++ b/api/rest.py @@ -1,8 +1,7 @@ -import os -from pathlib import Path from typing import Any -from api.console import create_console_router +from api.console import ConsoleService, create_console_router +from api.console_web import create_console_web_router, mount_console_assets from api.errors import semantic_error from core.models import Task from device.manager import DEFAULT_MANAGER, DeviceManager @@ -26,28 +25,8 @@ def create_app( timeline: Timeline | None = None, ) -> Any: from fastapi import BackgroundTasks, FastAPI, HTTPException - from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse - from fastapi.staticfiles import StaticFiles from pydantic import BaseModel - from starlette.exceptions import HTTPException as StarletteHTTPException - from starlette.types import Scope - - class SpaStaticFiles(StaticFiles): - """``StaticFiles`` variant that falls back to ``index.html`` for SPA routes. - - Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown - path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the - SPA's client-side router handle it. - """ - - async def get_response(self, path: str, scope: Scope) -> Any: - try: - return await super().get_response(path, scope) - except StarletteHTTPException as exc: - if exc.status_code == 404 and path != "index.html": - return await super().get_response("index.html", scope) - raise device_manager = manager or DEFAULT_MANAGER store = metadata_store or TaskMetadataStore() @@ -63,12 +42,6 @@ def create_app( ) _apply_max_steps(runner, max_steps) app = FastAPI(title="Apex Agent API") - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - ) class TapRequest(BaseModel): x: float @@ -85,6 +58,18 @@ def create_app( goal: str device_id: str + console_service = ConsoleService( + device_manager=device_manager, + metadata_store=store, + timeline=timeline_store, + config_store=config_store, + task_runner=runner, + ) + + @app.get("/", include_in_schema=False) + def root() -> RedirectResponse: + return RedirectResponse(url="/ui/") + @app.get("/devices") def devices() -> list[dict[str, Any]]: return [device.to_dict() for device in device_manager.list_devices()] @@ -139,33 +124,9 @@ def create_app( raise HTTPException(status_code=404, detail="task not found") return task - app.include_router( - create_console_router( - device_manager=device_manager, - metadata_store=store, - timeline=timeline_store, - config_store=config_store, - task_runner=runner, - ) - ) - - console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR") - if console_static_dir: - dist_dir = Path(console_static_dir) - if not dist_dir.is_dir(): - raise ValueError( - f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}" - ) - - @app.get("/", include_in_schema=False) - async def _redirect_to_console() -> RedirectResponse: - return RedirectResponse(url="/ui/") - - app.mount( - "/ui", - SpaStaticFiles(directory=str(dist_dir), html=True), - name="console", - ) + app.include_router(create_console_router(console_service)) + app.include_router(create_console_web_router(console_service)) + mount_console_assets(app) return app diff --git a/console/src/style.css b/api/static/runtime_console/console.css similarity index 88% rename from console/src/style.css rename to api/static/runtime_console/console.css index a4b4dc8..134377b 100644 --- a/console/src/style.css +++ b/api/static/runtime_console/console.css @@ -35,6 +35,11 @@ button:disabled { opacity: 0.55; } +a { + color: inherit; + text-decoration: none; +} + .app-shell { display: grid; grid-template-columns: 248px minmax(0, 1fr); @@ -57,6 +62,18 @@ button:disabled { gap: 10px; } +.brand-icon { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: #2f7c67; + color: #ffffff; + font-size: 12px; + font-weight: 700; +} + .brand strong, .brand span { display: block; @@ -140,13 +157,6 @@ button:disabled { gap: 16px; } -.tasks-layout { - display: grid; - grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); - gap: 16px; - align-items: start; -} - .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -336,7 +346,7 @@ select { text-align: left; } -.task-row.selected { +.task-row:hover { border-color: #2f7c67; box-shadow: 0 0 0 2px #d9efe8; } @@ -480,16 +490,6 @@ pre { color: #1f6b4a; } -.spin { - animation: spin 0.9s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - @media (max-width: 980px) { .app-shell { grid-template-columns: 1fr; @@ -498,55 +498,21 @@ pre { .sidebar { position: sticky; top: 0; - z-index: 2; - border-right: 0; - border-bottom: 1px solid #d9dde5; + flex-direction: row; + align-items: center; + overflow-x: auto; } .nav-list { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-flow: column; } - .tasks-layout, .timeline-stage { grid-template-columns: 1fr; } - .task-browser { - max-height: none; - } -} - -@media (max-width: 680px) { - .workspace { - padding: 14px; - } - - .topbar, - .section-title, - .device-row { - align-items: stretch; - } - - .topbar, - .device-row, - .settings-form { - flex-direction: column; + .detail-grid, + .form-grid { grid-template-columns: 1fr; } - - .metrics, - .filters, - .form-grid, - .detail-grid { - grid-template-columns: 1fr; - } - - .nav-button { - justify-content: center; - } - - .brand { - grid-template-columns: 32px minmax(0, 1fr); - } } diff --git a/api/static/runtime_console/dashboard.js b/api/static/runtime_console/dashboard.js new file mode 100644 index 0000000..1b049e2 --- /dev/null +++ b/api/static/runtime_console/dashboard.js @@ -0,0 +1,25 @@ +// Narrow server-rendered live-status enhancement. Fetches an HTML fragment +// from the same origin and replaces only the dashboard live region. Does +// not parse JSON, rebuild DOM from client state, or depend on any framework. +(function () { + "use strict"; + var region = document.getElementById("live-status"); + if (!region) { + return; + } + setInterval(function () { + fetch("/ui/_status_fragment", { headers: { Accept: "text/html" } }) + .then(function (response) { + if (!response.ok) { + throw new Error("fragment refresh failed"); + } + return response.text(); + }) + .then(function (html) { + region.innerHTML = html; + }) + .catch(function () { + // Ignore transient network errors; the next tick will retry. + }); + }, 10000); +})(); diff --git a/api/templates/runtime_console/_status_fragment.html b/api/templates/runtime_console/_status_fragment.html new file mode 100644 index 0000000..4b0dad3 --- /dev/null +++ b/api/templates/runtime_console/_status_fragment.html @@ -0,0 +1,40 @@ +
+
+ Devices + {{ device_count }} +
+
+ Running + {{ running_tasks }} +
+
+ Failed + {{ failed_tasks }} +
+
+ +
+
+

Device Status

+
+ {% if not devices %} +
+ No devices registered. Add one from Config. +
+ {% else %} + + {% endif %} +
diff --git a/api/templates/runtime_console/base.html b/api/templates/runtime_console/base.html new file mode 100644 index 0000000..d111084 --- /dev/null +++ b/api/templates/runtime_console/base.html @@ -0,0 +1,46 @@ + + + + + + {% block title %}Apex Console{% endblock %} + + {% block head %}{% endblock %} + + +
+ +
+ {% block body %}{% endblock %} +
+
+ + diff --git a/api/templates/runtime_console/config.html b/api/templates/runtime_console/config.html new file mode 100644 index 0000000..6858de7 --- /dev/null +++ b/api/templates/runtime_console/config.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}Config · Apex Console{% endblock %} +{% block body %} +
+
+

Config

+

Register devices and tune Runtime parameters.

+
+
+ +
+
+
+

Device Configuration

+
+ {% if device_error %} +

{{ device_error }}

+ {% endif %} + {% if device_added %} +

Device "{{ device_added }}" registered.

+ {% endif %} +
+ + + + + + +
+ +
    + {% for device in devices %} +
  • +
    + {{ device.name or device.id }} + {{ device.id }} +
    +
    + +
    +
  • + {% endfor %} +
+
+ +
+
+

Runtime Parameters

+
+ {% if config_error %} +

{{ config_error }}

+ {% endif %} + {% if config_saved %} +

Saved.

+ {% endif %} +
+ + +
+
+
+{% endblock %} diff --git a/api/templates/runtime_console/dashboard.html b/api/templates/runtime_console/dashboard.html new file mode 100644 index 0000000..f5bee57 --- /dev/null +++ b/api/templates/runtime_console/dashboard.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Devices · Apex Console{% endblock %} +{% block body %} +
+
+

Devices

+

{{ device_count }} device(s) / {{ task_count }} task(s)

+
+
+ +
+
+ {% include "_status_fragment.html" %} +
+
+ +{% endblock %} diff --git a/api/templates/runtime_console/task_detail.html b/api/templates/runtime_console/task_detail.html new file mode 100644 index 0000000..1f6b0cd --- /dev/null +++ b/api/templates/runtime_console/task_detail.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}Task {{ task.id }} · Apex Console{% endblock %} +{% block body %} +
+
+

Task Detail

+

← Back to tasks

+
+
+ +
+
+

{{ task.goal }}

+ {{ task.status }} +
+ +
+
+
Task ID
+
{{ task.id }}
+
+
+
Device
+
{{ device_name }}
+
+
+
Created
+
{{ task.created_at or "-" }}
+
+
+
Updated
+
{{ task.updated_at or "-" }}
+
+ {% if task.failure_reason %} +
+
Failure
+
{{ task.failure_reason }}
+
+ {% endif %} +
+ +
+ Step {{ current_step_index }} of {{ timeline|length }} +
+ + {% if not timeline %} +
+ No timeline records captured. +
+ {% else %} +
+ + +
+ +
+
+ {% if current_step.image_base64 %} + Task step screenshot + {% else %} + No screenshot + {% endif %} +
+
+
+

Tool Call

+
{{ current_step.tool_call | tojson(indent=2) }}
+
+
+

Result

+
{{ current_step.result | tojson(indent=2) }}
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/api/templates/runtime_console/tasks.html b/api/templates/runtime_console/tasks.html new file mode 100644 index 0000000..80e0cda --- /dev/null +++ b/api/templates/runtime_console/tasks.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Tasks · Apex Console{% endblock %} +{% block body %} +
+
+

Tasks

+

{{ tasks|length }} task(s) match the current filters

+
+
+ +
+
+

Task List

+
+
+ + + +
+ + {% if not tasks %} +
+ No tasks match the current filters. +
+ {% else %} + {% for task in tasks %} + + {{ task.goal }} + + {{ device_names.get(task.device_id, task.device_id) }} + + {{ task.status }} + + {% endfor %} + {% endif %} +
+{% endblock %} diff --git a/console/.env.example b/console/.env.example deleted file mode 100644 index 6c9bbfd..0000000 --- a/console/.env.example +++ /dev/null @@ -1 +0,0 @@ -VITE_API_BASE_URL=http://127.0.0.1:8000 diff --git a/console/.gitignore b/console/.gitignore deleted file mode 100644 index d70bb9c..0000000 --- a/console/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.DS_Store -*.local diff --git a/console/README.md b/console/README.md deleted file mode 100644 index 53b7246..0000000 --- a/console/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Apex Agent Console - -Independent Vue 3 + Vite SPA for the operator console. - -## Run Locally - -Start the backend from the repository root: - -```bash -uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000 -``` - -Start the frontend from this directory: - -```bash -npm install -npm run dev -``` - -The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`. -Copy `.env.example` to `.env.local` if the backend runs on another host or port. - -The backend mounts `/console/*` routes and enables permissive CORS in `create_app()` -for local frontend development. - -## Build - -```bash -npm run build -``` - -## Same-Origin, Single-Process Mode - -For an edge/dev setup where running a separate `npm run dev` process is too heavy, -the backend can serve the built console directly from the same process: - -```bash -VITE_API_BASE_URL= npm run build -RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000 -``` - -`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works -same-origin without CORS. The console is then served at `/ui/` (with `/` -redirecting there); `/console/*` remains the JSON API used by both this mode -and local `npm run dev`. Rebuild (`npm run build`) after frontend changes — -this mode does not hot-reload. diff --git a/console/index.html b/console/index.html deleted file mode 100644 index eb177a3..0000000 --- a/console/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Apex Agent Console - - -
- - - diff --git a/console/package-lock.json b/console/package-lock.json deleted file mode 100644 index 45016cc..0000000 --- a/console/package-lock.json +++ /dev/null @@ -1,1211 +0,0 @@ -{ - "name": "apex-agent-console", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "apex-agent-console", - "version": "0.1.0", - "dependencies": { - "@lucide/vue": "^1.23.0", - "vue": "^3.5.39" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6.0.7", - "typescript": "^6.0.3", - "vite": "^8.1.3", - "vue-tsc": "^3.3.6" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@lucide/vue": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.23.0.tgz", - "integrity": "sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==", - "license": "ISC", - "peerDependencies": { - "vue": ">=3.0.1" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", - "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", - "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.39", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", - "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.39", - "@vue/shared": "3.5.39" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", - "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.39", - "@vue/compiler-dom": "3.5.39", - "@vue/compiler-ssr": "3.5.39", - "@vue/shared": "3.5.39", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.15", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", - "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.39", - "@vue/shared": "3.5.39" - } - }, - "node_modules/@vue/language-core": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.6.tgz", - "integrity": "sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@vue/compiler-dom": "^3.5.0", - "@vue/shared": "^3.5.0", - "alien-signals": "^3.2.0", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1", - "picomatch": "^4.0.4" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", - "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.39" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", - "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.39", - "@vue/shared": "3.5.39" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", - "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.39", - "@vue/runtime-core": "3.5.39", - "@vue/shared": "3.5.39", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", - "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.39", - "@vue/shared": "3.5.39" - }, - "peerDependencies": { - "vue": "3.5.39" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", - "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", - "license": "MIT" - }, - "node_modules/alien-signals": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", - "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.138.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.39", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", - "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.39", - "@vue/compiler-sfc": "3.5.39", - "@vue/runtime-dom": "3.5.39", - "@vue/server-renderer": "3.5.39", - "@vue/shared": "3.5.39" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-tsc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.6.tgz", - "integrity": "sha512-ERXGgbKSBGFUkavrJ1Iwj0ZVxKqB/5UOx65IXy7fPf2UsoI21n3ssQLwdvx8xyUGgJe9PvSZTM/FYzIdwUDPFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "2.4.28", - "@vue/language-core": "3.3.6" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - } - } -} diff --git a/console/package.json b/console/package.json deleted file mode 100644 index e31ad2f..0000000 --- a/console/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "apex-agent-console", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview --host 127.0.0.1", - "typecheck": "vue-tsc --noEmit" - }, - "dependencies": { - "@lucide/vue": "^1.23.0", - "vue": "^3.5.39" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6.0.7", - "typescript": "^6.0.3", - "vite": "^8.1.3", - "vue-tsc": "^3.3.6" - } -} diff --git a/console/src/App.vue b/console/src/App.vue deleted file mode 100644 index a70b885..0000000 --- a/console/src/App.vue +++ /dev/null @@ -1,521 +0,0 @@ - - - diff --git a/console/src/api.ts b/console/src/api.ts deleted file mode 100644 index d8a5d8a..0000000 --- a/console/src/api.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { - Device, - RegisterDevicePayload, - RuntimeConfig, - TaskRecord, - TimelineRecord, -} from "./types"; - -const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined; -export const API_BASE_URL = ( - configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000" -).replace(/\/$/, ""); - -async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - ...init, - headers: { - Accept: "application/json", - ...(init.body ? { "Content-Type": "application/json" } : {}), - ...init.headers, - }, - }); - - if (!response.ok) { - let message = `${response.status} ${response.statusText}`; - try { - const payload = (await response.json()) as { detail?: unknown }; - if (typeof payload.detail === "string") { - message = payload.detail; - } else if (payload.detail) { - message = JSON.stringify(payload.detail); - } - } catch { - message = await response.text(); - } - throw new Error(message); - } - - if (response.status === 204) { - return undefined as T; - } - return (await response.json()) as T; -} - -export function listDevices(): Promise { - return request("/console/devices"); -} - -export function registerDevice(payload: RegisterDevicePayload): Promise { - return request("/console/devices", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export function unregisterDevice(deviceId: string): Promise { - return request(`/console/devices/${encodeURIComponent(deviceId)}`, { - method: "DELETE", - }); -} - -export function listTasks(filters: { - deviceId?: string; - status?: string; -}): Promise { - const params = new URLSearchParams(); - if (filters.deviceId) { - params.set("device_id", filters.deviceId); - } - if (filters.status) { - params.set("status", filters.status); - } - const query = params.toString(); - return request(`/console/tasks${query ? `?${query}` : ""}`); -} - -export function getTask(taskId: string): Promise { - return request(`/console/tasks/${encodeURIComponent(taskId)}`); -} - -export function getTimeline(taskId: string): Promise { - return request( - `/console/tasks/${encodeURIComponent(taskId)}/timeline`, - ); -} - -export function getConfig(): Promise { - return request("/console/config"); -} - -export function updateConfig(payload: RuntimeConfig): Promise { - return request("/console/config", { - method: "PUT", - body: JSON.stringify(payload), - }); -} diff --git a/console/src/main.ts b/console/src/main.ts deleted file mode 100644 index de275e7..0000000 --- a/console/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from "vue"; -import App from "./App.vue"; -import "./style.css"; - -createApp(App).mount("#app"); diff --git a/console/src/types.ts b/console/src/types.ts deleted file mode 100644 index c0ef851..0000000 --- a/console/src/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type DeviceStatus = "idle" | "busy" | "offline" | "error"; - -export interface Device { - id: string; - name: string | null; - status: DeviceStatus; - driver_type: string; - connection_info: Record; -} - -export type TaskStatus = - | "created" - | "running" - | "completed" - | "failed" - | "cancelled"; - -export interface TaskRecord { - id: string; - goal: string; - device_id: string; - status: TaskStatus; - created_at: string; - updated_at: string; - completed_at: string | null; - failure_reason: string | null; -} - -export interface TimelineRecord { - index: number; - scene: Record; - prompt: string; - tool_call: Record; - result: Record; - timestamp: string; - screenshot_path?: string | null; - image_base64?: string; -} - -export interface RuntimeConfig { - max_steps: number; -} - -export interface RegisterDevicePayload { - driver_type: string; - name?: string | null; - connection_info: Record; -} diff --git a/console/src/vite-env.d.ts b/console/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/console/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/console/tsconfig.json b/console/tsconfig.json deleted file mode 100644 index 20c2ac8..0000000 --- a/console/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "preserve", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "include": ["src/**/*.ts", "src/**/*.vue"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/console/tsconfig.node.json b/console/tsconfig.node.json deleted file mode 100644 index 91566d1..0000000 --- a/console/tsconfig.node.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} diff --git a/console/vite.config.ts b/console/vite.config.ts deleted file mode 100644 index bf8e9c1..0000000 --- a/console/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; - -export default defineConfig({ - plugins: [vue()], - base: "/ui/", -}); diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index 69d11b2..facb1e3 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an explicit workspace source; the Runtime distribution must never depend on or package `cloud`. -All Python members share the committed root `uv.lock`. The Vue/Vite `console/` -remains outside the Python workspace and keeps its independent npm lifecycle. +All Python members share the committed root `uv.lock`. The Runtime operator +console is server-rendered by the `api` layer through Jinja2 templates and +static assets packaged with `device-agent-runtime`; there is no separate +frontend project or Node build step for the Runtime console. The unrelated +`cloud-console/` Vue/Vite application keeps its own independent npm lifecycle. ## Change Discipline diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index 13aa274..a9cdfd5 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -331,23 +331,19 @@ curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \ 点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免 误操作。 -如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行: +Runtime API 自带同源 Web Console,无需额外的前端进程、Node 工具链或 +`RUNTIME_CONSOLE_STATIC_DIR`。保持 Runtime API 运行,浏览器访问 +`http://127.0.0.1:8000/`(会自动 307 跳转到 `/ui/`)即可: -```bash -cd console -npm install -npm run dev -``` +- `/ui/`:设备状态面板,约每 10 秒自动刷新一次; +- `/ui/tasks`:任务列表与筛选; +- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图); +- `/ui/config`:登记/移除设备、调整 `max_steps`。 -Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的 -`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记 -操作只写入配置,不会自动 connect。 - -如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给 -Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process -Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR` -指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新 -`npm run build`,不支持热更新。 +已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中 +重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与 +`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console +仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。 ## 9. 启动云端受管 Host Agent diff --git a/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml b/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml new file mode 100644 index 0000000..64105fc --- /dev/null +++ b/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/runtime-console-jinja2-templates/design.md b/openspec/changes/runtime-console-jinja2-templates/design.md new file mode 100644 index 0000000..2b4e6d7 --- /dev/null +++ b/openspec/changes/runtime-console-jinja2-templates/design.md @@ -0,0 +1,233 @@ +## Context + +The root Runtime API currently exposes console state and mutations through +`api/console.py` under `/console/*`. Its human interface is a separate +`console/` Vue/Vite SPA. `api/rest.py` only serves that SPA when +`RUNTIME_CONSOLE_STATIC_DIR` points to a built distribution, uses a +SPA-specific 404 fallback, and enables wildcard CORS for cross-origin Vite +development. + +The existing `web-console` change is still unarchived and contains the +opposite design decision: an independent SPA with no backend templates. There +is no canonical Runtime-console spec in `openspec/specs/`, so this change +introduces a new capability rather than modifying a pending change's delta +spec. The Host Agent already uses Jinja2, but it is a distinct application +with local-account authentication and must not become a dependency of the +Runtime API. + +The Runtime layering rule requires all HTTP, template, static-asset, and form +parsing concerns to remain in `api`. `core`, `driver`, `device`, `tools`, +`perception`, `storage`, and `runtime` retain their current framework-free +contracts. + +## Goals / Non-Goals + +**Goals:** + +- Serve the Runtime operator console from the same FastAPI process at `/ui/` + without Node, Vite, a prebuilt SPA directory, or a second development + process. +- Preserve the observable behavior of the existing device, task, timeline, + and runtime-config console workflows, while retaining `/console/*` JSON + endpoints for programmatic clients. +- Ensure every HTML interpolation is protected by Jinja2 autoescaping and + ship templates and assets in the Runtime wheel. +- Keep mutations server-owned through ordinary POST/Redirect/GET form flows + and reuse one API-local implementation for page and JSON routes. + +**Non-Goals:** + +- Add authentication, authorization, sessions, CSRF protection, rate limits, + or a public-network deployment model for the Runtime console. +- Change `/agent/task`, `/devices`, MCP, `DeviceManager`, `TaskRunner`, + `TaskMetadataStore`, `Timeline`, or persisted data formats. +- Add new driver types, editable planner settings, Cloud Console features, or + Host Agent console features. +- Retain a generic client-side application framework or a Node build pipeline. +- Edit or archive the pending `web-console` change as part of this change. + +## Decisions + +### D1: Add a Runtime page router in `api/console_web.py` + +`api/console_web.py` will expose `create_console_web_router()` with the `/ui` +prefix. `api/rest.py::create_app()` will build the existing Runtime stores and +manager once, construct the console service once, and include both the JSON +router and the page router in the same FastAPI application. `GET /` will +always redirect to `/ui/` because the console is no longer optional build +output. + +The page surface will use stable server routes: + +- `GET /ui/` for the device/status dashboard; +- `GET /ui/tasks` for task filtering and browsing; +- `GET /ui/tasks/{task_id}` for one task and its ordered timeline; +- `GET /ui/config` for device registration/removal and `max_steps`; +- POST routes below `/ui/` for device registration, device removal, and + configuration updates. + +Successful POST handlers use `303 See Other` redirects. Validation failures +return the originating page with an HTML `400` response and preserved safe +form values. This is preferred over trying to preserve the SPA's client-side +state machine because normal browser navigation and forms are sufficient for +the console's low-frequency operational actions. + +An alternative of replacing `/console/*` JSON endpoints with HTML endpoints +was rejected: the JSON API has explicit tests and remains useful to scripts +and future clients. An alternative of a separate FastAPI app was rejected: +it would duplicate the `DeviceManager`, stores, and `TaskRunner` composition. + +### D2: Share API-local console operations rather than issuing loopback HTTP +requests + +`api/console.py` will gain a small API-local `ConsoleService` (or equivalent +typed operation object) that owns the current console reads and mutations: +device listing/registration/removal, task listing/detail/timeline lookup, and +runtime configuration reads/updates. The existing JSON router and the new +page router receive the same service instance. + +The service stays in `api` and delegates to the existing injected +`DeviceManager`, `TaskMetadataStore`, `Timeline`, `DeviceConfigStore`, and +`TaskRunner`. Page handlers MUST NOT make HTTP requests to the process's own +`/console/*` endpoints. This keeps validation, driver allow-listing, +persistence, error mapping, and state mutation single-sourced without moving +web concepts into a lower layer. + +Duplicating the route bodies was rejected because future changes could make +JSON and form behavior diverge. Moving the service into `runtime` or +`storage` was rejected because it would introduce HTTP/UI-oriented +application concerns below the API adapter boundary. + +### D3: Use a module-level Jinja2 environment and package-owned assets + +`api/console_web.py` will construct one module-level Jinja2 `Environment` +using `FileSystemLoader` rooted at `api/templates/runtime_console/` and +`select_autoescape(["html", "xml"])`. Route handlers will render with +`get_template(...).render(...)` and return `HTMLResponse`; they will not +build HTML through f-strings, string concatenation, or post-process rendered +HTML. + +Templates will include a shared `base.html`, dashboard, task list, task +detail, configuration page, and any small server-rendered dashboard fragment. +CSS and the narrow polling script will be regular package assets mounted under +`/ui/assets`, not Vite output. The root package will explicitly include the +template, CSS, and JavaScript globs in setuptools package data so an installed +wheel behaves like an editable checkout. + +`jinja2>=3.1` and `python-multipart` will be direct root dependencies. The +latter is declared directly even though it is currently available through an +unrelated transitive dependency, because `Request.form()` is part of this +feature's runtime contract. + +`starlette.templating.Jinja2Templates` was rejected because direct Jinja2 +rendering is explicit, matches the existing Host Agent convention, and avoids +coupling the implementation to helper signature changes. Inline all-CSS HTML +was rejected because a dedicated static stylesheet keeps base templates +readable without restoring a frontend build system. + +### D4: Retain server-owned live status with a narrow rendered fragment + +The dashboard will keep the current approximately 10-second live-status +refresh through a small static browser script. It fetches an HTML fragment +rendered by the same Jinja2 environment and replaces only the dashboard's +live-status region. The browser does not fetch JSON and recreate console DOM +state; all stateful markup still originates on the server. + +Task filters, task selection, timeline step selection, device mutations, and +configuration updates use normal GET/POST navigation. This retains useful +status freshness without reintroducing a SPA framework or making a full-page +reload the only refresh mechanism. + +A zero-JavaScript periodic refresh was rejected because it would require full +page reloads and regress the existing dashboard behavior. Reusing the Vue +reactivity layer was rejected because it retains the independent build and +deployment boundary this change removes. + +### D5: Preserve JSON compatibility while retiring SPA deployment wiring + +The `/console/*` paths, methods, success payloads, error status codes, and +persistence semantics remain unchanged. Existing `tests/test_console_api.py` +continues to be the compatibility baseline. `api/rest.py` removes +`RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, and its SPA-only fallback. +The broad CORS middleware added only for cross-origin Vite development is +removed; same-origin `/ui/` must not depend on it. + +The top-level `console/` directory, Vite environment files, package lockfile, +and npm instructions are removed. Root documentation changes to a single +Runtime startup command followed by `/ui/`. The unrelated `cloud-console/` +SPA and its deployment remain unchanged. + +Keeping `RUNTIME_CONSOLE_STATIC_DIR` as a deprecated fallback was rejected: +it would preserve an unsupported second rendering path and force every future +console change to be tested twice. A backward-compatible redirect from the +old external Vite development port is impossible because it is a separate +process, so the operator migration is documented as a breaking change. + +### D6: Apply autoescaping uniformly and retain the trusted-network boundary + +All interpolated device names, IDs, connection values, task goals, failure +reasons, timeline text, JSON-like tool/result data, and errors pass through +the shared autoescaping environment. Structured values use Jinja's `tojson` +filter only in safe text contexts; no current template uses `|safe` or a +global autoescape opt-out. Screenshot bytes remain data sourced from the +existing timeline and are rendered only as the established PNG data URI. + +Removing wildcard CORS reduces the old SPA development surface but does not +provide authentication and does not by itself prevent cross-site HTML form +submission. The Runtime console therefore remains documented as +trusted-network-only. Authentication/session/CSRF design is intentionally +separate, so this migration does not create a misleading partial security +model. + +## Risks / Trade-offs + +- [A task, device name, or JSON value could carry HTML/script content] -> The + shared autoescaping environment, `tojson` for structured output, no safe + bypasses, and XSS regression tests make the protection mechanical. +- [Templates or assets work from a checkout but not an installed wheel] -> + Explicit setuptools package-data rules and a built-wheel smoke test verify + deployment behavior. +- [Removing the npm/Vite workflow disrupts an operator's existing runbook] -> + Mark the removal as breaking, update all Runtime console documentation, and + retain a Git-revert rollback path with no data migration. +- [Removing CORS breaks an undiscovered browser client] -> The existing CORS + configuration was introduced for the deleted Vite development flow; JSON + clients outside a browser remain unaffected. A future browser integration + must add an explicit origin policy rather than restore a wildcard. +- [Base64 screenshots make large task-detail responses expensive] -> Preserve + the existing bounded-task behavior and do not change timeline storage or + transfer format in this rendering migration. +- [The pending `web-console` change still contains a SPA decision and one + manual task] -> Record this change as the rendering-mechanism successor; + reconcile the older change only after the new server-rendered browser + workflow has been manually verified. + +## Migration Plan + +1. Add direct dependencies and package-data declarations, then create the + Jinja2 environment, templates, static assets, API-local console service, + and `/ui/` page routes while preserving existing JSON-route tests. +2. Add focused template, page-route, mutation/PRG, fragment-refresh, and XSS + tests. Build and install the Runtime wheel in an isolated environment to + verify package resources are present. +3. Remove `console/`, SPA static mounting, the static-directory environment + variable, Vite-specific CORS, and obsolete ignore/configuration files. +4. Update root Runtime and macOS setup documentation to direct operators to + `/ui/`, and document the removed npm/static-directory workflow. +5. Run formatting, linting, non-integration Runtime tests, wheel build/smoke + checks, strict OpenSpec validation, and a browser walkthrough covering + dashboard refresh, task replay, device mutation, and `max_steps` update. + +Rollback is a source revert. It restores the Vue sources and static mount if +needed and does not alter device configuration, task metadata, or timeline +data, so no data rollback or schema migration is required. + +## Open Questions + +- The remaining manual verification task in the pending `web-console` change + must be reconciled before that older change is archived. It is not safe to + mark it complete solely because this proposal exists; the new `/ui/` + browser walkthrough supplies the replacement evidence after implementation. +- Authentication and CSRF protection remain deliberately deferred. Any plan to + expose the Runtime console beyond a trusted local network requires a + separate threat model and change proposal. diff --git a/openspec/changes/runtime-console-jinja2-templates/proposal.md b/openspec/changes/runtime-console-jinja2-templates/proposal.md new file mode 100644 index 0000000..0fa75ad --- /dev/null +++ b/openspec/changes/runtime-console-jinja2-templates/proposal.md @@ -0,0 +1,60 @@ +## Why + +The local Runtime console is currently a separately built Vue/Vite SPA. It +requires a Node toolchain for development and an optional static-directory +configuration for same-process serving, even though its data and mutations +already live in the Runtime FastAPI process. Rendering the console with Jinja2 +will make the operator surface deploy with the Runtime itself while preserving +the existing REST contract for programmatic clients. + +## What Changes + +- Add a same-origin, server-rendered Runtime console under `/ui/`, with Jinja2 + pages for device status, task browsing/detail/timeline replay, device + registration/removal, and runtime configuration. +- Keep the existing `/console/*` JSON endpoints and make page handlers and + JSON handlers share API-layer console operations so their observable + registration, deletion, filtering, and configuration semantics cannot + drift. +- Package console templates and static assets with `device-agent-runtime`, add + direct Jinja2 and HTML form-parsing dependencies, and render every HTML page + through one autoescaping template environment. +- Replace the Vue/Vite `console/` project, `RUNTIME_CONSOLE_STATIC_DIR`, and + SPA fallback static mount with Runtime-owned templates and normal static + assets. Remove the permissive CORS configuration that existed only for + cross-origin Vite development. +- **BREAKING**: the independent `console/` npm workflow and + `RUNTIME_CONSOLE_STATIC_DIR` deployment mode are removed. Operators will + start the Runtime API normally and open `/ui/`; JSON API paths remain + unchanged. + +## Capabilities + +### New Capabilities + +- `runtime-console-template-rendering`: Same-origin Jinja2-rendered Runtime + console pages, automatic HTML escaping, form-based mutations, and packaged + Runtime-owned web assets. + +### Modified Capabilities + +- None. The existing canonical specs do not define the pending `web-console` + SPA, and the `/console/*` JSON API contract remains unchanged. + +## Impact + +- Affected code: `api/rest.py`, `api/console.py`, a new API-layer page router, + Runtime template/static asset directories, root `pyproject.toml`, and + console-focused tests. +- Removed code/assets: top-level `console/` Vue/Vite sources, Node lockfile, + Vite environment configuration, and SPA deployment wiring. +- Documentation: Runtime startup and console guidance in `README.md`, + `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` change to describe + the built-in `/ui/` console. +- Security boundary: this change preserves the existing trusted-network, + unauthenticated Runtime console assumption. It does not add authentication, + authorization, or session/CSRF protection; non-trusted exposure needs a + separate security change. +- Architecture: all new HTTP, HTML, and template concerns remain in the outer + `api` layer. No `core`, `driver`, `device`, `tools`, `perception`, or + `runtime` package gains web-framework dependencies. diff --git a/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md b/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md new file mode 100644 index 0000000..45bd757 --- /dev/null +++ b/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Runtime console pages SHALL be served by the Runtime FastAPI application +The Runtime FastAPI application SHALL serve its operator console from +same-origin `/ui/` routes without requiring a separately running frontend +process, a prebuilt SPA directory, or `RUNTIME_CONSOLE_STATIC_DIR`. `GET /` +SHALL redirect an operator to `/ui/`. + +#### Scenario: Open the built-in console without static-directory configuration +- **WHEN** the Runtime application starts without `RUNTIME_CONSOLE_STATIC_DIR` +- **THEN** `GET /` redirects to `/ui/` and `GET /ui/` returns an HTML + dashboard rendered by the Runtime process + +#### Scenario: Navigate the operator workflows through page routes +- **WHEN** an operator opens `/ui/tasks`, `/ui/tasks/{task_id}`, or + `/ui/config` +- **THEN** the Runtime returns HTML pages for task browsing, task timeline + detail, and device/runtime configuration respectively + +### Requirement: Runtime console HTML SHALL use one autoescaping template environment +Every Runtime console HTML response SHALL be rendered through one +process-wide Jinja2 environment configured to autoescape `.html` and `.xml` +templates. Page handlers SHALL NOT construct HTML through f-strings, string +concatenation, or post-process rendered output to bypass that environment. + +#### Scenario: Untrusted device and task values are rendered safely +- **WHEN** a device name, task goal, failure reason, or timeline value contains + `` +- **THEN** the rendered console HTML contains an escaped text representation + and contains no script element originating from that value + +#### Scenario: A future page inherits HTML autoescaping +- **WHEN** a future Runtime console route renders a `.html` template through + the shared environment +- **THEN** its interpolated values are HTML-escaped without route-specific + escaping configuration + +### Requirement: Runtime console pages SHALL preserve console operational workflows +The server-rendered console SHALL let an operator inspect device status, list +and filter tasks, inspect a task's ordered timeline including available +screenshots, register or remove a supported device, and view or update +`max_steps`. Successful configuration mutations SHALL use POST/Redirect/GET; +invalid form input SHALL be re-rendered as a readable HTML error without +applying a partial mutation. + +#### Scenario: Browse filtered tasks and inspect a timeline +- **WHEN** an operator selects a device or status filter and opens a known + task +- **THEN** the task list contains only matching tasks and the task page renders + its timeline in step order with its screenshot when one exists + +#### Scenario: Register a device from the configuration page +- **WHEN** an operator submits valid supported-device form values +- **THEN** the Runtime registers and persists the device, responds with a + redirect to the configuration page, and the device is visible after the + redirect + +#### Scenario: Reject invalid configuration without partial mutation +- **WHEN** an operator submits an unsupported driver type, malformed + connection value, or non-positive `max_steps` +- **THEN** the Runtime returns an HTML validation error, preserves the prior + Runtime/configuration state, and does not perform a redirect + +### Requirement: Dashboard live status SHALL remain server-rendered +The dashboard SHALL retain periodic live-status refresh without restoring a +client-side application framework. Its browser enhancement SHALL request a +server-rendered HTML fragment and replace only the live-status region; it +SHALL NOT rebuild console state from a JSON API response. + +#### Scenario: Refresh dashboard status after the polling interval +- **WHEN** the dashboard refresh enhancement runs while the Runtime is + available +- **THEN** it retrieves a Jinja2-rendered status fragment and updates the + dashboard's live-status region without a full-page reload + +### Requirement: Existing console JSON API SHALL remain compatible +The Runtime SHALL continue to expose the existing `/console/devices`, +`/console/tasks`, `/console/tasks/{task_id}`, `/console/tasks/{task_id}/timeline`, +and `/console/config` JSON endpoints with their existing methods, status +codes, payloads, filtering behavior, and persistence semantics. Page and JSON +routes SHALL use the same API-local console operations rather than issuing +HTTP requests to each other. + +#### Scenario: Programmatic client reads console data after the UI migration +- **WHEN** a client calls an existing `GET /console/*` endpoint after the + server-rendered console is deployed +- **THEN** it receives the same JSON response shape and status behavior as + before the migration + +#### Scenario: A page mutation is visible through the JSON API +- **WHEN** an operator registers or removes a device or updates `max_steps` + through a `/ui/` form +- **THEN** the corresponding `/console/*` JSON endpoint reports the same + resulting Runtime state + +### Requirement: Runtime console templates and assets SHALL ship with the Python package +The Runtime distribution SHALL package all console templates and static assets +needed by `/ui/`. The Runtime console SHALL not depend on the top-level Vue/Vite +`console/` project, Node package installation, Vite configuration, or the +SPA-only static-directory mount. Same-origin console operation SHALL not +require wildcard CORS configured for Vite development. + +#### Scenario: Run the console from an installed Runtime wheel +- **WHEN** `device-agent-runtime` is built and installed outside the source + checkout +- **THEN** the Runtime can serve `/ui/` and its required CSS/browser assets + from packaged resources + +#### Scenario: Start a Runtime after the SPA workflow is removed +- **WHEN** an operator starts the Runtime API using the documented Python + command +- **THEN** the console is available at `/ui/` without `npm install`, + `npm run build`, `VITE_API_BASE_URL`, or `RUNTIME_CONSOLE_STATIC_DIR` diff --git a/openspec/changes/runtime-console-jinja2-templates/tasks.md b/openspec/changes/runtime-console-jinja2-templates/tasks.md new file mode 100644 index 0000000..f3244ef --- /dev/null +++ b/openspec/changes/runtime-console-jinja2-templates/tasks.md @@ -0,0 +1,42 @@ +## 1. Runtime package and web-resource setup + +- [x] 1.1 Add direct `jinja2>=3.1` and `python-multipart` Runtime dependencies, configure setuptools package-data for Runtime console templates/CSS/JavaScript, regenerate `uv.lock`, and verify `uv lock --check`. +- [x] 1.2 Create the Runtime-owned template and static-asset layout under `api/` for the `/ui/` console, with names suitable for wheel packaging and standard static-file serving. +- [x] 1.3 Add a package-resource smoke test that builds and installs `device-agent-runtime` outside the source checkout and confirms `/ui/` can find its templates and assets. + +## 2. Shared API-layer console operations + +- [x] 2.1 Refactor `api/console.py` to expose an API-local typed console service for device, task, timeline, and runtime-config reads and mutations, preserving current validation and error semantics. +- [x] 2.2 Rewire `create_console_router()` to use the shared service and retain every existing `/console/*` method, JSON response shape, status code, filtering rule, and persistence behavior. +- [x] 2.3 Update `api/rest.py` to construct one shared service from its existing injected Runtime state and pass it to both JSON and HTML console routers without changing lower-layer dependencies. + +## 3. Jinja2 Runtime console routes and pages + +- [x] 3.1 Implement `api/console_web.py` with one module-level Jinja2 `Environment`, `FileSystemLoader`, `select_autoescape(["html", "xml"])`, and a small `HTMLResponse` render helper. +- [x] 3.2 Mount same-origin `/ui/` page routes and `/ui/assets` static resources; make `GET /` redirect to `/ui/` unconditionally. +- [x] 3.3 Implement the shared base layout, dashboard, and Jinja-rendered live-status fragment, including the small polling enhancement that replaces only the live region. +- [x] 3.4 Implement server-rendered task list/filter and task-detail/timeline pages, including ordered records, safe structured tool/result output, and available screenshot data URIs. +- [x] 3.5 Implement the configuration page and POST/Redirect/GET device registration/removal and `max_steps` update handlers, with readable `400` HTML validation errors that retain submitted safe values and perform no partial mutation. +- [x] 3.6 Port the existing console visual layout to package-owned CSS and the narrow polling script without adding a JavaScript framework or a frontend build step. + +## 4. Retire SPA deployment wiring and update documentation + +- [x] 4.1 Remove `RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, the SPA fallback, and the wildcard CORS middleware used only for Vite development from `api/rest.py`. +- [x] 4.2 Delete the top-level `console/` Vue/Vite project and remove its obsolete environment, npm, and ignore-file references while leaving `cloud-console/` untouched. +- [x] 4.3 Update `README.md`, `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` to document normal Runtime startup followed by `/ui/`, the breaking removal of the npm/static-directory workflow, and the trusted-network-only security boundary. +- [x] 4.4 Remove stale Docker ignore/configuration entries that only described the deleted Runtime SPA, without changing Cloud Console build or deployment behavior. + +## 5. Automated verification + +- [x] 5.1 Keep and extend console JSON API tests to prove `/console/*` compatibility before and after page-route mutations. +- [x] 5.2 Add template tests covering every Runtime console template, module-level autoescape configuration, XSS probes in device/task/timeline/configuration values, safe structured JSON output, and absence of unintended `|safe` bypasses. +- [x] 5.3 Add FastAPI `TestClient` coverage for root redirect, page navigation, empty/populated dashboard state, task filters/detail/timeline, dashboard fragment refresh, static assets, form PRG success paths, and invalid-form no-mutation paths. +- [x] 5.4 Add regression coverage that same-origin `/ui/` works without `RUNTIME_CONSOLE_STATIC_DIR` and does not depend on the removed wildcard CORS middleware. + +## 6. Validation and migration handoff + +- [x] 6.1 Run targeted formatting, lint, compile, and Runtime console tests, then run the repository non-integration test suite; record any pre-existing failures separately from this change. +- [x] 6.2 Build the Runtime wheel and run the isolated package-resource smoke test after the final dependency lock update. +- [x] 6.3 Run `openspec validate runtime-console-jinja2-templates --strict` and resolve all validation failures. +- [ ] 6.4 Perform a browser walkthrough of dashboard live refresh, task filtering/timeline replay, valid and invalid device configuration, device removal, and `max_steps` update using a real Runtime process. +- [ ] 6.5 After human browser verification, reconcile the remaining manual verification and superseded SPA decision in the pending `web-console` change before any archive decision; do not mark it complete from automated evidence alone. diff --git a/pyproject.toml b/pyproject.toml index 6ec6dda..8a3b481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,12 @@ dependencies = [ "Appium-Python-Client>=5.1.1", "fastapi>=0.115.0", "httpx>=0.27.0", + "jinja2>=3.1", "mcp>=1.27,<2", "openai>=1.0.0", "paddlepaddle>=3.0.0", "paddleocr>=3.0.0", + "python-multipart>=0.0.20", "uvicorn[standard]>=0.30.0", ] @@ -53,6 +55,13 @@ include = [ "workflow*", ] +[tool.setuptools.package-data] +api = [ + "templates/runtime_console/*.html", + "static/runtime_console/*.css", + "static/runtime_console/*.js", +] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra --import-mode=importlib" diff --git a/tests/test_console_api.py b/tests/test_console_api.py index 9906de0..1ba7bff 100644 --- a/tests/test_console_api.py +++ b/tests/test_console_api.py @@ -80,17 +80,18 @@ def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) -> "task-old", ] assert [ - task["id"] - for task in client.get("/console/tasks?device_id=iphone-1").json() + task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json() ] == ["task-old"] - assert [task["id"] for task in client.get("/console/tasks?status=running").json()] == [ - "task-new" - ] + assert [ + task["id"] for task in client.get("/console/tasks?status=running").json() + ] == ["task-new"] assert client.get("/console/tasks/task-old").json()["goal"] == "open settings" assert client.get("/console/tasks/missing").status_code == 404 -def test_console_timeline_inlines_screenshot_and_handles_empty_history(tmp_path) -> None: +def test_console_timeline_inlines_screenshot_and_handles_empty_history( + tmp_path, +) -> None: timeline = Timeline(ArtifactStore(tmp_path / "history")) client, metadata_store = _client(tmp_path, timeline=timeline) task = Task(id="task-1", goal="tap search", device_id="iphone-1") @@ -193,3 +194,27 @@ def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> Non assert runner.config.max_steps == 31 assert [device.id for device in manager.list_devices()] == ["persisted-1"] assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone" + + +def test_console_json_reflects_page_form_mutations(tmp_path) -> None: + """A device registered via the /ui/ form must be visible through /console/* JSON.""" + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + client, _ = _client(tmp_path, config_store=config_store) + + response = client.post( + "/ui/config/devices", + data={ + "name": "From Form", + "driver_type": "wda", + "server_url": "http://127.0.0.1:4723", + "udid": "form-udid", + "wda_local_port": "8100", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + + json_devices = client.get("/console/devices").json() + assert len(json_devices) == 1 + assert json_devices[0]["name"] == "From Form" + assert json_devices[0]["connection_info"]["udid"] == "form-udid" diff --git a/tests/test_runtime_console_packaging.py b/tests/test_runtime_console_packaging.py new file mode 100644 index 0000000..bfc5389 --- /dev/null +++ b/tests/test_runtime_console_packaging.py @@ -0,0 +1,79 @@ +"""Smoke test that console templates and assets ship inside the Runtime wheel. + +Builds ``device-agent-runtime`` into a temporary directory, installs it into an +isolated venv that cannot reach the source checkout, and asserts the packaged +``api`` package carries the Jinja2 templates and static assets needed by +``/ui/``. This guards against setuptools package-data regressions that would +let the console work from an editable checkout but break from a real install. +""" + +from __future__ import annotations + +import subprocess +import venv +from pathlib import Path + +import pytest + + +def _run(cmd: list[str], *, cwd: Path | None = None) -> str: + return subprocess.check_output( + cmd, + cwd=cwd, + stderr=subprocess.STDOUT, + text=True, + ) + + +@pytest.mark.integration +def test_runtime_wheel_packages_console_templates_and_assets(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parent.parent + + wheel_dir = tmp_path / "wheels" + wheel_dir.mkdir() + _run( + ["uv", "build", "--package", "device-agent-runtime", "--wheel", "--no-sources"], + cwd=repo_root, + ) + wheels = list(repo_root.glob("dist/*.whl")) + assert wheels, "uv build did not produce a wheel" + wheel_path = wheels[0] + + venv_dir = tmp_path / "venv" + venv.create(venv_dir, with_pip=True, clear=True) + pip = str(venv_dir / "Scripts" / "pip.exe") + if not Path(pip).exists(): + pip = str(venv_dir / "bin" / "pip") + _run([pip, "install", str(wheel_path)], cwd=tmp_path) + + python = str(venv_dir / "Scripts" / "python.exe") + if not Path(python).exists(): + python = str(venv_dir / "bin" / "python") + + probe = _run( + [ + python, + "-c", + ( + "from importlib.resources import files; " + "api_root = files('api'); " + "templates = sorted(p.name for p in " + "(api_root / 'templates' / 'runtime_console').iterdir()); " + "assets = sorted(p.name for p in " + "(api_root / 'static' / 'runtime_console').iterdir()); " + "print(','.join(templates)); " + "print(','.join(assets))" + ), + ], + cwd=tmp_path, + ) + template_names, asset_names = probe.strip().splitlines() + assert "base.html" in template_names + assert "dashboard.html" in template_names + assert "config.html" in template_names + assert "console.css" in asset_names + assert "dashboard.js" in asset_names + + # Clean up the build artifact so it does not leak into the working tree. + for wheel in wheels: + wheel.unlink() diff --git a/tests/test_runtime_console_web.py b/tests/test_runtime_console_web.py new file mode 100644 index 0000000..1a18869 --- /dev/null +++ b/tests/test_runtime_console_web.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from core.models import Task +from device.manager import DeviceManager +from runtime.task import TaskRunner, TaskRunnerConfig +from storage.artifact_store import ArtifactStore +from storage.device_config import DeviceConfigStore +from storage.task_metadata import TaskMetadataStore +from storage.timeline import Timeline +from tests.fakes import PNG_10X20, FakeDriver + + +def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None): + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from api.rest import create_app + + metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3") + app = create_app( + manager=manager or DeviceManager(), + metadata_store=metadata_store, + task_runner=runner, + device_config_store=config_store + or DeviceConfigStore(tmp_path / "device_config.sqlite3"), + timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")), + ) + return TestClient(app), metadata_store + + +_TEMPLATE_NAMES = [ + "base.html", + "dashboard.html", + "_status_fragment.html", + "tasks.html", + "task_detail.html", + "config.html", +] + + +# -- 5.2 Template tests ------------------------------------------------------ + + +def test_module_jinja_environment_autoescapes_html_and_xml() -> None: + from api.console_web import _ENV + + # select_autoescape(["html", "xml"]) returns a callable used by Jinja2. + assert callable(_ENV.autoescape) + assert _ENV.autoescape("foo.html") is True + assert _ENV.autoescape("foo.xml") is True + + +def test_every_console_template_is_known_and_loadable() -> None: + from api.console_web import _ENV + + for name in _TEMPLATE_NAMES: + assert _ENV.get_template(name) is not None + + +def test_no_template_uses_safe_filter_bypass() -> None: + template_dir = ( + Path(__file__).resolve().parent.parent / "api" / "templates" / "runtime_console" + ) + for path in template_dir.glob("*.html"): + source = path.read_text(encoding="utf-8") + assert "| safe" not in source, f"{path.name} uses |safe bypass" + assert "|safe" not in source, f"{path.name} uses |safe bypass" + + +def test_dashboard_escapes_untrusted_device_name(tmp_path) -> None: + manager = DeviceManager() + manager.register_device( + "dev-xss", + lambda: FakeDriver(), + name="", + driver_type="wda", + ) + client, _ = _client(tmp_path, manager=manager) + + body = client.get("/ui/").text + assert "<script>" in body + assert "" not in body + + +def test_tasks_list_escapes_untrusted_goal(tmp_path) -> None: + client, metadata_store = _client(tmp_path) + metadata_store.create_task( + Task( + id="task-xss", + goal="", + device_id="dev-1", + ) + ) + body = client.get("/ui/tasks").text + assert "<script>" in body + assert "" not in body + + +def test_task_detail_escapes_failure_reason_and_structured_output(tmp_path) -> None: + timeline = Timeline(ArtifactStore(tmp_path / "history")) + client, metadata_store = _client(tmp_path, timeline=timeline) + metadata_store.create_task( + Task( + id="task-detail", + goal="do thing", + device_id="dev-1", + failure_reason="", + ) + ) + timeline.append( + task_id="task-detail", + scene={"screen": {"width": 10, "height": 20}, "elements": []}, + prompt="do thing", + tool_call={"action": ""}, + screenshot=PNG_10X20, + ) + body = client.get("/ui/tasks/task-detail").text + assert "" not in body + assert "", + "driver_type": "bad-driver", + }, + ) + assert response.status_code == 400 + assert "" not in response.text + assert "<script>" in response.text + + +# -- 5.3 TestClient page-route coverage -------------------------------------- + + +def test_root_redirects_to_ui(tmp_path) -> None: + client, _ = _client(tmp_path) + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/ui/" + + +def test_dashboard_serves_html_with_empty_state(tmp_path) -> None: + client, _ = _client(tmp_path) + response = client.get("/ui/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "No devices registered" in response.text + + +def test_dashboard_shows_populated_metrics_and_devices(tmp_path) -> None: + manager = DeviceManager() + manager.register_device( + "iphone-1", + lambda: FakeDriver(), + name="Desk iPhone", + driver_type="wda", + ) + client, metadata_store = _client(tmp_path, manager=manager) + metadata_store.create_task( + Task(id="t-running", goal="run", device_id="iphone-1", status="running") + ) + metadata_store.create_task( + Task(id="t-failed", goal="fail", device_id="iphone-1", status="failed") + ) + body = client.get("/ui/").text + assert "Desk iPhone" in body + assert "iphone-1" in body + + +def test_status_fragment_endpoint_returns_html_partial(tmp_path) -> None: + client, _ = _client(tmp_path) + response = client.get("/ui/_status_fragment") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Device Status" in response.text + + +def test_tasks_page_supports_device_and_status_filters(tmp_path) -> None: + manager = DeviceManager() + manager.register_device( + "iphone-1", + lambda: FakeDriver(), + name="Desk", + driver_type="wda", + ) + client, metadata_store = _client(tmp_path, manager=manager) + older = Task( + id="task-old", + goal="open settings", + device_id="iphone-1", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + updated_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + newer = Task( + id="task-new", + goal="search", + device_id="iphone-2", + status="running", + created_at=datetime(2026, 1, 2, tzinfo=UTC), + updated_at=datetime(2026, 1, 2, tzinfo=UTC), + ) + metadata_store.create_task(older) + metadata_store.create_task(newer) + + body_all = client.get("/ui/tasks").text + assert "task-old" in body_all + assert "task-new" in body_all + + body_filtered = client.get("/ui/tasks?device_id=iphone-1").text + assert "task-old" in body_filtered + assert "task-new" not in body_filtered + + body_status = client.get("/ui/tasks?status=running").text + assert "task-new" in body_status + assert "task-old" not in body_status + + +def test_task_detail_renders_timeline_with_screenshot(tmp_path) -> None: + timeline = Timeline(ArtifactStore(tmp_path / "history")) + client, metadata_store = _client(tmp_path, timeline=timeline) + metadata_store.create_task( + Task(id="task-with-timeline", goal="tap search", device_id="iphone-1") + ) + timeline.append( + task_id="task-with-timeline", + scene={"screen": {"width": 10, "height": 20}, "elements": []}, + prompt="tap search", + tool_call={"action": "tap", "args": {"x": 1, "y": 2}}, + result={"ok": True}, + screenshot=PNG_10X20, + ) + body = client.get("/ui/tasks/task-with-timeline").text + expected_data_uri = "data:image/png;base64," + base64.b64encode(PNG_10X20).decode( + "ascii" + ) + assert expected_data_uri in body + assert "tap" in body + + +def test_task_detail_404_for_unknown_task(tmp_path) -> None: + client, _ = _client(tmp_path) + response = client.get("/ui/tasks/does-not-exist") + assert response.status_code == 404 + + +def test_config_page_lists_supported_drivers_and_current_max_steps(tmp_path) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + config_store.set_setting("max_steps", 5) + runner = TaskRunner(config=TaskRunnerConfig(max_steps=1)) + client, _ = _client(tmp_path, runner=runner, config_store=config_store) + body = client.get("/ui/config").text + assert "wda" in body + assert 'value="5"' in body + + +def test_register_device_prg_redirects_and_persists(tmp_path) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + client, _ = _client(tmp_path, config_store=config_store) + response = client.post( + "/ui/config/devices", + data={ + "name": "Desk iPhone", + "driver_type": "wda", + "server_url": "http://127.0.0.1:4723", + "udid": "abc123", + "wda_local_port": "8100", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"].endswith("/ui/config") + assert len(config_store.list()) == 1 + + +def test_register_device_rejects_bad_driver_without_partial_mutation(tmp_path) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + client, _ = _client(tmp_path, config_store=config_store) + response = client.post( + "/ui/config/devices", + data={"name": "Bad", "driver_type": "android"}, + ) + assert response.status_code == 400 + assert config_store.list() == [] + assert "unsupported driver_type" in response.text + + +def test_register_device_rejects_non_numeric_port_without_partial_mutation( + tmp_path, +) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + client, _ = _client(tmp_path, config_store=config_store) + response = client.post( + "/ui/config/devices", + data={ + "name": "Bad Port", + "driver_type": "wda", + "wda_local_port": "not-a-number", + }, + ) + assert response.status_code == 400 + assert config_store.list() == [] + assert "wda_local_port must be a number" in response.text + + +def test_remove_device_prg_redirects_and_removes(tmp_path) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + config_store.add( + device_id="removable-1", + name="To Remove", + driver_type="wda", + connection_info={"udid": "abc"}, + ) + client, _ = _client(tmp_path, config_store=config_store) + response = client.post( + "/ui/config/devices/removable-1/delete", + follow_redirects=False, + ) + assert response.status_code == 303 + assert config_store.list() == [] + + +def test_update_max_steps_prg_redirects_and_applies(tmp_path) -> None: + runner = TaskRunner(config=TaskRunnerConfig(max_steps=1)) + client, _ = _client(tmp_path, runner=runner) + response = client.post( + "/ui/config/max-steps", + data={"max_steps": "25"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert runner.config.max_steps == 25 + + +def test_update_max_steps_rejects_non_positive_without_partial_mutation( + tmp_path, +) -> None: + config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + config_store.set_setting("max_steps", 10) + runner = TaskRunner(config=TaskRunnerConfig(max_steps=1)) + client, _ = _client(tmp_path, runner=runner, config_store=config_store) + response = client.post("/ui/config/max-steps", data={"max_steps": "0"}) + assert response.status_code == 400 + assert runner.config.max_steps == 10 + assert "max_steps must be positive" in response.text + + +def test_update_max_steps_rejects_non_integer(tmp_path) -> None: + client, _ = _client(tmp_path) + response = client.post("/ui/config/max-steps", data={"max_steps": "abc"}) + assert response.status_code == 400 + assert "max_steps must be an integer" in response.text + + +def test_static_assets_are_served(tmp_path) -> None: + client, _ = _client(tmp_path) + assert client.get("/ui/assets/console.css").status_code == 200 + assert client.get("/ui/assets/dashboard.js").status_code == 200 + + +# -- 5.4 Regression: no SPA static dir, no wildcard CORS --------------------- + + +def test_ui_works_without_runtime_console_static_dir(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("RUNTIME_CONSOLE_STATIC_DIR", raising=False) + client, _ = _client(tmp_path) + assert client.get("/ui/").status_code == 200 + + +def test_runtime_app_does_not_register_wildcard_cors(tmp_path) -> None: + client, _ = _client(tmp_path) + # A same-origin browser client must not require CORS preflight. If wildcard + # CORS were still registered, an explicit Origin header would produce + # access-control-allow-origin in the response; assert it is absent for an + # arbitrary same-origin page request. + response = client.get( + "/ui/", + headers={"Origin": "http://127.0.0.1:8000"}, + ) + assert response.status_code == 200 + assert "access-control-allow-origin" not in {k.lower() for k in response.headers} diff --git a/uv.lock b/uv.lock index dec0273..02752d0 100644 --- a/uv.lock +++ b/uv.lock @@ -403,10 +403,12 @@ dependencies = [ { name = "appium-python-client" }, { name = "fastapi" }, { name = "httpx" }, + { name = "jinja2" }, { name = "mcp" }, { name = "openai" }, { name = "paddleocr" }, { name = "paddlepaddle" }, + { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -421,10 +423,12 @@ requires-dist = [ { name = "appium-python-client", specifier = ">=5.1.1" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "jinja2", specifier = ">=3.1" }, { name = "mcp", specifier = ">=1.27,<2" }, { name = "openai", specifier = ">=1.0.0" }, { name = "paddleocr", specifier = ">=3.0.0" }, { name = "paddlepaddle", specifier = ">=3.0.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ]