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
-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 %}