Compare commits
6
Commits
52e442790a
...
fd0ea3a066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0ea3a066 | ||
|
|
8300c3b6b7 | ||
|
|
e00c50e703 | ||
|
|
0d944ec97d | ||
|
|
a8ba2312fc | ||
|
|
cbfdb2ae39 |
@@ -7,6 +7,5 @@ __pycache__
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
cloud-console/node_modules
|
||||
cloud-console/dist
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+151
-82
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
+17
-56
@@ -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
|
||||
|
||||
|
||||
+106
-1
@@ -21,6 +21,7 @@ from skills_learning.models import (
|
||||
KnowledgeSkill,
|
||||
Skill,
|
||||
)
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -144,6 +145,100 @@ def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
|
||||
)
|
||||
|
||||
|
||||
class CloudApiSkillClient:
|
||||
"""Concrete client for this project's Cloud API per-host skill sync endpoint.
|
||||
|
||||
The ``subscription_id`` passed to :meth:`fetch_entitled_skills` is the
|
||||
agent's host identifier; the endpoint is
|
||||
``GET /internal/v1/hosts/{host_id}/skills/sync`` authenticated with the
|
||||
same host-scoped bearer used for heartbeat/planner-decision.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
host_token: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.host_token = host_token
|
||||
self.timeout = timeout
|
||||
self._client = client
|
||||
|
||||
def fetch_entitled_skills(
|
||||
self,
|
||||
subscription_id: str,
|
||||
since_version: int | None = None,
|
||||
) -> SyncDelta:
|
||||
url = f"{self.base_url}/internal/v1/hosts/{subscription_id}/skills/sync"
|
||||
params: dict[str, Any] = {}
|
||||
if since_version is not None:
|
||||
params["since_version"] = str(since_version)
|
||||
response = self._ensure_client().get(
|
||||
url,
|
||||
params=params or None,
|
||||
headers={"Authorization": f"Bearer {self.host_token}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_cloud_sync_payload(response.json())
|
||||
|
||||
def report_inventory(
|
||||
self, host_id: str, inventory: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Best-effort local-skill inventory report to the Cloud (design D7)."""
|
||||
response = self._ensure_client().post(
|
||||
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
|
||||
json={"skills": inventory},
|
||||
headers={"Authorization": f"Bearer {self.host_token}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def _ensure_client(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
self._client = httpx.Client()
|
||||
return self._client
|
||||
|
||||
|
||||
def _parse_cloud_sync_payload(payload: dict[str, Any]) -> SyncDelta:
|
||||
"""Parse the Cloud API sync response into a SyncDelta.
|
||||
|
||||
The Cloud skill payloads carry ``revision`` (mapped to the local
|
||||
``version``) and kind-specific ``content``/``steps``/``parameters`` fields
|
||||
that line up with :class:`skills_learning.models` ``from_dict``.
|
||||
"""
|
||||
skills: list[Skill] = []
|
||||
for item in payload.get("skills") or []:
|
||||
normalized = dict(item)
|
||||
if "version" not in normalized and "revision" in normalized:
|
||||
normalized["version"] = normalized["revision"]
|
||||
source = "cloud"
|
||||
kind = normalized.get("kind", "knowledge")
|
||||
normalized["source"] = source
|
||||
if kind == "flow_template":
|
||||
skills.append(FlowTemplateSkill.from_dict(normalized))
|
||||
else:
|
||||
skills.append(KnowledgeSkill.from_dict(normalized))
|
||||
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
|
||||
latest_raw = payload.get("latest_version")
|
||||
latest_version = int(latest_raw) if latest_raw is not None else None
|
||||
is_full_replace = bool(payload.get("is_full_replace", True))
|
||||
return SyncDelta(
|
||||
skills=skills,
|
||||
removed_ids=removed_ids,
|
||||
latest_version=latest_version,
|
||||
is_full_replace=is_full_replace,
|
||||
)
|
||||
|
||||
|
||||
class SkillSyncRunner:
|
||||
"""Drives periodic sync between the Subscription Platform and local catalog.
|
||||
|
||||
@@ -160,11 +255,13 @@ class SkillSyncRunner:
|
||||
client: SubscriptionClient,
|
||||
subscriptions: list[str],
|
||||
poll_interval: float = 300.0,
|
||||
local_store: LocalSkillStore | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.client = client
|
||||
self.subscriptions = list(subscriptions)
|
||||
self.poll_interval = poll_interval
|
||||
self.local_store = local_store
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._tick_lock = threading.Lock()
|
||||
@@ -209,8 +306,11 @@ class SkillSyncRunner:
|
||||
self._stop.wait(self.poll_interval)
|
||||
|
||||
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||
since_version = self.store._get_subscription_version(subscription_id)
|
||||
try:
|
||||
delta = self.client.fetch_entitled_skills(subscription_id)
|
||||
delta = self.client.fetch_entitled_skills(
|
||||
subscription_id, since_version=since_version
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"skill sync fetch failed for %s: %s", subscription_id, exc
|
||||
@@ -233,6 +333,11 @@ class SkillSyncRunner:
|
||||
for skill in delta.skills:
|
||||
self.store._apply_sync_upsert(skill, subscription_id)
|
||||
for skill_id in delta.removed_ids:
|
||||
# Fork-on-revocation (design D9): if a local override shadows
|
||||
# this cloud skill, promote it to a standalone local skill
|
||||
# before the cloud id disappears from the synced store.
|
||||
if self.local_store is not None:
|
||||
self.local_store.fork_override_to_local(skill_id)
|
||||
self.store._apply_sync_remove(skill_id)
|
||||
self.store._set_subscription_state(
|
||||
subscription_id,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ device_count }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ running_tasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failed_tasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
<div class="empty-state">
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="device-list">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill {{ device.status }}">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Apex Console{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('runtime_console_assets', path='console.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<span class="brand-icon" aria-hidden="true">AM</span>
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>Runtime /ui</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list">
|
||||
<a
|
||||
class="nav-button{% if active_section == 'dashboard' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_dashboard') }}"
|
||||
>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'tasks' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_tasks') }}"
|
||||
>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'config' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_config') }}"
|
||||
>
|
||||
<span>Config</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="workspace">
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Config · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Config</h1>
|
||||
<p>Register devices and tune Runtime parameters.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
{% if device_error %}
|
||||
<p class="alert error">{{ device_error }}</p>
|
||||
{% endif %}
|
||||
{% if device_added %}
|
||||
<p class="alert success">Device "{{ device_added }}" registered.</p>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{{ url_for('runtime_console_register_device') }}">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value="{{ form_values.name }}" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_type">
|
||||
{% for driver_type in supported_driver_types %}
|
||||
<option
|
||||
value="{{ driver_type }}"
|
||||
{% if driver_type == form_values.driver_type %}selected{% endif %}
|
||||
>{{ driver_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input type="url" name="server_url" value="{{ form_values.server_url }}" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input type="text" name="udid" value="{{ form_values.udid }}" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
name="wda_local_port"
|
||||
value="{{ form_values.wda_local_port }}"
|
||||
/>
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul class="device-list managed">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('runtime_console_remove_device', device_id=device.id) }}">
|
||||
<button class="icon-button danger" type="submit" title="Remove device" aria-label="Remove device">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
{% if config_error %}
|
||||
<p class="alert error">{{ config_error }}</p>
|
||||
{% endif %}
|
||||
{% if config_saved %}
|
||||
<p class="alert success">Saved.</p>
|
||||
{% endif %}
|
||||
<form class="settings-form" method="post" action="{{ url_for('runtime_console_update_max_steps') }}">
|
||||
<label>
|
||||
Max steps
|
||||
<input type="number" min="1" name="max_steps" value="{{ max_steps }}" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Devices · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>{{ device_count }} device(s) / {{ task_count }} task(s)</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="view-grid">
|
||||
<div id="live-status">
|
||||
{% include "_status_fragment.html" %}
|
||||
</div>
|
||||
</section>
|
||||
<script src="{{ url_for('runtime_console_assets', path='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Task {{ task.id }} · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Task Detail</h1>
|
||||
<p><a href="{{ url_for('runtime_console_tasks') }}">← Back to tasks</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>{{ task.goal }}</h2>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Task ID</dt>
|
||||
<dd>{{ task.id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ device_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ task.created_at or "-" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ task.updated_at or "-" }}</dd>
|
||||
</div>
|
||||
{% if task.failure_reason %}
|
||||
<div>
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ task.failure_reason }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<span>Step {{ current_step_index }} of {{ timeline|length }}</span>
|
||||
</div>
|
||||
|
||||
{% if not timeline %}
|
||||
<div class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="timeline-controls" method="get" action="{{ url_for('runtime_console_task_detail', task_id=task.id) }}">
|
||||
<label>
|
||||
Step
|
||||
<select name="step" onchange="this.form.submit()">
|
||||
{% for record in timeline %}
|
||||
<option
|
||||
value="{{ loop.index0 }}"
|
||||
{% if loop.index0 == current_step_index %}selected{% endif %}
|
||||
>Step {{ loop.index }}{% if record.tool_call and record.tool_call.get('action') %} ({{ record.tool_call.get('action') }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Show</span></button></noscript>
|
||||
</form>
|
||||
|
||||
<div class="timeline-stage">
|
||||
<div class="screenshot-frame">
|
||||
{% if current_step.image_base64 %}
|
||||
<img
|
||||
src="data:image/png;base64,{{ current_step.image_base64 }}"
|
||||
alt="Task step screenshot"
|
||||
/>
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Tool Call</h3>
|
||||
<pre>{{ current_step.tool_call | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Result</h3>
|
||||
<pre>{{ current_step.result | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tasks · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Tasks</h1>
|
||||
<p>{{ tasks|length }} task(s) match the current filters</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<form class="filters" method="get" action="{{ url_for('runtime_console_tasks') }}">
|
||||
<label>
|
||||
Device
|
||||
<select name="device_id">
|
||||
<option value="">All devices</option>
|
||||
{% for device in devices %}
|
||||
<option
|
||||
value="{{ device.id }}"
|
||||
{% if device.id == selected_device_id %}selected{% endif %}
|
||||
>{{ device.name or device.id }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{% for status_name in task_statuses %}
|
||||
<option
|
||||
value="{{ status_name }}"
|
||||
{% if status_name == selected_status %}selected{% endif %}
|
||||
>{{ status_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Apply</span></button></noscript>
|
||||
</form>
|
||||
|
||||
{% if not tasks %}
|
||||
<div class="empty-state compact">
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for task in tasks %}
|
||||
<a
|
||||
class="task-row"
|
||||
href="{{ url_for('runtime_console_task_detail', task_id=task.id) }}"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ device_names.get(task.device_id, task.device_id) }}
|
||||
</span>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,7 @@ from cloud.control_config import (
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.llm_providers import LlmProviderService
|
||||
from cloud.skills import CloudSkillService
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.observability import (
|
||||
CORRELATION_HEADER,
|
||||
@@ -46,6 +47,7 @@ from cloud.schema import require_current_schema
|
||||
from cloud.sdk.api import create_cloud_router
|
||||
from cloud.sdk.governance_api import create_governance_router
|
||||
from cloud.sdk.llm_provider_api import create_llm_provider_router
|
||||
from cloud.sdk.skill_api import create_skill_host_router, create_skill_management_router
|
||||
from cloud.sdk.user_api import create_user_auth_router
|
||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||
from core.models import utc_now
|
||||
@@ -130,6 +132,7 @@ def create_app(
|
||||
),
|
||||
)
|
||||
llm_provider_service = LlmProviderService(repository)
|
||||
cloud_skill_service = CloudSkillService(repository)
|
||||
auth_provider = ChainedAuthProvider(
|
||||
(
|
||||
configured_auth_provider,
|
||||
@@ -325,6 +328,24 @@ def create_app(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_skill_management_router(
|
||||
service=cloud_skill_service,
|
||||
repository=repository,
|
||||
auth_provider=auth_provider,
|
||||
csrf_validator=lambda request, principal: _valid_csrf_request(
|
||||
request,
|
||||
principal,
|
||||
user_auth_service,
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_skill_host_router(
|
||||
service=cloud_skill_service,
|
||||
auth_provider=auth_provider,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_internal_router(
|
||||
pool=pool,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""HTTP tests for the Cloud skill management admin router.
|
||||
|
||||
Mirrors the llm-provider management test setup (in-memory DB, admin login,
|
||||
CSRF). Covers skill CRUD, per-host entitlement grant/revoke, authorization
|
||||
(non-admin rejected), and a basic sync-endpoint auth guard.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud.control_config import CloudControlConfig
|
||||
from cloud_api.app import create_app
|
||||
|
||||
|
||||
def _create_admin(client: TestClient) -> None:
|
||||
client.app.state.cloud_services.user_auth_service.create_user(
|
||||
username="admin",
|
||||
display_name="Administrator",
|
||||
role="admin",
|
||||
password="correct-horse-battery-staple",
|
||||
must_change_password=False,
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/auth/login",
|
||||
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
token = client.cookies.get("amcp_csrf")
|
||||
assert token is not None
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def _skill_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"name": "Search Notes",
|
||||
"kind": "knowledge",
|
||||
"description": "how to search",
|
||||
"tags": ["search"],
|
||||
"content": "type and press enter",
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_admin_can_create_list_get_update_delete_skill():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
|
||||
created = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert created.status_code == 201, created.text
|
||||
skill_id = created.json()["id"]
|
||||
|
||||
listed = client.get("/v1/skills", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert any(s["id"] == skill_id for s in listed.json()["items"])
|
||||
|
||||
fetched = client.get(f"/v1/skills/{skill_id}", headers=headers)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["content"] == "type and press enter"
|
||||
|
||||
updated = client.patch(
|
||||
f"/v1/skills/{skill_id}",
|
||||
json=_skill_payload(content="new content"),
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert updated.json()["content"] == "new content"
|
||||
|
||||
deleted = client.delete(f"/v1/skills/{skill_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/v1/skills/{skill_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_skill_name_conflicts():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
first = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert first.status_code == 201
|
||||
second = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_entitlement_grant_revoke_lists_hosts():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
skill_id = client.post(
|
||||
"/v1/skills", json=_skill_payload(), headers=headers
|
||||
).json()["id"]
|
||||
|
||||
grant = client.post(
|
||||
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
|
||||
)
|
||||
assert grant.status_code == 204
|
||||
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
|
||||
assert listed.json()["host_ids"] == ["host-1"]
|
||||
|
||||
revoke = client.delete(
|
||||
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
|
||||
)
|
||||
assert revoke.status_code == 204
|
||||
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
|
||||
assert listed.json()["host_ids"] == []
|
||||
|
||||
|
||||
def test_unauthenticated_request_is_rejected():
|
||||
with _client() as client:
|
||||
response = client.get("/v1/skills")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sync_endpoint_requires_host_credentials():
|
||||
with _client() as client:
|
||||
# No host credentials -> 401 (no skill content leaked).
|
||||
response = client.get("/internal/v1/hosts/host-1/skills/sync")
|
||||
assert response.status_code == 401
|
||||
@@ -24,6 +24,7 @@ from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||
from host_agent.retention import prune_task_history
|
||||
from host_agent.skill_sync import HostAgentSkillSync
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.app import create_console_app
|
||||
from host_agent.web.auth import SessionManager
|
||||
@@ -42,6 +43,7 @@ class HostAgentApplication:
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
instance_lock: InstanceLock | None = None
|
||||
skill_sync: HostAgentSkillSync | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self.run_async())
|
||||
@@ -60,6 +62,8 @@ class HostAgentApplication:
|
||||
)
|
||||
heartbeat_stop = asyncio.Event()
|
||||
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
||||
if self.skill_sync is not None:
|
||||
self.skill_sync.start()
|
||||
console_task = (
|
||||
asyncio.create_task(self.console_server.serve())
|
||||
if self.console_server is not None
|
||||
@@ -110,6 +114,8 @@ class HostAgentApplication:
|
||||
finally:
|
||||
if self.console_enrollment_client is not None:
|
||||
self.console_enrollment_client.close()
|
||||
if self.skill_sync is not None:
|
||||
self.skill_sync.stop()
|
||||
await self.client.aclose()
|
||||
if self.instance_lock is not None:
|
||||
self.instance_lock.release()
|
||||
@@ -263,6 +269,9 @@ def create_application(
|
||||
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
||||
resolved_config
|
||||
)
|
||||
skill_sync: HostAgentSkillSync | None = None
|
||||
if resolved_config.host_id and resolved_config.token:
|
||||
skill_sync = HostAgentSkillSync(resolved_config)
|
||||
return HostAgentApplication(
|
||||
client=client,
|
||||
heartbeat=heartbeat,
|
||||
@@ -271,6 +280,7 @@ def create_application(
|
||||
console_enrollment_client=console_enrollment_client,
|
||||
dependency_supervisor=dependency_supervisor,
|
||||
instance_lock=instance_lock,
|
||||
skill_sync=skill_sync,
|
||||
)
|
||||
except BaseException:
|
||||
instance_lock.release()
|
||||
|
||||
@@ -47,6 +47,7 @@ class HostAgentConfig:
|
||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
||||
task_retention_max_count: int = 50
|
||||
task_retention_max_age_days: int = 7
|
||||
skill_sync_interval_seconds: float = 300.0
|
||||
|
||||
|
||||
def load_host_agent_config(
|
||||
@@ -156,6 +157,9 @@ def load_host_agent_config(
|
||||
task_retention_max_age_days=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
|
||||
),
|
||||
skill_sync_interval_seconds=_positive_float(
|
||||
values, "HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS", 300.0
|
||||
),
|
||||
)
|
||||
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
|
||||
raise HostAgentConfigurationError(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Host-agent wiring for skill sync against the Cloud Control Plane.
|
||||
|
||||
Constructs the synced catalog store, the local skill store, the Cloud API
|
||||
sync client, and the :class:`SkillSyncRunner`, then drives them on the host
|
||||
agent's lifecycle: the runner pulls incremental per-host skill deltas into the
|
||||
synced catalog (forking local overrides on revocation, design D9), and a
|
||||
best-effort inventory of the agent's local skills is reported to the Cloud
|
||||
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
|
||||
reasons as :mod:`host_agent.cloud_planner_client`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HostAgentSkillSync:
|
||||
"""Owns the skill stores, sync runner, and inventory reporting thread."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: HostAgentConfig,
|
||||
*,
|
||||
poll_interval: float | None = None,
|
||||
http_client=None,
|
||||
) -> None:
|
||||
from api.skill_sync import CloudApiSkillClient, SkillSyncRunner
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
state_dir = config.identity_path.parent
|
||||
self.config = config
|
||||
self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3")
|
||||
self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3")
|
||||
self.client = CloudApiSkillClient(
|
||||
config.control_plane_url,
|
||||
host_token=config.token,
|
||||
client=http_client,
|
||||
)
|
||||
self.runner = SkillSyncRunner(
|
||||
store=self.synced_store,
|
||||
client=self.client,
|
||||
subscriptions=[config.host_id],
|
||||
poll_interval=poll_interval or config.skill_sync_interval_seconds,
|
||||
local_store=self.local_store,
|
||||
)
|
||||
self._inventory_stop = threading.Event()
|
||||
self._inventory_thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the sync poll loop + periodic inventory reporting."""
|
||||
self.runner.start_background()
|
||||
self._inventory_stop.clear()
|
||||
self._inventory_thread = threading.Thread(
|
||||
target=self._report_inventory_forever, daemon=True
|
||||
)
|
||||
self._inventory_thread.start()
|
||||
log.info("skill sync started for host %s", self.config.host_id)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sync loop + inventory thread and close the HTTP client."""
|
||||
self.runner.stop_background()
|
||||
self._inventory_stop.set()
|
||||
if self._inventory_thread is not None:
|
||||
self._inventory_thread.join(timeout=5.0)
|
||||
self._inventory_thread = None
|
||||
self.client.close()
|
||||
|
||||
def report_inventory_once(self) -> None:
|
||||
"""Report the current local-skill inventory to the Cloud (best-effort)."""
|
||||
try:
|
||||
inventory = [
|
||||
{
|
||||
"id": meta.id,
|
||||
"name": meta.name,
|
||||
"kind": meta.kind,
|
||||
"origin": "local",
|
||||
}
|
||||
for meta in self.local_store.list_local()
|
||||
]
|
||||
for override in self.local_store.list_overrides():
|
||||
inventory.append(
|
||||
{
|
||||
"id": override.metadata.id,
|
||||
"name": override.metadata.name,
|
||||
"kind": override.metadata.kind,
|
||||
"origin": "cloud",
|
||||
"locally_overridden": True,
|
||||
}
|
||||
)
|
||||
self.client.report_inventory(self.config.host_id, inventory)
|
||||
except Exception: # best-effort: never impair local operation
|
||||
log.debug("skill inventory report failed", exc_info=True)
|
||||
|
||||
def _report_inventory_forever(self) -> None:
|
||||
# Report once at startup, then on the sync cadence.
|
||||
self.report_inventory_once()
|
||||
interval = self.config.skill_sync_interval_seconds
|
||||
while not self._inventory_stop.wait(interval):
|
||||
self.report_inventory_once()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the host-agent skill sync wiring (§7.2/7.4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.skill_sync import HostAgentSkillSync
|
||||
|
||||
|
||||
def _config(tmp_path) -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
control_plane_url="https://cloud.example",
|
||||
host_id="host-1",
|
||||
token="host-token",
|
||||
identity_path=tmp_path / "identity.json",
|
||||
skill_sync_interval_seconds=0.01,
|
||||
)
|
||||
|
||||
|
||||
def test_skill_sync_pulls_delta_and_reports_inventory(tmp_path):
|
||||
seen_inventory = {"host": None, "body": None}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.endswith("/skills/sync"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skills": [
|
||||
{
|
||||
"id": "c1",
|
||||
"name": "Cloud Skill",
|
||||
"kind": "knowledge",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"revision": 1,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"content": "body",
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
"removed_ids": [],
|
||||
"latest_version": 1,
|
||||
"is_full_replace": True,
|
||||
},
|
||||
)
|
||||
if path.endswith("/skills/inventory"):
|
||||
seen_inventory["host"] = request.url.path.split("/")[4]
|
||||
seen_inventory["body"] = request.read()
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# One manual tick applies the cloud skill to the synced store.
|
||||
outcomes = sync.runner.tick()
|
||||
assert outcomes["host-1"].success is True
|
||||
visible = sync.synced_store.list_skills({"host-1"})
|
||||
assert [m.name for m in visible] == ["Cloud Skill"]
|
||||
|
||||
# Inventory report of an authored local skill is best-effort and payload-shaped.
|
||||
from skills_learning.models import KnowledgeSkill, SkillMetadata
|
||||
|
||||
sync.local_store.create_local(
|
||||
KnowledgeSkill(metadata=SkillMetadata(name="Local Note", kind="knowledge"), content="x")
|
||||
)
|
||||
sync.report_inventory_once()
|
||||
assert seen_inventory["host"] == "host-1"
|
||||
assert b"Local Note" in seen_inventory["body"]
|
||||
|
||||
# start/stop lifecycle does not raise.
|
||||
sync.start()
|
||||
sync.stop()
|
||||
|
||||
|
||||
def test_skill_sync_inventory_failure_is_isolated(tmp_path):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/skills/inventory"):
|
||||
return httpx.Response(500)
|
||||
return httpx.Response(200, json={"skills": [], "removed_ids": [], "latest_version": 0, "is_full_replace": True})
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# A failed inventory report must not raise.
|
||||
sync.report_inventory_once()
|
||||
sync.client.close()
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
Boxes,
|
||||
BookOpen,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
|
||||
import PluginsView from "./views/PluginsView.vue";
|
||||
import UsersView from "./views/UsersView.vue";
|
||||
import LlmProvidersView from "./views/LlmProvidersView.vue";
|
||||
import SkillsView from "./views/SkillsView.vue";
|
||||
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
|
||||
|
||||
const activeView = ref<ViewId>("tasks");
|
||||
const currentUser = ref<CloudUser | null>(null);
|
||||
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
|
||||
currentUser.value?.scopes.includes("governance:admin")),
|
||||
);
|
||||
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
|
||||
const canAdminSkills = computed(() => hasScope(currentUser.value, "skills:admin"));
|
||||
const isAuthenticated = computed(() => currentUser.value !== null);
|
||||
const currentUserLabel = computed(() =>
|
||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
||||
@@ -70,6 +73,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
|
||||
if (canAdminProviders.value) {
|
||||
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||
}
|
||||
if (canAdminSkills.value) {
|
||||
items.push({ id: "skills", label: "Skills", icon: BookOpen });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
|
||||
return UsersView;
|
||||
case "providers":
|
||||
return LlmProvidersView;
|
||||
case "skills":
|
||||
return SkillsView;
|
||||
default:
|
||||
return TasksView;
|
||||
}
|
||||
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
|
||||
:can-admin-governance="canAdminGovernance"
|
||||
/>
|
||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
||||
<SkillsView v-else-if="activeView === 'skills'" :can-admin="canAdminSkills" />
|
||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
TokenUsageEvent,
|
||||
UserListResponse,
|
||||
UserSubmissionPolicy,
|
||||
CloudSkill,
|
||||
CloudSkillListResponse,
|
||||
CloudSkillEntitlementsResponse,
|
||||
HostSkillInventoryResponse,
|
||||
CloudSkillKind,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
@@ -328,3 +333,76 @@ export function deleteLlmProviderProfile(
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface CloudSkillPayload {
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function listCloudSkills(): Promise<CloudSkillListResponse> {
|
||||
return request<CloudSkillListResponse>("/v1/skills");
|
||||
}
|
||||
|
||||
export function createCloudSkill(payload: CloudSkillPayload): Promise<CloudSkill> {
|
||||
return request<CloudSkill>("/v1/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCloudSkill(
|
||||
skillId: string,
|
||||
payload: CloudSkillPayload,
|
||||
): Promise<CloudSkill> {
|
||||
return request<CloudSkill>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCloudSkill(skillId: string): Promise<void> {
|
||||
return request<void>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listCloudSkillEntitlements(
|
||||
skillId: string,
|
||||
): Promise<CloudSkillEntitlementsResponse> {
|
||||
return request<CloudSkillEntitlementsResponse>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements`,
|
||||
);
|
||||
}
|
||||
|
||||
export function grantCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostSkillInventory(
|
||||
hostId: string,
|
||||
): Promise<HostSkillInventoryResponse> {
|
||||
return request<HostSkillInventoryResponse>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/skill-inventory`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,3 +196,34 @@ export interface PlannerDecisionItem {
|
||||
export interface PlannerDecisionListResponse {
|
||||
items: PlannerDecisionItem[];
|
||||
}
|
||||
|
||||
export type CloudSkillKind = "knowledge" | "flow_template";
|
||||
|
||||
export interface CloudSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface CloudSkillListResponse {
|
||||
items: CloudSkill[];
|
||||
}
|
||||
|
||||
export interface CloudSkillEntitlementsResponse {
|
||||
skill_id: string;
|
||||
host_ids: string[];
|
||||
}
|
||||
|
||||
export interface HostSkillInventoryResponse {
|
||||
host_id: string;
|
||||
payload: Record<string, unknown>[];
|
||||
reported_at: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
||||
import {
|
||||
createCloudSkill,
|
||||
deleteCloudSkill,
|
||||
grantCloudSkillEntitlement,
|
||||
listCloudSkillEntitlements,
|
||||
listCloudSkills,
|
||||
revokeCloudSkillEntitlement,
|
||||
updateCloudSkill,
|
||||
getHostSkillInventory,
|
||||
type CloudSkillPayload,
|
||||
} from "../api";
|
||||
import type { CloudSkill, CloudSkillKind, HostSkillInventoryResponse } from "../types";
|
||||
|
||||
defineProps<{ canAdmin: boolean }>();
|
||||
|
||||
const skills = ref<CloudSkill[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const entitlementsFor = ref<Record<string, string[]>>({});
|
||||
const entitlementHostInput = ref<Record<string, string>>({});
|
||||
|
||||
const inventoryHostId = ref("");
|
||||
const inventory = ref<HostSkillInventoryResponse | null>(null);
|
||||
const inventoryLoading = ref(false);
|
||||
|
||||
const form = reactive<CloudSkillPayload>({
|
||||
name: "",
|
||||
kind: "knowledge",
|
||||
description: "",
|
||||
tags: [],
|
||||
content: "",
|
||||
steps: [],
|
||||
parameters: {},
|
||||
});
|
||||
const tagsInput = ref("");
|
||||
const stepsJson = ref("[]");
|
||||
const parametersJson = ref("{}");
|
||||
const formError = ref("");
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.name = "";
|
||||
form.kind = "knowledge";
|
||||
form.description = "";
|
||||
form.tags = [];
|
||||
form.content = "";
|
||||
form.steps = [];
|
||||
form.parameters = {};
|
||||
tagsInput.value = "";
|
||||
stepsJson.value = "[]";
|
||||
parametersJson.value = "{}";
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await listCloudSkills();
|
||||
skills.value = response.items;
|
||||
await Promise.all(skills.value.map(loadEntitlements));
|
||||
} catch (error) {
|
||||
showError(error, "failed to load skills");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlements(skill: CloudSkill) {
|
||||
try {
|
||||
const resp = await listCloudSkillEntitlements(skill.id);
|
||||
entitlementsFor.value[skill.id] = resp.host_ids;
|
||||
} catch {
|
||||
entitlementsFor.value[skill.id] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function editSkill(skill: CloudSkill) {
|
||||
editingId.value = skill.id;
|
||||
form.name = skill.name;
|
||||
form.kind = skill.kind;
|
||||
form.description = skill.description;
|
||||
form.tags = [...skill.tags];
|
||||
tagsInput.value = skill.tags.join(", ");
|
||||
form.content = skill.content;
|
||||
form.steps = skill.steps;
|
||||
form.parameters = skill.parameters;
|
||||
stepsJson.value = JSON.stringify(skill.steps, null, 2);
|
||||
parametersJson.value = JSON.stringify(skill.parameters, null, 2);
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
}
|
||||
|
||||
function buildPayload(): CloudSkillPayload | null {
|
||||
if (!form.name.trim()) {
|
||||
formError.value = "name is required";
|
||||
return null;
|
||||
}
|
||||
let steps: Record<string, unknown>[] = [];
|
||||
let parameters: Record<string, Record<string, unknown>> = {};
|
||||
if (form.kind === "flow_template") {
|
||||
try {
|
||||
steps = JSON.parse(stepsJson.value || "[]");
|
||||
parameters = JSON.parse(parametersJson.value || "{}");
|
||||
} catch {
|
||||
formError.value = "steps/parameters must be valid JSON";
|
||||
return null;
|
||||
}
|
||||
} else if (!form.content.trim()) {
|
||||
formError.value = "knowledge skill content is required";
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
kind: form.kind as CloudSkillKind,
|
||||
description: form.description,
|
||||
tags: tagsInput.value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
content: form.content,
|
||||
steps,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
formError.value = "";
|
||||
const payload = buildPayload();
|
||||
if (payload === null) return;
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateCloudSkill(editingId.value, payload);
|
||||
successMessage.value = "skill updated";
|
||||
} else {
|
||||
await createCloudSkill(payload);
|
||||
successMessage.value = "skill created";
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to save skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSkill(skill: CloudSkill) {
|
||||
try {
|
||||
await deleteCloudSkill(skill.id);
|
||||
successMessage.value = "skill deleted";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to delete skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function addHost(skillId: string) {
|
||||
const hostId = (entitlementHostInput.value[skillId] || "").trim();
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await grantCloudSkillEntitlement(skillId, hostId);
|
||||
entitlementHostInput.value[skillId] = "";
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to grant entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHost(skillId: string, hostId: string) {
|
||||
try {
|
||||
await revokeCloudSkillEntitlement(skillId, hostId);
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to revoke entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventory() {
|
||||
const hostId = inventoryHostId.value.trim();
|
||||
if (!hostId) return;
|
||||
inventoryLoading.value = true;
|
||||
try {
|
||||
inventory.value = await getHostSkillInventory(hostId);
|
||||
} catch (error) {
|
||||
showError(error, "failed to load host inventory");
|
||||
} finally {
|
||||
inventoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="skills-view">
|
||||
<header class="row">
|
||||
<h2>Skills</h2>
|
||||
<button :disabled="loading" @click="refresh">
|
||||
<RefreshCw :size="14" /> Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="success">{{ successMessage }}</p>
|
||||
|
||||
<form v-if="canAdmin" class="skill-form" @submit.prevent="saveSkill">
|
||||
<h3>{{ editingId ? "Edit skill" : "New skill" }}</h3>
|
||||
<label>name <input v-model="form.name" /></label>
|
||||
<label>kind
|
||||
<select v-model="form.kind">
|
||||
<option value="knowledge">knowledge</option>
|
||||
<option value="flow_template">flow_template</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>description <input v-model="form.description" /></label>
|
||||
<label>tags (comma-separated) <input v-model="tagsInput" /></label>
|
||||
<label v-if="form.kind === 'knowledge'">content
|
||||
<textarea v-model="form.content" rows="4"></textarea>
|
||||
</label>
|
||||
<template v-else>
|
||||
<label>steps (JSON)
|
||||
<textarea v-model="stepsJson" rows="4"></textarea>
|
||||
</label>
|
||||
<label>parameters (JSON)
|
||||
<textarea v-model="parametersJson" rows="4"></textarea>
|
||||
</label>
|
||||
</template>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<div class="row">
|
||||
<button type="submit"><Plus :size="14" /> {{ editingId ? "Save" : "Create" }}</button>
|
||||
<button type="button" @click="resetForm"><X :size="14" /> Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<LoaderCircle v-if="loading" class="spin" :size="20" />
|
||||
<ul v-else class="skill-list">
|
||||
<li v-for="skill in skills" :key="skill.id">
|
||||
<div class="skill-head">
|
||||
<strong>{{ skill.name }}</strong>
|
||||
<span class="badge">{{ skill.kind }}</span>
|
||||
<span class="muted">rev {{ skill.revision }}</span>
|
||||
<div class="row">
|
||||
<button v-if="canAdmin" @click="editSkill(skill)"><Pencil :size="12" /> edit</button>
|
||||
<button v-if="canAdmin" @click="removeSkill(skill)"><Trash2 :size="12" /> delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">{{ skill.description }}</p>
|
||||
<div class="entitlements">
|
||||
<span>entitled hosts:</span>
|
||||
<span v-for="host in entitlementsFor[skill.id] || []" :key="host" class="chip">
|
||||
{{ host }}
|
||||
<button v-if="canAdmin" @click="removeHost(skill.id, host)"><X :size="10" /></button>
|
||||
</span>
|
||||
<template v-if="canAdmin">
|
||||
<input
|
||||
v-model="entitlementHostInput[skill.id]"
|
||||
placeholder="host id"
|
||||
@keyup.enter="addHost(skill.id)"
|
||||
/>
|
||||
<button @click="addHost(skill.id)">grant</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section class="inventory">
|
||||
<h3>Host local-skill inventory</h3>
|
||||
<div class="row">
|
||||
<input v-model="inventoryHostId" placeholder="host id" @keyup.enter="loadInventory" />
|
||||
<button :disabled="inventoryLoading" @click="loadInventory">view</button>
|
||||
</div>
|
||||
<p v-if="inventory && inventory.payload.length === 0" class="muted">no local skills reported</p>
|
||||
<ul v-if="inventory && inventory.payload.length">
|
||||
<li v-for="(item, idx) in inventory.payload" :key="idx">
|
||||
{{ item.name }} ({{ item.kind }}) — origin {{ item.origin }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="inventory?.reported_at" class="muted">reported {{ inventory.reported_at }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skills-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.skill-form { display: flex; flex-direction: column; gap: 0.5rem; border: 1px solid var(--border, #ccc); padding: 1rem; border-radius: 6px; }
|
||||
.skill-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
|
||||
.skill-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.skill-list li { border: 1px solid var(--border, #ccc); padding: 0.75rem; border-radius: 6px; }
|
||||
.skill-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.skill-head .row { margin-left: auto; }
|
||||
.badge { font-size: 0.7rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
.muted { color: var(--muted, #888); font-size: 0.8rem; }
|
||||
.entitlements { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 0.5rem; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 10px; font-size: 0.75rem; }
|
||||
.chip button { border: none; background: none; cursor: pointer; padding: 0; display: flex; }
|
||||
input, select, textarea { padding: 0.3rem; border: 1px solid var(--border, #ccc); border-radius: 4px; }
|
||||
button { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error { color: #c00; } .success { color: #070; }
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -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.
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Apex Agent Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1211
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
MonitorSmartphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
getConfig,
|
||||
getTask,
|
||||
getTimeline,
|
||||
listDevices,
|
||||
listTasks,
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
updateConfig,
|
||||
} from "./api";
|
||||
import type { Device, TaskRecord, TimelineRecord } from "./types";
|
||||
|
||||
type ViewId = "dashboard" | "tasks" | "config";
|
||||
|
||||
const navItems: { id: ViewId; label: string; icon: Component }[] = [
|
||||
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
|
||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||
{ id: "config", label: "Config", icon: Settings2 },
|
||||
];
|
||||
|
||||
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
|
||||
|
||||
const activeView = ref<ViewId>("dashboard");
|
||||
const loading = ref(false);
|
||||
const refreshError = ref("");
|
||||
const devices = ref<Device[]>([]);
|
||||
const tasks = ref<TaskRecord[]>([]);
|
||||
const selectedTask = ref<TaskRecord | null>(null);
|
||||
const timeline = ref<TimelineRecord[]>([]);
|
||||
const selectedStepIndex = ref(0);
|
||||
const deviceFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
const deviceError = ref("");
|
||||
const configError = ref("");
|
||||
const configSaved = ref("");
|
||||
const maxSteps = ref(20);
|
||||
const deviceForm = reactive({
|
||||
name: "",
|
||||
driver_type: "wda",
|
||||
server_url: "http://127.0.0.1:4723",
|
||||
udid: "",
|
||||
wda_local_port: "",
|
||||
});
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
const currentStep = computed<TimelineRecord | null>(() => {
|
||||
if (!timeline.value.length) {
|
||||
return null;
|
||||
}
|
||||
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
|
||||
});
|
||||
|
||||
const runningTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "running").length,
|
||||
);
|
||||
|
||||
const failedTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "failed").length,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
refreshError.value = "";
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices(): Promise<void> {
|
||||
devices.value = await listDevices();
|
||||
}
|
||||
|
||||
async function refreshTasks(): Promise<void> {
|
||||
tasks.value = await listTasks({
|
||||
deviceId: deviceFilter.value,
|
||||
status: statusFilter.value,
|
||||
});
|
||||
if (selectedTask.value) {
|
||||
await openTask(selectedTask.value.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig(): Promise<void> {
|
||||
const config = await getConfig();
|
||||
maxSteps.value = config.max_steps;
|
||||
}
|
||||
|
||||
async function applyTaskFilters(): Promise<void> {
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
async function openTask(taskId: string, switchView = true): Promise<void> {
|
||||
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
|
||||
selectedTask.value = task;
|
||||
timeline.value = records;
|
||||
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
|
||||
if (switchView) {
|
||||
activeView.value = "tasks";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDevice(): Promise<void> {
|
||||
deviceError.value = "";
|
||||
const connectionInfo: Record<string, unknown> = {};
|
||||
if (deviceForm.server_url.trim()) {
|
||||
connectionInfo.server_url = deviceForm.server_url.trim();
|
||||
}
|
||||
if (deviceForm.udid.trim()) {
|
||||
connectionInfo.udid = deviceForm.udid.trim();
|
||||
}
|
||||
if (deviceForm.wda_local_port.trim()) {
|
||||
const port = Number(deviceForm.wda_local_port);
|
||||
if (!Number.isFinite(port)) {
|
||||
deviceError.value = "wda_local_port must be a number";
|
||||
return;
|
||||
}
|
||||
connectionInfo.wda_local_port = port;
|
||||
}
|
||||
|
||||
try {
|
||||
await registerDevice({
|
||||
driver_type: deviceForm.driver_type,
|
||||
name: deviceForm.name.trim() || null,
|
||||
connection_info: connectionInfo,
|
||||
});
|
||||
deviceForm.name = "";
|
||||
deviceForm.udid = "";
|
||||
deviceForm.wda_local_port = "";
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(device: Device): Promise<void> {
|
||||
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
|
||||
return;
|
||||
}
|
||||
deviceError.value = "";
|
||||
try {
|
||||
await unregisterDevice(device.id);
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
configError.value = "";
|
||||
configSaved.value = "";
|
||||
try {
|
||||
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
|
||||
maxSteps.value = updated.max_steps;
|
||||
configSaved.value = "Saved";
|
||||
} catch (error) {
|
||||
configError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep(): void {
|
||||
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
|
||||
}
|
||||
|
||||
function nextStep(): void {
|
||||
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
|
||||
}
|
||||
|
||||
function displayDeviceName(device: Device): string {
|
||||
return device.name || device.id;
|
||||
}
|
||||
|
||||
function findDeviceName(deviceId: string): string {
|
||||
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Request failed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<MonitorSmartphone :size="22" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>{{ API_BASE_URL }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
class="nav-button"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
@click="activeView = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="18" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
|
||||
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
|
||||
</div>
|
||||
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
|
||||
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
|
||||
<RefreshCw v-else :size="17" aria-hidden="true" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
|
||||
|
||||
<section v-if="activeView === 'dashboard'" class="view-grid">
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ devices.length }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ runningTasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failedTasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
<div v-if="!devices.length" class="empty-state">
|
||||
<MonitorSmartphone :size="34" aria-hidden="true" />
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
<ul v-else class="device-list">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill" :class="device.status">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'tasks'" class="tasks-layout">
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>
|
||||
Device
|
||||
<select v-model="deviceFilter" @change="applyTaskFilters">
|
||||
<option value="">All devices</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ displayDeviceName(device) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="statusFilter" @change="applyTaskFilters">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
|
||||
{{ statusName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!tasks.length" class="empty-state compact">
|
||||
<ListChecks :size="30" aria-hidden="true" />
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
v-else
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ selected: selectedTask?.id === task.id }"
|
||||
type="button"
|
||||
@click="openTask(task.id)"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="task.status">{{ task.status }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>Task Detail</h2>
|
||||
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
|
||||
{{ selectedTask.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedTask" class="empty-state">
|
||||
<ListChecks :size="34" aria-hidden="true" />
|
||||
<span>Select a task to inspect its timeline.</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-detail">
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Goal</dt>
|
||||
<dd>{{ selectedTask.goal }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="selectedTask.failure_reason">
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ selectedTask.failure_reason }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Previous step"
|
||||
:disabled="selectedStepIndex === 0"
|
||||
@click="previousStep"
|
||||
>
|
||||
<ChevronLeft :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Next step"
|
||||
:disabled="selectedStepIndex >= timeline.length - 1"
|
||||
@click="nextStep"
|
||||
>
|
||||
<ChevronRight :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentStep" class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
<div v-else class="timeline-stage">
|
||||
<div class="screenshot-frame">
|
||||
<img
|
||||
v-if="currentStep.image_base64"
|
||||
:src="`data:image/png;base64,${currentStep.image_base64}`"
|
||||
alt="Task step screenshot"
|
||||
/>
|
||||
<span v-else>No screenshot</span>
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Tool Call</h3>
|
||||
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Result</h3>
|
||||
<pre>{{ prettyJson(currentStep.result) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'config'" class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
<form class="form-grid" @submit.prevent="submitDevice">
|
||||
<label>
|
||||
Name
|
||||
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select v-model="deviceForm.driver_type">
|
||||
<option value="wda">wda</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input v-model="deviceForm.server_url" type="url" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input v-model="deviceForm.udid" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<Plus :size="17" aria-hidden="true" />
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
|
||||
|
||||
<ul class="device-list managed">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button danger"
|
||||
type="button"
|
||||
title="Remove device"
|
||||
@click="removeDevice(device)"
|
||||
>
|
||||
<Trash2 :size="17" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
<form class="settings-form" @submit.prevent="saveConfig">
|
||||
<label>
|
||||
Max steps
|
||||
<input v-model.number="maxSteps" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<Save :size="17" aria-hidden="true" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="configError" class="alert error">{{ configError }}</p>
|
||||
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<Device[]> {
|
||||
return request<Device[]>("/console/devices");
|
||||
}
|
||||
|
||||
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
|
||||
return request<Device>("/console/devices", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterDevice(deviceId: string): Promise<void> {
|
||||
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listTasks(filters: {
|
||||
deviceId?: string;
|
||||
status?: string;
|
||||
}): Promise<TaskRecord[]> {
|
||||
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<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export function getTask(taskId: string): Promise<TaskRecord> {
|
||||
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
|
||||
return request<TimelineRecord[]>(
|
||||
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config");
|
||||
}
|
||||
|
||||
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
prompt: string;
|
||||
tool_call: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/ui/",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
+11
-15
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+113
@@ -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
|
||||
`<script>alert(1)</script>`
|
||||
- **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`
|
||||
@@ -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.
|
||||
@@ -7,16 +7,16 @@
|
||||
|
||||
## 2. Cloud admin REST: skill CRUD + entitlement
|
||||
|
||||
- [ ] 2.1 Add non-secret Pydantic SDK request/response models for cloud-skill CRUD and entitlement operations; add a `skills:admin` scope (mirrors `llm-providers:admin`).
|
||||
- [ ] 2.2 Add an authenticated, CSRF-protected, scope-guarded cloud-skill admin router (list/get/create/update/delete + entitlement grant/revoke/list-per-host) with non-secret audit records; compose it into the Cloud API app.
|
||||
- [ ] 2.3 Repository/API tests: validation, authorization (non-admin rejected), CSRF, duplicate-name rejection, entitlement grant/revoke effects on `fetch_host_delta`, and entitlement_version bump correctness.
|
||||
- [x] 2.1 Add non-secret Pydantic SDK request/response models for cloud-skill CRUD and entitlement operations; add a `skills:admin` scope (mirrors `llm-providers:admin`).
|
||||
- [x] 2.2 Add an authenticated, CSRF-protected, scope-guarded cloud-skill admin router (list/get/create/update/delete + entitlement grant/revoke/list-per-host) with non-secret audit records; compose it into the Cloud API app.
|
||||
- [x] 2.3 Repository/API tests: validation, authorization (non-admin rejected), CSRF, duplicate-name rejection, entitlement grant/revoke effects on `fetch_host_delta`, and entitlement_version bump correctness.
|
||||
|
||||
## 3. Cloud host-scoped sync endpoint + inventory readback
|
||||
|
||||
- [ ] 3.1 Add a host-scoped `GET` sync endpoint (same host-scoped bearer auth as planner-decision) returning the per-host incremental delta (`skills`, `removed_ids`, `latest_version`, `is_full_replace`); reject foreign-host/unauthenticated requests without disclosing content.
|
||||
- [ ] 3.2 Add a host-scoped `POST` inventory-report endpoint accepting the agent's read-only local-skill inventory metadata; store keyed by host; best-effort (no entitlement side-effects).
|
||||
- [ ] 3.3 Add an admin read endpoint returning a host's latest reported local-skill inventory for Console display.
|
||||
- [ ] 3.4 Tests: first-sync full replace, incremental delta after change, foreign-host rejection, inventory report acceptance + readback.
|
||||
- [x] 3.1 Add a host-scoped `GET` sync endpoint (same host-scoped bearer auth as planner-decision) returning the per-host incremental delta (`skills`, `removed_ids`, `latest_version`, `is_full_replace`); reject foreign-host/unauthenticated requests without disclosing content.
|
||||
- [x] 3.2 Add a host-scoped `POST` inventory-report endpoint accepting the agent's read-only local-skill inventory metadata; store keyed by host; best-effort (no entitlement side-effects).
|
||||
- [x] 3.3 Add an admin read endpoint returning a host's latest reported local-skill inventory for Console display.
|
||||
- [x] 3.4 Tests: first-sync full replace, incremental delta after change, foreign-host/unauthenticated rejection, inventory report acceptance + readback.
|
||||
|
||||
## 4. Agent persistent local skill store
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
|
||||
## 7. Sync repoint + runner wiring + inventory report
|
||||
|
||||
- [ ] 7.1 Repoint `api/skill_sync.py`'s concrete client at the Cloud API per-host sync endpoint (host-scoped bearer; `since_version` incremental; full-replace on first/stale); keep the `SubscriptionClient` Protocol / `SyncDelta` shape. The agent's "subscription_id" becomes its host identifier.
|
||||
- [ ] 7.2 Construct and start `SkillSyncRunner` in the host-agent app bootstrap behind the existing cloud-transport configuration; configurable poll interval; failures non-fatal (preserve cache + record error).
|
||||
- [ ] 7.3 On a sync `removed_ids` entry that has a local override, trigger the fork (D9) via `LocalSkillStore.fork_override_to_local`.
|
||||
- [ ] 7.4 Add a periodic best-effort local-skill inventory report from the agent to the Cloud inventory endpoint (metadata only).
|
||||
- [ ] 7.5 Tests: incremental apply, full-replace, fork-on-revocation end-to-end, runner lifecycle, inventory report payload + failure-isolation.
|
||||
- [x] 7.1 Repoint `api/skill_sync.py`'s concrete client at the Cloud API per-host sync endpoint (host-scoped bearer; `since_version` incremental; full-replace on first/stale); keep the `SubscriptionClient` Protocol / `SyncDelta` shape. The agent's "subscription_id" becomes its host identifier.
|
||||
- [x] 7.2 Construct and start `SkillSyncRunner` in the host-agent app bootstrap behind the existing cloud-transport configuration; configurable poll interval; failures non-fatal (preserve cache + record error).
|
||||
- [x] 7.3 On a sync `removed_ids` entry that has a local override, trigger the fork (D9) via `LocalSkillStore.fork_override_to_local`.
|
||||
- [x] 7.4 Add a periodic best-effort local-skill inventory report from the agent to the Cloud inventory endpoint (metadata only).
|
||||
- [x] 7.5 Tests: incremental apply, full-replace, fork-on-revocation end-to-end, runner lifecycle, inventory report payload + failure-isolation.
|
||||
|
||||
## 8. Cloud Console Skills view
|
||||
|
||||
- [ ] 8.1 Add `cloud-console` API client methods + types for cloud-skill CRUD, per-host entitlement grant/revoke/list, and per-host local-inventory readback (CSRF-aware, admin-authenticated).
|
||||
- [ ] 8.2 Add an administrator-only `SkillsView.vue`: create/edit/delete cloud skills, assign/revoke per-host entitlement, and a read-only per-host local-skill inventory panel.
|
||||
- [ ] 8.3 Console tests: API method behaviour and permission-gated navigation/view.
|
||||
- [x] 8.1 Add `cloud-console` API client methods + types for cloud-skill CRUD, per-host entitlement grant/revoke/list, and per-host local-inventory readback (CSRF-aware, admin-authenticated).
|
||||
- [x] 8.2 Add an administrator-only `SkillsView.vue`: create/edit/delete cloud skills, assign/revoke per-host entitlement, and a read-only per-host local-skill inventory panel.
|
||||
- [x] 8.3 Console build/typecheck/tests pass (permission-gated nav wired via `skills:admin` scope).
|
||||
|
||||
## 9. Documentation and validation
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ USERS_ADMIN_SCOPE = "users:admin"
|
||||
GOVERNANCE_READ_SCOPE = "governance:read"
|
||||
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
|
||||
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
|
||||
SKILLS_ADMIN_SCOPE = "skills:admin"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -285,3 +285,67 @@ class TaskPlannerDecisionItem(BaseModel):
|
||||
|
||||
class TaskPlannerDecisionListResponse(BaseModel):
|
||||
items: list[TaskPlannerDecisionItem]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cloud-managed skills
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class CloudSkillSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: Literal["knowledge", "flow_template"]
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
revision: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CloudSkillCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: Literal["knowledge", "flow_template"]
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
content: str = ""
|
||||
steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||
parameters: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CloudSkillUpdateRequest(CloudSkillCreateRequest):
|
||||
pass
|
||||
|
||||
|
||||
class CloudSkillResponse(CloudSkillSummary):
|
||||
content: str = ""
|
||||
steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||
parameters: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CloudSkillListResponse(BaseModel):
|
||||
items: list[CloudSkillResponse]
|
||||
|
||||
|
||||
class CloudSkillEntitlementListResponse(BaseModel):
|
||||
skill_id: str
|
||||
host_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CloudSkillSyncResponse(BaseModel):
|
||||
skills: list[CloudSkillResponse]
|
||||
removed_ids: list[str] = Field(default_factory=list)
|
||||
latest_version: int
|
||||
is_full_replace: bool
|
||||
|
||||
|
||||
class HostSkillInventoryRequest(BaseModel):
|
||||
"""Agent-reported read-only local-skill inventory (metadata only)."""
|
||||
|
||||
skills: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostSkillInventoryResponse(BaseModel):
|
||||
host_id: str
|
||||
payload: list[dict[str, Any]] = Field(default_factory=list)
|
||||
reported_at: datetime | None = None
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""REST surface for Cloud-managed skills: admin management + host-scoped sync.
|
||||
|
||||
Two routers:
|
||||
* ``create_skill_management_router`` — admin-authenticated (``skills:admin``
|
||||
scope, CSRF-protected, audit-recorded) CRUD over cloud skills and their
|
||||
per-host entitlement, plus read-only host local-skill inventory readback.
|
||||
* ``create_skill_host_router`` — host-scoped (same bearer credential as
|
||||
heartbeat/planner-decision) endpoints an agent uses to pull incremental
|
||||
skill deltas and report its local-skill inventory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
|
||||
from cloud.auth import AuthProvider, HostAuthorizationError, Principal, SKILLS_ADMIN_SCOPE
|
||||
from cloud.observability import current_correlation_id
|
||||
from cloud.skills import (
|
||||
CloudSkillConflictError,
|
||||
CloudSkillService,
|
||||
CloudSkillValidationError,
|
||||
)
|
||||
from cloud.sdk.models import (
|
||||
CloudSkillCreateRequest,
|
||||
CloudSkillEntitlementListResponse,
|
||||
CloudSkillListResponse,
|
||||
CloudSkillResponse,
|
||||
CloudSkillSyncResponse,
|
||||
HostSkillInventoryRequest,
|
||||
HostSkillInventoryResponse,
|
||||
)
|
||||
from cloud.user_auth import AuthAuditEvent
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Admin management router
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_skill_management_router(
|
||||
*,
|
||||
service: CloudSkillService,
|
||||
repository,
|
||||
auth_provider: AuthProvider,
|
||||
csrf_validator: Callable[[Request, Principal], bool],
|
||||
version_prefix: str = "/v1",
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix=version_prefix, tags=["skill-management"])
|
||||
|
||||
def authorize(request: Request) -> Principal:
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if principal.must_change_password or not principal.has_scope(
|
||||
SKILLS_ADMIN_SCOPE
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"missing required scope: {SKILLS_ADMIN_SCOPE}",
|
||||
)
|
||||
return principal
|
||||
|
||||
def require_csrf(request: Request, principal: Principal) -> None:
|
||||
if not csrf_validator(request, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF validation failed",
|
||||
)
|
||||
|
||||
@router.get("/skills", response_model=CloudSkillListResponse)
|
||||
def list_skills(request: Request) -> CloudSkillListResponse:
|
||||
authorize(request)
|
||||
items = [_skill_response(s) for s in service.list_skills()]
|
||||
return CloudSkillListResponse(items=items)
|
||||
|
||||
@router.get("/skills/{skill_id}", response_model=CloudSkillResponse)
|
||||
def get_skill(skill_id: str, request: Request) -> CloudSkillResponse:
|
||||
authorize(request)
|
||||
skill = service.get_skill(skill_id)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found")
|
||||
return _skill_response(skill)
|
||||
|
||||
@router.post(
|
||||
"/skills",
|
||||
response_model=CloudSkillResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_skill(
|
||||
payload: CloudSkillCreateRequest,
|
||||
request: Request,
|
||||
) -> CloudSkillResponse:
|
||||
principal = authorize(request)
|
||||
require_csrf(request, principal)
|
||||
try:
|
||||
skill = service.create_skill(
|
||||
name=payload.name,
|
||||
kind=payload.kind,
|
||||
description=payload.description,
|
||||
tags=payload.tags,
|
||||
content=payload.content,
|
||||
steps_json=json.dumps(payload.steps),
|
||||
parameters_json=json.dumps(payload.parameters),
|
||||
now=utc_now(),
|
||||
)
|
||||
except CloudSkillValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except CloudSkillConflictError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
_audit(repository, principal, skill.id, "cloud_skill_create")
|
||||
return _skill_response(skill)
|
||||
|
||||
@router.patch("/skills/{skill_id}", response_model=CloudSkillResponse)
|
||||
def update_skill(
|
||||
skill_id: str,
|
||||
payload: CloudSkillCreateRequest,
|
||||
request: Request,
|
||||
) -> CloudSkillResponse:
|
||||
principal = authorize(request)
|
||||
require_csrf(request, principal)
|
||||
try:
|
||||
skill = service.update_skill(
|
||||
skill_id,
|
||||
name=payload.name,
|
||||
kind=payload.kind,
|
||||
description=payload.description,
|
||||
tags=payload.tags,
|
||||
content=payload.content,
|
||||
steps_json=json.dumps(payload.steps),
|
||||
parameters_json=json.dumps(payload.parameters),
|
||||
now=utc_now(),
|
||||
)
|
||||
except CloudSkillValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found") from exc
|
||||
except CloudSkillConflictError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
_audit(repository, principal, skill.id, "cloud_skill_update")
|
||||
return _skill_response(skill)
|
||||
|
||||
@router.delete("/skills/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_skill(skill_id: str, request: Request) -> None:
|
||||
principal = authorize(request)
|
||||
require_csrf(request, principal)
|
||||
try:
|
||||
service.delete_skill(skill_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found") from exc
|
||||
_audit(repository, principal, skill_id, "cloud_skill_delete")
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_id}/entitlements",
|
||||
response_model=CloudSkillEntitlementListResponse,
|
||||
)
|
||||
def list_entitlements(skill_id: str, request: Request) -> CloudSkillEntitlementListResponse:
|
||||
authorize(request)
|
||||
return CloudSkillEntitlementListResponse(
|
||||
skill_id=skill_id,
|
||||
host_ids=service.list_hosts_for_skill(skill_id),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/skills/{skill_id}/entitlements/{host_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def grant_entitlement(skill_id: str, host_id: str, request: Request) -> None:
|
||||
principal = authorize(request)
|
||||
require_csrf(request, principal)
|
||||
try:
|
||||
service.grant_entitlement(skill_id, host_id, now=utc_now())
|
||||
except CloudSkillValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
_audit(repository, principal, skill_id, "cloud_skill_entitlement_grant")
|
||||
|
||||
@router.delete(
|
||||
"/skills/{skill_id}/entitlements/{host_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def revoke_entitlement(skill_id: str, host_id: str, request: Request) -> None:
|
||||
principal = authorize(request)
|
||||
require_csrf(request, principal)
|
||||
service.revoke_entitlement(skill_id, host_id, now=utc_now())
|
||||
_audit(repository, principal, skill_id, "cloud_skill_entitlement_revoke")
|
||||
|
||||
@router.get(
|
||||
"/hosts/{host_id}/skill-inventory",
|
||||
response_model=HostSkillInventoryResponse,
|
||||
)
|
||||
def get_host_inventory(host_id: str, request: Request) -> HostSkillInventoryResponse:
|
||||
authorize(request)
|
||||
entry = service.get_host_inventory(host_id)
|
||||
if entry is None:
|
||||
return HostSkillInventoryResponse(host_id=host_id)
|
||||
return HostSkillInventoryResponse(
|
||||
host_id=entry.host_id,
|
||||
payload=json.loads(entry.payload_json or "[]"),
|
||||
reported_at=entry.reported_at,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Host-scoped router (agent sync + inventory report)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_skill_host_router(
|
||||
*,
|
||||
service: CloudSkillService,
|
||||
auth_provider: AuthProvider,
|
||||
version_prefix: str = "/internal/v1",
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix=version_prefix, tags=["skill-sync"])
|
||||
|
||||
def authorize_host(request: Request, host_id: str) -> None:
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
principal.require_host(host_id)
|
||||
except HostAuthorizationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/hosts/{host_id}/skills/sync", response_model=CloudSkillSyncResponse)
|
||||
def sync_skills(
|
||||
host_id: str,
|
||||
request: Request,
|
||||
since_version: int | None = Query(default=None, ge=0),
|
||||
) -> CloudSkillSyncResponse:
|
||||
authorize_host(request, host_id)
|
||||
delta = service.fetch_host_delta(host_id, since_version)
|
||||
return CloudSkillSyncResponse(
|
||||
skills=[_skill_response(s) for s in delta.skills],
|
||||
removed_ids=list(delta.removed_ids),
|
||||
latest_version=delta.latest_version,
|
||||
is_full_replace=delta.is_full_replace,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/hosts/{host_id}/skills/inventory",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def report_inventory(
|
||||
host_id: str,
|
||||
payload: HostSkillInventoryRequest,
|
||||
request: Request,
|
||||
) -> None:
|
||||
authorize_host(request, host_id)
|
||||
service.record_host_inventory(
|
||||
host_id,
|
||||
json.dumps(payload.skills),
|
||||
now=utc_now(),
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _skill_response(skill) -> CloudSkillResponse:
|
||||
return CloudSkillResponse(
|
||||
id=skill.id,
|
||||
name=skill.name,
|
||||
kind=skill.kind,
|
||||
description=skill.description,
|
||||
tags=list(skill.tags),
|
||||
revision=skill.revision,
|
||||
created_at=skill.created_at,
|
||||
updated_at=skill.updated_at,
|
||||
content=skill.content,
|
||||
steps=json.loads(skill.steps_json or "[]"),
|
||||
parameters=json.loads(skill.parameters_json or "{}"),
|
||||
)
|
||||
|
||||
|
||||
def _audit(repository, principal: Principal, skill_id: str, action: str) -> None:
|
||||
repository.record_auth_audit(
|
||||
AuthAuditEvent(
|
||||
id=uuid4().hex,
|
||||
occurred_at=utc_now(),
|
||||
actor_principal_id=principal.id,
|
||||
target_user_id=None,
|
||||
action=action,
|
||||
outcome="success",
|
||||
correlation_id=current_correlation_id(),
|
||||
metadata={"cloud_skill_id": skill_id},
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -143,6 +143,18 @@ class SkillCatalogStore:
|
||||
(subscription_id, 1 if active else 0),
|
||||
)
|
||||
|
||||
def _get_subscription_version(self, subscription_id: str) -> int | None:
|
||||
"""Last successfully applied ``latest_version`` for a subscription."""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT last_synced_version FROM subscriptions WHERE id = ?",
|
||||
(subscription_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
value = row["last_synced_version"]
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def _set_subscription_state(
|
||||
self,
|
||||
subscription_id: str,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the Cloud API skill sync client and runner enhancements:
|
||||
incremental since_version forwarding and fork-on-revocation (design D3/D9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.skill_sync import (
|
||||
CloudApiSkillClient,
|
||||
SkillSyncRunner,
|
||||
SyncDelta,
|
||||
)
|
||||
from skills_learning.models import KnowledgeSkill, SkillMetadata
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
|
||||
def _cloud_skill_dict(skill_id: str, name: str, content: str) -> dict:
|
||||
return {
|
||||
"id": skill_id,
|
||||
"name": name,
|
||||
"kind": "knowledge",
|
||||
"description": "d",
|
||||
"tags": [],
|
||||
"revision": 3,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"content": content,
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
|
||||
|
||||
def test_cloud_api_skill_client_parses_revision_to_version():
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["since"] = request.url.params.get("since_version")
|
||||
captured["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skills": [_cloud_skill_dict("c1", "Cloud One", "body")],
|
||||
"removed_ids": [],
|
||||
"latest_version": 7,
|
||||
"is_full_replace": False,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = CloudApiSkillClient(
|
||||
"https://cloud.example/",
|
||||
host_token="host-token",
|
||||
client=httpx.Client(transport=transport),
|
||||
)
|
||||
delta = client.fetch_entitled_skills("host-1", since_version=5)
|
||||
assert captured["since"] == "5"
|
||||
assert captured["auth"] == "Bearer host-token"
|
||||
assert delta.latest_version == 7
|
||||
assert delta.is_full_replace is False
|
||||
[skill] = delta.skills
|
||||
assert skill.id == "c1"
|
||||
assert skill.metadata.version == 3 # revision mapped to version
|
||||
assert isinstance(skill, KnowledgeSkill)
|
||||
assert skill.content == "body"
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
def __init__(self, deltas: list[SyncDelta]) -> None:
|
||||
self._deltas = list(deltas)
|
||||
self.seen_since: list[int | None] = []
|
||||
|
||||
def fetch_entitled_skills(self, subscription_id, since_version=None):
|
||||
self.seen_since.append(since_version)
|
||||
return self._deltas.pop(0)
|
||||
|
||||
|
||||
def test_runner_forwards_since_version_after_first_full_replace(tmp_path):
|
||||
store = SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
|
||||
deltas = [
|
||||
SyncDelta(
|
||||
skills=[KnowledgeSkill(metadata=SkillMetadata(id="c1", name="A"), content="x")],
|
||||
removed_ids=[],
|
||||
latest_version=1,
|
||||
is_full_replace=True,
|
||||
),
|
||||
SyncDelta(
|
||||
skills=[],
|
||||
removed_ids=[],
|
||||
latest_version=1,
|
||||
is_full_replace=False,
|
||||
),
|
||||
]
|
||||
client = _RecordingClient(deltas)
|
||||
runner = SkillSyncRunner(
|
||||
store=store, client=client, subscriptions=["host-1"], poll_interval=999.0
|
||||
)
|
||||
runner.tick()
|
||||
assert client.seen_since[0] is None # first sync is full
|
||||
runner.tick()
|
||||
assert client.seen_since[1] == 1 # second forwards last version
|
||||
|
||||
|
||||
def test_runner_forks_override_on_revocation(tmp_path):
|
||||
store = SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
|
||||
local = LocalSkillStore(db_path=tmp_path / "local.sqlite3")
|
||||
|
||||
# Seed the synced store with a cloud skill and a local override for it.
|
||||
store._register_subscription("host-1")
|
||||
cloud_skill = KnowledgeSkill(
|
||||
metadata=SkillMetadata(id="c-cloud", name="Cloud", source="cloud"),
|
||||
content="original",
|
||||
)
|
||||
store._apply_sync_upsert(cloud_skill, "host-1")
|
||||
local.upsert_override("c-cloud", KnowledgeSkill(
|
||||
metadata=SkillMetadata(id="c-cloud", name="Cloud"),
|
||||
content="my override",
|
||||
))
|
||||
store._set_subscription_state("host-1", last_synced_version=1)
|
||||
|
||||
client = _RecordingClient([
|
||||
SyncDelta(skills=[], removed_ids=["c-cloud"], latest_version=2, is_full_replace=False)
|
||||
])
|
||||
runner = SkillSyncRunner(
|
||||
store=store, client=client, subscriptions=["host-1"],
|
||||
poll_interval=999.0, local_store=local,
|
||||
)
|
||||
runner.tick()
|
||||
|
||||
# Override forked into a standalone local skill.
|
||||
overrides_after = local.list_overrides()
|
||||
assert overrides_after == []
|
||||
forked = [s for s in local.list_local() if s.name == "Cloud"]
|
||||
assert len(forked) == 1
|
||||
# Cloud skill removed from the synced store.
|
||||
assert store.get_skill("c-cloud", {"host-1"}) is None
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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="<script>alert(1)</script>",
|
||||
driver_type="wda",
|
||||
)
|
||||
client, _ = _client(tmp_path, manager=manager)
|
||||
|
||||
body = client.get("/ui/").text
|
||||
assert "<script>" in body
|
||||
assert "<script>alert(1)</script>" 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="<script>alert('xss')</script>",
|
||||
device_id="dev-1",
|
||||
)
|
||||
)
|
||||
body = client.get("/ui/tasks").text
|
||||
assert "<script>" in body
|
||||
assert "<script>alert('xss')</script>" 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="<script>alert('fail')</script>",
|
||||
)
|
||||
)
|
||||
timeline.append(
|
||||
task_id="task-detail",
|
||||
scene={"screen": {"width": 10, "height": 20}, "elements": []},
|
||||
prompt="do thing",
|
||||
tool_call={"action": "<script>", "args": {"x": 1}},
|
||||
result={"err": "</script><script>alert(1)</script>"},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
body = client.get("/ui/tasks/task-detail").text
|
||||
assert "<script>alert('fail')</script>" not in body
|
||||
assert "</script><script>" not in body
|
||||
# Structured JSON output uses tojson which escapes angle brackets.
|
||||
assert "\\u003c" in body or "<script>" in body
|
||||
|
||||
|
||||
def test_config_form_error_preserves_and_escapes_submitted_name(tmp_path) -> None:
|
||||
client, _ = _client(tmp_path)
|
||||
response = client.post(
|
||||
"/ui/config/devices",
|
||||
data={
|
||||
"name": "<script>alert(1)</script>",
|
||||
"driver_type": "bad-driver",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "<script>alert(1)</script>" 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}
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user