Compare commits
6
Commits
52e442790a
...
fd0ea3a066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0ea3a066 | ||
|
|
8300c3b6b7 | ||
|
|
e00c50e703 | ||
|
|
0d944ec97d | ||
|
|
a8ba2312fc | ||
|
|
cbfdb2ae39 |
@@ -7,6 +7,5 @@ __pycache__
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
tasks
|
tasks
|
||||||
console/node_modules
|
|
||||||
cloud-console/node_modules
|
cloud-console/node_modules
|
||||||
cloud-console/dist
|
cloud-console/dist
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ uv build --package device-agent-runtime
|
|||||||
uv build --package device-cloud-platform
|
uv build --package device-cloud-platform
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vue/Vite application under `console/` remains an independent npm project;
|
The Runtime API ships a same-origin operator console at `/ui/`, rendered through
|
||||||
uv does not install or modify its JavaScript dependencies.
|
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
|
## Project Direction
|
||||||
|
|
||||||
|
|||||||
+151
-82
@@ -24,58 +24,160 @@ class RuntimeConfigRequest(BaseModel):
|
|||||||
max_steps: int
|
max_steps: int
|
||||||
|
|
||||||
|
|
||||||
def create_console_router(
|
class ConsoleService:
|
||||||
*,
|
"""API-local typed operations shared by JSON and HTML console routers.
|
||||||
device_manager: DeviceManager,
|
|
||||||
metadata_store: TaskMetadataStore,
|
Stays in the ``api`` layer: it only depends on the existing injected
|
||||||
timeline: Timeline,
|
Runtime state (device manager, metadata store, timeline, config store,
|
||||||
config_store: DeviceConfigStore,
|
task runner) and does not introduce HTTP/UI concerns below this layer.
|
||||||
task_runner: TaskRunner,
|
Page and JSON handlers receive the same instance so validation, driver
|
||||||
) -> Any:
|
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
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
router = APIRouter(prefix="/console", tags=["console"])
|
router = APIRouter(prefix="/console", tags=["console"])
|
||||||
|
|
||||||
@router.get("/devices")
|
@router.get("/devices")
|
||||||
def devices() -> list[dict[str, Any]]:
|
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)
|
@router.post("/devices", status_code=status.HTTP_201_CREATED)
|
||||||
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
|
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
driver_factory = build_driver_factory(
|
return service.register_device(
|
||||||
request.driver_type,
|
driver_type=request.driver_type,
|
||||||
request.connection_info,
|
connection_info=request.connection_info,
|
||||||
|
name=request.name,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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(
|
@router.delete(
|
||||||
"/devices/{device_id}",
|
"/devices/{device_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
response_model=None,
|
response_model=None,
|
||||||
)
|
)
|
||||||
def unregister_device(device_id: str) -> Response:
|
def unregister_device(device_id: str) -> Response:
|
||||||
if not _has_device(device_manager, device_id):
|
try:
|
||||||
raise HTTPException(status_code=404, detail="device not found")
|
service.unregister_device(device_id)
|
||||||
device_manager.unregister_device(device_id)
|
except KeyError as exc:
|
||||||
config_store.remove(device_id)
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
@@ -83,66 +185,33 @@ def create_console_router(
|
|||||||
device_id: str | None = None,
|
device_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = metadata_store.list_tasks()
|
return service.list_tasks(device_id=device_id, status=status)
|
||||||
if device_id is not None:
|
|
||||||
rows = [row for row in rows if row["device_id"] == device_id]
|
|
||||||
if status is not None:
|
|
||||||
rows = [row for row in rows if row["status"] == status]
|
|
||||||
return rows
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
def task_detail(task_id: str) -> dict[str, Any]:
|
def task_detail(task_id: str) -> dict[str, Any]:
|
||||||
task = metadata_store.get_task(task_id)
|
try:
|
||||||
if task is None:
|
return service.get_task(task_id)
|
||||||
raise HTTPException(status_code=404, detail="task not found")
|
except KeyError as exc:
|
||||||
return task
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/timeline")
|
@router.get("/tasks/{task_id}/timeline")
|
||||||
def task_timeline(task_id: str) -> list[dict[str, Any]]:
|
def task_timeline(task_id: str) -> list[dict[str, Any]]:
|
||||||
if metadata_store.get_task(task_id) is None:
|
try:
|
||||||
raise HTTPException(status_code=404, detail="task not found")
|
return service.get_task_timeline(task_id)
|
||||||
return [_inline_screenshot(record) for record in timeline.read(task_id)]
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
def runtime_config() -> dict[str, int]:
|
def runtime_config() -> dict[str, int]:
|
||||||
return {"max_steps": _runner_max_steps(task_runner)}
|
return service.get_runtime_config()
|
||||||
|
|
||||||
@router.put("/config")
|
@router.put("/config")
|
||||||
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
|
def update_runtime_config(
|
||||||
if request.max_steps <= 0:
|
request: RuntimeConfigRequest,
|
||||||
raise HTTPException(status_code=400, detail="max_steps must be positive")
|
) -> dict[str, int]:
|
||||||
_set_runner_max_steps(task_runner, request.max_steps)
|
try:
|
||||||
config_store.set_setting("max_steps", request.max_steps)
|
return service.update_runtime_config(max_steps=request.max_steps)
|
||||||
return {"max_steps": request.max_steps}
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
return router
|
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 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 api.errors import semantic_error
|
||||||
from core.models import Task
|
from core.models import Task
|
||||||
from device.manager import DEFAULT_MANAGER, DeviceManager
|
from device.manager import DEFAULT_MANAGER, DeviceManager
|
||||||
@@ -26,28 +25,8 @@ def create_app(
|
|||||||
timeline: Timeline | None = None,
|
timeline: Timeline | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from pydantic import BaseModel
|
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
|
device_manager = manager or DEFAULT_MANAGER
|
||||||
store = metadata_store or TaskMetadataStore()
|
store = metadata_store or TaskMetadataStore()
|
||||||
@@ -63,12 +42,6 @@ def create_app(
|
|||||||
)
|
)
|
||||||
_apply_max_steps(runner, max_steps)
|
_apply_max_steps(runner, max_steps)
|
||||||
app = FastAPI(title="Apex Agent API")
|
app = FastAPI(title="Apex Agent API")
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
class TapRequest(BaseModel):
|
class TapRequest(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
@@ -85,6 +58,18 @@ def create_app(
|
|||||||
goal: str
|
goal: str
|
||||||
device_id: 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")
|
@app.get("/devices")
|
||||||
def devices() -> list[dict[str, Any]]:
|
def devices() -> list[dict[str, Any]]:
|
||||||
return [device.to_dict() for device in device_manager.list_devices()]
|
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")
|
raise HTTPException(status_code=404, detail="task not found")
|
||||||
return task
|
return task
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(create_console_router(console_service))
|
||||||
create_console_router(
|
app.include_router(create_console_web_router(console_service))
|
||||||
device_manager=device_manager,
|
mount_console_assets(app)
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
+106
-1
@@ -21,6 +21,7 @@ from skills_learning.models import (
|
|||||||
KnowledgeSkill,
|
KnowledgeSkill,
|
||||||
Skill,
|
Skill,
|
||||||
)
|
)
|
||||||
|
from storage.local_skills import LocalSkillStore
|
||||||
from storage.skill_catalog import SkillCatalogStore
|
from storage.skill_catalog import SkillCatalogStore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
class SkillSyncRunner:
|
||||||
"""Drives periodic sync between the Subscription Platform and local catalog.
|
"""Drives periodic sync between the Subscription Platform and local catalog.
|
||||||
|
|
||||||
@@ -160,11 +255,13 @@ class SkillSyncRunner:
|
|||||||
client: SubscriptionClient,
|
client: SubscriptionClient,
|
||||||
subscriptions: list[str],
|
subscriptions: list[str],
|
||||||
poll_interval: float = 300.0,
|
poll_interval: float = 300.0,
|
||||||
|
local_store: LocalSkillStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.client = client
|
self.client = client
|
||||||
self.subscriptions = list(subscriptions)
|
self.subscriptions = list(subscriptions)
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
|
self.local_store = local_store
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._tick_lock = threading.Lock()
|
self._tick_lock = threading.Lock()
|
||||||
@@ -209,8 +306,11 @@ class SkillSyncRunner:
|
|||||||
self._stop.wait(self.poll_interval)
|
self._stop.wait(self.poll_interval)
|
||||||
|
|
||||||
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||||
|
since_version = self.store._get_subscription_version(subscription_id)
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
log.warning(
|
log.warning(
|
||||||
"skill sync fetch failed for %s: %s", subscription_id, exc
|
"skill sync fetch failed for %s: %s", subscription_id, exc
|
||||||
@@ -233,6 +333,11 @@ class SkillSyncRunner:
|
|||||||
for skill in delta.skills:
|
for skill in delta.skills:
|
||||||
self.store._apply_sync_upsert(skill, subscription_id)
|
self.store._apply_sync_upsert(skill, subscription_id)
|
||||||
for skill_id in delta.removed_ids:
|
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._apply_sync_remove(skill_id)
|
||||||
self.store._set_subscription_state(
|
self.store._set_subscription_state(
|
||||||
subscription_id,
|
subscription_id,
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ button:disabled {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 248px minmax(0, 1fr);
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
@@ -57,6 +62,18 @@ button:disabled {
|
|||||||
gap: 10px;
|
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 strong,
|
||||||
.brand span {
|
.brand span {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -140,13 +157,6 @@ button:disabled {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tasks-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -336,7 +346,7 @@ select {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-row.selected {
|
.task-row:hover {
|
||||||
border-color: #2f7c67;
|
border-color: #2f7c67;
|
||||||
box-shadow: 0 0 0 2px #d9efe8;
|
box-shadow: 0 0 0 2px #d9efe8;
|
||||||
}
|
}
|
||||||
@@ -480,16 +490,6 @@ pre {
|
|||||||
color: #1f6b4a;
|
color: #1f6b4a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -498,55 +498,21 @@ pre {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
flex-direction: row;
|
||||||
border-right: 0;
|
align-items: center;
|
||||||
border-bottom: 1px solid #d9dde5;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-auto-flow: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tasks-layout,
|
|
||||||
.timeline-stage {
|
.timeline-stage {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-browser {
|
.detail-grid,
|
||||||
max-height: none;
|
.form-grid {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.workspace {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar,
|
|
||||||
.section-title,
|
|
||||||
.device-row {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar,
|
|
||||||
.device-row,
|
|
||||||
.settings-form {
|
|
||||||
flex-direction: column;
|
|
||||||
grid-template-columns: 1fr;
|
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.database import CloudDatabase
|
||||||
from cloud.internal_api.api import create_internal_router
|
from cloud.internal_api.api import create_internal_router
|
||||||
from cloud.llm_providers import LlmProviderService
|
from cloud.llm_providers import LlmProviderService
|
||||||
|
from cloud.skills import CloudSkillService
|
||||||
from cloud.plugins import PluginRegistry
|
from cloud.plugins import PluginRegistry
|
||||||
from cloud.observability import (
|
from cloud.observability import (
|
||||||
CORRELATION_HEADER,
|
CORRELATION_HEADER,
|
||||||
@@ -46,6 +47,7 @@ from cloud.schema import require_current_schema
|
|||||||
from cloud.sdk.api import create_cloud_router
|
from cloud.sdk.api import create_cloud_router
|
||||||
from cloud.sdk.governance_api import create_governance_router
|
from cloud.sdk.governance_api import create_governance_router
|
||||||
from cloud.sdk.llm_provider_api import create_llm_provider_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.sdk.user_api import create_user_auth_router
|
||||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||||
from core.models import utc_now
|
from core.models import utc_now
|
||||||
@@ -130,6 +132,7 @@ def create_app(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
llm_provider_service = LlmProviderService(repository)
|
llm_provider_service = LlmProviderService(repository)
|
||||||
|
cloud_skill_service = CloudSkillService(repository)
|
||||||
auth_provider = ChainedAuthProvider(
|
auth_provider = ChainedAuthProvider(
|
||||||
(
|
(
|
||||||
configured_auth_provider,
|
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(
|
app.include_router(
|
||||||
create_internal_router(
|
create_internal_router(
|
||||||
pool=pool,
|
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.policy_cache import HostPolicyCacheStore
|
||||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||||
from host_agent.retention import prune_task_history
|
from host_agent.retention import prune_task_history
|
||||||
|
from host_agent.skill_sync import HostAgentSkillSync
|
||||||
from host_agent.status import AgentStatusTracker
|
from host_agent.status import AgentStatusTracker
|
||||||
from host_agent.web.app import create_console_app
|
from host_agent.web.app import create_console_app
|
||||||
from host_agent.web.auth import SessionManager
|
from host_agent.web.auth import SessionManager
|
||||||
@@ -42,6 +43,7 @@ class HostAgentApplication:
|
|||||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||||
dependency_supervisor: DependencySupervisor | None = None
|
dependency_supervisor: DependencySupervisor | None = None
|
||||||
instance_lock: InstanceLock | None = None
|
instance_lock: InstanceLock | None = None
|
||||||
|
skill_sync: HostAgentSkillSync | None = None
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
asyncio.run(self.run_async())
|
asyncio.run(self.run_async())
|
||||||
@@ -60,6 +62,8 @@ class HostAgentApplication:
|
|||||||
)
|
)
|
||||||
heartbeat_stop = asyncio.Event()
|
heartbeat_stop = asyncio.Event()
|
||||||
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
||||||
|
if self.skill_sync is not None:
|
||||||
|
self.skill_sync.start()
|
||||||
console_task = (
|
console_task = (
|
||||||
asyncio.create_task(self.console_server.serve())
|
asyncio.create_task(self.console_server.serve())
|
||||||
if self.console_server is not None
|
if self.console_server is not None
|
||||||
@@ -110,6 +114,8 @@ class HostAgentApplication:
|
|||||||
finally:
|
finally:
|
||||||
if self.console_enrollment_client is not None:
|
if self.console_enrollment_client is not None:
|
||||||
self.console_enrollment_client.close()
|
self.console_enrollment_client.close()
|
||||||
|
if self.skill_sync is not None:
|
||||||
|
self.skill_sync.stop()
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
if self.instance_lock is not None:
|
if self.instance_lock is not None:
|
||||||
self.instance_lock.release()
|
self.instance_lock.release()
|
||||||
@@ -263,6 +269,9 @@ def create_application(
|
|||||||
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
||||||
resolved_config
|
resolved_config
|
||||||
)
|
)
|
||||||
|
skill_sync: HostAgentSkillSync | None = None
|
||||||
|
if resolved_config.host_id and resolved_config.token:
|
||||||
|
skill_sync = HostAgentSkillSync(resolved_config)
|
||||||
return HostAgentApplication(
|
return HostAgentApplication(
|
||||||
client=client,
|
client=client,
|
||||||
heartbeat=heartbeat,
|
heartbeat=heartbeat,
|
||||||
@@ -271,6 +280,7 @@ def create_application(
|
|||||||
console_enrollment_client=console_enrollment_client,
|
console_enrollment_client=console_enrollment_client,
|
||||||
dependency_supervisor=dependency_supervisor,
|
dependency_supervisor=dependency_supervisor,
|
||||||
instance_lock=instance_lock,
|
instance_lock=instance_lock,
|
||||||
|
skill_sync=skill_sync,
|
||||||
)
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
instance_lock.release()
|
instance_lock.release()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class HostAgentConfig:
|
|||||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
task_artifact_dir: Path = Path("host_agent_data/history")
|
||||||
task_retention_max_count: int = 50
|
task_retention_max_count: int = 50
|
||||||
task_retention_max_age_days: int = 7
|
task_retention_max_age_days: int = 7
|
||||||
|
skill_sync_interval_seconds: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
def load_host_agent_config(
|
def load_host_agent_config(
|
||||||
@@ -156,6 +157,9 @@ def load_host_agent_config(
|
|||||||
task_retention_max_age_days=_positive_int(
|
task_retention_max_age_days=_positive_int(
|
||||||
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
|
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:
|
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
|
||||||
raise HostAgentConfigurationError(
|
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 type { Component } from "vue";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
BookOpen,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
|
|||||||
import PluginsView from "./views/PluginsView.vue";
|
import PluginsView from "./views/PluginsView.vue";
|
||||||
import UsersView from "./views/UsersView.vue";
|
import UsersView from "./views/UsersView.vue";
|
||||||
import LlmProvidersView from "./views/LlmProvidersView.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 activeView = ref<ViewId>("tasks");
|
||||||
const currentUser = ref<CloudUser | null>(null);
|
const currentUser = ref<CloudUser | null>(null);
|
||||||
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
|
|||||||
currentUser.value?.scopes.includes("governance:admin")),
|
currentUser.value?.scopes.includes("governance:admin")),
|
||||||
);
|
);
|
||||||
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers: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 isAuthenticated = computed(() => currentUser.value !== null);
|
||||||
const currentUserLabel = computed(() =>
|
const currentUserLabel = computed(() =>
|
||||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
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) {
|
if (canAdminProviders.value) {
|
||||||
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||||
}
|
}
|
||||||
|
if (canAdminSkills.value) {
|
||||||
|
items.push({ id: "skills", label: "Skills", icon: BookOpen });
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
|
|||||||
return UsersView;
|
return UsersView;
|
||||||
case "providers":
|
case "providers":
|
||||||
return LlmProvidersView;
|
return LlmProvidersView;
|
||||||
|
case "skills":
|
||||||
|
return SkillsView;
|
||||||
default:
|
default:
|
||||||
return TasksView;
|
return TasksView;
|
||||||
}
|
}
|
||||||
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
|
|||||||
:can-admin-governance="canAdminGovernance"
|
:can-admin-governance="canAdminGovernance"
|
||||||
/>
|
/>
|
||||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
<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" />
|
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import type {
|
|||||||
TokenUsageEvent,
|
TokenUsageEvent,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserSubmissionPolicy,
|
UserSubmissionPolicy,
|
||||||
|
CloudSkill,
|
||||||
|
CloudSkillListResponse,
|
||||||
|
CloudSkillEntitlementsResponse,
|
||||||
|
HostSkillInventoryResponse,
|
||||||
|
CloudSkillKind,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||||
@@ -328,3 +333,76 @@ export function deleteLlmProviderProfile(
|
|||||||
{ method: "DELETE" },
|
{ 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 {
|
export interface PlannerDecisionListResponse {
|
||||||
items: PlannerDecisionItem[];
|
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
|
explicit workspace source; the Runtime distribution must never depend on or
|
||||||
package `cloud`.
|
package `cloud`.
|
||||||
|
|
||||||
All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
|
All Python members share the committed root `uv.lock`. The Runtime operator
|
||||||
remains outside the Python workspace and keeps its independent npm lifecycle.
|
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
|
## Change Discipline
|
||||||
|
|
||||||
|
|||||||
+11
-15
@@ -331,23 +331,19 @@ curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
|
|||||||
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
|
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 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
|
- `/ui/`:设备状态面板,约每 10 秒自动刷新一次;
|
||||||
cd console
|
- `/ui/tasks`:任务列表与筛选;
|
||||||
npm install
|
- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图);
|
||||||
npm run dev
|
- `/ui/config`:登记/移除设备、调整 `max_steps`。
|
||||||
```
|
|
||||||
|
|
||||||
Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
|
已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中
|
||||||
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
|
重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与
|
||||||
操作只写入配置,不会自动 connect。
|
`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console
|
||||||
|
仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。
|
||||||
如果不想为 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`,不支持热更新。
|
|
||||||
|
|
||||||
## 9. 启动云端受管 Host Agent
|
## 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. 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`).
|
- [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`).
|
||||||
- [ ] 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.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.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. 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.
|
- [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.
|
||||||
- [ ] 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.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.
|
- [x] 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.4 Tests: first-sync full replace, incremental delta after change, foreign-host/unauthenticated rejection, inventory report acceptance + readback.
|
||||||
|
|
||||||
## 4. Agent persistent local skill store
|
## 4. Agent persistent local skill store
|
||||||
|
|
||||||
@@ -38,17 +38,17 @@
|
|||||||
|
|
||||||
## 7. Sync repoint + runner wiring + inventory report
|
## 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.
|
- [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.
|
||||||
- [ ] 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.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`.
|
- [x] 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).
|
- [x] 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.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. 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).
|
- [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).
|
||||||
- [ ] 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.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.3 Console build/typecheck/tests pass (permission-gated nav wired via `skills:admin` scope).
|
||||||
|
|
||||||
## 9. Documentation and validation
|
## 9. Documentation and validation
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ USERS_ADMIN_SCOPE = "users:admin"
|
|||||||
GOVERNANCE_READ_SCOPE = "governance:read"
|
GOVERNANCE_READ_SCOPE = "governance:read"
|
||||||
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
|
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
|
||||||
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
|
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
|
||||||
|
SKILLS_ADMIN_SCOPE = "skills:admin"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -285,3 +285,67 @@ class TaskPlannerDecisionItem(BaseModel):
|
|||||||
|
|
||||||
class TaskPlannerDecisionListResponse(BaseModel):
|
class TaskPlannerDecisionListResponse(BaseModel):
|
||||||
items: list[TaskPlannerDecisionItem]
|
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",
|
"Appium-Python-Client>=5.1.1",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
"jinja2>=3.1",
|
||||||
"mcp>=1.27,<2",
|
"mcp>=1.27,<2",
|
||||||
"openai>=1.0.0",
|
"openai>=1.0.0",
|
||||||
"paddlepaddle>=3.0.0",
|
"paddlepaddle>=3.0.0",
|
||||||
"paddleocr>=3.0.0",
|
"paddleocr>=3.0.0",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,6 +55,13 @@ include = [
|
|||||||
"workflow*",
|
"workflow*",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
api = [
|
||||||
|
"templates/runtime_console/*.html",
|
||||||
|
"static/runtime_console/*.css",
|
||||||
|
"static/runtime_console/*.js",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-ra --import-mode=importlib"
|
addopts = "-ra --import-mode=importlib"
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ class SkillCatalogStore:
|
|||||||
(subscription_id, 1 if active else 0),
|
(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(
|
def _set_subscription_state(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
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",
|
"task-old",
|
||||||
]
|
]
|
||||||
assert [
|
assert [
|
||||||
task["id"]
|
task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
|
||||||
for task in client.get("/console/tasks?device_id=iphone-1").json()
|
|
||||||
] == ["task-old"]
|
] == ["task-old"]
|
||||||
assert [task["id"] for task in client.get("/console/tasks?status=running").json()] == [
|
assert [
|
||||||
"task-new"
|
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/task-old").json()["goal"] == "open settings"
|
||||||
assert client.get("/console/tasks/missing").status_code == 404
|
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"))
|
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||||
client, metadata_store = _client(tmp_path, timeline=timeline)
|
client, metadata_store = _client(tmp_path, timeline=timeline)
|
||||||
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
|
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 runner.config.max_steps == 31
|
||||||
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
|
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
|
||||||
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
|
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 = "appium-python-client" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "jinja2" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "paddleocr" },
|
{ name = "paddleocr" },
|
||||||
{ name = "paddlepaddle" },
|
{ name = "paddlepaddle" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -421,10 +423,12 @@ requires-dist = [
|
|||||||
{ name = "appium-python-client", specifier = ">=5.1.1" },
|
{ name = "appium-python-client", specifier = ">=5.1.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1" },
|
||||||
{ name = "mcp", specifier = ">=1.27,<2" },
|
{ name = "mcp", specifier = ">=1.27,<2" },
|
||||||
{ name = "openai", specifier = ">=1.0.0" },
|
{ name = "openai", specifier = ">=1.0.0" },
|
||||||
{ name = "paddleocr", specifier = ">=3.0.0" },
|
{ name = "paddleocr", specifier = ">=3.0.0" },
|
||||||
{ name = "paddlepaddle", 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" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user