feat(host-agent): make execution history authoritative
Tests / Test failed: 2, passed: 830

This commit is contained in:
2026-07-15 11:46:27 +08:00
parent ccde30e378
commit 77d4813bb2
46 changed files with 890 additions and 3132 deletions
+9 -10
View File
@@ -16,10 +16,10 @@ contracts.
- `driver/`: the `Driver` contract, concrete driver adapters, and driver-type
registry.
- `device/`: device lifecycle and active driver management.
- `tools/`: device capabilities exposed to runtime and API layers.
- `tools/`: device capabilities exposed to Runtime and adapter layers.
- `perception/`: screen-to-`Scene` perception behind `PerceptionProvider`.
- `runtime/`: planning and execution orchestration.
- `api/`: REST/MCP transport adapters.
- `api/`: MCP and supporting integration adapters.
- `storage/`: timeline, task, and device configuration persistence.
- `packages/cloud-platform/`: cloud scheduling, device pooling, plugins, and
the Python cloud SDK as the `device-cloud-platform` workspace member.
@@ -54,12 +54,11 @@ uv build --package device-agent-runtime
uv build --package device-cloud-platform
```
The Runtime API ships a same-origin operator console at `/ui/`, rendered through
Jinja2 templates packaged with `device-agent-runtime`. Start the API
(`uvicorn api.rest:create_app --factory`) and open `/` (it redirects to `/ui/`).
The `/console/*` JSON endpoints remain available for programmatic clients. The
console assumes a trusted local network; it has no authentication, authorization,
or CSRF protection.
Runtime is an in-process execution library, not a standalone HTTP service. The
Host Agent console at `http://127.0.0.1:8765/tasks` is the authenticated
operator view for the tasks that actually execute on that Host, including
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
remains the fleet-level view for dispatch status and Cloud-proxy planner history.
## Project Direction
@@ -73,5 +72,5 @@ invariants future changes must preserve are in
or deployed PostgreSQL, configure credentials and Runtime AI planning, and
perform orderly shutdown or rollback.
- [macOS migration and real iPhone setup](docs/MACOS_IPHONE_SETUP.md): install
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and start a
connected Runtime API.
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and run a
connected Host Agent.
-225
View File
@@ -1,225 +0,0 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
from uuid import uuid4
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from pydantic import BaseModel, Field
from runtime.task import TaskRunner
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
class RegisterDeviceRequest(BaseModel):
driver_type: str
connection_info: dict[str, Any] = Field(default_factory=dict)
name: str | None = None
class RuntimeConfigRequest(BaseModel):
max_steps: int
class ConsoleService:
"""API-local typed operations shared by JSON and HTML console routers.
Stays in the ``api`` layer: it only depends on the existing injected
Runtime state (device manager, metadata store, timeline, config store,
task runner) and does not introduce HTTP/UI concerns below this layer.
Page and JSON handlers receive the same instance so validation, driver
allow-listing, persistence, and error mapping cannot drift apart.
"""
def __init__(
self,
*,
device_manager: DeviceManager,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config_store: DeviceConfigStore,
task_runner: TaskRunner,
) -> None:
self._device_manager = device_manager
self._metadata_store = metadata_store
self._timeline = timeline
self._config_store = config_store
self._task_runner = task_runner
def list_devices(self) -> list[dict[str, Any]]:
return [device.to_dict() for device in self._device_manager.list_devices()]
def register_device(
self,
*,
driver_type: str,
connection_info: dict[str, Any],
name: str | None,
) -> dict[str, Any]:
driver_factory = build_driver_factory(driver_type, connection_info)
device_id = uuid4().hex
device = self._device_manager.register_device(
device_id,
driver_factory,
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
self._config_store.add(
device_id=device_id,
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
return device.to_dict()
def unregister_device(self, device_id: str) -> None:
if not self._has_device(device_id):
raise KeyError("device not found")
self._device_manager.unregister_device(device_id)
self._config_store.remove(device_id)
def list_tasks(
self,
*,
device_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
rows = self._metadata_store.list_tasks()
if device_id is not None:
rows = [row for row in rows if row["device_id"] == device_id]
if status is not None:
rows = [row for row in rows if row["status"] == status]
return rows
def get_task(self, task_id: str) -> dict[str, Any]:
task = self._metadata_store.get_task(task_id)
if task is None:
raise KeyError("task not found")
return task
def get_task_timeline(self, task_id: str) -> list[dict[str, Any]]:
if self._metadata_store.get_task(task_id) is None:
raise KeyError("task not found")
return [
self._inline_screenshots(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_screenshots(record: dict[str, Any]) -> dict[str, Any]:
payload = dict(record)
for path_key, image_key in (
("before_screenshot_path", "before_image_base64"),
("after_screenshot_path", "after_image_base64"),
("screenshot_path", "image_base64"),
):
screenshot_path = payload.get(path_key)
if not screenshot_path:
continue
path = Path(str(screenshot_path))
if path.exists():
payload[image_key] = base64.b64encode(path.read_bytes()).decode("ascii")
if "after_image_base64" not in payload and "image_base64" in payload:
payload["after_image_base64"] = payload["image_base64"]
elif "after_image_base64" in payload:
payload["image_base64"] = payload["after_image_base64"]
return payload
def _runner_max_steps(self) -> int:
config = getattr(self._task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
return int(config.max_steps)
def _set_runner_max_steps(self, max_steps: int) -> None:
config = getattr(self._task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
config.max_steps = max_steps
def create_console_router(service: ConsoleService) -> Any:
from fastapi import APIRouter, HTTPException, Response, status
router = APIRouter(prefix="/console", tags=["console"])
@router.get("/devices")
def devices() -> list[dict[str, Any]]:
return service.list_devices()
@router.post("/devices", status_code=status.HTTP_201_CREATED)
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
try:
return service.register_device(
driver_type=request.driver_type,
connection_info=request.connection_info,
name=request.name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete(
"/devices/{device_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def unregister_device(device_id: str) -> Response:
try:
service.unregister_device(device_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/tasks")
def tasks(
device_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
return service.list_tasks(device_id=device_id, status=status)
@router.get("/tasks/{task_id}")
def task_detail(task_id: str) -> dict[str, Any]:
try:
return service.get_task(task_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/tasks/{task_id}/timeline")
def task_timeline(task_id: str) -> list[dict[str, Any]]:
try:
return service.get_task_timeline(task_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/config")
def runtime_config() -> dict[str, int]:
return service.get_runtime_config()
@router.put("/config")
def update_runtime_config(
request: RuntimeConfigRequest,
) -> dict[str, int]:
try:
return service.update_runtime_config(max_steps=request.max_steps)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return router
-360
View File
@@ -1,360 +0,0 @@
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 _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_results = record.get("ocr_results")
if not isinstance(raw_results, list):
return []
return [result for result in raw_results if isinstance(result, dict)]
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
tool_call = record.get("tool_call")
if not isinstance(tool_call, dict):
return []
if tool_call.get("action") not in {"get_ui_tree", "ui_tree"}:
return []
step_result = record.get("result")
if not isinstance(step_result, dict):
return []
raw_nodes = step_result.get("result")
if not isinstance(raw_nodes, list):
return []
return [node for node in raw_nodes if isinstance(node, dict)]
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,
ocr_results=_ocr_results(current_step)
if current_step is not None
else [],
ui_tree_nodes=_ui_tree_nodes(current_step)
if current_step is not None
else [],
)
)
@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",
)
-171
View File
@@ -1,171 +0,0 @@
from typing import Any
from api.console import ConsoleService, create_console_router
from api.console_web import create_console_web_router, mount_console_assets
from api.errors import semantic_error
from core.models import Task
from device.manager import DEFAULT_MANAGER, DeviceManager
from driver.registry import build_driver_factory
from runtime.executor import Executor, default_tool_registry
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.device_config import DEFAULT_MAX_STEPS, DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tools.launch_app import launch_app
from tools.screenshot import take_screenshot
from tools.tap import tap
def create_app(
*,
manager: DeviceManager | None = None,
task_runner: TaskRunner | None = None,
metadata_store: TaskMetadataStore | None = None,
device_config_store: DeviceConfigStore | None = None,
timeline: Timeline | None = None,
) -> Any:
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
device_manager = manager or DEFAULT_MANAGER
store = metadata_store or TaskMetadataStore()
config_store = device_config_store or DeviceConfigStore()
timeline_store = timeline or Timeline()
max_steps = _load_max_steps(config_store)
_reload_device_configs(device_manager, config_store)
runner = task_runner or TaskRunner(
metadata_store=store,
executor=Executor(tools=default_tool_registry(manager=device_manager)),
timeline=timeline_store,
config=TaskRunnerConfig(max_steps=max_steps),
)
_apply_max_steps(runner, max_steps)
app = FastAPI(title="Apex Agent API")
class TapRequest(BaseModel):
x: float
y: float
class ScreenshotResponse(BaseModel):
image_base64: str
mime_type: str = "image/png"
class LaunchRequest(BaseModel):
app_id: str
class AgentTaskRequest(BaseModel):
goal: str
device_id: str
console_service = ConsoleService(
device_manager=device_manager,
metadata_store=store,
timeline=timeline_store,
config_store=config_store,
task_runner=runner,
)
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
return RedirectResponse(url="/ui/")
@app.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@app.post("/devices/{device_id}/tap")
def tap_device(device_id: str, request: TapRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: tap(
request.x,
request.y,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/devices/{device_id}/screenshot")
def screenshot_device(device_id: str) -> dict[str, str]:
import base64
image = _raise_semantic(
lambda: take_screenshot(device_id, manager=device_manager)
)
response = ScreenshotResponse(
image_base64=base64.b64encode(image).decode("ascii")
)
return response.model_dump()
@app.post("/devices/{device_id}/launch")
def launch_device(device_id: str, request: LaunchRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: launch_app(
request.app_id,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/agent/task")
def start_task(
request: AgentTaskRequest,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
task = Task(goal=request.goal, device_id=request.device_id)
store.create_task(task)
background_tasks.add_task(runner.run, task)
return {"task_id": task.id, "status": task.status}
@app.get("/task/{task_id}")
def get_task(task_id: str) -> dict[str, Any]:
task = store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
app.include_router(create_console_router(console_service))
app.include_router(create_console_web_router(console_service))
mount_console_assets(app)
return app
def _raise_semantic(func: Any) -> Any:
from fastapi import HTTPException
try:
return func()
except Exception as exc:
raise HTTPException(status_code=400, detail=semantic_error(exc)) from exc
def _load_max_steps(config_store: DeviceConfigStore) -> int:
raw_value = config_store.get_setting("max_steps")
try:
max_steps = int(raw_value) if raw_value is not None else DEFAULT_MAX_STEPS
except ValueError:
return DEFAULT_MAX_STEPS
if max_steps <= 0:
return DEFAULT_MAX_STEPS
return max_steps
def _apply_max_steps(task_runner: Any, max_steps: int) -> None:
config = getattr(task_runner, "config", None)
if config is not None and hasattr(config, "max_steps"):
config.max_steps = max_steps
def _reload_device_configs(
device_manager: DeviceManager,
config_store: DeviceConfigStore,
) -> None:
for config in config_store.list():
device_manager.register_device(
config["device_id"],
build_driver_factory(config["driver_type"], config["connection_info"]),
name=config["name"],
driver_type=config["driver_type"],
connection_info=config["connection_info"],
)
-623
View File
@@ -1,623 +0,0 @@
:root {
color: #202124;
background: #f6f7f9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
letter-spacing: 0;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d9dde5;
background: #ffffff;
padding: 20px 16px;
}
.brand {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.brand-icon {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: #2f7c67;
color: #ffffff;
font-size: 12px;
font-weight: 700;
}
.brand strong,
.brand span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand strong {
font-size: 16px;
}
.brand span {
color: #667085;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
}
.nav-button,
.icon-text-button,
.icon-button,
.task-row {
border: 1px solid #d4d9e2;
border-radius: 8px;
background: #ffffff;
color: #202124;
}
.nav-button,
.icon-text-button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 11px;
}
.nav-button {
width: 100%;
justify-content: flex-start;
}
.nav-button.active {
border-color: #2f7c67;
background: #e7f4ef;
color: #1f5f4e;
}
.workspace {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.topbar p {
margin: 4px 0 0;
color: #667085;
font-size: 13px;
}
.view-grid,
.config-layout {
display: grid;
gap: 16px;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
border: 1px solid #d9dde5;
border-radius: 8px;
background: #ffffff;
}
.metric {
padding: 14px;
}
.metric-label {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
font-size: 26px;
}
.panel {
padding: 16px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
line-height: 1.3;
}
.empty-state {
display: grid;
place-items: center;
gap: 10px;
min-height: 170px;
border: 1px dashed #c8ced8;
border-radius: 8px;
color: #667085;
text-align: center;
padding: 22px;
}
.empty-state.compact {
min-height: 88px;
}
.device-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 58px;
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px 12px;
}
.device-row strong,
.device-row span,
.task-goal,
.task-meta {
overflow-wrap: anywhere;
}
.device-row span {
display: block;
color: #667085;
font-size: 12px;
}
.row-meta {
display: flex;
align-items: center;
gap: 8px;
}
.driver-label,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
font-weight: 650;
}
.driver-label {
background: #eef0f4;
color: #444b56;
}
.status-pill.idle,
.status-pill.completed {
background: #e5f4ec;
color: #1f6b4a;
}
.status-pill.busy,
.status-pill.running {
background: #e8f1fb;
color: #275b8d;
}
.status-pill.created,
.status-pill.cancelled {
background: #f0edf8;
color: #67508f;
}
.status-pill.offline,
.status-pill.failed,
.status-pill.error {
background: #fdebea;
color: #a43c37;
}
.filters,
.form-grid,
.settings-form,
.detail-grid {
display: grid;
gap: 12px;
}
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
label {
display: grid;
gap: 6px;
color: #475467;
font-size: 12px;
font-weight: 650;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid #cbd2dc;
border-radius: 8px;
background: #ffffff;
color: #202124;
padding: 8px 10px;
}
.task-browser {
max-height: calc(100vh - 96px);
overflow: auto;
}
.task-row {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
margin-bottom: 8px;
padding: 11px;
text-align: left;
}
.task-row:hover {
border-color: #2f7c67;
box-shadow: 0 0 0 2px #d9efe8;
}
.task-goal {
font-weight: 650;
}
.task-meta {
color: #667085;
font-size: 12px;
}
.task-row .status-pill {
grid-row: 1 / span 2;
grid-column: 2;
align-self: center;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0 0 14px;
}
.detail-grid div {
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px;
}
.detail-grid dt {
margin-bottom: 4px;
color: #667085;
font-size: 12px;
}
.detail-grid dd {
margin: 0;
overflow-wrap: anywhere;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.icon-button {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-button.danger {
color: #a43c37;
}
.timeline-stage {
display: grid;
gap: 14px;
}
.evidence-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.evidence-pane {
min-width: 0;
}
.evidence-pane h3 {
margin: 0 0 6px;
font-size: 14px;
}
.screenshot-frame {
display: grid;
place-items: center;
min-height: 280px;
border: 1px solid #d9dde5;
border-radius: 8px;
background: #111827;
color: #e5e7eb;
overflow: hidden;
}
.screenshot-frame img {
display: block;
width: 100%;
height: 100%;
max-height: 520px;
object-fit: contain;
}
.step-data {
display: grid;
gap: 12px;
}
.step-data h3 {
margin: 0 0 6px;
font-size: 14px;
}
.operation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0 0 8px;
}
.operation-grid div {
min-width: 0;
}
.operation-grid dt {
color: #667085;
font-size: 12px;
}
.operation-grid dd {
margin: 2px 0 0;
overflow-wrap: anywhere;
}
.ocr-results {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ocr-results li {
display: grid;
gap: 3px;
border-left: 3px solid #2f7c67;
background: #f5faf7;
padding: 8px 10px;
overflow-wrap: anywhere;
}
.ocr-results span,
.muted {
color: #667085;
font-size: 12px;
}
.ui-tree {
border: 1px solid #e2e6ec;
border-radius: 8px;
background: #f9fafb;
padding: 10px;
}
.ui-tree summary {
cursor: pointer;
font-size: 13px;
font-weight: 650;
}
.ui-tree-nodes {
display: grid;
gap: 8px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.ui-tree-nodes li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 3px 8px;
border-left: 3px solid #5f7fb0;
background: #f1f5fb;
padding: 8px 10px;
overflow-wrap: anywhere;
}
.ui-tree-nodes code,
.ui-tree-nodes li > span {
grid-column: 1 / -1;
color: #667085;
font-size: 12px;
}
.muted {
margin: 0;
}
pre {
max-height: 248px;
overflow: auto;
margin: 0;
border: 1px solid #e2e6ec;
border-radius: 8px;
background: #f9fafb;
padding: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.submit-button {
align-self: end;
}
.managed {
margin-top: 14px;
}
.settings-form {
grid-template-columns: minmax(140px, 240px) auto;
align-items: end;
justify-content: start;
}
.alert {
margin: 12px 0 0;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.alert.error {
background: #fdebea;
color: #a43c37;
}
.alert.success {
background: #e5f4ec;
color: #1f6b4a;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
flex-direction: row;
align-items: center;
overflow-x: auto;
}
.nav-list {
grid-auto-flow: column;
}
.timeline-stage {
grid-template-columns: 1fr;
}
.evidence-grid,
.operation-grid {
grid-template-columns: 1fr;
}
.detail-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
-25
View File
@@ -1,25 +0,0 @@
// 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);
})();
@@ -1,40 +0,0 @@
<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>
-46
View File
@@ -1,46 +0,0 @@
<!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>
-98
View File
@@ -1,98 +0,0 @@
{% extends "base.html" %}
{% block title %}Config &middot; 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 &quot;{{ device_added }}&quot; 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>&times;</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 %}
@@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block title %}Devices &middot; 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 %}
@@ -1,156 +0,0 @@
{% extends "base.html" %}
{% block title %}Task {{ task.id }} &middot; Apex Console{% endblock %}
{% block body %}
<header class="topbar">
<div>
<h1>Task Detail</h1>
<p><a href="{{ url_for('runtime_console_tasks') }}">&larr; 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="evidence-grid">
<section class="evidence-pane">
<h3>Before action</h3>
<div class="screenshot-frame">
{% if current_step.before_image_base64 %}
<img
src="data:image/png;base64,{{ current_step.before_image_base64 }}"
alt="Screenshot captured before the action"
/>
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</section>
<section class="evidence-pane">
<h3>After action</h3>
<div class="screenshot-frame">
{% if current_step.after_image_base64 %}
<img
src="data:image/png;base64,{{ current_step.after_image_base64 }}"
alt="Screenshot captured after the action"
/>
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</section>
</div>
<div class="step-data">
<div>
<h3>Operation</h3>
<dl class="operation-grid">
<div>
<dt>Action</dt>
<dd>{{ current_step.tool_call.get('action', '-') }}</dd>
</div>
<div>
<dt>Description</dt>
<dd>{{ current_step.tool_call.get('description', '-') }}</dd>
</div>
</dl>
<pre>{{ current_step.tool_call | tojson(indent=2) }}</pre>
</div>
<div>
<h3>Execution result</h3>
<pre>{{ current_step.result | tojson(indent=2) }}</pre>
</div>
<div>
<h3>OCR results</h3>
{% if ocr_results %}
<ul class="ocr-results">
{% for ocr in ocr_results %}
<li>
<strong>{{ ocr.get('text') or '-' }}</strong>
<span>{{ ocr.get('bounds') | tojson }}</span>
{% if ocr.get('confidence') is not none %}
<span>confidence {{ '%.3f' | format(ocr.get('confidence')) }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No OCR output captured for this step.</p>
{% endif %}
</div>
{% if ui_tree_nodes %}
<div>
<h3>UI tree</h3>
<details class="ui-tree" open>
<summary>{{ ui_tree_nodes|length }} normalized nodes</summary>
<ul class="ui-tree-nodes">
{% for node in ui_tree_nodes %}
<li>
<strong>{{ node.get('type') or 'unknown' }}</strong>
<span>{{ node.get('text') or node.get('id') or '-' }}</span>
<code>{{ node.get('bounds') | tojson }}</code>
{% if node.get('confidence') is not none %}
<span>confidence {{ '%.3f' | format(node.get('confidence')) }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</details>
</div>
{% endif %}
</div>
</div>
{% endif %}
</section>
{% endblock %}
-62
View File
@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block title %}Tasks &middot; 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 %}
@@ -56,6 +56,12 @@ class AssignmentExecutor:
should_stop: Callable[[], bool] | None,
) -> AssignmentExecutionResult:
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
if self.factories.metadata_store is not None:
self.factories.metadata_store.create_task(
task,
source_task_id=assignment.task_id,
source_attempt=assignment.attempt,
)
runner = self.factories.task_runner_factory()
runner.on_step_progress = self._progress.update
if should_stop is None:
+20 -6
View File
@@ -13,6 +13,11 @@ class HostAgentConfigurationError(ValueError):
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
"HOST_AGENT_RUNTIME_SUPERVISED",
"HOST_AGENT_RUNTIME_HOST",
"HOST_AGENT_RUNTIME_PORT",
)
@dataclass(frozen=True)
@@ -39,9 +44,6 @@ class HostAgentConfig:
appium_supervised: bool = False
appium_host: str = "127.0.0.1"
appium_port: int = 4723
runtime_supervised: bool = False
runtime_host: str = "127.0.0.1"
runtime_port: int = 8000
dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history")
@@ -54,6 +56,7 @@ def load_host_agent_config(
env: Mapping[str, str] | None = None,
) -> HostAgentConfig:
values = os.environ if env is None else env
_reject_removed_runtime_supervision_settings(values)
control_plane_url = (
values.get(
"HOST_AGENT_CONTROL_PLANE_URL",
@@ -134,9 +137,6 @@ def load_host_agent_config(
appium_supervised=_truthy(values, "HOST_AGENT_APPIUM_SUPERVISED", False),
appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
runtime_supervised=_truthy(values, "HOST_AGENT_RUNTIME_SUPERVISED", False),
runtime_host=values.get("HOST_AGENT_RUNTIME_HOST", "127.0.0.1").strip(),
runtime_port=_positive_int(values, "HOST_AGENT_RUNTIME_PORT", 8000),
dependency_restart_max_attempts=_positive_int(
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
),
@@ -177,6 +177,20 @@ def load_host_agent_config(
return config
def _reject_removed_runtime_supervision_settings(values: Mapping[str, str]) -> None:
configured = [
setting
for setting in _REMOVED_RUNTIME_SUPERVISION_SETTINGS
if setting in values
]
if configured:
raise HostAgentConfigurationError(
f"{', '.join(configured)} has been removed with the standalone "
"Runtime service. Use the Host Agent console for task evidence "
"and HOST_AGENT_APPIUM_SUPERVISED for optional Appium supervision."
)
def _parse_ai_planner_transport(value: str | None) -> str:
if value is None:
return "cloud"
@@ -1,7 +1,6 @@
"""Optional supervisor for the two local external processes the Host Agent
depends on for the macOS single-machine real-device workflow: the Appium
server (which gates real ``Driver.connect()``) and the local Runtime API
(used for local inspection).
"""Optional supervisor for Appium in the macOS single-machine real-device
workflow. Appium gates real driver connections; task inspection is provided by
the Host Agent's own console.
Lives in ``host_agent`` because it spawns and monitors host-level processes
alongside the heartbeat/claim loop. Off by default; see ``HostAgentConfig``.
@@ -56,18 +55,6 @@ def probe_appium(host: str, port: int) -> ProbeResult:
return _probe_http(host, port, path="/status")
def probe_runtime(host: str, port: int) -> ProbeResult:
"""Probe the local Runtime API at ``host:port``. Healthy iff ``GET /devices``
returns 200 with a JSON body.
``api.rest.create_app`` does not expose a dedicated ``/health`` endpoint;
``/devices`` is the stable read-only GET that proves the FastAPI app is
mounted and the device manager is reachable. Per design.md Decision 2 this
is the "equivalent existing endpoint" used for the readiness check.
"""
return _probe_http(host, port, path="/devices")
def _probe_http(host: str, port: int, *, path: str) -> ProbeResult:
# Step 1: plain TCP connect — distinguish "nothing listening" (→ spawn)
# from "something is there but wrong" (→ port conflict, skip).
@@ -98,18 +85,6 @@ def appium_argv_factory(host: str, port: int) -> list[str]:
return ["appium", "--address", host, "--port", str(port)]
def runtime_argv_factory(host: str, port: int) -> list[str]:
return [
"uvicorn",
"api.rest:create_app",
"--factory",
"--host",
host,
"--port",
str(port),
]
@dataclass
class SupervisedDependency:
"""Config + mutable runtime state for one supervised external process."""
@@ -190,16 +165,6 @@ class DependencySupervisor:
probe=probe_appium,
)
)
if ha_config.runtime_supervised:
deps.append(
SupervisedDependency(
name="runtime",
host=ha_config.runtime_host,
port=ha_config.runtime_port,
argv_factory=runtime_argv_factory,
probe=probe_runtime,
)
)
return cls(
deps,
max_attempts=ha_config.dependency_restart_max_attempts,
@@ -25,6 +25,7 @@ class ExecutionFactories:
task_runner_factory: Callable[[], TaskRunner]
workflow_runner_factory: Callable[[], WorkflowRunner]
workflow_store: WorkflowStore
metadata_store: TaskMetadataStore | None = None
def create_execution_factories(
@@ -61,6 +62,7 @@ def create_execution_factories(
task_runner_factory=create_task_runner,
workflow_runner_factory=create_workflow_runner,
workflow_store=shared_workflow_store,
metadata_store=metadata_store,
)
+64 -31
View File
@@ -70,9 +70,13 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
return device.status
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
screenshot_path = record.get("screenshot_path")
def _screenshot_data_uri(
record: dict[str, Any],
*,
path_key: str = "screenshot_path",
) -> str | None:
"""Return a ``data:`` URI for one step screenshot, or ``None``."""
screenshot_path = record.get(path_key)
if not screenshot_path:
return None
path = Path(str(screenshot_path))
@@ -82,6 +86,49 @@ def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
return f"data:image/png;base64,{encoded}"
def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_results = record.get("ocr_results")
if not isinstance(raw_results, list):
return []
return [result for result in raw_results if isinstance(result, dict)]
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
tool_call = record.get("tool_call")
if not isinstance(tool_call, dict):
return []
if tool_call.get("action") not in {"get_ui_tree", "ui_tree"}:
return []
step_result = record.get("result")
if not isinstance(step_result, dict):
return []
raw_nodes = step_result.get("result")
if not isinstance(raw_nodes, list):
return []
return [node for node in raw_nodes if isinstance(node, dict)]
def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
tool_call = record.get("tool_call")
result = record.get("result")
return {
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call": tool_call if isinstance(tool_call, dict) else {},
"result": result if isinstance(result, dict) else {},
"before_screenshot_src": _screenshot_data_uri(
record, path_key="before_screenshot_path"
),
"after_screenshot_src": _screenshot_data_uri(
record, path_key="after_screenshot_path"
)
or _screenshot_data_uri(record),
"ocr_results": _ocr_results(record),
"ui_tree_nodes": _ui_tree_nodes(record),
}
def _safe_submission_error(detail: str) -> str:
"""Return a safe, single-line error message for the operator.
@@ -525,7 +572,7 @@ def create_console_app(
if task_id:
notice = (
f"Task submitted. Cloud task ID: {task_id}. "
"Track it from the Cloud console for execution progress."
"It will appear below when this Host begins executing it."
)
elif request.query_params.get("outcome") == "unknown":
unknown = True
@@ -664,36 +711,22 @@ def create_console_app(
if timeline is not None:
timeline_records = await asyncio.to_thread(timeline.read, task_id)
task_rows = [
(key, task[key])
for key in (
"id",
"goal",
"device_id",
"status",
"created_at",
"updated_at",
(label, task[key])
for key, label in (
("source_task_id", "Cloud task ID"),
("source_attempt", "Cloud attempt"),
("id", "Execution ID"),
("goal", "Goal"),
("device_id", "Device"),
("status", "Status"),
("created_at", "Created"),
("updated_at", "Updated"),
("completed_at", "Completed"),
("failure_reason", "Failure reason"),
)
if task.get(key) is not None
]
timeline_steps = [
{
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call_text": (
json.dumps(record.get("tool_call"), ensure_ascii=False)
if record.get("tool_call")
else ""
),
"result_text": (
json.dumps(record.get("result"), ensure_ascii=False)
if record.get("result")
else ""
),
"screenshot_src": _screenshot_data_uri(record),
}
for record in timeline_records
]
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
return _render(
"task_detail.html",
title=f"Task {task_id}",
@@ -1,6 +1,33 @@
{% extends "base.html" %}
{% block styles %}
{{ super() }}
.task-back { margin-top: 0; }
.timeline-step { border: 1px solid #c8d0d6; background: #fff; padding: 1rem; margin-bottom: 1rem; }
.step-heading { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin-bottom: 0.75rem; }
.step-heading p { margin: 0; color: #4d5a63; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.evidence-pane { margin: 0; min-width: 0; }
.evidence-pane h3 { font-size: 1rem; margin: 0 0 0.35rem; }
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; }
.screenshot-frame img { display: block; width: 100%; height: auto; }
.step-details { margin-top: 0.75rem; }
.step-details summary { cursor: pointer; font-weight: 600; }
.step-details pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.65rem 0 0; padding: 0.65rem; border: 1px solid #d5dce0; background: #f8fafb; }
.operation-grid { display: grid; grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); gap: 0.35rem 0.75rem; margin: 0.65rem 0 0; }
.operation-grid dt { font-weight: 600; }
.operation-grid dd { margin: 0; overflow-wrap: anywhere; }
.observation-list, .ui-tree-nodes { margin: 0.65rem 0 0; padding-left: 1.25rem; }
.observation-list li, .ui-tree-nodes li { margin-bottom: 0.45rem; overflow-wrap: anywhere; }
.observation-list span, .ui-tree-nodes span { color: #4d5a63; margin-left: 0.4rem; }
.ui-tree-nodes code { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.evidence-grid { grid-template-columns: minmax(0, 1fr); }
.timeline-step { padding: 0.75rem; }
}
{% endblock %}
{% block body %}
<h1>Task {{ task.get("id") or "" }}</h1>
<p class="task-back"><a href="/tasks">&larr; Back to executions</a></p>
<h1>Execution</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
@@ -10,13 +37,81 @@
<p>No timeline records.</p>
{% else %}
{% for step in timeline_steps %}
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
<p><strong>Step {{ step.index }}</strong> &mdash; {{ step.timestamp }}</p>
<p>Prompt: {{ step.prompt }}</p>
<p>Tool call: <code>{{ step.tool_call_text }}</code></p>
<p>Result: <code>{{ step.result_text }}</code></p>
{% if step.screenshot_src %}<img src="{{ step.screenshot_src }}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">{% endif %}
</div>
<section class="timeline-step">
<div class="step-heading">
<strong>Step {{ step.index }}</strong>
<p>{{ step.timestamp }}</p>
</div>
<div class="evidence-grid">
<figure class="evidence-pane">
<h3>Before action</h3>
<div class="screenshot-frame">
{% if step.before_screenshot_src %}
<img src="{{ step.before_screenshot_src }}" alt="Screenshot before action">
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
<figure class="evidence-pane">
<h3>After action</h3>
<div class="screenshot-frame">
{% if step.after_screenshot_src %}
<img src="{{ step.after_screenshot_src }}" alt="Screenshot after action">
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
</div>
<details class="step-details" open>
<summary>Operation</summary>
<dl class="operation-grid">
<dt>Action</dt><dd>{{ step.tool_call.get("action") or "-" }}</dd>
<dt>Description</dt><dd>{{ step.tool_call.get("description") or "-" }}</dd>
</dl>
<pre>{{ step.tool_call | tojson(indent=2) }}</pre>
</details>
<details class="step-details">
<summary>Result</summary>
<pre>{{ step.result | tojson(indent=2) }}</pre>
</details>
{% if step.prompt %}
<details class="step-details">
<summary>Planner prompt</summary>
<pre>{{ step.prompt }}</pre>
</details>
{% endif %}
{% if step.ocr_results %}
<details class="step-details" open>
<summary>OCR results ({{ step.ocr_results|length }})</summary>
<ul class="observation-list">
{% for ocr in step.ocr_results %}
<li>
<strong>{{ ocr.get("text") or "-" }}</strong>
<span>{{ ocr.get("bounds") | tojson }}</span>
{% if ocr.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(ocr.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if step.ui_tree_nodes %}
<details class="step-details">
<summary>UI tree ({{ step.ui_tree_nodes|length }} normalized nodes)</summary>
<ul class="ui-tree-nodes">
{% for node in step.ui_tree_nodes %}
<li>
<strong>{{ node.get("type") or "unknown" }}</strong>
<span>{{ node.get("text") or node.get("id") or "-" }}</span>
<code>{{ node.get("bounds") | tojson }}</code>
{% if node.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(node.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endfor %}
{% endif %}
{% endblock %}
@@ -29,17 +29,19 @@
</section>
<section id="local-tasks">
<h2>Local Runtime tasks</h2>
<h2>Executed tasks on this Host</h2>
{% if metadata_store_missing %}
<p class="error">Task metadata store is not configured.</p>
{% elif not tasks %}
<p>No tasks recorded.</p>
<p>No executions recorded yet.</p>
{% else %}
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<thead><tr><th>Execution ID</th><th>Cloud task</th><th>Attempt</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("source_task_id") or "" }}</td>
<td>{{ task.get("source_attempt") if task.get("source_attempt") is not none else "" }}</td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
@@ -49,4 +51,4 @@
</table>
{% endif %}
</section>
{% endblock %}
{% endblock %}
@@ -25,6 +25,7 @@ def _assignment(**overrides) -> AssignmentModel:
def test_goal_assignment_executes_through_task_runner() -> None:
received: list[Task] = []
created: list[tuple[str, str | None, int | None]] = []
class FakeTaskRunner:
def run(self, task: Task) -> Task:
@@ -32,10 +33,21 @@ def test_goal_assignment_executes_through_task_runner() -> None:
task.status = "completed"
return task
class FakeMetadataStore:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
created.append((task.id, source_task_id, source_attempt))
factories = ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(_assignment())
@@ -44,6 +56,7 @@ def test_goal_assignment_executes_through_task_runner() -> None:
assert received[0].goal == "open settings"
assert received[0].device_id == "device-a"
assert result.metadata["runtime_task_id"] == received[0].id
assert created == [(received[0].id, "cloud-task", 1)]
def test_goal_assignment_preserves_runtime_failure_reason() -> None:
+16 -10
View File
@@ -191,9 +191,6 @@ def test_dependency_supervisor_defaults_to_disabled() -> None:
assert config.appium_supervised is False
assert config.appium_host == "127.0.0.1"
assert config.appium_port == 4723
assert config.runtime_supervised is False
assert config.runtime_host == "127.0.0.1"
assert config.runtime_port == 8000
assert config.dependency_restart_max_attempts == 5
@@ -204,9 +201,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
"HOST_AGENT_APPIUM_SUPERVISED": "1",
"HOST_AGENT_APPIUM_HOST": "0.0.0.0",
"HOST_AGENT_APPIUM_PORT": "4724",
"HOST_AGENT_RUNTIME_SUPERVISED": "true",
"HOST_AGENT_RUNTIME_HOST": "localhost",
"HOST_AGENT_RUNTIME_PORT": "8001",
"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "8",
}
)
@@ -215,9 +209,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
assert config.appium_supervised is True
assert config.appium_host == "0.0.0.0"
assert config.appium_port == 4724
assert config.runtime_supervised is True
assert config.runtime_host == "localhost"
assert config.runtime_port == 8001
assert config.dependency_restart_max_attempts == 8
@@ -225,7 +216,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
"overrides",
[
{"HOST_AGENT_APPIUM_PORT": "0"},
{"HOST_AGENT_RUNTIME_PORT": "not-a-number"},
{"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "-1"},
],
)
@@ -234,3 +224,19 @@ def test_dependency_supervisor_numeric_fields_reject_invalid_values(
) -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config(overrides)
@pytest.mark.parametrize(
"setting,value",
[
("HOST_AGENT_RUNTIME_SUPERVISED", "true"),
("HOST_AGENT_RUNTIME_HOST", "127.0.0.1"),
("HOST_AGENT_RUNTIME_PORT", "8000"),
],
)
def test_removed_runtime_supervision_settings_are_rejected(
setting: str,
value: str,
) -> None:
with pytest.raises(HostAgentConfigurationError, match="standalone Runtime service"):
load_host_agent_config({setting: value})
@@ -16,8 +16,6 @@ from host_agent.dependency_supervisor import (
_SupervisorKnobs,
appium_argv_factory,
probe_appium,
probe_runtime,
runtime_argv_factory,
)
from host_agent.config import HostAgentConfig
@@ -175,7 +173,6 @@ def test_probe_returns_no_listener_when_port_is_closed(monkeypatch) -> None:
_raise_connection_refused,
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.NO_LISTENER
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.NO_LISTENER
def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
@@ -190,18 +187,6 @@ def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
assert probe_appium("127.0.0.1", 4723) is ProbeResult.HEALTHY
def test_probe_returns_healthy_on_runtime_devices_endpoint(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
monkeypatch.setattr(
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, json=[]),
)
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.HEALTHY
def test_probe_returns_unhealthy_when_listener_returns_non_200(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
@@ -223,7 +208,7 @@ def test_probe_returns_unhealthy_when_listener_returns_non_json(monkeypatch) ->
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, text="not json"),
)
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.UNHEALTHY_LISTENER
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
def test_probe_returns_unhealthy_on_http_transport_error(monkeypatch) -> None:
@@ -335,38 +320,6 @@ def test_spawn_uses_appium_argv_factory() -> None:
asyncio.run(scenario())
def test_spawn_uses_runtime_argv_factory() -> None:
async def scenario() -> None:
captured: list[list[str]] = []
def recording_popen(argv, **kwargs):
captured.append(list(argv))
return _FakePopen(argv)
dep, _ = _dep(
name="runtime",
port=8000,
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
argv_factory=runtime_argv_factory,
)
sup, _ = _build_supervisor([dep], popen_factory=recording_popen)
await sup.start()
assert captured == [
[
"uvicorn",
"api.rest:create_app",
"--factory",
"--host",
"127.0.0.1",
"--port",
"8000",
]
]
asyncio.run(scenario())
def test_readiness_timeout_leaves_process_running_without_restart_loop() -> None:
async def scenario() -> None:
# NO_LISTENER for initial probe; probe never becomes HEALTHY → startup
@@ -529,7 +482,7 @@ def test_from_host_agent_config_builds_empty_supervisor_when_no_dep_selected() -
assert sup.dependencies == []
def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> None:
def test_from_host_agent_config_includes_appium_when_selected() -> None:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
@@ -538,17 +491,12 @@ def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> N
appium_supervised=True,
appium_host="10.0.0.5",
appium_port=4724,
runtime_supervised=True,
runtime_host="10.0.0.5",
runtime_port=8001,
dependency_restart_max_attempts=7,
)
sup = DependencySupervisor.from_host_agent_config(config)
names = [dep.name for dep in sup.dependencies]
assert names == ["appium", "runtime"]
assert names == ["appium"]
appium = sup.dependencies[0]
assert appium.host == "10.0.0.5"
assert appium.port == 4724
runtime = sup.dependencies[1]
assert runtime.port == 8001
assert sup._max_attempts == 7
+20 -17
View File
@@ -37,14 +37,14 @@ uv run --package device-host-agent device-host-agent
```
At startup, the Host Agent loads device registrations from
`tasks/device_config.sqlite3`, the same `DeviceConfigStore` used by the local
Runtime console API. Register or update devices before starting the Host Agent,
then restart it to reload changes. In Compose,
`tasks/device_config.sqlite3`. Register or update devices before starting the
Host Agent, then restart it to reload changes. In Compose,
`HOST_AGENT_TASKS_PATH` selects the host directory mounted at `/app/tasks`; it
defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
The Host Agent only initiates outbound requests to the Cloud Control Plane. It
does expose an authenticated local console on loopback by default; this is not
a Cloud-facing inbound API.
## Direct Edge Enrollment
@@ -96,9 +96,9 @@ image, repository, log, or general backup.
The Host Agent always serves a small local-only web console on the
edge machine: heartbeat/enrollment status, registered local devices, current
assignment progress, local device add/edit/remove, a local account password
change, and recent assignment/heartbeat history. It authenticates with the
same local account created by `device-host-agent setup` above — there is no
separate console credential.
change, assignment/heartbeat history, and complete execution evidence. It
authenticates with the same local account created by `device-host-agent setup`
above; there is no separate console credential.
```text
HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
@@ -123,10 +123,10 @@ HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
### Task progress storage and retention
The Host Agent persists step-by-step task execution state (metadata +
timeline screenshots) to local SQLite/files on the edge machine. These
paths are independent from the Runtime's own `tasks/tasks.sqlite3` and
do not collide when both processes run on the same host.
The Host Agent persists step-by-step task execution state (metadata, Timeline
artifacts, screenshots, OCR, and UI-tree results) to local SQLite/files on the
edge machine. Runtime is an in-process library, so there is no second Runtime
database or service to inspect.
```text
HOST_AGENT_TASK_PROGRESS_DB_PATH=host_agent_data/task_progress.sqlite3
@@ -147,12 +147,15 @@ HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7
**Viewing live and historical task progress:**
- **Host Agent console**: Open `http://127.0.0.1:8765/tasks` for the task
list (status, device, timestamps). Click a task ID to see the detail page
with full step-by-step timeline and inlined screenshots.
- **Host Agent console**: Open `http://127.0.0.1:8765/tasks` for the
authoritative list of tasks actually executing on that Host (Cloud task ID,
attempt, status, device, timestamps). Click an execution ID for the full
step-by-step timeline with before/after screenshots, operation details, OCR,
and UI-tree results.
- **Cloud console**: The Cloud Console task detail page shows the latest
coarse-grained progress badge (step index, status, summary) that the Host
Agent piggybacks on each lease renewal.
Agent piggybacks on each lease renewal. For Cloud-proxy hosts it also shows
retained LLM prompt/decision history, but it does not store screenshots.
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
operator-accepted risk: the console has no built-in TLS and no rate
@@ -380,7 +383,7 @@ back to the upstream.
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. Unlike the shared Runtime
The Host Agent reuses the shared Runtime planner. Unlike the shared Runtime
library (whose own default is the deterministic stub planner), the **Host
Agent defaults `AI_PLANNER_ENABLED` to on** -- it is the actual device-control
path, so goal assignments use a model unless an operator explicitly opts out.
+5 -5
View File
@@ -48,11 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
explicit workspace source; the Runtime distribution must never depend on or
package `cloud`.
All Python members share the committed root `uv.lock`. The Runtime operator
console is server-rendered by the `api` layer through Jinja2 templates and
static assets packaged with `device-agent-runtime`; there is no separate
frontend project or Node build step for the Runtime console. The unrelated
`cloud-console/` Vue/Vite application keeps its own independent npm lifecycle.
All Python members share the committed root `uv.lock`. Runtime is an
in-process execution library; its operator evidence view is the authenticated,
server-rendered Host Agent console rather than a separately packaged Runtime
service. The unrelated `cloud-console/` Vue/Vite application keeps its own
independent npm lifecycle.
## Change Discipline
+27 -118
View File
@@ -273,77 +273,15 @@ uv run --package device-agent-runtime pytest -m integration tests/test_wda_integ
该测试只从环境变量读取 server URL、UDID 和 device name,不传签名 capabilities,
因此应在 WDA 已成功签名/安装后运行。
## 8. 启动可控制真机的 Runtime API
## 8. 独立 Runtime API 已撤销
当前不能只运行 README 中的普通 `uvicorn ... --factory` 命令,因为它只会加载已登记
设备,不会调用 `DeviceManager.connect()`。使用下面的启动方式,在同一进程中完成
设备注册、WDA 连接和 REST API 启动:
Runtime 现在是由 Host Agent 在进程内调用的执行库,不再提供 `api.rest`
端口 `8000``/ui/``/console/*`。不要再启动单独的 Runtime 服务,
也不要使用无鉴权的 REST 调用控制设备。
```bash
python - <<'PY'
import os
import uvicorn
from api.rest import create_app
from device.manager import DeviceManager
from driver.registry import build_driver_factory
device_id = "iphone-1"
connection_info = {
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
}
manager = DeviceManager()
app = create_app(manager=manager)
manager.register_device(
device_id,
build_driver_factory("wda", connection_info),
name="Local iPhone",
driver_type="wda",
connection_info=connection_info,
)
manager.connect(device_id, max_retries=1)
uvicorn.run(app, host="127.0.0.1", port=8000)
PY
```
保持进程运行,在另一个 Terminal 验证:
```bash
curl -s http://127.0.0.1:8000/devices
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/tap \
-H 'Content-Type: application/json' \
-d '{"x": 100, "y": 200}'
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
-H 'Content-Type: application/json' \
-d '{"app_id": "com.apple.Preferences"}'
```
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
误操作。
Runtime API 自带同源 Web Console,无需额外的前端进程、Node 工具链或
`RUNTIME_CONSOLE_STATIC_DIR`。保持 Runtime API 运行,浏览器访问
`http://127.0.0.1:8000/`(会自动 307 跳转到 `/ui/`)即可:
- `/ui/`:设备状态面板,约每 10 秒自动刷新一次;
- `/ui/tasks`:任务列表与筛选;
- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图);
- `/ui/config`:登记/移除设备、调整 `max_steps`
已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中
重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与
`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console
仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。
真机连接和任务执行都由下一节的受管 Host Agent 完成。保持 §6 的 Appium 服务
可用,然后启动 Host Agent;本机执行记录、截图、OCR 和 UI 树均从其已鉴权的
Console 查看。
## 9. 启动云端受管 Host Agent
@@ -459,15 +397,16 @@ http://127.0.0.1:8765
`DeviceManager` 上生效,无需重启 Host Agent。
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
- **任务进度页面**`http://127.0.0.1:8765/tasks` 展示本机 Host Agent 上已执行/正在执行
的任务列表(状态、设备、时间戳),点击任务 ID 可查看逐步 timeline 含截图。
- **任务进度页面**`http://127.0.0.1:8765/tasks` 是本机实际执行任务的权威查看入口。
它展示已执行/正在执行的任务、Cloud task ID 与 attempt;点击执行 ID 可查看每步
timeline 的操作、前后截图、OCR 和 UI 树结果。
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系统,路径与 Runtime 自身的
`tasks/tasks.sqlite3` 不冲突
Host Agent 会把每步执行状态、前后截图、OCR 和 UI 树结果持久化到本地
SQLite/文件系统。该 Host Console 是这些实际执行证据的唯一 Web 查看入口
```bash
# 任务进度持久化路径(默认值,可通过环境变量覆盖)
@@ -477,64 +416,34 @@ Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系
# HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7 # 超过 7 天的任务自动清理
```
Retention 策略取"数量上限与天数上限中更严格的"——即先按 `max_count` 取最近 N 个、
Retention 策略取"数量上限与天数上限中更严格的" - 即先按 `max_count` 取最近 N 个、
再按 `max_age_days` 过滤掉过老的,最终保留两者中较小的集合。任务完成后,这些记录
在 Console 的 `/tasks` 页面可查。
### 可选:由 Host Agent 托管 Appium 和 Runtime API
### 可选:由 Host Agent 托管 Appium
默认情况下 Host Agent **不会**自动启动 Appium 或本地 Runtime API:必须按
§6 在独立 Terminal 中保持 `appium --address 127.0.0.1 --port 4723` 运行,按
§8 在另一个 Terminal 中启动 Runtime API。忘记其中任意一个,Host Agent 不会报错,
heartbeat 仍会成功,但设备会静默保持 `offline`、所有任务卡在 `queued`
`host-agent-dependency-supervisor` 是一个可选模式,让 Host Agent 自己把这两个
外部进程作为子进程托管,覆盖单机真机工作流。它默认关闭,需要显式 opt-in:
默认情况下 Host Agent 不会自动启动 Appium;可继续按 §6 在独立 Terminal 中保持
`appium --address 127.0.0.1 --port 4723` 运行。也可以显式让 Host Agent 托管
Appium,避免忘记启动导致设备保持 `offline`
```bash
export HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED="true"
# 任选其一或两者都开。两者默认 false。
export HOST_AGENT_APPIUM_SUPERVISED="true"
export HOST_AGENT_RUNTIME_SUPERVISED="true"
# 可覆盖默认地址/端口(默认值与 §6/§8 手动流程一致):
# export HOST_AGENT_APPIUM_HOST="127.0.0.1"
# export HOST_AGENT_APPIUM_PORT="4723"
# export HOST_AGENT_RUNTIME_HOST="127.0.0.1"
# export HOST_AGENT_RUNTIME_PORT="8000"
# 单次 Host Agent 进程生命周期内允许的最大重启次数,默认 5。
# export HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS="5"
uv run --package device-host-agent device-host-agent
```
启用后的行为(详见 `openspec/changes/host-agent-dependency-supervisor/`):
- **启动顺序**:Host Agent 在第一次 `connect_devices()` 之前,先按上面选中的
依赖项依次做"先探测后启动"。这样一旦开启,Appium 不再需要单独的 Terminal
- **Adopt-don't-fight**:探测 `(host, port)` 时若已经有进程在监听并通过健康检查
(Appium `GET /status` 返回 200 JSON,Runtime `GET /devices` 返回 200 JSON),
Host Agent 会以 *adopted* 方式记录日志,**不会**再 spawn 一个重复进程,也不会
在退出/崩溃时杀掉或重启它。如果端口被占但健康检查失败,记一条 port-conflict
错误并跳过该依赖,不抢端口、不静默继续。
- **崩溃重启**:只有 Host Agent 自己 spawn 出来的子进程才会被监控。子进程意外
退出时,按指数退避(1s、2s、4s、8s,封顶 30s)重启;当某个依赖在本进程生命
周期内累计达到 `HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS` 次重启后,停止
再次尝试直到 Host Agent 重启。被 adopt 的进程永远不会被 Host Agent 重启或杀死。
- **spawn 失败 ≠ crash**:如果 `appium` 可执行文件不在 `PATH` 上,启动会以
"dependency-supervisor: appium spawn failed — executable not found" 形式记一条
依赖主管特有的错误,与正常 crash 区分开。请确认按 §4.3 安装好 `appium`
XCUITest/UiAutomator2 driver。
- **退出时**:Host Agent 在自身 graceful shutdown 阶段只会 `terminate` 它自己
spawn 的子进程;adopted 进程保留不动。
- **不影响 Docker/Compose**:本模式只针对 macOS 单机真机工作流;
`compose.yaml` / `compose.deploy.yaml` 完全不受影响。
Host Agent 会先探测 Appium;健康实例会被 adopt 而不会重复启动或停止。仅由 Host
Agent 自己启动的 Appium 子进程会在异常退出后按有界指数退避重启,并在 Host Agent
正常关闭时 terminate。已移除的 `HOST_AGENT_RUNTIME_*` 变量会使启动明确失败;
不要再配置或启动独立 Runtime 服务
如果偏好保持对 Appium 终端日志的完全控制、或者已经在用其他进程管理工具
(launchd、systemd、tmux 等)托管 Appium,可以继续使用 §6/§8 的手动流程,
不开启本模式即可。
(launchd、systemd、tmux 等)托管 Appium,可以继续使用 §6 的手动流程,不开启
本模式即可。
## 10. 多设备与端口
@@ -629,16 +538,16 @@ wheel 与 Apple Silicon 兼容性。
- [ ] `curl http://127.0.0.1:4723/status` 返回正常。
- [ ] 直接 Python 验证能生成 `/tmp/device-agent-runtime.png`
- [ ] 实机 integration test 通过。
- [ ] Runtime API 返回 `iphone-1`,并能执行 screenshot/tap/launch
- [ ] Host Agent Console 显示已连接设备,并能在 `/tasks` 查看一次完成任务的完整证据
- [ ] 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。
- [ ] 云端受管部署已保存 Host identity,并能在 `/v1/hosts``/v1/devices` 中看到。
## 13. 后续代码改进建议
为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现:
为了让后续设备管理和验收更易操作,建议另开变更实现:
- 为 Console/REST 增加显式 connect/disconnect endpoint
- 增加正式 CLI,例如 `device-runtime serve --device-config ...`
- 为 Host Agent Console 增加显式 connect/disconnect 状态诊断
- 增加正式 CLI,用于校验 Host 的设备配置和 Appium 连通性
- 统一将遗留的 `APEX_WDA_*` 环境变量改名为 `DEVICE_RUNTIME_WDA_*`,并保留兼容期。
- 将 PaddleOCR 改成 optional dependency,拆分基础控制与 OCR 安装路径。
- 增加 macOS CI 的无真机 smoke test,以及受控环境中的真机验收脚本。
@@ -1,137 +1,176 @@
## Context
Three surfaces currently cannot show a task's in-progress status:
Host Agent executes Cloud assignments in-process through the shared
`runtime.TaskRunner`. Its `HostAgentApplication` already constructs a
Host-local `TaskMetadataStore` and `Timeline` and injects them into the
runner factory. The missing link is task creation: `AssignmentExecutor`
constructs a `Task`, calls `runner.run(task)`, and `TaskRunner` only
issues updates. SQLite therefore receives updates for a row that does not
exist, so the Host Agent `/tasks` UI is empty.
1. **Host Agent local console** (`apps/device-host-agent/host_agent/web/app.py`): `HostAgentApplication`'s builder calls `create_execution_factories(resolved_manager, host_agent_config=resolved_config)` without `metadata_store`/`timeline` (`app.py:218-223`), so the in-process `TaskRunner` (`execution.py:39-46`) runs with `metadata_store=None, timeline=None`. `runtime/task.py::TaskRunner._update_task()` only persists when `metadata_store` is truthy, so step transitions vanish. The console's only signal is `AgentStatusTracker.snapshot()` (`status.py:14-88`), a single opaque span: `task_id/device_id/goal/started_at`, cleared on `mark_assignment_finished()`.
2. **Cloud Control Plane / Cloud Console**: the internal protocol (`packages/cloud-platform/cloud/internal_api/models.py`) only has claim, heartbeat, lease-renewal, and terminal-result messages. Lease renewal (`ActiveAssignmentRunner._renew_while_running`, `apps/device-host-agent/host_agent/lease.py:79-106`) already fires periodically (~1/3 of remaining lease) for every in-flight assignment, but carries no task-progress payload.
3. **Runtime `console/` SPA**: `api/rest.py::create_app()` builds its own `TaskMetadataStore`/`Timeline`/`TaskRunner` (`api/rest.py:24-41`), queried by `api/console.py::create_console_router()`'s `/console/tasks*` routes, which `console/src/api.ts` calls against `API_BASE_URL` (default `http://127.0.0.1:8000`). This is a wholly separate process from Host Agent, with no shared store or IPC. `runtime/`-owned packages are forbidden from importing host/cloud concerns (`test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`), and `host-agent-local-console/design.md` already treats Host Agent's local history as "not a system of record," distinct from Cloud's.
The separate `api.rest` process was never on this execution path. It owned a
different database and artifact root, so its REST endpoints and UI could only
show tasks submitted directly to that unrelated process. Running it alongside
a Host Agent created two competing operator entry points without transferring
any history between them.
## Goals / Non-Goals
**Goals:**
- Host Agent's local console shows live step-level progress for its current assignment (step index, step status, short summary), not just a static started-at snapshot.
- Cloud Control Plane learns step-level progress near-real-time (piggybacked on existing periodic traffic, not a new polling loop) and Cloud Console renders it for an operator watching a remote task.
- An operator can inspect a Host-Agent-executed task's progress/history from a web console, without coupling `runtime/` to host/cloud concerns.
- Reuse existing, already-tested building blocks (`TaskMetadataStore`, `Timeline`, `api/console.py`'s query shape, the lease-renewal cadence) instead of inventing new storage or transport primitives.
- Both the Host Agent's local task history and the Cloud Control Plane persist the *actual* per-step content sent to and received from the LLM (the real prompt text, not just the task's overall goal; the model's resulting decision, not just the parsed tool call) — so a step's record answers "what did we actually ask the model and what did it say," not just "what call did we make." Cloud's copy is the durable, centrally-searchable record for troubleshooting; the Host Agent's local copy remains the on-device record of what actually ran.
- Make durable task-row creation an invariant of `TaskRunner.run()` whenever
a metadata store is configured.
- Correlate a Host-local goal execution with the Cloud task ID and attempt that
caused it, without making shared Runtime or storage packages import Cloud
models.
- Make the authenticated Host Agent console at port `8765` the authoritative
web view for actual task execution: task list, detail, live status, before
screenshot, operation, after screenshot, OCR observations, and normalized
UI-tree results.
- Preserve the shared Runtime Timeline as the evidence model and keep its
before/after screenshot, OCR, and UI-tree capture behavior.
- Retire the standalone Runtime REST service/UI and its Host Agent supervisor
configuration while retaining Runtime, storage, MCP, and skill-sync library
modules.
- Retain the existing Cloud latest-progress and Cloud-proxy planner-decision
history behavior. Cloud remains a fleet-level view and never stores
screenshots.
**Non-Goals:**
- No SSE/WebSocket push. All three surfaces keep polling (Host Agent console 5s, Cloud Console/`cloud-console` and `console/` at their existing intervals). Nothing here requires push infra, and adding it would be a bigger, separate change.
- The coarse live step index/status/summary reported via lease renewal (D4/D5) stays *latest-snapshot-only* on the Cloud side (overwrite semantics) — it is a "what's happening right now" indicator, not a history mechanism, and is unaffected by the LLM-content decisions below.
- Full step-by-step **LLM interaction** history *is* now synced to and durably persisted by Cloud (see D7/D8) — this is a deliberate reversal of this change's earlier draft, which had scoped Cloud to latest-only. The motivation: Host Agent deployments already route every AI Planner decision through Cloud API's existing `cloud-planner-proxy` endpoint when configured for the `cloud` transport, so Cloud is already on the natural path for this data and centralizing it there (rather than only on whichever edge host happens to still be running) is the more useful place for an operator doing centralized troubleshooting. This durable log is scoped strictly to LLM prompt/response text — it is not a general-purpose duplicate of the Host Agent's full `Timeline` (no screenshots, no scene JSON dumps beyond what's embedded in the prompt text itself).
- No multi-backend Runtime console or cross-origin Host Agent integration. Runtime changes remain local to its existing same-origin API/UI and shared timeline evidence model.
- No change to `driver/`/`device/`/`core/` device-control internals.
- No screenshot or full-scene data leaves the Host Agent process as part of progress reporting, and no screenshot bytes are ever persisted by the Cloud Control Plane. (Full LLM prompt/response *text* is, by contrast, an explicit Goal below — see D7/D8 — which partially supersedes the original `cloud-planner-proxy` proposal's "does not durably persist ... full prompt text" statement. Screenshots remain excluded; prompts no longer are.)
- No SSE/WebSocket push; Host and Cloud consoles keep their existing polling.
- No duplicate full evidence upload to Cloud. Screenshots and Timeline
artifacts remain Host-local.
- No new Host/Cloud dependencies in `runtime/`, `storage/`, `driver/`, or
`device/`.
- No replacement general-purpose device-control REST API. Operators use Host
Agent device management and task pages for the managed execution workflow.
## Decisions
### D1: Wire a real `metadata_store`/`timeline` into Host Agent's `TaskRunner`, reusing the existing classes
### D1: TaskRunner owns idempotent task metadata creation
`create_execution_factories()` already accepts `metadata_store: TaskMetadataStore | None` and `timeline: Timeline | None` (`execution.py:28-34`) — these are the same classes `api/rest.py` uses for the Runtime's own console. Reuse them as-is rather than inventing a Host-Agent-specific store: `HostAgentApplication`'s builder constructs a `TaskMetadataStore(db_path=<host-agent-local path>)` and `Timeline(ArtifactStore(<host-agent-local path>))` and passes them into `create_execution_factories()`.
`TaskRunner.run()` will call `TaskMetadataStore.create_task(task)` before
the first running-state update. `create_task()` will use idempotent insert
semantics so callers that already created a direct Runtime task remain
compatible and workflow-created tasks are captured automatically.
**Alternative considered**: a bespoke in-memory-only step recorder (cheaper, no disk I/O). Rejected because `TaskMetadataStore`/`Timeline` are already proven in production (Runtime's own console has run on them since the original `web-console` change) and reusing them means Host Agent's task/timeline data has the *exact* same shape as `api/console.py` already serializes — a prerequisite for D3 below. A bespoke format would need its own (de)serialization and its own console rendering code for no real benefit.
This places the invariant at the shared execution boundary rather than relying
on every caller to remember an out-of-band persistence call. It fixes goal
assignments and prevents the same failure for `WorkflowRunner` planned-goal
steps.
New config: `HOST_AGENT_TASK_PROGRESS_DB_PATH` (default e.g. `host_agent_data/task_progress.sqlite3`) and a matching artifact directory, kept separate from any local Runtime `tasks/` directory that might exist on the same machine, to avoid two unrelated processes silently sharing or colliding on a path.
### D2: Host assignment correlation stays in the Host adapter
### D2: Bounded retention for the Host-Agent-local store
Before executing a Cloud goal assignment, `AssignmentExecutor` creates the
local task record with optional generic `source_task_id` and
`source_attempt` metadata. Shared storage uses generic names and does not
import Cloud types. The Host Agent task list/detail renders those fields as the
Cloud task ID and attempt.
Unlike a Runtime dev session (short-lived, manually cleared), a Host Agent process runs indefinitely and executes many assignments over its lifetime. Unbounded `TaskMetadataStore` rows and `Timeline` screenshot artifacts would grow without limit.
The local `Task.id` remains generated by the Runtime. Reusing the Cloud task
ID as an artifact directory name would make retries overwrite each other and
would admit unsafe path characters from an external identifier.
Decision: add a lightweight retention pass (e.g. on a timer, or opportunistically after each assignment finishes) that prunes tasks older than a configurable window or beyond a configurable count, deleting both the `tasks` row and its `Timeline`/`ArtifactStore` files. This is new logic — neither `TaskMetadataStore` nor `Timeline` currently supports deletion — scoped as a small addition to `storage/task_metadata.py` / `storage/timeline.py` (or a Host-Agent-side wrapper if adding delete methods to the shared `storage` package feels too broad; prefer extending `storage` since both Runtime and Host Agent benefit from bounded retention).
### D3: Host Agent console is the authoritative evidence UI
### D3: Give Host Agent's own console read-only task/timeline pages instead of making `console/` multi-backend
The Host Agent already owns the device manager, assignment executor,
metadata store, Timeline, local account, session, and CSRF boundary. Its
same-origin `/tasks` and `/tasks/{task_id}` pages therefore render the
shared Timeline directly. The task list is named for Host executions rather
than "Local Runtime tasks", and submission feedback tells the operator that
the submitted Cloud task appears there when this Host begins execution.
Two options were evaluated for capability `host-agent-console-task-pages`:
No browser needs to point a separate frontend at the Host Agent. This keeps
the conservative local-account/session model and does not add CORS.
- **(a) Multi-backend Runtime `console/` SPA**: let the existing Vue SPA point at a Host Agent's console origin (a saved/selectable base URL) in addition to the local Runtime. Since D1 makes Host Agent's data shape identical to what `api/console.py` already serializes, the SPA's existing fetch/render code would work unmodified against a Host Agent origin.
- **(b) Extend Host Agent's own server-rendered console** (`host_agent/web/app.py`) with new read-only pages for task list/detail/timeline, reusing `TaskMetadataStore`/`Timeline` query calls directly (the same calls `api/console.py`'s handlers make), rendered as server-side HTML via the existing f-string + `html.escape()` convention (no SPA, no frontend build) established by `host-agent-local-console`.
### D4: Complete per-step evidence is rendered by the Host Agent
**Decision: (b).** (a) requires Host Agent's console to serve cross-origin, credentialed requests from whatever origin `console/` is running on (Vite dev server, or a different deployed origin) — meaning either relaxing Host Agent's CORS posture for its session-cookie+CSRF-protected endpoints, or reworking its auth model to tolerate cross-origin fetches. That is real new attack surface on a console whose entire local-only threat model (`console_bind_host` defaulting to loopback, explicit opt-in for non-loopback) was deliberately conservative. (b) reuses an already-accepted pattern (server-rendered, same-origin, same auth) and fully satisfies the actual operator need — a web page showing what a Host Agent is doing — without touching `runtime/`, `console/`, or Host Agent's CORS/auth posture at all. The cost is a small amount of view duplication (Host Agent's console and Runtime's `console/` render similar-shaped data with different templates/frameworks), judged acceptable given they serve different operational contexts (local direct-connect Runtime vs. remote Host Agent fleet).
Timeline records retain distinct pre-action and post-action screenshot paths,
the action description and arguments, the execution result, raw OCR
observations, and the existing normalized UI-tree result. The Host Agent page
creates data URIs only for available local artifacts and supports legacy
records where the single `screenshot_path` is the post-action image.
Any future desire for a unified SPA across both surfaces is left as a follow-up, not blocked by this decision (D1's shared data shape keeps that door open).
OCR is rendered when present. UI-tree output is rendered only for
`get_ui_tree` and `ui_tree` records that contain normalized nodes; it uses a
collapsible structured view while retaining the JSON result. No tool contract
or duplicate persistence field is introduced.
### D4: Piggyback progress reporting on the existing lease-renewal call
### D5: Host-local retention remains bounded
`ActiveAssignmentRunner._renew_while_running` (`lease.py:79-106`) already makes an authenticated, per-assignment, periodic call (`client.renew(assignment)`, roughly every 1/3 of remaining lease) while a task executes, validated server-side against `host_id`/`task_id`/`attempt`/`lease_id` (`internal_api/api.py:291-320`). This is the natural piggyback point for progress: extend `LeaseRenewalRequest` with an optional `progress: TaskProgressModel | None` field (new small model: `step_index: int`, `step_status: Literal[...]`, `summary: str` bounded length — no screenshot/scene payload) in `cloud/internal_api/models.py`, the single schema both sides already import directly (no parallel schema, matching existing convention noted for this protocol).
The existing Host retention pass continues to remove metadata rows, Timeline
records, and artifacts according to the configured count and age thresholds.
The new task creation invariant must use that same store so it cannot create
an unbounded second history source.
A small thread-safe "latest progress" holder (similar in spirit to `LeaseGuard`) is written by the execution thread (via a hook on `TaskRunner`/`AssignmentExecutor`, updated once per step) and read by `_renew_while_running` just before each renewal call, so no new timer/cadence is introduced.
### D6: Cloud progress remains a latest snapshot on lease renewal
**Alternative considered**: a dedicated `POST /internal/v1/hosts/{host_id}/assignments/{task_id}/progress` endpoint called on its own cadence. Rejected — it would duplicate the auth/validation `renew_assignment` already does, and introduce a second periodic call where one already exists and fires at a reasonable frequency for this purpose.
The Host execution thread writes a bounded latest-progress holder. The lease
renewal path optionally carries its step index, status, and summary; Cloud
stores only the latest snapshot for an active assignment and stops exposing it
after terminal completion. No screenshot or scene payload enters this
protocol.
### D5: Cloud persists only the latest progress snapshot, overwritten alongside lease renewal
### D7: Cloud-proxy planner decisions remain durable Cloud history
`renew_lease()` (`repository.py:407`, `sql_repository.py:1480`) already takes a row lock and writes a lease-expiry update on every renewal; extend it to also accept and store the optional progress fields (few scalar columns — `progress_step_index`, `progress_step_status`, `progress_summary`, `progress_updated_at` — rather than a schema-flexible JSON blob, keeping it queryable and consistent with the rest of the row's typed columns). This requires a new Alembic migration (head is currently `0007_llm_provider_management`) plus the equivalent SQLite schema change, applied to both `repository.py` and `sql_repository.py` to keep the dual-backend contract (`cloud-control-plane` spec's "Deployment and local persistence modes share one contract" requirement).
For `AI_PLANNER_TRANSPORT=cloud`, Cloud's existing planner-decision endpoint
persists successful system/user prompts, tool calls, arguments, and a
task/attempt-scoped step index. The direct transport intentionally produces no
such Cloud history. This log has bounded terminal-task retention and never
persists request screenshot bytes.
Progress columns are cleared (or simply superseded and ignored) once a terminal result is recorded — they represent "what's happening right now," not history; Cloud's durable attempt/result history is unaffected and unduplicated.
### D8: Retire the standalone Runtime REST service and UI
### D6: Cloud Console reads progress from the existing task/attempt query path, not a new endpoint
Remove `api/rest.py`, `api/console.py`, `api/console_web.py`, their
templates/static assets, their package-data declarations, and their
service/UI tests. Preserve `api/mcp.py`, `api/errors.py`, skill-sync, and
skill-catalog modules because they are independent library integrations.
Extend whatever response model Cloud Console's task list/detail already uses (Cloud API public router) with the optional latest-progress fields from D5, rather than adding a new endpoint. Cloud Console renders it as a small inline badge/line ("step 4: tapping login button") next to the existing status, refreshed on the SPA's existing polling interval.
Remove `HOST_AGENT_RUNTIME_SUPERVISED`,
`HOST_AGENT_RUNTIME_HOST`, and `HOST_AGENT_RUNTIME_PORT`. The optional
dependency supervisor continues to support Appium only. Configuration with a
removed Runtime-supervision variable fails with an actionable migration error
instead of silently doing nothing.
### D7: Capture full LLM interaction history for free via the existing `cloud-planner-proxy` decide endpoint, not a new reporting channel
### D9: Documentation points operators to Host Agent
Investigated the Host Agent → Cloud call path in detail: when a Host Agent is configured with `AI_PLANNER_TRANSPORT=cloud`, `AIPlanner.plan()` (`runtime/ai_planner.py`) calls `CloudProxyToolCallingClient.decide()` (`apps/device-host-agent/host_agent/cloud_planner_client.py:46-95`), which `POST`s the *complete* `system_prompt`/`user_prompt` (plus `task_id`/`attempt`/`lease_id` from `current_planner_execution_context()`) to Cloud API's `/hosts/{host_id}/planner/decide` (`packages/cloud-platform/cloud/internal_api/api.py::decide_planner_call`, line ~367). That handler already resolves and returns a `ToolCallDecision` (`tool_name`/`arguments`/`usage`) and already does per-call bookkeeping (`pool.store.settle_host_token_reservation(...)`, line ~463) using the same repository object this change's D5 already touches for lease renewal.
This means **every planning step's actual prompt and resulting decision already flows through a Cloud-owned request handler** when the `cloud` transport is used — no new endpoint, no new protocol field, no queue/batching scheme is needed to get full LLM content to Cloud. The only change needed is to make that handler *persist* what it currently discards.
**Decision**: extend `decide_planner_call` to, immediately after computing `decision` (success path only — a `ToolCallUnavailable`/502 path persists nothing), insert one row into a new log table (D8) keyed by `(task_id, attempt, step_index)`, where `step_index` is assigned by the Cloud side itself (an auto-incrementing counter scoped to `task_id`+`attempt`, e.g. `select count(*) + 1` under the same row lock, or a DB sequence/identity column) — Host Agent does not need to track or send a step counter for this.
**Explicit limitation, called out rather than papered over**: this only captures LLM content for hosts using the `cloud` transport. A host on the (still-default) `direct` transport never sends its prompts to Cloud at all — Cloud has zero LLM content for that host's tasks, and only ever sees the coarse index/status/summary from D4/D5's lease-renewal piggyback (which is transport-agnostic, since it's driven by `TaskRunner` step completion, not by the planner's transport choice). This is a real operational dependency: centralized LLM-interaction troubleshooting via Cloud Console requires the fleet (or the hosts an operator cares about) to run with `AI_PLANNER_TRANSPORT=cloud`. This change does not make `cloud` the new default transport — that remains a separate, already-existing configuration decision outside this change's scope.
**Alternative considered**: extend the D4 lease-renewal piggyback to also carry full prompt/response text (queued, not overwritten, so no step is lost between renewals). Rejected: it would duplicate a transport that already exists for exactly this payload (the decide call itself) whenever `cloud` transport is active, and would still need a *separate* new channel for the `direct`-transport case where Cloud never sees the prompt anyway — i.e., it does not actually solve the `direct`-transport gap, so it only adds complexity without expanding coverage.
### D8: New bounded-retention table for the full per-step LLM decision log, extended on both repository backends
Add a new table (e.g. `planner_decision_log`): `id`, `host_id`, `task_id`, `attempt`, `step_index`, `system_prompt` (text), `user_prompt` (text), `tool_name`, `arguments_json` (text), `created_at`. No screenshot column — screenshots are never sent to this endpoint's persistence path (the request's `screenshot_base64` is used only to call the LLM provider and is never written to this log, consistent with the Non-Goals screenshot exclusion).
Like D5, this needs a new Alembic migration and the equivalent SQLite path, implemented on both `repository.py`'s SQLite-backed implementation and `sql_repository.py::SQLAlchemyCloudRepository` to preserve the existing dual-backend contract. Unlike D5's few-nullable-columns-on-an-existing-row approach, this is an independent append-only table (one row per decide call, not an overwrite), since the whole point is durable per-step history rather than a live snapshot.
**Retention**: this table grows once per planning step across the whole fleet, indefinitely, on a shared multi-tenant Cloud database — unbounded growth is a real risk here in a way D5's single-row-per-assignment overwrite never was. Decision: a scheduled prune job (mirrors D2's Host-Agent-local retention) deletes rows whose owning task reached a terminal state more than a configurable window ago (default: prune 7 days after task terminal, or once the task itself is pruned/archived by whatever existing Cloud task-retention policy applies — reuse that cadence rather than inventing a second one if `cloud-control-plane` already has one; otherwise default to a simple time-based prune).
### D9: Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` path so the *actual* per-step prompt and response are recorded locally, not just the task goal and the parsed tool call
Independent of Cloud persistence, the existing local recording is itself wrong today: `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) calls `Timeline.append(prompt=task.goal, tool_call={"action": ..., "description": ..., "args": ...}, ...)`. `task.goal` is the overall task goal, not the per-step prompt actually sent to the LLM — the real per-step prompt (`planner_user_prompt(goal, scene_json, history_summary)`, built in `ai_planner.py::plan()`) is constructed, sent, and discarded entirely within `AIPlanner.plan()`, never reaching `TaskRunner`. Likewise `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) carries only the parsed `tool_name`/`arguments`/`usage` — any raw response text/content the model returned is discarded during parsing (`_decision_from_anthropic_response`/`_decision_from_openai_response`).
This is a pre-existing gap in a component shared by Runtime and Host Agent alike (not new to this change), and it undermines the very "step-level detail" goal D1 already committed to — a persisted step whose "prompt" field is just the task's goal repeated on every row is not useful for troubleshooting.
**Decision**: extend `ToolCallDecision` with the actual `user_prompt`/`system_prompt` it was given (or have `AIPlanner.plan()` return a small side-channel result instead of changing the `Planner` interface's return type) so `TaskRunner._append_timeline()` can pass the real per-step prompt into `Timeline.append()`. Rename `Timeline`/`TimelineRecord`'s `prompt` field's meaning (or add a new field) to unambiguously mean "the prompt actually sent to the LLM for this step." This fix lands in the shared `runtime`/`storage` packages, so both Host Agent's local console (D3) and the existing Runtime `console/` automatically benefit — it is not Host-Agent-specific plumbing.
### D10: Persist and render before/after action evidence in the Runtime timeline
The existing Timeline captures only one screenshot after an action, while the planning screenshot used before the action is transient. Extend each Timeline record with distinct before/after screenshot paths, preserving the existing `screenshot_path` as a backward-compatible alias for the after screenshot. `TaskRunner` captures each image immediately before and after invoking the executor, including failed action attempts. The Runtime console API inlines both images and the existing task-detail page renders them beside the recorded action and result.
OCR is already available during perception but fused into the normalized Scene, where an OCR value that overlaps a UI-tree node can lose its raw provenance. Preserve raw OCR observations on `Scene` for local timeline capture only; keep them out of `Scene.to_dict()` so the LLM-facing planner payload does not grow with duplicated text. The Runtime task-detail page renders the persisted OCR list when it is available and handles legacy records without it.
The existing `get_ui_tree`/`ui_tree` tool returns a normalized flat list of UI nodes, not the driver-specific raw XML hierarchy. Its StepResult is already persisted in the Timeline. Detect those action names in the Runtime UI and render the returned nodes in a collapsible structured view while retaining the full JSON result below it. This makes the inspection result readable without changing the tool response contract or storing a second tree copy.
Operator documentation no longer instructs users to start `uvicorn
api.rest:create_app` or browse port `8000`. It identifies the Host Agent
console at `http://127.0.0.1:8765/tasks` as the execution-history authority,
explains its local authentication, and documents that the Cloud Console is a
fleet/progress and Cloud-proxy LLM-history surface rather than a screenshot
store.
## Risks / Trade-offs
- [Risk] Per-step SQLite writes in a long-lived Host Agent process add I/O overhead → Mitigation: this is the same write pattern Runtime's own console has always used per step; no new proof of acceptability needed. If profiling later shows it matters for very high step-rate tasks, batching/debouncing is a follow-up, not a blocker here.
- [Risk] Unbounded local disk growth from `Timeline` screenshots on an indefinitely-running Host Agent → Mitigation: D2's bounded retention pass; must ship in the same change as D1, not deferred, since D1 alone would otherwise introduce an unbounded-growth regression.
- [Risk] New DB columns/migration touch both `repository.py` (SQLite) and `sql_repository.py` (Postgres) — drift between the two has been a real defect category in this codebase (see `cloud-control-plane-integration` archive notes) → Mitigation: contract/parity tests already exist for this dual-backend boundary; extend them to cover the new progress columns.
- [Risk] View duplication between Host Agent's server-rendered task pages (D3) and Runtime `console/`'s task pages (different frameworks, same data shape) → Mitigation: accepted trade-off (see D3 rationale); shared data shape keeps future unification possible without rework.
- [Risk] Progress payload could accidentally grow to include sensitive data (scene dumps, prompts) if a future change casually extends `TaskProgressModel` → Mitigation: `summary` field is explicitly bounded/plain-text only; code review for this change and future extensions should treat this the same as the existing `cloud-planner-proxy` no-screenshot-persistence rule (screenshots specifically remain excluded from every Cloud-side table this change introduces).
- [Risk] `planner_decision_log` (D8) grows unboundedly across the whole fleet, unlike D5's single-row-per-assignment overwrite → Mitigation: D8's retention/prune job must ship in the same change as D7/D8, not deferred, for the same reason D2 must ship alongside D1.
- [Risk] Full prompt text can itself contain sensitive on-screen content (whatever text was visible in the scene description embedded in the prompt) — persisting it centrally is a deliberate trade-off the user has explicitly requested for centralized troubleshooting, but it is a real expansion of what Cloud stores → Mitigation: no additional mitigation beyond what's already decided (screenshots still excluded); flagged here so it is a visible, intentional decision rather than a silent scope creep.
- [Risk] Full LLM history in Cloud is silently absent for any host on the `direct` transport, which could read as "it's broken" rather than "expected" → Mitigation: Cloud Console should visibly distinguish "no progress reported yet" from "this host does not report LLM content" (e.g. by also surfacing the host's configured transport), rather than just showing an empty history with no explanation.
- Per-step metadata writes add small SQLite I/O. This is the same local store
already selected for Host history, and bounded retention limits growth.
- A Host-local evidence record is only available while retained on that Host.
This is intentional: it reflects the actual device execution and avoids
sending screenshots to Cloud.
- Removing the unauthenticated Runtime REST service is a breaking operator
change. Clear configuration errors and documentation avoid a silent
fallback to a nonexistent inspection surface.
- Full Cloud-proxy prompts can contain visible screen text. This is the
previously accepted Cloud troubleshooting trade-off; screenshot bytes remain
excluded.
## Migration Plan
1. Add `TaskProgressModel` and the optional `progress` field to `LeaseRenewalRequest`/response in `cloud/internal_api/models.py`. Backward compatible: `progress` is optional, older Host Agents omit it.
2. Add progress columns + Alembic migration `0008_task_progress_columns`; extend `renew_lease()` in both repository implementations to accept/store them.
3. Extend Cloud Console's task read model and UI to render the new fields (no-op if absent, keeping rollback trivial).
4. Wire `metadata_store`/`timeline` into Host Agent's `create_execution_factories()` call site, add the retention pass (D2), and add the progress-holder hook feeding D4's renewal piggyback.
5. Extend Host Agent's local console with the read-only task/timeline pages (D3).
6. Fix `Timeline`/`TaskRunner`/`AIPlanner`/`ToolCallingClient` to record the real per-step prompt and response locally (D9) — independent of Cloud, benefits both Host Agent and Runtime consoles immediately.
7. Add `planner_decision_log` + Alembic migration for it (D8) on both repository backends, with its retention/prune job.
8. Extend `decide_planner_call` (`internal_api/api.py`) to persist each resolved decision into `planner_decision_log` (D7).
9. Extend Cloud Console with a per-task LLM interaction history view reading the new table, including the "host uses `direct` transport, no LLM content available" distinction from the Risks section.
10. Rollback: each step is independently revertible (optional field, additive columns, additive tables, additive UI, additive Host Agent wiring) — no destructive migration is required; both `0008` and the new decision-log migration's down-revisions drop only what they added.
1. Upgrade the Host Agent code. Existing task databases gain nullable source
correlation columns on startup; legacy Timeline records remain readable.
2. Remove any `HOST_AGENT_RUNTIME_*` environment variables and stop any
`api.rest` process. Start or browse only the Host Agent console for local
execution evidence.
3. Confirm a completed Host assignment appears at `/tasks` with its Cloud
task/attempt correlation and complete Timeline evidence.
4. Roll back only by restoring the prior release. The retired REST/UI routes
are deliberately not kept as a compatibility alias because their storage
was not connected to Host execution.
## Open Questions
- Exact retention window/count defaults for D2 (time-based vs. count-based, or both) — left for tasks.md to pick a concrete, documented default (e.g. keep last 50 tasks or 7 days, whichever is smaller) rather than block design on it.
- Whether Cloud Console's existing task list/detail component can absorb the new fields with a small edit or needs a new sub-component — an implementation detail, not an architectural fork.
- Whether a future change should unify Host Agent's server-rendered task pages and Runtime `console/`'s SPA into one shared frontend, now that D1 gives them an identical underlying data shape — explicitly deferred, not part of this change.
- Exact retention default for D8's `planner_decision_log` (prune-after-terminal window, or reuse an existing Cloud task-retention cadence if one already exists) — left for tasks.md to pick a concrete default rather than block design on it.
- Whether `step_index` in D8 should be assigned via a `SELECT count(*) + 1` under a row lock or a dedicated per-`(task_id, attempt)` counter/sequence — an implementation detail to resolve in tasks.md, not an architectural fork.
- Manual verification still requires a real Host Agent, Appium, and device.
Automated coverage verifies persistence, correlation, rendering, and
service removal; real hardware validates screenshots and OCR availability.
@@ -1,26 +1,28 @@
## Why
Nobody can see a task while it is running. Host Agent's local console only shows a coarse "current assignment" snapshot (task id, device id, goal, started-at) because the `TaskRunner` it drives is wired with `metadata_store=None, timeline=None` — every step transition happens in memory and is discarded the instant the assignment finishes. Cloud Control Plane only learns about an assignment at claim, heartbeat, and terminal-result time, so Cloud Console has nothing better to show. The local Runtime (`api/rest.py` + `console/`) is a separate process with its own independent `TaskMetadataStore`/`Timeline`, so it never sees a task that a Host Agent executed at all. An operator debugging a stuck or misbehaving task currently has no live signal anywhere in the system until the task finishes or times out.
Host Agent now wires a local `TaskMetadataStore` and `Timeline` into its `TaskRunner`, but its Cloud-assignment path still creates a `Task` and immediately calls `runner.run(task)`. `TaskRunner` only updates an existing metadata row, so every update affects zero rows and the Host Agent task page remains empty. The standalone Runtime REST service/UI is a separate process with unrelated storage, so it cannot be the inspection surface for Host Agent executions. An operator who submits or receives a task therefore has no authoritative web view of the actions that actually ran on that Host.
## What Changes
- Wire Host Agent's `AssignmentExecutor` / `create_execution_factories()` with a real `metadata_store` and `timeline` (or Host-Agent-local equivalents) so the in-process `TaskRunner` actually records step-by-step status instead of discarding it.
- Make `TaskRunner.run()` create its metadata row idempotently before its first status update, so every execution path, including workflow-owned tasks, persists history when a metadata store is configured. Have Host Agent register Cloud task/attempt correlation before goal execution so operators can match a submitted Cloud task to its local execution record.
- Expose that step-level detail through Host Agent's local console: extend `AgentStatusTracker`/`/api/status` (or add a focused endpoint) with current step index, step status, and a short in-progress step log; render it in the dashboard's "Current assignment" section instead of the static started-at-only view.
- Add a progress-reporting path from Host Agent to Cloud Control Plane so the control plane learns step-level state near-real-time rather than only at claim/heartbeat/terminal-result. Extend the existing `cloud.internal_api.models` Pydantic schema (the single source of truth Host Agent already imports directly) rather than introducing a parallel schema.
- Persist and expose the latest per-assignment progress on the Cloud Control Plane side, and surface it in Cloud Console so an operator watching a remote task sees live step progress, not just "dispatched" / "succeeded" / "failed".
- Give an operator a web console view of Host-Agent-executed task progress without making the `runtime/`-owned packages import host or cloud concerns (enforced by `test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`). After evaluating the alternative of making the Runtime `console/` SPA multi-backend (pointing its existing JS bundle at a Host Agent's console origin), this change instead extends Host Agent's own server-rendered local console with read-only task list/detail/timeline pages, reusing the same query shape `api/console.py` already exposes to the Runtime SPA. This avoids new cross-origin/session-cookie surface between the SPA and Host Agent, and keeps `runtime/` untouched. See design.md for the full trade-off analysis.
- Make Host Agent's own authenticated, server-rendered `:8765/tasks` pages the authoritative web entry point for actual execution history. They render the shared Timeline records directly and retain the existing same-origin session/CSRF boundary.
- All three surfaces continue to use polling (matching current behavior); this change does not introduce SSE/WebSocket infrastructure unless design.md finds a compelling reason to.
- Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` recording path so a persisted step's "prompt" is the *actual* prompt sent to the LLM for that step (not the task's overall goal) and the model's resulting decision is captured too — this pre-existing gap affects Runtime and Host Agent alike and undermines the step-level detail this change otherwise adds.
- Persist a durable, per-step log of full LLM prompt/response content on the Cloud Control Plane, for centralized troubleshooting — reusing the already-existing `cloud-planner-proxy` decide endpoint as the capture point (no new protocol/endpoint) rather than the coarse lease-renewal piggyback used for live index/status. This durable log is populated only for hosts using the `cloud` planner transport; hosts on the `direct` transport still get only the coarse index/status via lease renewal. Cloud Console gains a view to browse a task's full LLM interaction history.
- Extend the local Runtime timeline so every executed action retains a before screenshot, operation detail, after screenshot, and any available raw OCR observations; render that evidence in the Runtime task-detail UI and expose it from the existing Runtime console API. When a step uses the existing UI-tree tool, render its normalized node result as a structured, collapsible view as well.
- Keep before/after screenshots, operation detail, raw OCR observations, and normalized UI-tree results in the shared Runtime Timeline; render all of that evidence in the Host Agent task-detail page.
- Retire the standalone Runtime REST service, its unauthenticated console/UI, and Host Agent Runtime-supervision settings. Preserve the shared `runtime/`, `storage/`, and non-REST `api/` library modules used by the Host Agent and MCP integrations.
## Capabilities
### New Capabilities
- `host-agent-task-progress`: Host Agent captures step-level execution progress for its in-flight assignment (via a wired `metadata_store`/`timeline`) and exposes it through its local console/API.
- `host-agent-task-progress`: Host Agent captures step-level execution progress for every in-process task, correlates Cloud assignments with local records, and exposes it through its local console/API.
- `cloud-task-progress-visibility`: Cloud Control Plane receives, persists, and exposes near-real-time step-level progress for assignments it has dispatched to a Host Agent, and Cloud Console renders it.
- `host-agent-console-task-pages`: Host Agent's local server-rendered console gains read-only task list/detail/timeline pages (mirroring `api/console.py`'s task query shape) so an operator can inspect a Host-Agent-executed task's progress and history without needing the separate Runtime `console/` SPA or violating the `runtime/`-package host/cloud isolation boundary.
- `runtime-task-evidence`: Runtime task history retains and renders pre/post action evidence, raw OCR observations, and existing UI-tree inspection results.
- `host-agent-console-task-pages`: Host Agent's local server-rendered console gains the complete before/after evidence, OCR, and UI-tree views for Host-Agent-executed tasks and is the only web inspection surface for those executions.
- `runtime-task-evidence`: Runtime task history retains complete pre/post action evidence, raw OCR observations, and existing UI-tree inspection results for Host Agent rendering.
- `runtime-standalone-service`: the standalone Runtime REST service and its UI are removed; Runtime remains an execution library rather than a second operational console.
### Modified Capabilities
- `host-agent-protocol`: add a requirement that the Host Agent reports in-progress step-level status updates to the control plane (in addition to the existing heartbeat/claim/renewal/result operations), and that the control plane accepts and stores them per active assignment.
@@ -32,5 +34,6 @@ Nobody can see a task while it is running. Host Agent's local console only shows
- `runtime/task.py`, `runtime/ai_planner.py`, `runtime/tool_calling_client.py`, `storage/timeline.py`, `storage/artifact_store.py`, `core/models.py`, `perception/scene_builder.py` — fix the shared step-recording path so the real per-step prompt and the model's response are captured, retain pre/post action screenshots and raw OCR observations, not just the task goal and parsed tool call.
- `packages/cloud-platform/cloud/internal_api/models.py`, `packages/cloud-platform/cloud/internal_api/api.py` (`decide_planner_call`), `repository.py`/`sql_repository.py`, a new Alembic migration — new progress-reporting request/response models and a persistence + query path for latest per-assignment coarse progress, *and* a new durable per-step `planner_decision_log` table (with its own retention job) populated from the existing planner-decision endpoint.
- `cloud-console/` (Vue3 SPA) — new UI to render live per-assignment progress, and a new view to browse a task's full LLM interaction history.
- `api/console.py`, `api/console_web.py`, `api/templates/runtime_console/*` — expose and render Runtime-local step evidence; Host Agent task pages receive the data through the shared timeline shape.
- No changes anticipated to `driver/`, `device/`, `core/` device-control internals.
- `api/rest.py`, `api/console.py`, `api/console_web.py`, `api/templates/runtime_console/*`, `api/static/runtime_console/*`, their tests, package data, and Runtime-supervision configuration — removed.
- `apps/device-host-agent/host_agent/assignment.py`, `execution.py`, `web/app.py`, and its task templates — create correlated execution records and render the complete shared Timeline evidence.
- `runtime/task.py`, `storage/task_metadata.py`, and `storage/timeline.py` — make durable task creation an execution invariant while retaining generic storage boundaries.
@@ -1,34 +1,67 @@
## ADDED Requirements
### Requirement: Host Agent local console exposes step-level status for the current assignment
The Host Agent's local console SHALL display, for its currently executing assignment, the current step index, step status, and a short summary, sourced from the Host Agent's local task metadata store, refreshed on the console's existing polling interval.
#### Scenario: An assignment is currently executing
- **WHEN** an operator views the Host Agent local console dashboard while an assignment is executing
- **THEN** the dashboard shows the current step index, step status, and a short summary for that assignment, updating on subsequent polls
#### Scenario: No assignment is currently executing
- **WHEN** an operator views the dashboard while the Host Agent is idle
- **THEN** the dashboard shows no in-progress step information
## MODIFIED Requirements
### Requirement: Host Agent local console exposes read-only task history with per-step detail and screenshots
The Host Agent's local console SHALL provide authenticated, read-only pages listing recently executed tasks and, for a selected task, its full per-step history including any captured screenshots, sourced from the Host Agent's local task metadata store and timeline.
The Host Agent's local console SHALL provide authenticated, read-only pages
listing recently executed local tasks and, for a selected task, its full
per-step history from the Host-local metadata store and Timeline. The task
detail SHALL show available before and after screenshots, operation details and
arguments, execution result, OCR observations, and normalized UI-tree results.
It SHALL render legacy Timeline records that only have a single screenshot as a
post-action image.
#### Scenario: Operator lists recent tasks
- **WHEN** an authenticated operator opens the Host Agent local console's task list page
- **THEN** it shows tasks from the local task metadata store, most recent first, including tasks that have already reached a terminal state
#### Scenario: Operator lists recent Host executions
- **WHEN** an authenticated operator opens the Host Agent local console's task
list page
- **THEN** it shows local executions most recent first, including terminal
tasks and any available Cloud task ID and attempt correlation
#### Scenario: Operator inspects a completed task's step history
- **WHEN** an authenticated operator opens the detail page for a specific completed task
- **THEN** the page shows each recorded step in order, including its tool call, result, and any captured screenshot
- **WHEN** an authenticated operator opens the detail page for a completed
Host execution
- **THEN** the page shows each recorded step in order with its tool call,
result, and available before/after screenshots
#### Scenario: OCR was captured for a step
- **WHEN** the selected Timeline record contains OCR observations
- **THEN** the detail page shows each observation's text, confidence, and
bounds
#### Scenario: A UI-tree tool returned normalized nodes
- **WHEN** the selected Timeline record invoked `get_ui_tree` or `ui_tree`
and its result contains normalized nodes
- **THEN** the detail page exposes a structured, collapsible node view while
retaining the persisted result JSON
#### Scenario: A legacy timeline record is displayed
- **WHEN** a Timeline record has only `screenshot_path`
- **THEN** the detail page renders it as the post-action image without failing
#### Scenario: Unauthenticated request
- **WHEN** a request to the task list or task detail pages is made without a valid Host Agent console session
- **THEN** the Host Agent rejects the request the same way it rejects unauthenticated requests to its other console pages
- **WHEN** a request to the task list or task detail pages is made without a
valid Host Agent console session
- **THEN** the Host Agent rejects the request the same way it rejects
unauthenticated requests to its other console pages
### Requirement: Host Agent local console task pages require no new cross-origin surface
The Host Agent local console's task pages SHALL be served same-origin from the Host Agent's existing web application, without introducing new CORS allowances or a dependency on the separate Runtime `console/` frontend.
The Host Agent local console's task pages SHALL be served same-origin from the
Host Agent's existing web application, without introducing new CORS allowances
or a dependency on a separate Runtime frontend.
#### Scenario: Task pages are requested
- **WHEN** an operator's browser requests the Host Agent local console's task pages
- **THEN** the pages are served by the Host Agent's own application using its existing session/CSRF protections, with no additional cross-origin configuration required
- **WHEN** an operator's browser requests the Host Agent local console's task
pages
- **THEN** the pages are served by the Host Agent's own application using its
existing session/CSRF protections, with no additional cross-origin
configuration required
## ADDED Requirements
### Requirement: Host Agent console is the authority for actual execution evidence
The Host Agent local console SHALL be the web authority for task evidence
produced by that Host's in-process execution path. A standalone Runtime
service/UI SHALL NOT be required or consulted to inspect a Host execution.
#### Scenario: A Cloud task is executed by a Host Agent
- **WHEN** an operator opens that Host Agent's task page after execution starts
- **THEN** the page reads the same Host-local metadata and Timeline that the
executing `TaskRunner` writes
@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Supervisor is opt-in and disabled by default
The Host Agent SHALL NOT start, adopt-check, or supervise Appium unless
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED` is explicitly set to true. Appium
supervision SHALL additionally require `HOST_AGENT_APPIUM_SUPERVISED=true`
and SHALL default to false.
#### Scenario: Default configuration behaves exactly as before
- **WHEN** a Host Agent starts with no
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED` or related Appium environment
variable set
- **THEN** the Host Agent does not attempt to connect to, probe, or spawn
Appium, and its heartbeat/claim behavior is unchanged
#### Scenario: Top-level flag on and Appium flag off
- **WHEN** `HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=true` and
`HOST_AGENT_APPIUM_SUPERVISED=false`
- **THEN** the Host Agent does not probe, adopt, or spawn Appium
### Requirement: Spawn supervised dependencies that are not already running
The Host Agent SHALL spawn Appium as a child process when Appium supervision is
enabled and no healthy Appium instance is adopted, via
`appium --address <host> --port <port>` and SHALL forward the child
process's stdout/stderr into the Host Agent's own logging, tagged by dependency
name.
#### Scenario: Appium is not running at Host Agent startup
- **WHEN** Appium supervision is enabled and no healthy Appium instance is
already listening
- **THEN** the Host Agent spawns Appium before proceeding to its first
device-connect attempt, and its output is visible in Host Agent logs
#### Scenario: Spawn fails because the executable is missing
- **WHEN** the Host Agent attempts to spawn Appium but `appium` is not found
on `PATH`
- **THEN** the Host Agent logs a dependency-supervisor-specific startup error
naming the missing dependency, distinct from a runtime crash of an
already-started process
## ADDED Requirements
### Requirement: Runtime supervision settings are retired
The Host Agent SHALL reject `HOST_AGENT_RUNTIME_SUPERVISED`,
`HOST_AGENT_RUNTIME_HOST`, and `HOST_AGENT_RUNTIME_PORT` because the
standalone Runtime service no longer exists.
#### Scenario: A legacy Runtime supervision variable is set
- **WHEN** startup configuration includes any removed Runtime supervision
variable
- **THEN** configuration fails with an actionable migration error
@@ -1,30 +1,74 @@
## ADDED Requirements
## MODIFIED Requirements
### Requirement: Host Agent records step-level execution detail for its in-process TaskRunner
The Host Agent SHALL construct its in-process `TaskRunner` with a durable metadata store and timeline so that every step transition (status, index, the actual prompt submitted to the LLM for that step, the model's resulting decision, result, and screenshot when captured) is persisted as it happens, rather than discarded when the assignment completes. The persisted prompt SHALL be the prompt actually sent to the LLM for that specific step, not the task's overall goal.
The Host Agent SHALL construct its in-process `TaskRunner` with a durable
metadata store and Timeline. `TaskRunner.run()` SHALL create the task's
metadata row idempotently before its first status update, so every execution
path persists its task status and evidence rather than discarding updates for a
missing row. Every completed step SHALL retain its index, actual per-step LLM
prompt and decision when available, tool call, result, distinct before/after
screenshots when captured, raw OCR observations when available, and normalized
UI-tree result when the invoked tool returned one.
#### Scenario: A goal assignment starts execution
- **WHEN** the Host Agent's `AssignmentExecutor` invokes its `TaskRunner`
- **THEN** the task metadata row exists before the runner records its running
status
#### Scenario: A step completes during goal execution
- **WHEN** the Host Agent's `TaskRunner` completes a step while executing an assigned goal
- **THEN** the step's status, index, the actual per-step LLM prompt and response, tool call, result, and any captured screenshot are persisted to the Host Agent's local task metadata store and timeline before the next step begins
- **WHEN** the Host Agent's `TaskRunner` completes a step while executing an
assigned goal
- **THEN** the step's status, index, actual per-step LLM prompt and response,
tool call, result, and available evidence are persisted before the next step
begins
#### Scenario: A workflow creates a planned-goal task
- **WHEN** a `WorkflowRunner` invokes a Host Agent-configured
`TaskRunner` for a planned-goal step
- **THEN** that task is persisted without requiring the workflow caller to
create a metadata row separately
#### Scenario: An assignment finishes
- **WHEN** an assignment reaches a terminal state (succeeded or failed)
- **THEN** its full step history remains queryable from the Host Agent's local store after the in-memory `Task` object is discarded
- **THEN** its full step history remains queryable from the Host Agent's local
store after the in-memory `Task` object is discarded
### Requirement: Host-Agent-local task history is retained within a bounded window
The Host Agent SHALL prune persisted task metadata, timeline records, and associated screenshot artifacts once they exceed a configurable retention window or count, so that indefinite process uptime does not cause unbounded local disk growth.
The Host Agent SHALL prune persisted task metadata, Timeline records, and
associated screenshot artifacts once they exceed a configurable retention
window or count, so that indefinite process uptime does not cause unbounded
local disk growth.
#### Scenario: Retention window is exceeded
- **WHEN** a persisted task's age or position exceeds the configured retention threshold
- **THEN** the Host Agent removes that task's metadata row, timeline records, and screenshot artifacts from local storage
- **WHEN** a persisted task's age or position exceeds the configured retention
threshold
- **THEN** the Host Agent removes that task's metadata row, Timeline records,
and screenshot artifacts from local storage
#### Scenario: Retention has not been exceeded
- **WHEN** a persisted task is within the configured retention threshold
- **THEN** its metadata, timeline records, and screenshot artifacts remain available for query
- **THEN** its metadata, Timeline records, and screenshot artifacts remain
available for query
## ADDED Requirements
### Requirement: Host Agent correlates local execution records with Cloud assignments
For a Cloud-dispatched goal assignment, the Host Agent SHALL persist the Cloud
task ID and attempt alongside its generated local Runtime task ID before
execution starts. The correlation fields SHALL remain optional and generic in
the shared storage layer.
#### Scenario: A Cloud goal assignment begins
- **WHEN** the Host Agent begins executing a Cloud goal assignment
- **THEN** the local task row records that assignment's Cloud task ID and
attempt
#### Scenario: A task is not Cloud-dispatched
- **WHEN** a shared Runtime caller executes a task without Host/Cloud
assignment context
- **THEN** the task metadata row is created and the optional source
correlation fields remain empty
## REMOVED Requirements
### Requirement: Host Agent local task storage is isolated from an unrelated local Runtime
The Host Agent SHALL use a configurable, Host-Agent-specific database and artifact path for its task metadata store and timeline, distinct from any local Runtime API's own task storage path, so that the two processes cannot silently collide or share state when run on the same machine.
#### Scenario: Host Agent and local Runtime run on the same machine
- **WHEN** both a Host Agent process and a local Runtime API process run on the same machine with their default configurations
- **THEN** each process reads and writes its own task metadata store and timeline without observing or modifying the other's data
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Runtime is not exposed as a standalone REST service or web console
The repository SHALL not ship a standalone Runtime REST application, its
unauthenticated web console, or JSON console routes. The shared Runtime and
storage packages SHALL remain reusable execution libraries for the Host Agent
and other in-process callers.
#### Scenario: An operator needs to inspect a Host-executed task
- **WHEN** an operator needs task evidence for a Host Agent execution
- **THEN** the operator uses the authenticated Host Agent console rather than
starting or querying a separate Runtime service
#### Scenario: A package uses shared Runtime execution
- **WHEN** the Host Agent or another in-process caller creates a
`TaskRunner`
- **THEN** it continues to use the shared Runtime and storage packages without
importing a REST or UI adapter
### Requirement: Host Agent does not supervise a retired Runtime service
The Host Agent SHALL not expose Runtime-supervision configuration or spawn a
Runtime REST subprocess. It MAY continue to optionally supervise Appium.
#### Scenario: Host Agent dependency supervision is enabled
- **WHEN** `HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=true` and Appium
supervision is enabled
- **THEN** the Host Agent probes and supervises Appium only
#### Scenario: A removed Runtime-supervision variable is configured
- **WHEN** a Host Agent configuration includes a removed
`HOST_AGENT_RUNTIME_*` variable
- **THEN** startup fails with a message directing the operator to the Host
Agent console and Appium-only supervision
@@ -1,38 +1,61 @@
## ADDED Requirements
### Requirement: Runtime persists complete evidence for each executed action
The Runtime SHALL persist, for each action it attempts, a screenshot captured immediately before the executor call, the action description and arguments, the execution result, and a screenshot captured immediately after the executor call. Existing timeline records that contain only the legacy single screenshot SHALL remain readable, with that screenshot treated as the post-action image.
The shared Runtime SHALL persist, for each action it attempts, a screenshot
captured immediately before the executor call, the action description and
arguments, the execution result, and a screenshot captured immediately after
the executor call. Existing Timeline records that contain only the legacy
single screenshot SHALL remain readable, with that screenshot treated as the
post-action image.
#### Scenario: An action succeeds
- **WHEN** the Runtime executes an action for a task
- **THEN** its timeline record includes distinct before and after screenshots, the action detail, and the execution result
- **THEN** its Timeline record includes distinct before and after screenshots,
action detail, and execution result
#### Scenario: An action fails
- **WHEN** the Runtime executor exhausts its retries for an action
- **THEN** the action's timeline record still includes any screenshots that were captured and the failure result before the task is marked failed
- **THEN** the Timeline record still includes any captured screenshots and the
failure result before the task is marked failed
#### Scenario: A legacy timeline record is read
- **WHEN** a timeline record has only the prior `screenshot_path` field
- **THEN** the Runtime exposes it as the post-action screenshot without failing to render the record
#### Scenario: A legacy Timeline record is read
- **WHEN** a Timeline record has only the prior `screenshot_path` field
- **THEN** the Runtime exposes it as the post-action screenshot without
failing to render the record
### Requirement: Runtime task evidence exposes available OCR observations
The Runtime SHALL persist raw OCR observations associated with the scene used to plan an action when available, without adding duplicate OCR data to the LLM-facing normalized Scene payload. The Runtime task-detail UI SHALL render available OCR text, confidence, and bounds, and SHALL render normally when no OCR result exists.
### Requirement: Runtime task evidence retains available OCR observations
The shared Runtime SHALL persist raw OCR observations associated with the scene
used to plan an action when available, without adding duplicate OCR data to the
LLM-facing normalized Scene payload. The Host Agent task-detail UI SHALL render
available OCR text, confidence, and bounds, and SHALL render normally when no
OCR result exists.
#### Scenario: OCR found text while planning an action
- **WHEN** perception produced one or more OCR observations for the action's planning scene
- **THEN** the corresponding timeline record includes those observations and the Runtime task-detail UI displays them
- **WHEN** perception produced one or more OCR observations for the action's
planning scene
- **THEN** the corresponding Timeline record includes those observations and
the Host Agent task-detail page displays them
#### Scenario: OCR was unavailable or found no text
- **WHEN** perception yields no OCR observations
- **THEN** the Runtime records the action evidence and renders the task detail without an OCR result list
- **THEN** the Runtime records the action evidence and the Host Agent task
detail renders without an OCR result list
### Requirement: Runtime task evidence renders UI-tree inspection results
When a Runtime step invokes the existing `get_ui_tree` or `ui_tree` tool and the persisted result contains normalized UI nodes, the Runtime task-detail UI SHALL render those nodes in a structured, collapsible view while retaining the recorded JSON result. The Runtime SHALL NOT change the tool's response contract or duplicate the result in a separate persistence field.
### Requirement: Runtime task evidence retains UI-tree inspection results
The Runtime SHALL retain a UI-tree inspection result when a step invokes the
existing `get_ui_tree` or `ui_tree` tool and the result contains normalized
nodes. The Host Agent task-detail UI SHALL render those nodes in a structured,
collapsible view while retaining the recorded JSON result. The Runtime SHALL
NOT change the tool response contract or duplicate the result in a separate
persistence field.
#### Scenario: UI-tree inspection succeeds
- **WHEN** a task step uses `get_ui_tree` or `ui_tree` and returns one or more normalized nodes
- **THEN** the task-detail UI displays each node's type, visible text or identifier, bounds, and available confidence
- **WHEN** a task step uses `get_ui_tree` or `ui_tree` and returns one or
more normalized nodes
- **THEN** the Host Agent task-detail page displays each node's type, visible
text or identifier, bounds, and available confidence
#### Scenario: A non-UI-tree step is displayed
- **WHEN** a task step did not invoke a UI-tree tool
- **THEN** the task-detail UI does not render an empty UI-tree section
- **THEN** the Host Agent task-detail page does not render an empty UI-tree
section
@@ -1,10 +1,10 @@
## 1. Host Agent local task storage (D1, D2)
- [x] 1.1 Add `HOST_AGENT_TASK_PROGRESS_DB_PATH` (and matching artifact directory config) to `HostAgentConfig`/`load_host_agent_config()`, with a Host-Agent-specific default distinct from any local Runtime `tasks/` path.
- [x] 1.1 Add `HOST_AGENT_TASK_PROGRESS_DB_PATH` (and matching artifact directory config) to `HostAgentConfig`/`load_host_agent_config()`, with Host-Agent-specific defaults for durable execution history.
- [x] 1.2 In `HostAgentApplication`'s builder (`app.py:218-223`), construct a `TaskMetadataStore`/`Timeline` from that config and pass them into `create_execution_factories(..., metadata_store=..., timeline=...)`.
- [x] 1.3 Add delete/prune methods to `storage/task_metadata.py::TaskMetadataStore` and `storage/timeline.py::Timeline` (remove a task's row, timeline records, and screenshot artifacts).
- [x] 1.4 Implement a bounded retention pass in the Host Agent (default: keep the newer of "last 50 tasks" or "7 days", whichever keeps fewer rows) that runs after each assignment finishes, calling the new prune methods.
- [x] 1.5 Add unit tests: step transitions persist during execution; a task's history is queryable after the assignment completes and the in-memory `Task` is discarded; retention prunes tasks beyond the configured threshold; Host Agent and a local Runtime process using default paths on the same machine do not collide.
- [x] 1.5 Add unit tests: step transitions persist during execution; a task's history is queryable after the assignment completes and the in-memory `Task` is discarded; retention prunes tasks beyond the configured threshold; Host Agent task metadata and artifacts use their configured local paths.
## 2. Host Agent reports progress during lease renewal (D4)
@@ -31,14 +31,14 @@
## 5. Host Agent local console task pages (D3)
- [x] 5.1 Extend `AgentStatusTracker`/`/api/status` (or a small addition alongside it) to surface the current step index/status/summary for the in-progress assignment, sourced from the same progress holder built in section 2, and update the dashboard's "Current assignment" rendering to show it.
- [x] 5.2 Add authenticated, read-only task list and task detail/timeline routes to `host_agent/web/app.py`, querying the Host-Agent-local `TaskMetadataStore`/`Timeline` (reusing the same query calls `api/console.py` makes) and rendering server-side HTML consistent with the existing dashboard's f-string + `html.escape()` convention, including inlined screenshots on the detail/timeline page.
- [x] 5.2 Add authenticated, read-only task list and task detail/timeline routes to `host_agent/web/app.py`, querying the Host-Agent-local `TaskMetadataStore`/`Timeline` and rendering server-side Jinja HTML consistent with the existing console, including inlined screenshots on the detail/timeline page.
- [x] 5.3 Gate the new routes behind the existing Host Agent console session/CSRF protection; verify no new CORS configuration is introduced.
- [x] 5.4 Add tests: unauthenticated requests to the new routes are rejected the same way as other console routes; task list/detail/timeline pages render expected data including screenshots for a completed task.
## 6. Documentation and verification
- [x] 6.1 Update `docs/MACOS_IPHONE_SETUP.md` and/or `docs/CLOUD_DEPLOYMENT.md` with the new Host Agent config vars (`HOST_AGENT_TASK_PROGRESS_DB_PATH` and retention settings) and a short note on where to view live/historical task progress in each console.
- [x] 6.2 Run `uv run --all-packages pytest -m "not integration"` and targeted Cloud API / Host Agent test suites; run `cloud-console/` and `console/`-equivalent Vitest suites for the touched frontend.
- [x] 6.2 Run `uv run --all-packages pytest -m "not integration"` and targeted Cloud API / Host Agent test suites; run the Cloud Console Vitest suite for its touched frontend.
- [x] 6.3 Run Ruff check/format and `compileall` across touched packages.
- [x] 6.4 Run `openspec validate --strict` for this change.
- [ ] 6.5 Manual verification (requires a real Host Agent + Appium/device setup per `docs/MACOS_IPHONE_SETUP.md`): run a real task end-to-end and confirm step progress appears live in the Host Agent console and Cloud Console, and that full step history with screenshots is browsable afterward in the Host Agent console.
@@ -48,7 +48,7 @@
- [x] 7.1 Extend `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) with the actual prompt content given to that call (or return it via a small side-channel from `AIPlanner.plan()`, not by changing the `Planner.plan()` return type used by other planners).
- [x] 7.2 Update `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) to pass the real per-step prompt and the model's resulting decision into `Timeline.append()`, instead of `task.goal`.
- [x] 7.3 Update `storage/timeline.py`'s `Timeline`/`TimelineRecord` field(s) so the persisted meaning is unambiguously "the prompt actually sent to the LLM for this step" (rename or add a field; keep backward-compatible read of any already-persisted rows if a Runtime dev DB might already have old-shaped rows).
- [x] 7.4 Update any renderer of this data (`api/console.py`, Runtime `console/`, and the new Host Agent console task pages from section 5) to show the corrected field.
- [x] 7.4 Update the Host Agent console task pages to show the corrected field without requiring a separate Runtime renderer.
- [x] 7.5 Add unit tests: a persisted step's prompt matches what `AIPlanner.plan()` actually sent for that step (not the task goal), across at least one multi-step task.
## 8. Cloud persists full per-step LLM decision history via the existing cloud-planner-proxy endpoint (D7, D8)
@@ -67,10 +67,20 @@
- [x] 9.3 When a task's host used the `direct` transport (no persisted decisions and the host's configured transport is known to be `direct`), show an explicit "not reported by this host's transport" state rather than an empty list.
- [x] 9.4 Add Vitest coverage for populated history, empty-but-cloud-transport (task hasn't produced any decisions yet), and direct-transport-hidden cases.
## 10. Runtime per-step evidence (D10)
## 10. Shared Runtime per-step evidence (D4)
- [x] 10.1 Extend the shared Scene/Timeline/ArtifactStore model to retain raw OCR observations and separate before/after screenshots while preserving compatibility with existing single-screenshot records.
- [x] 10.2 Capture before and after screenshots around every TaskRunner executor call, then persist the action, result, and available OCR observations in the same timeline record.
- [x] 10.3 Extend the Runtime console JSON API and task-detail UI to render before/after screenshots, operation details, and available OCR results.
- [x] 10.4 Render persisted normalized UI-tree tool results in the Runtime task-detail UI without changing the tool contract or duplicating stored data.
- [x] 10.5 Add focused Timeline, TaskRunner, perception, JSON API, Runtime UI, and UI-tree regression coverage; run the relevant format, lint, test, and strict OpenSpec validation commands.
- [x] 10.3 Preserve the shared Timeline representation needed to render before/after screenshots, operation details, and available OCR results without changing Runtime execution contracts.
- [x] 10.4 Preserve persisted normalized UI-tree tool results without changing the tool contract or duplicating stored data.
- [x] 10.5 Add focused Timeline, TaskRunner, perception, and UI-tree regression coverage; run the relevant format, lint, test, and strict OpenSpec validation commands.
## 11. Host Agent execution authority and Runtime service retirement (D1-D9)
- [x] 11.1 Make `TaskRunner.run()` create a task metadata row idempotently before status updates; add optional generic source task/attempt fields to task metadata and record Cloud assignment correlation in the Host adapter.
- [x] 11.2 Add regression coverage for Host-dispatched goal execution, workflow-owned TaskRunner execution, retry-safe metadata creation, and Cloud task/attempt correlation.
- [x] 11.3 Extend Host Agent `/tasks` and task-detail rendering so the actual execution list and detail page show correlation, before/after screenshots, operation detail, OCR observations, and structured normalized UI-tree results, including legacy screenshot compatibility.
- [x] 11.4 Remove the standalone Runtime REST service/UI, its package data and dedicated tests, and remove Runtime supervision from Host Agent configuration/supervision with an actionable legacy-config error.
- [x] 11.5 Update operator documentation and OpenSpec artifacts to direct execution inspection to Host Agent `:8765/tasks` and remove port `8000` instructions.
- [x] 11.6 Run focused Host Agent and shared Runtime tests, workspace non-integration tests, Ruff, compileall, and strict OpenSpec validation.
- [ ] 11.7 Manual verification (requires a real Host Agent + Appium/device setup): submit or dispatch a task, then confirm the Host Agent console is the only local execution-history UI and shows the complete retained evidence.
-11
View File
@@ -6,15 +6,11 @@ requires-python = ">=3.13,<3.14"
dependencies = [
"anthropic>=0.69.0",
"Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"mcp>=1.27,<2",
"openai>=1.0.0",
"paddlepaddle>=3.0.0",
"paddleocr>=3.0.0",
"python-multipart>=0.0.20",
"uvicorn[standard]>=0.30.0",
]
[build-system]
@@ -55,13 +51,6 @@ include = [
"workflow*",
]
[tool.setuptools.package-data]
api = [
"templates/runtime_console/*.html",
"static/runtime_console/*.css",
"static/runtime_console/*.js",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --import-mode=importlib"
+2
View File
@@ -99,6 +99,8 @@ class TaskRunner:
*,
should_stop: StopRequested | None = None,
) -> Task:
if self.metadata_store:
self.metadata_store.create_task(task)
context = TaskContext(task_id=task.id, goal=task.goal)
world_handle = self._start_world_view(task.id)
if world_handle is not None:
+30 -5
View File
@@ -13,14 +13,20 @@ class TaskMetadataStore:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
def create_task(self, task: Task) -> None:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
with self._connect() as connection:
connection.execute(
"""
insert into tasks (
insert or ignore into tasks (
id, goal, device_id, status, created_at, updated_at,
completed_at, failure_reason
) values (?, ?, ?, ?, ?, ?, ?, ?)
completed_at, failure_reason, source_task_id, source_attempt
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task.id,
@@ -31,6 +37,8 @@ class TaskMetadataStore:
task.updated_at.isoformat(),
task.completed_at.isoformat() if task.completed_at else None,
task.failure_reason,
source_task_id,
source_attempt,
),
)
@@ -100,10 +108,27 @@ class TaskMetadataStore:
created_at text not null,
updated_at text not null,
completed_at text,
failure_reason text
failure_reason text,
source_task_id text,
source_attempt integer
)
"""
)
self._ensure_column(connection, "source_task_id", "text")
self._ensure_column(connection, "source_attempt", "integer")
@staticmethod
def _ensure_column(
connection: sqlite3.Connection,
name: str,
definition: str,
) -> None:
columns = {
str(row["name"])
for row in connection.execute("pragma table_info(tasks)").fetchall()
}
if name not in columns:
connection.execute(f"alter table tasks add column {name} {definition}")
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
+1 -4
View File
@@ -2,7 +2,7 @@
Verifies that the cloud workspace package is purely additive: every existing
module it composes (``runtime.task``, ``workflow.runner``, ``driver.registry``,
``api.console``) remains unaware of the ``cloud`` package in its source.
``api.mcp``) remains unaware of the ``cloud`` package in its source.
"""
from __future__ import annotations
@@ -38,9 +38,6 @@ def _existing_module_paths() -> list[Path]:
continue
for path in root.rglob("*.py"):
files.append(path)
console = PROJECT_ROOT / "api" / "console.py"
if console.exists():
files.append(console)
mcp = PROJECT_ROOT / "api" / "mcp.py"
if mcp.exists():
files.append(mcp)
-242
View File
@@ -1,242 +0,0 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
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
def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) -> None:
manager = DeviceManager()
client, metadata_store = _client(tmp_path, manager=manager)
assert client.get("/console/devices").json() == []
assert client.get("/console/tasks").json() == []
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk iPhone",
driver_type="wda",
)
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)
devices = client.get("/console/devices").json()
assert devices == [
{
"id": "iphone-1",
"name": "Desk iPhone",
"status": "idle",
"driver_type": "wda",
"connection_info": {},
"capability_tags": [],
}
]
assert [task["id"] for task in client.get("/console/tasks").json()] == [
"task-new",
"task-old",
]
assert [
task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
] == ["task-old"]
assert [
task["id"] for task in client.get("/console/tasks?status=running").json()
] == ["task-new"]
assert client.get("/console/tasks/task-old").json()["goal"] == "open settings"
assert client.get("/console/tasks/missing").status_code == 404
def test_console_timeline_inlines_screenshot_and_handles_empty_history(
tmp_path,
) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
metadata_store.create_task(task)
assert client.get("/console/tasks/task-1/timeline").json() == []
timeline.append(
task_id="task-1",
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,
)
records = client.get("/console/tasks/task-1/timeline").json()
assert records[0]["index"] == 1
assert records[0]["image_base64"] == base64.b64encode(PNG_10X20).decode("ascii")
assert client.get("/console/tasks/missing/timeline").status_code == 404
def test_console_timeline_inlines_before_and_after_screenshots(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(Task(id="task-evidence", goal="tap", device_id="phone"))
before = b"before"
after = b"after"
timeline.append(
task_id="task-evidence",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="tap",
tool_call={"action": "tap", "description": "tap search"},
result={"ok": True},
before_screenshot=before,
after_screenshot=after,
)
record = client.get("/console/tasks/task-evidence/timeline").json()[0]
assert record["before_image_base64"] == base64.b64encode(before).decode("ascii")
assert record["after_image_base64"] == base64.b64encode(after).decode("ascii")
assert record["image_base64"] == base64.b64encode(after).decode("ascii")
def test_console_device_registration_and_unregistration(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
manager = DeviceManager()
client, _ = _client(tmp_path, manager=manager, config_store=config_store)
rejected = client.post(
"/console/devices",
json={"driver_type": "android", "connection_info": {}},
)
assert rejected.status_code == 400
assert config_store.list() == []
assert manager.list_devices() == []
response = client.post(
"/console/devices",
json={
"driver_type": "wda",
"name": "Desk iPhone",
"connection_info": {
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
},
},
)
assert response.status_code == 201
device_id = response.json()["id"]
assert response.json()["status"] == "idle"
assert config_store.get(device_id)["connection_info"]["udid"] == "abc123"
assert [device.id for device in manager.list_devices()] == [device_id]
delete_response = client.delete(f"/console/devices/{device_id}")
assert delete_response.status_code == 204
assert config_store.get(device_id) is None
assert manager.list_devices() == []
assert client.delete("/console/devices/missing").status_code == 404
def test_console_config_get_update_and_validation(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 7)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
assert runner.config.max_steps == 7
assert client.get("/console/config").json() == {"max_steps": 7}
response = client.put("/console/config", json={"max_steps": 30})
assert response.status_code == 200
assert response.json() == {"max_steps": 30}
assert runner.config.max_steps == 30
assert config_store.get_setting("max_steps") == "30"
rejected = client.put("/console/config", json={"max_steps": 0})
assert rejected.status_code == 400
assert runner.config.max_steps == 30
assert config_store.get_setting("max_steps") == "30"
def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.add(
device_id="persisted-1",
name="Persisted iPhone",
driver_type="wda",
connection_info={"udid": "abc123"},
)
config_store.set_setting("max_steps", 31)
manager = DeviceManager()
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(
tmp_path,
manager=manager,
runner=runner,
config_store=config_store,
)
assert runner.config.max_steps == 31
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
def test_console_json_reflects_page_form_mutations(tmp_path) -> None:
"""A device registered via the /ui/ form must be visible through /console/* JSON."""
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "From Form",
"driver_type": "wda",
"server_url": "http://127.0.0.1:4723",
"udid": "form-udid",
"wda_local_port": "8100",
},
follow_redirects=False,
)
assert response.status_code == 303
json_devices = client.get("/console/devices").json()
assert len(json_devices) == 1
assert json_devices[0]["name"] == "From Form"
assert json_devices[0]["connection_info"]["udid"] == "form-udid"
+72 -2
View File
@@ -139,7 +139,11 @@ def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
completed_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
metadata_store.create_task(
task,
source_task_id="cloud-task-visible",
source_attempt=2,
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
@@ -148,7 +152,10 @@ def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
response = client.get("/tasks")
assert response.status_code == 200
assert "Executed tasks on this Host" in response.text
assert "task-visible" in response.text
assert "cloud-task-visible" in response.text
assert "2" in response.text
assert "completed" in response.text
@@ -190,6 +197,69 @@ def test_authenticated_task_detail_renders_timeline_with_screenshot(tmp_path) ->
assert "find search button" in response.text
# Screenshot inlined as base64 data URI.
assert "data:image/png;base64," in response.text
assert "Before action" in response.text
assert "After action" in response.text
def test_authenticated_task_detail_renders_complete_step_evidence(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-evidence",
goal="inspect search",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(
task,
source_task_id="cloud-evidence",
source_attempt=3,
)
timeline.append(
task_id=task.id,
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="inspect the current UI tree",
tool_call={"action": "get_ui_tree", "description": "inspect UI tree"},
result={
"result": [
{
"id": "search",
"type": "button",
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 0.98,
}
]
},
before_screenshot=PNG_10X20,
after_screenshot=PNG_10X20 + b"after",
ocr_results=[
{
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 0.95,
}
],
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get(f"/tasks/{task.id}")
assert response.status_code == 200
assert "cloud-evidence" in response.text
assert "Cloud attempt" in response.text
assert "OCR results (1)" in response.text
assert "UI tree (1 normalized nodes)" in response.text
assert "inspect the current UI tree" in response.text
assert response.text.count("data:image/png;base64,") == 2
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
@@ -207,4 +277,4 @@ def test_tasks_list_shows_empty_state(tmp_path) -> None:
response = client.get("/tasks")
assert response.status_code == 200
assert "No tasks recorded" in response.text
assert "No executions recorded yet" in response.text
+52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -75,6 +76,57 @@ def test_step_transitions_persist_during_execution(tmp_path) -> None:
assert [r["index"] for r in records] == [1, 2]
def test_task_metadata_creation_is_idempotent_and_keeps_source_correlation(
tmp_path,
) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
task = _make_task("task-source")
metadata_store.create_task(
task,
source_task_id="cloud-task-source",
source_attempt=2,
)
metadata_store.create_task(task)
row = metadata_store.get_task(task.id)
assert row is not None
assert row["source_task_id"] == "cloud-task-source"
assert row["source_attempt"] == 2
def test_existing_task_database_gains_source_correlation_columns(tmp_path) -> None:
db_path = tmp_path / "legacy.sqlite3"
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
create table tasks (
id text primary key,
goal text not null,
device_id text not null,
status text not null,
created_at text not null,
updated_at text not null,
completed_at text,
failure_reason text
)
"""
)
metadata_store = TaskMetadataStore(db_path)
task = _make_task("task-migrated")
metadata_store.create_task(
task,
source_task_id="cloud-task-migrated",
source_attempt=1,
)
row = metadata_store.get_task(task.id)
assert row is not None
assert row["source_task_id"] == "cloud-task-migrated"
assert row["source_attempt"] == 1
# --------------------------------------------------------------------------- #
# Queryable after completion (in-memory Task discarded)
# --------------------------------------------------------------------------- #
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
import pytest
from device.manager import DeviceManager
from core.models import Task
from storage.task_metadata import TaskMetadataStore
from tests.fakes import FakeDriver
def test_rest_start_task_and_poll_until_complete(tmp_path) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
driver = FakeDriver()
manager = DeviceManager()
manager.register_device("iphone-1", lambda: driver)
manager.connect("iphone-1", max_retries=1)
store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
class CompletingRunner:
def run(self, task: Task) -> None:
store.update_task(task.id, status="completed", completed=True)
app = create_app(
manager=manager,
metadata_store=store,
task_runner=CompletingRunner(),
)
client = TestClient(app)
response = client.post(
"/agent/task",
json={"goal": "search", "device_id": "iphone-1"},
)
assert response.status_code == 200
task_id = response.json()["task_id"]
status_response = client.get(f"/task/{task_id}")
assert status_response.status_code == 200
assert status_response.json()["status"] == "completed"
-79
View File
@@ -1,79 +0,0 @@
"""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()
-447
View File
@@ -1,447 +0,0 @@
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 "&lt;script&gt;" 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 "&lt;script&gt;" 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 "&lt;script&gt;" 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 "&lt;script&gt;" 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",
"description": "tap search",
"args": {"x": 1, "y": 2},
},
result={"ok": True},
before_screenshot=PNG_10X20 + b"before",
after_screenshot=PNG_10X20 + b"after",
ocr_results=[
{
"text": "Search",
"confidence": 0.98,
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
}
],
)
body = client.get("/ui/tasks/task-with-timeline").text
before_data_uri = "data:image/png;base64," + base64.b64encode(
PNG_10X20 + b"before"
).decode("ascii")
after_data_uri = "data:image/png;base64," + base64.b64encode(
PNG_10X20 + b"after"
).decode("ascii")
assert before_data_uri in body
assert after_data_uri in body
assert "Before action" in body
assert "After action" in body
assert "Operation" in body
assert "OCR results" in body
assert "Search" in body
assert "UI tree" not 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_task_detail_renders_normalized_ui_tree_result(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(id="task-ui-tree", goal="inspect the screen", device_id="iphone-1")
)
timeline.append(
task_id="task-ui-tree",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="inspect the screen",
tool_call={"action": "get_ui_tree", "description": "inspect UI tree"},
result={
"success": True,
"result": [
{
"id": "ui-000",
"type": "button",
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 1.0,
}
],
},
)
body = client.get("/ui/tasks/task-ui-tree").text
assert "UI tree" in body
assert "1 normalized nodes" in body
assert "button" in body
assert "Search" in body
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}
-1
View File
@@ -60,7 +60,6 @@ def test_task_runner_executes_loop_and_writes_timeline(tmp_path) -> None:
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(goal="open app and search", device_id="iphone-1")
metadata.create_task(task)
runner = TaskRunner(
planner=planner,
+35
View File
@@ -6,6 +6,7 @@ from runtime.planner import Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.store import SkillStore
from storage.task_metadata import TaskMetadataStore
from tests.fakes import PNG_10X20
from workflow.models import (
BranchStep,
@@ -39,6 +40,14 @@ class FakeTaskRunner:
return task
class ImmediateCompletionPlanner(Planner):
def plan(self, *, goal, scene, context):
return []
def goal_reached(self, *, goal, scene, context):
return True
def _scene(text: str = "Ready") -> Scene:
return Scene(
width=10,
@@ -96,6 +105,32 @@ def test_workflow_runner_linear_planned_goal_completes(tmp_path) -> None:
assert [result.step_id for result in run.step_results] == ["first", "second"]
def test_workflow_planned_goal_creates_task_metadata_without_precreation(
tmp_path,
) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
definition = WorkflowDefinition(
name="persisted goal",
entry_step_id="goal",
steps=[PlannedGoalStep("goal", "inspect screen")],
)
runner = WorkflowRunner(
_store(tmp_path),
task_runner_factory=lambda: TaskRunner(
planner=ImmediateCompletionPlanner(),
metadata_store=metadata_store,
observer=lambda device_id: _scene(),
config=TaskRunnerConfig(max_steps=1),
),
)
run = runner.run(definition, "phone")
task_id = run.step_results[0].task_id
assert task_id is not None
assert metadata_store.get_task(task_id)["status"] == "completed"
def test_workflow_runner_stops_before_the_next_step(tmp_path) -> None:
stop_requested = False
calls: list[str] = []
Generated
-8
View File
@@ -401,15 +401,11 @@ source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "appium-python-client" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "mcp" },
{ name = "openai" },
{ name = "paddleocr" },
{ name = "paddlepaddle" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
@@ -421,15 +417,11 @@ dev = [
requires-dist = [
{ name = "anthropic", specifier = ">=0.69.0" },
{ name = "appium-python-client", specifier = ">=5.1.1" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "mcp", specifier = ">=1.27,<2" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "paddleocr", specifier = ">=3.0.0" },
{ name = "paddlepaddle", specifier = ">=3.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
[package.metadata.requires-dev]