Compare commits
110
Commits
82567fd248
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9076f8ddb0 | ||
|
|
8c99dc015a | ||
|
|
60ee157e97 | ||
|
|
dd8df33910 | ||
|
|
5458f3b8a4 | ||
|
|
44e1a6651a | ||
|
|
5b8daab457 | ||
|
|
697e54427b | ||
|
|
fdaca7539b | ||
|
|
3c9e65c78e | ||
|
|
71fd182f50 | ||
|
|
a315c62f3a | ||
|
|
050d1329c4 | ||
|
|
433ab41f95 | ||
|
|
70e0624a47 | ||
|
|
e69cea0245 | ||
|
|
6d9237a592 | ||
|
|
6241fb9d6d | ||
|
|
2d0c740c88 | ||
|
|
ce64c4eb47 | ||
|
|
ce2469616e | ||
|
|
dcb4798408 | ||
|
|
ab15218b27 | ||
|
|
b0932dd398 | ||
|
|
9e3007e7f6 | ||
|
|
98089b6748 | ||
|
|
b73db01626 | ||
|
|
cf8affe4d7 | ||
|
|
61c923b92b | ||
|
|
c7faee8da3 | ||
|
|
29b9a8c39a | ||
|
|
d1b0fffabb | ||
|
|
47eac0f2a7 | ||
|
|
2f8f4a36c6 | ||
|
|
28dccb908c | ||
|
|
3b62195a36 | ||
|
|
358f4623ba | ||
|
|
240b7be7b8 | ||
|
|
9a17297f1e | ||
|
|
059fb272bb | ||
|
|
41006b098a | ||
|
|
f64f98834f | ||
|
|
9da73cc6e3 | ||
|
|
5a93651db7 | ||
|
|
bd6b7e64e2 | ||
|
|
dda70940c0 | ||
|
|
865c163683 | ||
|
|
85f0d6e188 | ||
|
|
fd6365cf6e | ||
|
|
d8e7be4ccb | ||
|
|
c4ee4279ef | ||
|
|
1dd24825ca | ||
|
|
18f053e64b | ||
|
|
8a73edf4db | ||
|
|
c25ccb491d | ||
|
|
ff91bd4f70 | ||
|
|
7d79f677fe | ||
|
|
4d04d7ac83 | ||
|
|
6776ac2f2d | ||
|
|
d3024b4810 | ||
|
|
a58ded055e | ||
|
|
557c8a25ba | ||
|
|
d69be48f96 | ||
|
|
8a0d48eada | ||
|
|
361dada276 | ||
|
|
19c6669800 | ||
|
|
7c6cdc5b67 | ||
|
|
88189770ff | ||
|
|
947434b65a | ||
|
|
a25542694d | ||
|
|
17a709c92f | ||
|
|
4046c9452d | ||
|
|
7f439f0db5 | ||
|
|
c50ce1faec | ||
|
|
701983ccdd | ||
|
|
8162509158 | ||
|
|
367fd0d412 | ||
|
|
24992fc9fb | ||
|
|
a5aeb8889c | ||
|
|
96e403ee47 | ||
|
|
77d4813bb2 | ||
|
|
ccde30e378 | ||
|
|
8d5b02e37f | ||
|
|
778af2da53 | ||
|
|
f8054cb58c | ||
|
|
fd0ea3a066 | ||
|
|
8300c3b6b7 | ||
|
|
e00c50e703 | ||
|
|
0d944ec97d | ||
|
|
a8ba2312fc | ||
|
|
cbfdb2ae39 | ||
|
|
52e442790a | ||
|
|
8baf3a6a8b | ||
|
|
dd03abbbb0 | ||
|
|
e5a12f9b74 | ||
|
|
56f3f96363 | ||
|
|
8e37b965aa | ||
|
|
c01dd4c6b2 | ||
|
|
9669b52498 | ||
|
|
fa10cccf71 | ||
|
|
989fdbf878 | ||
|
|
30f09b6268 | ||
|
|
6e511111c4 | ||
|
|
25ebc10a8a | ||
|
|
ecb1dba9ff | ||
|
|
a883903b66 | ||
|
|
a46f7d02a1 | ||
|
|
fb09924835 | ||
|
|
bb6f24bbcb | ||
|
|
08cef7ca3c |
@@ -7,6 +7,5 @@ __pycache__
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
cloud-console/node_modules
|
||||
cloud-console/dist
|
||||
|
||||
@@ -14,3 +14,13 @@ CLOUD_API_PORT=8001
|
||||
# undecryptable):
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY=change-me-generate-a-fernet-key
|
||||
|
||||
# Cloud-proxy planner budget reservations. These apply only to Hosts reporting
|
||||
# AI_PLANNER_TRANSPORT=cloud.
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING=4096
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS=300
|
||||
|
||||
# Successful Cloud-proxy planner decisions with task context retain prompt and
|
||||
# tool-call history until their terminal task exceeds this retention window.
|
||||
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS=3600
|
||||
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS=7
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ COPY cloud-console/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: the existing Python image, now carrying the SPA build output.
|
||||
FROM registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim
|
||||
FROM registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
Vendored
+1
-1
@@ -55,7 +55,7 @@ pipeline {
|
||||
when { expression { return !params.SKIP_TESTS } }
|
||||
agent {
|
||||
docker {
|
||||
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim'
|
||||
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim'
|
||||
reuseNode true
|
||||
// The Docker Pipeline plugin runs the container as the Jenkins host
|
||||
// user (non-root), so /root isn't writable. Cache under /tmp, which is
|
||||
|
||||
@@ -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,8 +54,88 @@ uv build --package device-agent-runtime
|
||||
uv build --package device-cloud-platform
|
||||
```
|
||||
|
||||
The Vue/Vite application under `console/` remains an independent npm project;
|
||||
uv does not install or modify its JavaScript dependencies.
|
||||
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.
|
||||
|
||||
## Local-only Host Agent
|
||||
|
||||
Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks
|
||||
submitted in the Host console are queued and executed in the same process:
|
||||
|
||||
```bash
|
||||
export HOST_AGENT_MODE=local
|
||||
export AI_PLANNER_ENABLED=true
|
||||
export AI_PLANNER_PROVIDER=openai-compatible
|
||||
export AI_PLANNER_MODEL=qwen2.5
|
||||
export AI_PLANNER_API_KEY=local-key
|
||||
export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1
|
||||
export AI_PLANNER_MULTIMODAL=true
|
||||
uv run --package device-host-agent device-host-agent setup
|
||||
uv run --package device-host-agent device-host-agent
|
||||
```
|
||||
|
||||
`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that
|
||||
implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers
|
||||
also accept `AI_PLANNER_API_KEY` and their conventional API key variables.
|
||||
|
||||
The local Host console also exposes an authenticated conversational Agent API:
|
||||
|
||||
```text
|
||||
POST http://127.0.0.1:8765/api/chat
|
||||
```
|
||||
|
||||
Send a JSON body containing `device_id` and `messages` (`user`/`assistant` roles). The Agent can
|
||||
return ordinary assistant text or call the same device-operation tool contracts
|
||||
used by the Runtime; each tool result is fed back to the model before the final
|
||||
reply is returned. The endpoint uses the Host console session cookie, so it is
|
||||
not an unauthenticated device-control endpoint.
|
||||
|
||||
Every chat request is bound to exactly one registered `device_id`. Keep a
|
||||
separate message history and client session for each phone; the Agent injects
|
||||
the bound device into device tools and rejects cross-device tool arguments.
|
||||
|
||||
For vision-capable OpenAI models, a user message may contain standard OpenAI
|
||||
multimodal blocks:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "点击图片中的登录按钮"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The compact form `{ "role": "user", "text": "...", "image_base64": "..." }`
|
||||
is also accepted. The model can inspect the supplied image and then call a
|
||||
phone tool such as `tap` in the same conversation.
|
||||
|
||||
Set `AI_PLANNER_MULTIMODAL=true` for a vision model. The Planner sends the
|
||||
screenshot and omits OCR-only elements and OCR metadata from the structured
|
||||
scene payload, avoiding duplicate OCR text.
|
||||
|
||||
Local mode records LLM responses, reasoning fields, tool calls, tool results,
|
||||
and final replies in a local SQLite database. View them at
|
||||
`http://127.0.0.1:8765/conversations`; image bytes are excluded. Set
|
||||
`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path.
|
||||
|
||||
The authenticated `Devices` page has an on-demand `Get screenshot` button for
|
||||
each connected device. Screenshots are captured only after the operator clicks
|
||||
the button; the page does not auto-refresh or capture screenshots as part of
|
||||
heartbeat synchronization.
|
||||
|
||||
In local mode, Appium supervision is enabled by default. Host Agent probes
|
||||
`/status`, adopts a healthy existing Appium instance, starts Appium when no
|
||||
listener exists, restarts only processes it started if they crash, and stops
|
||||
those child processes on shutdown. Override `HOST_AGENT_APPIUM_*` or set
|
||||
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=false` when an external process
|
||||
manager owns Appium.
|
||||
|
||||
## Project Direction
|
||||
|
||||
@@ -69,5 +149,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.
|
||||
|
||||
+27
-4
@@ -4,7 +4,6 @@ import logging
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from agents.config import CollaborationConfig, load_config
|
||||
from agents.models import Observation, VerificationVerdict
|
||||
from agents.observer import Observer
|
||||
from agents.reflector import Reflector
|
||||
from agents.verifier import Verifier
|
||||
@@ -21,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class CollaborativeTaskRunnerConfig:
|
||||
max_steps: int = 20
|
||||
max_steps: int = 999999
|
||||
max_recovery_attempts: int = 3
|
||||
|
||||
|
||||
@@ -94,10 +93,21 @@ class CollaborativeTaskRunner:
|
||||
|
||||
for step in steps:
|
||||
executable_step = self._step_for_device(step, task.device_id)
|
||||
before_screenshot = self.task_runner._planning_screenshot(
|
||||
task.device_id
|
||||
)
|
||||
result = self.executor.execute(executable_step, context=context)
|
||||
after_screenshot = self.task_runner._planning_screenshot(task.device_id)
|
||||
context.add_step_result(result)
|
||||
self.task_runner._record_step_result(
|
||||
world_handle, context, task, scene, step, result
|
||||
world_handle,
|
||||
context,
|
||||
task,
|
||||
scene,
|
||||
step,
|
||||
result,
|
||||
before_screenshot=before_screenshot,
|
||||
after_screenshot=after_screenshot,
|
||||
)
|
||||
|
||||
post_scene = self._observe_scene(task.device_id)
|
||||
@@ -146,13 +156,26 @@ class CollaborativeTaskRunner:
|
||||
description=outcome.action.description,
|
||||
args=outcome.action.args,
|
||||
)
|
||||
before_screenshot = self.task_runner._planning_screenshot(
|
||||
task.device_id
|
||||
)
|
||||
recovery_result = self.executor.execute(
|
||||
self._step_for_device(recovery_step, task.device_id),
|
||||
context=context,
|
||||
)
|
||||
after_screenshot = self.task_runner._planning_screenshot(
|
||||
task.device_id
|
||||
)
|
||||
context.add_step_result(recovery_result)
|
||||
self.task_runner._record_step_result(
|
||||
world_handle, context, task, post_scene, recovery_step, recovery_result
|
||||
world_handle,
|
||||
context,
|
||||
task,
|
||||
post_scene,
|
||||
recovery_step,
|
||||
recovery_result,
|
||||
before_screenshot=before_screenshot,
|
||||
after_screenshot=after_screenshot,
|
||||
)
|
||||
if not recovery_result.success:
|
||||
return self._fail(
|
||||
|
||||
-148
@@ -1,148 +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
|
||||
|
||||
|
||||
def create_console_router(
|
||||
*,
|
||||
device_manager: DeviceManager,
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config_store: DeviceConfigStore,
|
||||
task_runner: TaskRunner,
|
||||
) -> Any:
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
router = APIRouter(prefix="/console", tags=["console"])
|
||||
|
||||
@router.get("/devices")
|
||||
def devices() -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in device_manager.list_devices()]
|
||||
|
||||
@router.post("/devices", status_code=status.HTTP_201_CREATED)
|
||||
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
|
||||
try:
|
||||
driver_factory = build_driver_factory(
|
||||
request.driver_type,
|
||||
request.connection_info,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
device_id = uuid4().hex
|
||||
device = device_manager.register_device(
|
||||
device_id,
|
||||
driver_factory,
|
||||
name=request.name,
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
)
|
||||
config_store.add(
|
||||
device_id=device_id,
|
||||
name=request.name,
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
)
|
||||
return device.to_dict()
|
||||
|
||||
@router.delete(
|
||||
"/devices/{device_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def unregister_device(device_id: str) -> Response:
|
||||
if not _has_device(device_manager, device_id):
|
||||
raise HTTPException(status_code=404, detail="device not found")
|
||||
device_manager.unregister_device(device_id)
|
||||
config_store.remove(device_id)
|
||||
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]]:
|
||||
rows = metadata_store.list_tasks()
|
||||
if device_id is not None:
|
||||
rows = [row for row in rows if row["device_id"] == device_id]
|
||||
if status is not None:
|
||||
rows = [row for row in rows if row["status"] == status]
|
||||
return rows
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def task_detail(task_id: str) -> dict[str, Any]:
|
||||
task = metadata_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return task
|
||||
|
||||
@router.get("/tasks/{task_id}/timeline")
|
||||
def task_timeline(task_id: str) -> list[dict[str, Any]]:
|
||||
if metadata_store.get_task(task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return [_inline_screenshot(record) for record in timeline.read(task_id)]
|
||||
|
||||
@router.get("/config")
|
||||
def runtime_config() -> dict[str, int]:
|
||||
return {"max_steps": _runner_max_steps(task_runner)}
|
||||
|
||||
@router.put("/config")
|
||||
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
|
||||
if request.max_steps <= 0:
|
||||
raise HTTPException(status_code=400, detail="max_steps must be positive")
|
||||
_set_runner_max_steps(task_runner, request.max_steps)
|
||||
config_store.set_setting("max_steps", request.max_steps)
|
||||
return {"max_steps": request.max_steps}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(record)
|
||||
screenshot_path = payload.get("screenshot_path")
|
||||
if screenshot_path:
|
||||
path = Path(str(screenshot_path))
|
||||
if path.exists():
|
||||
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
|
||||
"ascii"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _has_device(device_manager: DeviceManager, device_id: str) -> bool:
|
||||
return any(device.id == device_id for device in device_manager.list_devices())
|
||||
|
||||
|
||||
def _runner_max_steps(task_runner: TaskRunner) -> int:
|
||||
config = getattr(task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
return int(config.max_steps)
|
||||
|
||||
|
||||
def _set_runner_max_steps(task_runner: TaskRunner, max_steps: int) -> None:
|
||||
config = getattr(task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
config.max_steps = max_steps
|
||||
+45
-30
@@ -3,7 +3,7 @@ from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from api.errors import call_with_semantic_errors
|
||||
from device.manager import DEFAULT_MANAGER, DeviceManager
|
||||
from device.manager import DeviceManager
|
||||
from tools.describe_screen import describe_screen
|
||||
from tools.find_icon import find_icon_on_screen
|
||||
from tools.find_text import find_text_on_screen
|
||||
@@ -17,12 +17,11 @@ from tools.ui_tree import get_ui_tree
|
||||
|
||||
def tool_handlers(
|
||||
*,
|
||||
manager: DeviceManager | None = None,
|
||||
manager: DeviceManager,
|
||||
) -> dict[str, Callable[..., Any]]:
|
||||
device_manager = manager or DEFAULT_MANAGER
|
||||
|
||||
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
|
||||
image = take_screenshot(device_id, manager=device_manager)
|
||||
image = take_screenshot(device_id, manager=manager)
|
||||
return {
|
||||
"ok": True,
|
||||
"image_base64": base64.b64encode(image).decode("ascii"),
|
||||
@@ -39,70 +38,77 @@ def tool_handlers(
|
||||
x,
|
||||
y,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
manager=manager,
|
||||
),
|
||||
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: call_with_semantic_errors(
|
||||
swipe,
|
||||
start_x,
|
||||
start_y,
|
||||
end_x,
|
||||
end_y,
|
||||
duration_ms=duration_ms,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: (
|
||||
call_with_semantic_errors(
|
||||
swipe,
|
||||
start_x,
|
||||
start_y,
|
||||
end_x,
|
||||
end_y,
|
||||
duration_ms=duration_ms,
|
||||
device_id=device_id,
|
||||
manager=manager,
|
||||
)
|
||||
),
|
||||
"input_text": lambda text, device_id=None: call_with_semantic_errors(
|
||||
input_text,
|
||||
text,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
manager=manager,
|
||||
),
|
||||
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
|
||||
launch_app,
|
||||
app_id,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
manager=manager,
|
||||
),
|
||||
"find_text": lambda query, device_id=None: call_with_semantic_errors(
|
||||
find_text_on_screen,
|
||||
query,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
manager=manager,
|
||||
),
|
||||
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
|
||||
find_icon_on_screen,
|
||||
name,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
manager=manager,
|
||||
),
|
||||
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
|
||||
get_ui_tree,
|
||||
device_id,
|
||||
manager=device_manager,
|
||||
"get_ui_tree": lambda device_id=None, include_app_info=False: (
|
||||
call_with_semantic_errors(
|
||||
get_ui_tree,
|
||||
device_id,
|
||||
manager=manager,
|
||||
include_app_info=include_app_info,
|
||||
)
|
||||
),
|
||||
"describe_screen": lambda device_id=None: call_with_semantic_errors(
|
||||
lambda: describe_screen(device_id, manager=device_manager).to_dict()
|
||||
lambda: describe_screen(device_id, manager=manager).to_dict()
|
||||
),
|
||||
"list_devices": lambda: [
|
||||
device.to_dict() for device in device_manager.list_devices()
|
||||
],
|
||||
"list_devices": lambda: [device.to_dict() for device in manager.list_devices()],
|
||||
"device_status": lambda device_id: call_with_semantic_errors(
|
||||
lambda: {"device_id": device_id, "status": device_manager.status(device_id)}
|
||||
lambda: {"device_id": device_id, "status": manager.status(device_id)}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_mcp_server(
|
||||
*,
|
||||
manager: DeviceManager | None = None,
|
||||
manager: DeviceManager,
|
||||
skill_catalog_store: Any | None = None,
|
||||
skill_active_subscriptions: set[str] | None = None,
|
||||
skill_local_store: Any | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("mcp SDK is not installed") from exc
|
||||
|
||||
if manager is None:
|
||||
raise ValueError("create_mcp_server requires a non-None manager")
|
||||
|
||||
handlers = tool_handlers(manager=manager)
|
||||
server = FastMCP("apex-agent")
|
||||
|
||||
@@ -149,8 +155,14 @@ def create_mcp_server(
|
||||
return handlers["find_icon"](name=name, device_id=device_id)
|
||||
|
||||
@server.tool(name="get_ui_tree")
|
||||
def _get_ui_tree(device_id: str | None = None) -> Any:
|
||||
return handlers["get_ui_tree"](device_id=device_id)
|
||||
def _get_ui_tree(
|
||||
device_id: str | None = None,
|
||||
include_app_info: bool = False,
|
||||
) -> Any:
|
||||
return handlers["get_ui_tree"](
|
||||
device_id=device_id,
|
||||
include_app_info=include_app_info,
|
||||
)
|
||||
|
||||
@server.tool(name="describe_screen")
|
||||
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
|
||||
@@ -166,10 +178,13 @@ def create_mcp_server(
|
||||
|
||||
if skill_catalog_store is not None:
|
||||
from api.skill_catalog_mcp import register_skill_catalog_tools
|
||||
from storage.local_skills import LocalSkillStore
|
||||
|
||||
local_store = skill_local_store or LocalSkillStore()
|
||||
register_skill_catalog_tools(
|
||||
server,
|
||||
store=skill_catalog_store,
|
||||
local_store=local_store,
|
||||
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
|
||||
get_registered_tools=lambda: set(handlers.keys()),
|
||||
)
|
||||
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.console import create_console_router
|
||||
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.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.types import Scope
|
||||
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
|
||||
|
||||
Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown
|
||||
path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the
|
||||
SPA's client-side router handle it.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Any:
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code == 404 and path != "index.html":
|
||||
return await super().get_response("index.html", scope)
|
||||
raise
|
||||
|
||||
device_manager = manager or DEFAULT_MANAGER
|
||||
store = metadata_store or TaskMetadataStore()
|
||||
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")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@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(
|
||||
device_manager=device_manager,
|
||||
metadata_store=store,
|
||||
timeline=timeline_store,
|
||||
config_store=config_store,
|
||||
task_runner=runner,
|
||||
)
|
||||
)
|
||||
|
||||
console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR")
|
||||
if console_static_dir:
|
||||
dist_dir = Path(console_static_dir)
|
||||
if not dist_dir.is_dir():
|
||||
raise ValueError(
|
||||
f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def _redirect_to_console() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
app.mount(
|
||||
"/ui",
|
||||
SpaStaticFiles(directory=str(dist_dir), html=True),
|
||||
name="console",
|
||||
)
|
||||
|
||||
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"],
|
||||
)
|
||||
+168
-26
@@ -1,22 +1,31 @@
|
||||
"""MCP tool surface for the Skill Catalog.
|
||||
|
||||
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
|
||||
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
|
||||
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
|
||||
Reads go through the unified merge surface in :mod:`api.skill_catalog_view`
|
||||
(synced + local, with override precedence); flow-template parameter
|
||||
resolution delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
|
||||
No server-side execution primitive is exposed — flow templates are resolved
|
||||
here but executed step-by-step by the LLM via the existing device-capability
|
||||
tools (design D5).
|
||||
|
||||
Authoring tools (``create_skill``/``update_skill``/``delete_skill``) dispatch by
|
||||
origin (design D10): they edit/delete local skills and create/update/remove
|
||||
local overrides for cloud skills, never writing to the synced store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from skills_learning.models import (
|
||||
FlowStep,
|
||||
FlowTemplateSkill,
|
||||
KnowledgeSkill,
|
||||
SkillMetadata,
|
||||
)
|
||||
from api.skill_catalog_view import SkillCatalogView, SkillSummary, SkillView
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
|
||||
|
||||
@@ -27,6 +36,9 @@ SKILL_TOOL_NAMES = (
|
||||
"search_skills",
|
||||
"get_skill",
|
||||
"resolve_flow_template",
|
||||
"create_skill",
|
||||
"update_skill",
|
||||
"delete_skill",
|
||||
)
|
||||
|
||||
|
||||
@@ -51,9 +63,15 @@ class MissingParameterError(SkillCatalogError):
|
||||
"""Raised when required flow-template parameters are missing/invalid."""
|
||||
|
||||
|
||||
class SkillAuthoringError(SkillCatalogError):
|
||||
"""Raised when an authoring operation cannot be applied (e.g., deleting a
|
||||
cloud skill that has no local override)."""
|
||||
|
||||
|
||||
def skill_tool_handlers(
|
||||
*,
|
||||
store: SkillCatalogStore,
|
||||
local_store: LocalSkillStore,
|
||||
get_active_subscriptions: Callable[[], set[str]],
|
||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||
) -> dict[str, Callable[..., dict[str, Any]]]:
|
||||
@@ -62,44 +80,41 @@ def skill_tool_handlers(
|
||||
Decoupled from FastMCP so handlers can be tested directly without
|
||||
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
|
||||
"""
|
||||
view = SkillCatalogView(store, local_store)
|
||||
local = local_store
|
||||
tools_getter = get_registered_tools or (lambda: set())
|
||||
|
||||
def _list_skills() -> dict[str, Any]:
|
||||
metas = store.list_skills(get_active_subscriptions())
|
||||
return {
|
||||
"ok": True,
|
||||
"skills": [_metadata_to_summary(m) for m in metas],
|
||||
}
|
||||
summaries = view.list_skills(get_active_subscriptions())
|
||||
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
|
||||
|
||||
def _search_skills(query: str) -> dict[str, Any]:
|
||||
metas = store.search_skills(query, get_active_subscriptions())
|
||||
return {
|
||||
"ok": True,
|
||||
"skills": [_metadata_to_summary(m) for m in metas],
|
||||
}
|
||||
summaries = view.search_skills(query, get_active_subscriptions())
|
||||
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
|
||||
|
||||
def _get_skill(skill_id: str) -> dict[str, Any]:
|
||||
skill = store.get_skill(
|
||||
result = view.get_skill(
|
||||
skill_id,
|
||||
get_active_subscriptions(),
|
||||
registered_tools=tools_getter(),
|
||||
)
|
||||
if skill is None:
|
||||
if result is None:
|
||||
return _error_response(SkillNotFoundError(skill_id))
|
||||
return {"ok": True, "skill": _skill_to_full_dict(skill)}
|
||||
return {"ok": True, "skill": _view_to_dict(result)}
|
||||
|
||||
def _resolve_flow_template(
|
||||
skill_id: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
skill = store.get_skill(
|
||||
result = view.get_skill(
|
||||
skill_id,
|
||||
get_active_subscriptions(),
|
||||
registered_tools=tools_getter(),
|
||||
)
|
||||
if skill is None:
|
||||
if result is None:
|
||||
return _error_response(SkillNotFoundError(skill_id))
|
||||
skill = result.skill
|
||||
if not isinstance(skill, FlowTemplateSkill):
|
||||
return _error_response(
|
||||
InvalidFlowTemplateError(
|
||||
@@ -112,11 +127,60 @@ def skill_tool_handlers(
|
||||
return _error_response(MissingParameterError(str(exc)))
|
||||
return {"ok": True, "steps": steps}
|
||||
|
||||
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
skill = _build_skill(payload)
|
||||
except ValueError as exc:
|
||||
return _error_response(SkillAuthoringError(str(exc)))
|
||||
stored = local.create_local(skill)
|
||||
return {"ok": True, "skill": _skill_to_full_dict(stored), "origin": "local"}
|
||||
|
||||
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
skill = _build_skill(payload)
|
||||
except ValueError as exc:
|
||||
return _error_response(SkillAuthoringError(str(exc)))
|
||||
if view.is_local_skill(skill_id):
|
||||
stored = local.update_local(_with_id(skill, skill_id))
|
||||
return {
|
||||
"ok": True,
|
||||
"skill": _skill_to_full_dict(stored),
|
||||
"origin": "local",
|
||||
}
|
||||
# Cloud skill id (or anticipated one): create/update a local override.
|
||||
stored = local.upsert_override(skill_id, skill)
|
||||
return {
|
||||
"ok": True,
|
||||
"skill": _skill_to_full_dict(stored),
|
||||
"origin": "cloud",
|
||||
"locally_overridden": True,
|
||||
}
|
||||
|
||||
def _delete_skill(skill_id: str) -> dict[str, Any]:
|
||||
if view.is_local_skill(skill_id):
|
||||
local.delete_local(skill_id)
|
||||
return {"ok": True, "deleted": skill_id, "origin": "local"}
|
||||
if view.has_override(skill_id):
|
||||
local.remove_override(skill_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"deleted_override": skill_id,
|
||||
"origin": "cloud",
|
||||
}
|
||||
return _error_response(
|
||||
SkillAuthoringError(
|
||||
f"skill {skill_id} is a cloud skill with no local override to remove"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"list_skills": _list_skills,
|
||||
"search_skills": _search_skills,
|
||||
"get_skill": _get_skill,
|
||||
"resolve_flow_template": _resolve_flow_template,
|
||||
"create_skill": _create_skill,
|
||||
"update_skill": _update_skill,
|
||||
"delete_skill": _delete_skill,
|
||||
}
|
||||
|
||||
|
||||
@@ -124,18 +188,20 @@ def register_skill_catalog_tools(
|
||||
server: Any,
|
||||
*,
|
||||
store: SkillCatalogStore,
|
||||
local_store: LocalSkillStore,
|
||||
get_active_subscriptions: Callable[[], set[str]],
|
||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||
) -> Any:
|
||||
"""Register ``list_skills``/``search_skills``/``get_skill``/
|
||||
``resolve_flow_template`` as MCP tools on ``server``.
|
||||
"""Register the skill MCP tools (read + authoring) on ``server``.
|
||||
|
||||
Returns the server so the caller can chain. No batch-execute tool is
|
||||
registered (design D5): the LLM issues each resulting device-capability
|
||||
tool call itself, preserving the Observe-Think-Act loop.
|
||||
tool call itself, preserving the Observe-Think-Act loop. Authoring tools
|
||||
are always registered (design D6): there is no enable/disable gate.
|
||||
"""
|
||||
handlers = skill_tool_handlers(
|
||||
store=store,
|
||||
local_store=local_store,
|
||||
get_active_subscriptions=get_active_subscriptions,
|
||||
get_registered_tools=get_registered_tools,
|
||||
)
|
||||
@@ -159,25 +225,58 @@ def register_skill_catalog_tools(
|
||||
) -> dict[str, Any]:
|
||||
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
|
||||
|
||||
@server.tool(name="create_skill")
|
||||
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return handlers["create_skill"](payload=payload)
|
||||
|
||||
@server.tool(name="update_skill")
|
||||
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return handlers["update_skill"](skill_id=skill_id, payload=payload)
|
||||
|
||||
@server.tool(name="delete_skill")
|
||||
def _delete_skill(skill_id: str) -> dict[str, Any]:
|
||||
return handlers["delete_skill"](skill_id=skill_id)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _metadata_to_summary(meta: SkillMetadata) -> dict[str, Any]:
|
||||
"""Compact metadata for list/search — no Subscription-Platform-specific fields."""
|
||||
# ----------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _summary_to_dict(summary: SkillSummary) -> dict[str, Any]:
|
||||
meta = summary.metadata
|
||||
return {
|
||||
"id": meta.id,
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"kind": meta.kind,
|
||||
"tags": list(meta.tags),
|
||||
"origin": summary.origin,
|
||||
"locally_overridden": summary.locally_overridden,
|
||||
}
|
||||
|
||||
|
||||
def _view_to_dict(view: SkillView) -> dict[str, Any]:
|
||||
base = _skill_to_full_dict(view.skill)
|
||||
base["origin"] = view.origin
|
||||
base["locally_overridden"] = view.locally_overridden
|
||||
return base
|
||||
|
||||
|
||||
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
|
||||
"""Full skill payload for ``get_skill``."""
|
||||
base = _metadata_to_summary(skill.metadata)
|
||||
base["version"] = skill.metadata.version
|
||||
base["updated_at"] = skill.metadata.updated_at.isoformat()
|
||||
"""Full skill payload for ``get_skill`` / authoring responses."""
|
||||
meta: SkillMetadata = skill.metadata
|
||||
base = {
|
||||
"id": meta.id,
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"kind": meta.kind,
|
||||
"tags": list(meta.tags),
|
||||
"version": meta.version,
|
||||
"updated_at": meta.updated_at.isoformat(),
|
||||
}
|
||||
if isinstance(skill, KnowledgeSkill):
|
||||
base["content"] = skill.content
|
||||
elif isinstance(skill, FlowTemplateSkill):
|
||||
@@ -188,6 +287,46 @@ def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def _build_skill(payload: dict[str, Any]) -> KnowledgeSkill | FlowTemplateSkill:
|
||||
"""Construct a Skill from an authoring payload."""
|
||||
kind = str(payload.get("kind") or "").strip()
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("skill name must not be empty")
|
||||
if kind not in ("knowledge", "flow_template"):
|
||||
raise ValueError(f"unsupported skill kind: {kind!r}")
|
||||
tags = [str(tag) for tag in payload.get("tags") or []]
|
||||
meta = SkillMetadata(name=name, kind=kind, tags=tags) # type: ignore[arg-type]
|
||||
if kind == "knowledge":
|
||||
return KnowledgeSkill(metadata=meta, content=str(payload.get("content") or ""))
|
||||
steps = [
|
||||
FlowStep(
|
||||
tool_name=str(step.get("tool_name") or step.get("action") or ""),
|
||||
args=dict(step.get("args") or {}),
|
||||
)
|
||||
for step in (payload.get("steps") or [])
|
||||
]
|
||||
parameters = {
|
||||
str(name): dict(schema)
|
||||
for name, schema in (payload.get("parameters") or {}).items()
|
||||
}
|
||||
return FlowTemplateSkill(metadata=meta, steps=steps, parameters=parameters)
|
||||
|
||||
|
||||
def _with_id(skill: KnowledgeSkill | FlowTemplateSkill, skill_id: str):
|
||||
meta = skill.metadata
|
||||
from dataclasses import replace
|
||||
|
||||
new_meta = replace(meta, id=skill_id)
|
||||
if isinstance(skill, KnowledgeSkill):
|
||||
return KnowledgeSkill(metadata=new_meta, content=skill.content)
|
||||
return FlowTemplateSkill(
|
||||
metadata=new_meta,
|
||||
steps=list(skill.steps),
|
||||
parameters={n: dict(s) for n, s in skill.parameters.items()},
|
||||
)
|
||||
|
||||
|
||||
def _error_response(exc: Exception) -> dict[str, Any]:
|
||||
return {"ok": False, "error": _semantic_skill_error(exc)}
|
||||
|
||||
@@ -200,4 +339,7 @@ def _semantic_skill_error(exc: Exception) -> str:
|
||||
if isinstance(exc, MissingParameterError):
|
||||
message = str(exc)
|
||||
return f"missing parameter: {message}" if message else "invalid parameter"
|
||||
if isinstance(exc, SkillAuthoringError):
|
||||
message = str(exc)
|
||||
return f"authoring error: {message}" if message else "authoring error"
|
||||
return "operation failed"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Unified read-merge surface over the synced catalog and the local skill store.
|
||||
|
||||
API layer per CONSTITUTION.md: this module reads from both
|
||||
:mod:`storage.skill_catalog` (cloud-synced, read-only-except-sync) and
|
||||
:mod:`storage.local_skills` (agent-authored + overrides) and presents a single
|
||||
origin-discriminated catalog. It is the only module that reads both stores.
|
||||
|
||||
Override semantics (design D8/D9/D11): a local override shadows the cloud
|
||||
skill at read time, wins against sync updates, and is reported with
|
||||
``origin = "cloud"`` and ``locally_overridden = true`` while it shadows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from skills_learning.models import (
|
||||
FlowTemplateSkill,
|
||||
KnowledgeSkill,
|
||||
SkillMetadata,
|
||||
)
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
Origin = Literal["cloud", "local"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillSummary:
|
||||
"""Metadata + origin for list/search results."""
|
||||
|
||||
metadata: SkillMetadata
|
||||
origin: Origin
|
||||
locally_overridden: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillView:
|
||||
"""Full skill + origin for get_skill results."""
|
||||
|
||||
skill: KnowledgeSkill | FlowTemplateSkill
|
||||
origin: Origin
|
||||
locally_overridden: bool
|
||||
|
||||
|
||||
class SkillCatalogView:
|
||||
"""Merged read surface over synced + local skills, with override precedence."""
|
||||
|
||||
def __init__(self, synced: SkillCatalogStore, local: LocalSkillStore) -> None:
|
||||
self._synced = synced
|
||||
self._local = local
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Merged reads
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(
|
||||
self,
|
||||
active_subscriptions: set[str],
|
||||
) -> list[SkillSummary]:
|
||||
override_ids = self._local.list_override_cloud_ids()
|
||||
summaries: list[SkillSummary] = []
|
||||
|
||||
# Local authored skills.
|
||||
for meta in self._local.list_local():
|
||||
summaries.append(SkillSummary(meta, "local", False))
|
||||
|
||||
# Cloud-synced skills, applying overrides where present.
|
||||
for meta in self._synced.list_skills(active_subscriptions):
|
||||
if meta.id in override_ids:
|
||||
override = self._local.get_override(meta.id)
|
||||
if override is not None:
|
||||
summaries.append(SkillSummary(override.metadata, "cloud", True))
|
||||
continue
|
||||
summaries.append(SkillSummary(meta, "cloud", False))
|
||||
|
||||
summaries.sort(key=lambda s: s.metadata.name)
|
||||
return summaries
|
||||
|
||||
def search_skills(
|
||||
self,
|
||||
query: str,
|
||||
active_subscriptions: set[str],
|
||||
) -> list[SkillSummary]:
|
||||
override_ids = self._local.list_override_cloud_ids()
|
||||
summaries: list[SkillSummary] = []
|
||||
|
||||
for meta in self._local.search_local(query):
|
||||
summaries.append(SkillSummary(meta, "local", False))
|
||||
|
||||
for meta in self._synced.search_skills(query, active_subscriptions):
|
||||
if meta.id in override_ids:
|
||||
override = self._local.get_override(meta.id)
|
||||
if override is not None:
|
||||
summaries.append(SkillSummary(override.metadata, "cloud", True))
|
||||
continue
|
||||
summaries.append(SkillSummary(meta, "cloud", False))
|
||||
|
||||
return summaries
|
||||
|
||||
def get_skill(
|
||||
self,
|
||||
skill_id: str,
|
||||
active_subscriptions: set[str],
|
||||
*,
|
||||
registered_tools: set[str] | None = None,
|
||||
) -> SkillView | None:
|
||||
# 1. Local authored skill.
|
||||
local_skill = self._local.get_local(skill_id)
|
||||
if local_skill is not None:
|
||||
return SkillView(local_skill, "local", False)
|
||||
|
||||
# 2. Override shadowing a cloud skill id.
|
||||
override = self._local.get_override(skill_id)
|
||||
if override is not None:
|
||||
if (
|
||||
isinstance(override, FlowTemplateSkill)
|
||||
and registered_tools is not None
|
||||
and not _tools_valid(override, registered_tools)
|
||||
):
|
||||
return None
|
||||
return SkillView(override, "cloud", True)
|
||||
|
||||
# 3. Cloud-synced skill (None for unknown AND not-visible — no leak).
|
||||
cloud_skill = self._synced.get_skill(
|
||||
skill_id,
|
||||
active_subscriptions,
|
||||
registered_tools=registered_tools,
|
||||
)
|
||||
if cloud_skill is not None:
|
||||
return SkillView(cloud_skill, "cloud", False)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Origin classification for authoring dispatch (D10)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_local_skill(self, skill_id: str) -> bool:
|
||||
return self._local.get_local(skill_id) is not None
|
||||
|
||||
def has_override(self, cloud_skill_id: str) -> bool:
|
||||
return self._local.has_override(cloud_skill_id)
|
||||
|
||||
|
||||
def _tools_valid(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool:
|
||||
return all(step.tool_name in registered_tools for step in skill.steps)
|
||||
|
||||
|
||||
def make_view_from_stores(
|
||||
synced: SkillCatalogStore,
|
||||
local: LocalSkillStore,
|
||||
) -> SkillCatalogView:
|
||||
return SkillCatalogView(synced, local)
|
||||
|
||||
|
||||
def default_local_store() -> LocalSkillStore:
|
||||
"""Lazy default local store (created on first use)."""
|
||||
return LocalSkillStore()
|
||||
+106
-4
@@ -7,6 +7,7 @@ truth" contract is enforced by that import boundary.
|
||||
|
||||
Push (webhook) is optional; the baseline pull loop is correct standalone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -21,6 +22,7 @@ from skills_learning.models import (
|
||||
KnowledgeSkill,
|
||||
Skill,
|
||||
)
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -144,6 +146,98 @@ def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
|
||||
)
|
||||
|
||||
|
||||
class CloudApiSkillClient:
|
||||
"""Concrete client for this project's Cloud API per-host skill sync endpoint.
|
||||
|
||||
The ``subscription_id`` passed to :meth:`fetch_entitled_skills` is the
|
||||
agent's host identifier; the endpoint is
|
||||
``GET /internal/v1/hosts/{host_id}/skills/sync`` authenticated with the
|
||||
same host-scoped bearer used for heartbeat/planner-decision.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
host_token: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.host_token = host_token
|
||||
self.timeout = timeout
|
||||
self._client = client
|
||||
|
||||
def fetch_entitled_skills(
|
||||
self,
|
||||
subscription_id: str,
|
||||
since_version: int | None = None,
|
||||
) -> SyncDelta:
|
||||
url = f"{self.base_url}/internal/v1/hosts/{subscription_id}/skills/sync"
|
||||
params: dict[str, Any] = {}
|
||||
if since_version is not None:
|
||||
params["since_version"] = str(since_version)
|
||||
response = self._ensure_client().get(
|
||||
url,
|
||||
params=params or None,
|
||||
headers={"Authorization": f"Bearer {self.host_token}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_cloud_sync_payload(response.json())
|
||||
|
||||
def report_inventory(self, host_id: str, inventory: list[dict[str, Any]]) -> None:
|
||||
"""Best-effort local-skill inventory report to the Cloud (design D7)."""
|
||||
response = self._ensure_client().post(
|
||||
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
|
||||
json={"skills": inventory},
|
||||
headers={"Authorization": f"Bearer {self.host_token}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def _ensure_client(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
self._client = httpx.Client()
|
||||
return self._client
|
||||
|
||||
|
||||
def _parse_cloud_sync_payload(payload: dict[str, Any]) -> SyncDelta:
|
||||
"""Parse the Cloud API sync response into a SyncDelta.
|
||||
|
||||
The Cloud skill payloads carry ``revision`` (mapped to the local
|
||||
``version``) and kind-specific ``content``/``steps``/``parameters`` fields
|
||||
that line up with :class:`skills_learning.models` ``from_dict``.
|
||||
"""
|
||||
skills: list[Skill] = []
|
||||
for item in payload.get("skills") or []:
|
||||
normalized = dict(item)
|
||||
if "version" not in normalized and "revision" in normalized:
|
||||
normalized["version"] = normalized["revision"]
|
||||
source = "cloud"
|
||||
kind = normalized.get("kind", "knowledge")
|
||||
normalized["source"] = source
|
||||
if kind == "flow_template":
|
||||
skills.append(FlowTemplateSkill.from_dict(normalized))
|
||||
else:
|
||||
skills.append(KnowledgeSkill.from_dict(normalized))
|
||||
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
|
||||
latest_raw = payload.get("latest_version")
|
||||
latest_version = int(latest_raw) if latest_raw is not None else None
|
||||
is_full_replace = bool(payload.get("is_full_replace", True))
|
||||
return SyncDelta(
|
||||
skills=skills,
|
||||
removed_ids=removed_ids,
|
||||
latest_version=latest_version,
|
||||
is_full_replace=is_full_replace,
|
||||
)
|
||||
|
||||
|
||||
class SkillSyncRunner:
|
||||
"""Drives periodic sync between the Subscription Platform and local catalog.
|
||||
|
||||
@@ -160,11 +254,13 @@ class SkillSyncRunner:
|
||||
client: SubscriptionClient,
|
||||
subscriptions: list[str],
|
||||
poll_interval: float = 300.0,
|
||||
local_store: LocalSkillStore | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.client = client
|
||||
self.subscriptions = list(subscriptions)
|
||||
self.poll_interval = poll_interval
|
||||
self.local_store = local_store
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._tick_lock = threading.Lock()
|
||||
@@ -209,12 +305,13 @@ class SkillSyncRunner:
|
||||
self._stop.wait(self.poll_interval)
|
||||
|
||||
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||
since_version = self.store._get_subscription_version(subscription_id)
|
||||
try:
|
||||
delta = self.client.fetch_entitled_skills(subscription_id)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"skill sync fetch failed for %s: %s", subscription_id, exc
|
||||
delta = self.client.fetch_entitled_skills(
|
||||
subscription_id, since_version=since_version
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
|
||||
self.store._set_subscription_state(
|
||||
subscription_id,
|
||||
last_error=f"{type(exc).__name__}: {exc}",
|
||||
@@ -233,6 +330,11 @@ class SkillSyncRunner:
|
||||
for skill in delta.skills:
|
||||
self.store._apply_sync_upsert(skill, subscription_id)
|
||||
for skill_id in delta.removed_ids:
|
||||
# Fork-on-revocation (design D9): if a local override shadows
|
||||
# this cloud skill, promote it to a standalone local skill
|
||||
# before the cloud id disappears from the synced store.
|
||||
if self.local_store is not None:
|
||||
self.local_store.fork_override_to_local(skill_id)
|
||||
self.store._apply_sync_remove(skill_id)
|
||||
self.store._set_subscription_state(
|
||||
subscription_id,
|
||||
|
||||
@@ -32,6 +32,7 @@ from cloud.control_config import (
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.llm_providers import LlmProviderService
|
||||
from cloud.skills import CloudSkillService
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.observability import (
|
||||
CORRELATION_HEADER,
|
||||
@@ -46,6 +47,7 @@ from cloud.schema import require_current_schema
|
||||
from cloud.sdk.api import create_cloud_router
|
||||
from cloud.sdk.governance_api import create_governance_router
|
||||
from cloud.sdk.llm_provider_api import create_llm_provider_router
|
||||
from cloud.sdk.skill_api import create_skill_host_router, create_skill_management_router
|
||||
from cloud.sdk.user_api import create_user_auth_router
|
||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||
from core.models import utc_now
|
||||
@@ -130,6 +132,7 @@ def create_app(
|
||||
),
|
||||
)
|
||||
llm_provider_service = LlmProviderService(repository)
|
||||
cloud_skill_service = CloudSkillService(repository)
|
||||
auth_provider = ChainedAuthProvider(
|
||||
(
|
||||
configured_auth_provider,
|
||||
@@ -325,6 +328,24 @@ def create_app(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_skill_management_router(
|
||||
service=cloud_skill_service,
|
||||
repository=repository,
|
||||
auth_provider=auth_provider,
|
||||
csrf_validator=lambda request, principal: _valid_csrf_request(
|
||||
request,
|
||||
principal,
|
||||
user_auth_service,
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_skill_host_router(
|
||||
service=cloud_skill_service,
|
||||
auth_provider=auth_provider,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_internal_router(
|
||||
pool=pool,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "device-cloud-api"
|
||||
version = "0.1.0"
|
||||
description = "Deployable Cloud Control Plane API for Device Agent Runtime."
|
||||
requires-python = ">=3.14"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = [
|
||||
"device-cloud-platform==0.1.0",
|
||||
"fastapi>=0.115.0",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Repository + service tests for Cloud-managed skills and per-host sync.
|
||||
|
||||
Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the
|
||||
monotonic entitlement_version bump, and incremental vs full-replace
|
||||
fetch_host_delta semantics (design D2/D3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from cloud.db_models import Base
|
||||
from cloud.skills import (
|
||||
CloudSkillConflictError,
|
||||
CloudSkillValidationError,
|
||||
CloudSkillService,
|
||||
)
|
||||
from cloud.sql_repository import SQLAlchemyCloudRepository
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False)
|
||||
return CloudSkillService(repo)
|
||||
|
||||
|
||||
def _knowledge_payload(name: str, content: str = "body") -> dict:
|
||||
return dict(
|
||||
name=name,
|
||||
kind="knowledge",
|
||||
description="d",
|
||||
tags=["t"],
|
||||
content=content,
|
||||
steps_json="[]",
|
||||
parameters_json="{}",
|
||||
)
|
||||
|
||||
|
||||
def test_create_list_get_skill(service):
|
||||
now = utc_now()
|
||||
created = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
assert created.name == "Alpha"
|
||||
assert created.kind == "knowledge"
|
||||
[fetched] = service.list_skills()
|
||||
assert fetched.id == created.id
|
||||
assert service.get_skill(created.id).content == "body"
|
||||
|
||||
|
||||
def test_create_rejects_duplicate_name(service):
|
||||
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
|
||||
with pytest.raises(CloudSkillConflictError):
|
||||
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
|
||||
|
||||
|
||||
def test_create_rejects_blank_content_for_knowledge(service):
|
||||
payload = _knowledge_payload("Alpha", content=" ")
|
||||
with pytest.raises(CloudSkillValidationError):
|
||||
service.create_skill(now=utc_now(), **payload)
|
||||
|
||||
|
||||
def test_grant_revoke_entitlement_drives_delta(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
host = "host-1"
|
||||
|
||||
# First sync: no entitlements yet -> empty full replace.
|
||||
delta = service.fetch_host_delta(host, since_version=None)
|
||||
assert delta.is_full_replace is True
|
||||
assert delta.skills == []
|
||||
assert delta.latest_version == 0
|
||||
|
||||
# Grant -> version bumps, next full sync sees the skill.
|
||||
service.grant_entitlement(skill.id, host, now=now)
|
||||
full = service.fetch_host_delta(host, since_version=None)
|
||||
assert [s.id for s in full.skills] == [skill.id]
|
||||
assert full.latest_version == 1
|
||||
|
||||
# Incremental from 0 returns the grant.
|
||||
incr = service.fetch_host_delta(host, since_version=0)
|
||||
assert incr.is_full_replace is False
|
||||
assert [s.id for s in incr.skills] == [skill.id]
|
||||
assert incr.removed_ids == []
|
||||
|
||||
# Revoke -> version bumps, incremental reports removal.
|
||||
service.revoke_entitlement(skill.id, host, now=now)
|
||||
after = service.fetch_host_delta(host, since_version=incr.latest_version)
|
||||
assert after.removed_ids == [skill.id]
|
||||
assert after.skills == []
|
||||
|
||||
|
||||
def test_skill_content_update_notifies_entitled_hosts(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
|
||||
|
||||
updated = service.update_skill(
|
||||
skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2")
|
||||
)
|
||||
assert updated.revision == 2
|
||||
incr = service.fetch_host_delta("host-1", since_version=baseline)
|
||||
assert [s.id for s in incr.skills] == [skill.id]
|
||||
assert incr.skills[0].content == "v2"
|
||||
|
||||
|
||||
def test_delete_skill_removes_and_notifies_entitled_hosts(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
|
||||
|
||||
service.delete_skill(skill.id)
|
||||
assert service.get_skill(skill.id) is None
|
||||
after = service.fetch_host_delta("host-1", since_version=baseline)
|
||||
assert after.removed_ids == [skill.id]
|
||||
|
||||
|
||||
def test_stale_since_version_falls_back_to_full_replace(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
# A version older than anything in the changelog must yield a full replace.
|
||||
delta = service.fetch_host_delta("host-1", since_version=-5)
|
||||
assert delta.is_full_replace is True
|
||||
|
||||
|
||||
def test_inventory_record_and_readback(service):
|
||||
now = utc_now()
|
||||
payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}])
|
||||
service.record_host_inventory("host-1", payload, now=now)
|
||||
entry = service.get_host_inventory("host-1")
|
||||
assert entry is not None
|
||||
assert json.loads(entry.payload_json)[0]["name"] == "My Note"
|
||||
@@ -10,10 +10,10 @@ from cloud_api.app import create_app
|
||||
|
||||
class _FakePlannerClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def decide(self, **_kwargs) -> ToolCallDecision:
|
||||
self.calls += 1
|
||||
def decide(self, **kwargs) -> ToolCallDecision:
|
||||
self.calls.append(kwargs)
|
||||
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@ def _login_admin(client: TestClient) -> dict[str, str]:
|
||||
|
||||
|
||||
def _create_profile(
|
||||
client: TestClient, headers: dict[str, str], *, name: str, model: str
|
||||
client: TestClient,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
name: str,
|
||||
model: str,
|
||||
timeout_seconds: float,
|
||||
) -> dict:
|
||||
response = client.post(
|
||||
"/v1/planner/providers",
|
||||
@@ -48,7 +53,7 @@ def _create_profile(
|
||||
"provider_type": "openai-compatible",
|
||||
"model": model,
|
||||
"base_url": "https://compat.example/v1",
|
||||
"timeout_seconds": 30,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"api_key": f"key-for-{name}",
|
||||
},
|
||||
)
|
||||
@@ -95,7 +100,11 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
|
||||
with TestClient(app) as client:
|
||||
admin_headers = _login_admin(client)
|
||||
first = _create_profile(
|
||||
client, admin_headers, name="First", model="first-model"
|
||||
client,
|
||||
admin_headers,
|
||||
name="First",
|
||||
model="first-model",
|
||||
timeout_seconds=41,
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
@@ -115,9 +124,14 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
|
||||
assert first_decision.status_code == 200, first_decision.text
|
||||
assert resolved_profiles[-1].profile.model == "first-model"
|
||||
assert resolved_profiles[-1].api_key == "key-for-First"
|
||||
assert fake.calls[-1]["timeout"] == 41
|
||||
|
||||
second = _create_profile(
|
||||
client, admin_headers, name="Second", model="second-model"
|
||||
client,
|
||||
admin_headers,
|
||||
name="Second",
|
||||
model="second-model",
|
||||
timeout_seconds=57,
|
||||
)
|
||||
settings = client.get("/v1/planner/providers").json()["settings"]
|
||||
activated = client.post(
|
||||
@@ -134,7 +148,8 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
|
||||
)
|
||||
assert second_decision.status_code == 200, second_decision.text
|
||||
assert resolved_profiles[-1].profile.model == "second-model"
|
||||
assert fake.calls == 2
|
||||
assert fake.calls[-1]["timeout"] == 57
|
||||
assert len(fake.calls) == 2
|
||||
|
||||
|
||||
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""HTTP tests for the Cloud skill management admin router.
|
||||
|
||||
Mirrors the llm-provider management test setup (in-memory DB, admin login,
|
||||
CSRF). Covers skill CRUD, per-host entitlement grant/revoke, authorization
|
||||
(non-admin rejected), and a basic sync-endpoint auth guard.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud.control_config import CloudControlConfig
|
||||
from cloud_api.app import create_app
|
||||
|
||||
|
||||
def _create_admin(client: TestClient) -> None:
|
||||
client.app.state.cloud_services.user_auth_service.create_user(
|
||||
username="admin",
|
||||
display_name="Administrator",
|
||||
role="admin",
|
||||
password="correct-horse-battery-staple",
|
||||
must_change_password=False,
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/auth/login",
|
||||
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
token = client.cookies.get("amcp_csrf")
|
||||
assert token is not None
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def _skill_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"name": "Search Notes",
|
||||
"kind": "knowledge",
|
||||
"description": "how to search",
|
||||
"tags": ["search"],
|
||||
"content": "type and press enter",
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_admin_can_create_list_get_update_delete_skill():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
|
||||
created = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert created.status_code == 201, created.text
|
||||
skill_id = created.json()["id"]
|
||||
|
||||
listed = client.get("/v1/skills", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert any(s["id"] == skill_id for s in listed.json()["items"])
|
||||
|
||||
fetched = client.get(f"/v1/skills/{skill_id}", headers=headers)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["content"] == "type and press enter"
|
||||
|
||||
updated = client.patch(
|
||||
f"/v1/skills/{skill_id}",
|
||||
json=_skill_payload(content="new content"),
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert updated.json()["content"] == "new content"
|
||||
|
||||
deleted = client.delete(f"/v1/skills/{skill_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/v1/skills/{skill_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_skill_name_conflicts():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
first = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert first.status_code == 201
|
||||
second = client.post("/v1/skills", json=_skill_payload(), headers=headers)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_entitlement_grant_revoke_lists_hosts():
|
||||
with _client() as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
skill_id = client.post(
|
||||
"/v1/skills", json=_skill_payload(), headers=headers
|
||||
).json()["id"]
|
||||
|
||||
grant = client.post(
|
||||
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
|
||||
)
|
||||
assert grant.status_code == 204
|
||||
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
|
||||
assert listed.json()["host_ids"] == ["host-1"]
|
||||
|
||||
revoke = client.delete(
|
||||
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
|
||||
)
|
||||
assert revoke.status_code == 204
|
||||
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
|
||||
assert listed.json()["host_ids"] == []
|
||||
|
||||
|
||||
def test_unauthenticated_request_is_rejected():
|
||||
with _client() as client:
|
||||
response = client.get("/v1/skills")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sync_endpoint_requires_host_credentials():
|
||||
with _client() as client:
|
||||
# No host credentials -> 401 (no skill content leaked).
|
||||
response = client.get("/internal/v1/hosts/host-1/skills/sync")
|
||||
assert response.status_code == 401
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
import uvicorn
|
||||
|
||||
@@ -11,6 +12,8 @@ from device.manager import DeviceManager
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
||||
from host_agent.config import HostAgentConfig, load_host_agent_config
|
||||
from host_agent.conversation import ConversationAgent
|
||||
from host_agent.conversation_log import ConversationLogStore
|
||||
from host_agent.dependency_supervisor import DependencySupervisor
|
||||
from host_agent.devices import register_local_device
|
||||
from host_agent.enrollment import resolve_host_identity
|
||||
@@ -21,12 +24,19 @@ from host_agent.identity import HostIdentityStore
|
||||
from host_agent.instance_lock import InstanceLock
|
||||
from host_agent.lease import ActiveAssignmentRunner
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.local_client import LocalHostAgentClient
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||
from host_agent.retention import prune_task_history
|
||||
from host_agent.skill_sync import HostAgentSkillSync
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.app import create_console_app
|
||||
from host_agent.web.auth import SessionManager
|
||||
from host_agent.web.mcp import build_mcp_server
|
||||
from runtime.executor import default_tool_registry
|
||||
from runtime.planner_config import load_config as load_planner_config
|
||||
from storage.artifact_store import ArtifactStore
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
@@ -42,6 +52,7 @@ class HostAgentApplication:
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
instance_lock: InstanceLock | None = None
|
||||
skill_sync: HostAgentSkillSync | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self.run_async())
|
||||
@@ -60,6 +71,8 @@ class HostAgentApplication:
|
||||
)
|
||||
heartbeat_stop = asyncio.Event()
|
||||
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
||||
if self.skill_sync is not None:
|
||||
self.skill_sync.start()
|
||||
console_task = (
|
||||
asyncio.create_task(self.console_server.serve())
|
||||
if self.console_server is not None
|
||||
@@ -110,6 +123,8 @@ class HostAgentApplication:
|
||||
finally:
|
||||
if self.console_enrollment_client is not None:
|
||||
self.console_enrollment_client.close()
|
||||
if self.skill_sync is not None:
|
||||
self.skill_sync.stop()
|
||||
await self.client.aclose()
|
||||
if self.instance_lock is not None:
|
||||
self.instance_lock.release()
|
||||
@@ -163,25 +178,37 @@ def create_application(
|
||||
startup_config.identity_path
|
||||
)
|
||||
owned_enrollment_client = enrollment_client is None
|
||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(
|
||||
startup_config
|
||||
)
|
||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
||||
try:
|
||||
resolved_config = resolve_host_identity(
|
||||
startup_config,
|
||||
identity_store=resolved_identity_store,
|
||||
client=bootstrap_client,
|
||||
)
|
||||
bootstrap_client.config = resolved_config
|
||||
resolved_manager = manager or _configured_device_manager(
|
||||
config_store,
|
||||
config=resolved_config,
|
||||
enrollment_client=bootstrap_client,
|
||||
)
|
||||
if startup_config.mode == "local":
|
||||
resolved_config = replace(
|
||||
startup_config, control_plane_url="", host_id="local-host"
|
||||
)
|
||||
resolved_manager = manager or _configured_device_manager(
|
||||
config_store, config=resolved_config, enrollment_client=None
|
||||
)
|
||||
else:
|
||||
resolved_config = resolve_host_identity(
|
||||
startup_config,
|
||||
identity_store=resolved_identity_store,
|
||||
client=bootstrap_client,
|
||||
)
|
||||
bootstrap_client.config = resolved_config
|
||||
resolved_manager = manager or _configured_device_manager(
|
||||
config_store,
|
||||
config=resolved_config,
|
||||
enrollment_client=bootstrap_client,
|
||||
)
|
||||
finally:
|
||||
if owned_enrollment_client:
|
||||
bootstrap_client.close()
|
||||
client = HostAgentClient(resolved_config)
|
||||
if resolved_config.mode == "local":
|
||||
client = LocalHostAgentClient(
|
||||
host_id="local-host",
|
||||
device_ids=lambda: [device.id for device in resolved_manager.list_devices()],
|
||||
)
|
||||
else:
|
||||
client = HostAgentClient(resolved_config)
|
||||
|
||||
history_store = ConsoleHistoryStore(
|
||||
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
||||
@@ -195,13 +222,40 @@ def create_application(
|
||||
db_path=resolved_config.task_progress_db_path
|
||||
)
|
||||
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
|
||||
mcp_token_path = resolved_config.identity_path.parent / "host_mcp_token.json"
|
||||
mcp_token_existed = mcp_token_path.exists()
|
||||
mcp_token_store = McpTokenStore(mcp_token_path)
|
||||
mcp_token_store.load_or_create()
|
||||
if not mcp_token_existed:
|
||||
logging.getLogger(__name__).info(
|
||||
"MCP token generated at %s", mcp_token_path
|
||||
)
|
||||
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
|
||||
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
host_agent_config=resolved_config,
|
||||
conversation_log=conversation_log,
|
||||
),
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
)
|
||||
mcp_server = build_mcp_server(
|
||||
manager=resolved_manager,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
status_tracker=status_tracker,
|
||||
)
|
||||
planner_config = load_planner_config()
|
||||
conversation_agent = (
|
||||
ConversationAgent(
|
||||
config=planner_config,
|
||||
tools=default_tool_registry(manager=resolved_manager),
|
||||
event_logger=conversation_log.append if conversation_log else None,
|
||||
)
|
||||
if resolved_config.ai_planner_transport == "direct"
|
||||
else None
|
||||
)
|
||||
console_app = create_console_app(
|
||||
config=resolved_config,
|
||||
@@ -215,9 +269,15 @@ def create_application(
|
||||
ttl_seconds=resolved_config.console_session_ttl_seconds
|
||||
),
|
||||
enrollment_client=console_enrollment_client,
|
||||
host_client=client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
executor=executor,
|
||||
mcp_server=mcp_server,
|
||||
mcp_token_store=mcp_token_store,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
conversation_agent=conversation_agent,
|
||||
conversation_log=conversation_log,
|
||||
)
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
@@ -233,6 +293,7 @@ def create_application(
|
||||
client,
|
||||
resolved_config,
|
||||
status_tracker=status_tracker,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
on_sync=lambda device_count: history_store.record_heartbeat(
|
||||
device_count=device_count
|
||||
),
|
||||
@@ -262,6 +323,9 @@ def create_application(
|
||||
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
||||
resolved_config
|
||||
)
|
||||
skill_sync: HostAgentSkillSync | None = None
|
||||
if resolved_config.host_id and resolved_config.token:
|
||||
skill_sync = HostAgentSkillSync(resolved_config)
|
||||
return HostAgentApplication(
|
||||
client=client,
|
||||
heartbeat=heartbeat,
|
||||
@@ -270,6 +334,7 @@ def create_application(
|
||||
console_enrollment_client=console_enrollment_client,
|
||||
dependency_supervisor=dependency_supervisor,
|
||||
instance_lock=instance_lock,
|
||||
skill_sync=skill_sync,
|
||||
)
|
||||
except BaseException:
|
||||
instance_lock.release()
|
||||
@@ -318,7 +383,7 @@ def _configured_device_manager(
|
||||
config_store: DeviceConfigStore,
|
||||
*,
|
||||
config: HostAgentConfig,
|
||||
enrollment_client: HostAgentEnrollmentClient,
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
) -> DeviceManager:
|
||||
manager = DeviceManager()
|
||||
for device in config_store.list():
|
||||
|
||||
@@ -2,13 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from core.models import Task
|
||||
from host_agent.execution import ExecutionFactories
|
||||
from host_agent.planner_context import bind_planner_execution_context
|
||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
||||
from runtime.task import is_cancellation_reason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,9 +23,15 @@ class AssignmentExecutionResult:
|
||||
|
||||
|
||||
class AssignmentExecutor:
|
||||
def __init__(self, factories: ExecutionFactories) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
factories: ExecutionFactories,
|
||||
*,
|
||||
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||
) -> None:
|
||||
self.factories = factories
|
||||
self._progress = TaskProgressHolder()
|
||||
self._mcp_busy_tracker = mcp_busy_tracker
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None:
|
||||
"""Latest step progress reported by the currently-running assignment."""
|
||||
@@ -32,18 +42,33 @@ class AssignmentExecutor:
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
stop_reason: Callable[[], str | None] | None = None,
|
||||
) -> AssignmentExecutionResult:
|
||||
self._progress.clear()
|
||||
if self._mcp_busy_tracker is not None and (
|
||||
assignment.device_id in self._mcp_busy_tracker.busy_device_ids()
|
||||
):
|
||||
return AssignmentExecutionResult(
|
||||
status="failed",
|
||||
failure_reason=(
|
||||
f"device {assignment.device_id} is held by an active MCP session"
|
||||
),
|
||||
)
|
||||
with bind_planner_execution_context(assignment):
|
||||
if should_stop is not None and should_stop():
|
||||
reason = stop_reason() if stop_reason is not None else None
|
||||
return AssignmentExecutionResult(
|
||||
status="failed",
|
||||
failure_reason="execution interrupted",
|
||||
status="cancelled" if is_cancellation_reason(reason) else "failed",
|
||||
failure_reason=reason or "execution interrupted",
|
||||
)
|
||||
if assignment.workflow_definition_id is not None:
|
||||
return self._execute_workflow(assignment, should_stop=should_stop)
|
||||
return self._execute_workflow(
|
||||
assignment, should_stop=should_stop, stop_reason=stop_reason
|
||||
)
|
||||
if assignment.goal is not None:
|
||||
return self._execute_goal(assignment, should_stop=should_stop)
|
||||
return self._execute_goal(
|
||||
assignment, should_stop=should_stop, stop_reason=stop_reason
|
||||
)
|
||||
return AssignmentExecutionResult(
|
||||
status="failed",
|
||||
failure_reason="assignment has neither goal nor workflow definition",
|
||||
@@ -54,16 +79,25 @@ class AssignmentExecutor:
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None,
|
||||
stop_reason: Callable[[], str | None] | 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:
|
||||
completed = runner.run(task)
|
||||
else:
|
||||
completed = runner.run(task, should_stop=should_stop)
|
||||
completed = runner.run(
|
||||
task, should_stop=should_stop, stop_reason=stop_reason
|
||||
)
|
||||
return AssignmentExecutionResult(
|
||||
status="done" if completed.status == "completed" else "failed",
|
||||
status=_terminal_status(completed.status),
|
||||
failure_reason=completed.failure_reason,
|
||||
metadata={
|
||||
"runtime_task_id": completed.id,
|
||||
@@ -76,6 +110,7 @@ class AssignmentExecutor:
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None,
|
||||
stop_reason: Callable[[], str | None] | None,
|
||||
) -> AssignmentExecutionResult:
|
||||
definition_id = assignment.workflow_definition_id or ""
|
||||
definition = self.factories.workflow_store.get_definition(definition_id)
|
||||
@@ -92,9 +127,10 @@ class AssignmentExecutor:
|
||||
definition,
|
||||
device_id=assignment.device_id,
|
||||
should_stop=should_stop,
|
||||
stop_reason=stop_reason,
|
||||
)
|
||||
return AssignmentExecutionResult(
|
||||
status="done" if run.status == "completed" else "failed",
|
||||
status=_terminal_status(run.status),
|
||||
failure_reason=(
|
||||
None if run.status == "completed" else f"workflow ended as {run.status}"
|
||||
),
|
||||
@@ -103,3 +139,11 @@ class AssignmentExecutor:
|
||||
"workflow_status": run.status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _terminal_status(runtime_status: str) -> str:
|
||||
if runtime_status == "completed":
|
||||
return "done"
|
||||
if runtime_status == "cancelled":
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
|
||||
@@ -10,6 +12,7 @@ from host_agent.app import create_application
|
||||
from host_agent.config import load_host_agent_config
|
||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
|
||||
|
||||
class LocalAccountSetupError(RuntimeError):
|
||||
@@ -17,11 +20,20 @@ class LocalAccountSetupError(RuntimeError):
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
_load_dotenv()
|
||||
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
subparsers.add_parser("setup", help="Create the local operator account")
|
||||
subparsers.add_parser(
|
||||
"mcp-token",
|
||||
help="Print the MCP server bearer token (generating if missing)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "mcp-token":
|
||||
_print_mcp_token()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == "setup":
|
||||
_run_setup()
|
||||
@@ -54,6 +66,12 @@ def _run_setup() -> None:
|
||||
print(f"Local account '{account.username}' created.")
|
||||
|
||||
|
||||
def _print_mcp_token() -> None:
|
||||
config = load_host_agent_config()
|
||||
store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
|
||||
print(store.load_or_create().token)
|
||||
|
||||
|
||||
def _resolve_config_with_local_account():
|
||||
config = load_host_agent_config()
|
||||
store = LocalAccountStore(config.local_account_path)
|
||||
@@ -81,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
|
||||
if password != confirm:
|
||||
raise LocalAccountSetupError("passwords do not match")
|
||||
return store.create(username, password)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
"""Load a simple repository-root .env without overriding shell values."""
|
||||
path = Path.cwd() / ".env"
|
||||
if not path.is_file():
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
name, value = line.split("=", 1)
|
||||
name = name.strip()
|
||||
value = value.strip()
|
||||
if name and name not in os.environ:
|
||||
os.environ[name] = value
|
||||
|
||||
@@ -14,6 +14,7 @@ from cloud.internal_api.models import (
|
||||
DeviceSnapshotModel,
|
||||
HeartbeatResponse,
|
||||
HostEnrollmentResponse,
|
||||
HostTaskCancellationResponse,
|
||||
HostTaskSubmissionResponse,
|
||||
LeaseRenewalResponse,
|
||||
TaskProgressModel,
|
||||
@@ -38,6 +39,21 @@ class StaleLeaseError(HostAgentAPIError):
|
||||
pass
|
||||
|
||||
|
||||
class HostTaskSubmissionUnknownError(RuntimeError):
|
||||
"""Raised when a Host self-submission request's Cloud outcome is uncertain.
|
||||
|
||||
This is distinct from :class:`HostAgentAPIError` because the request may
|
||||
have reached the control plane but the response was lost, the server
|
||||
returned a 5xx, or the success payload was malformed. Retrying would
|
||||
duplicate the create, so the caller must treat the task as unknown and
|
||||
surface that to the operator.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
super().__init__(f"host task submission outcome is unknown: {reason}")
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class HostAgentEnrollmentClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -152,17 +168,21 @@ class HostAgentClient:
|
||||
*,
|
||||
address: str | None = None,
|
||||
policy_revision: int = 0,
|
||||
mcp_busy_device_ids: list[str] | None = None,
|
||||
) -> HeartbeatResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"host_id": self.config.host_id,
|
||||
"address": address,
|
||||
"devices": [device.model_dump(mode="json") for device in devices],
|
||||
"policy_revision": policy_revision,
|
||||
"planner_transport": self.config.ai_planner_transport,
|
||||
}
|
||||
if mcp_busy_device_ids:
|
||||
payload["mcp_busy_device_ids"] = list(mcp_busy_device_ids)
|
||||
response = await self._request(
|
||||
"PUT",
|
||||
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
|
||||
json={
|
||||
"host_id": self.config.host_id,
|
||||
"address": address,
|
||||
"devices": [device.model_dump(mode="json") for device in devices],
|
||||
"policy_revision": policy_revision,
|
||||
"planner_transport": self.config.ai_planner_transport,
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
return HeartbeatResponse.model_validate(response.json())
|
||||
|
||||
@@ -172,16 +192,48 @@ class HostAgentClient:
|
||||
goal: str,
|
||||
device_id: str | None = None,
|
||||
) -> HostTaskSubmissionResponse:
|
||||
response = await self._request(
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"POST",
|
||||
f"/internal/v1/hosts/{self.config.host_id}/tasks",
|
||||
json={
|
||||
"host_id": self.config.host_id,
|
||||
"goal": goal,
|
||||
"device_id": device_id,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
)
|
||||
except httpx.TransportError as exc:
|
||||
raise HostTaskSubmissionUnknownError(str(exc)) from exc
|
||||
if response.status_code >= 500:
|
||||
raise HostTaskSubmissionUnknownError(
|
||||
f"control plane returned status {response.status_code}"
|
||||
)
|
||||
if not response.is_success:
|
||||
_raise_api_error(response, stale_lease=False)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HostTaskSubmissionUnknownError(
|
||||
"control plane returned malformed success payload"
|
||||
) from exc
|
||||
try:
|
||||
return HostTaskSubmissionResponse.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise HostTaskSubmissionUnknownError(
|
||||
"control plane returned malformed success payload"
|
||||
) from exc
|
||||
|
||||
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
|
||||
response = await self._client.request(
|
||||
"POST",
|
||||
f"/internal/v1/hosts/{self.config.host_id}/tasks",
|
||||
json={
|
||||
"host_id": self.config.host_id,
|
||||
"goal": goal,
|
||||
"device_id": device_id,
|
||||
},
|
||||
f"/internal/v1/hosts/{self.config.host_id}/tasks/{task_id}/cancel",
|
||||
json={"host_id": self.config.host_id},
|
||||
headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
)
|
||||
return HostTaskSubmissionResponse.model_validate(response.json())
|
||||
if not response.is_success:
|
||||
_raise_api_error(response)
|
||||
return HostTaskCancellationResponse.model_validate(response.json())
|
||||
|
||||
async def claim(self) -> AssignmentModel | None:
|
||||
response = await self._request(
|
||||
|
||||
@@ -28,10 +28,21 @@ import httpx
|
||||
from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.planner_context import current_planner_execution_context
|
||||
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable, ToolCallUsage
|
||||
from runtime.tool_calling_client import (
|
||||
ToolCallDecision,
|
||||
ToolCallUnavailable,
|
||||
ToolCallUsage,
|
||||
)
|
||||
from runtime.tool_specs import ToolSpec
|
||||
|
||||
|
||||
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS = 120.0
|
||||
_CLOUD_PROXY_TRANSPORT_GRACE_SECONDS = 5.0
|
||||
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS = (
|
||||
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS + _CLOUD_PROXY_TRANSPORT_GRACE_SECONDS
|
||||
)
|
||||
|
||||
|
||||
class CloudProxyToolCallingClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,11 +62,13 @@ class CloudProxyToolCallingClient:
|
||||
screenshot: bytes | None,
|
||||
tools: list[ToolSpec],
|
||||
timeout: float,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
) -> ToolCallDecision:
|
||||
payload: dict[str, Any] = {
|
||||
"host_id": self.config.host_id,
|
||||
"system_prompt": system_prompt,
|
||||
"user_prompt": user_prompt,
|
||||
"history": history or [],
|
||||
"screenshot_base64": (
|
||||
base64.b64encode(screenshot).decode("ascii")
|
||||
if screenshot is not None
|
||||
@@ -69,7 +82,9 @@ class CloudProxyToolCallingClient:
|
||||
}
|
||||
for spec in tools
|
||||
],
|
||||
"timeout_seconds": timeout,
|
||||
# Legacy Cloud API versions use this field. Current Cloud API versions
|
||||
# resolve the provider timeout from the active Provider profile.
|
||||
"timeout_seconds": min(timeout, _CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS),
|
||||
}
|
||||
context = current_planner_execution_context()
|
||||
if context is not None:
|
||||
@@ -85,7 +100,7 @@ class CloudProxyToolCallingClient:
|
||||
f"/internal/v1/hosts/{self.config.host_id}/planner/decide",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
timeout=timeout + 5,
|
||||
timeout=_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ToolCallUnavailable(str(exc)) from exc
|
||||
@@ -111,6 +126,10 @@ class CloudProxyToolCallingClient:
|
||||
)
|
||||
else None
|
||||
),
|
||||
text_output=decoded.rationale,
|
||||
thinking=decoded.thinking,
|
||||
purpose=decoded.purpose,
|
||||
expected_outcome=decoded.expected_outcome,
|
||||
)
|
||||
raise ToolCallUnavailable(_error_detail(response))
|
||||
|
||||
|
||||
@@ -13,11 +13,18 @@ class HostAgentConfigurationError(ValueError):
|
||||
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
||||
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
|
||||
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
||||
"HOST_AGENT_RUNTIME_SUPERVISED",
|
||||
"HOST_AGENT_RUNTIME_HOST",
|
||||
"HOST_AGENT_RUNTIME_PORT",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostAgentConfig:
|
||||
control_plane_url: str
|
||||
control_plane_url: str = ""
|
||||
mode: str = "cloud"
|
||||
host_id: str = ""
|
||||
token: str = field(default="", repr=False)
|
||||
identity_path: Path = Path("tasks/host_identity.json")
|
||||
@@ -34,35 +41,38 @@ class HostAgentConfig:
|
||||
console_allow_non_loopback: bool = False
|
||||
console_session_ttl_seconds: float = 43200.0
|
||||
console_history_limit: int = 200
|
||||
ai_planner_transport: str = "direct"
|
||||
ai_planner_transport: str = "cloud"
|
||||
dependency_supervisor_enabled: bool = False
|
||||
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")
|
||||
conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3")
|
||||
task_retention_max_count: int = 50
|
||||
task_retention_max_age_days: int = 7
|
||||
skill_sync_interval_seconds: float = 300.0
|
||||
|
||||
|
||||
def load_host_agent_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> HostAgentConfig:
|
||||
values = os.environ if env is None else env
|
||||
_reject_removed_runtime_supervision_settings(values)
|
||||
mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower()
|
||||
if mode not in _HOST_AGENT_MODES:
|
||||
raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local")
|
||||
control_plane_url = (
|
||||
values.get(
|
||||
"HOST_AGENT_CONTROL_PLANE_URL",
|
||||
"https://amcp.home.jerryyan.top",
|
||||
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
|
||||
)
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
parsed_url = urlparse(control_plane_url)
|
||||
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
||||
if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc):
|
||||
raise HostAgentConfigurationError(
|
||||
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
||||
)
|
||||
@@ -78,9 +88,10 @@ def load_host_agent_config(
|
||||
|
||||
config = HostAgentConfig(
|
||||
control_plane_url=control_plane_url,
|
||||
mode=mode,
|
||||
identity_path=identity_path,
|
||||
local_account_path=local_account_path,
|
||||
enrollment_managed=True,
|
||||
enrollment_managed=mode == "cloud",
|
||||
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
||||
heartbeat_interval_seconds=_positive_float(
|
||||
values,
|
||||
@@ -124,18 +135,17 @@ def load_host_agent_config(
|
||||
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
|
||||
200,
|
||||
),
|
||||
ai_planner_transport=_parse_ai_planner_transport(
|
||||
values.get("AI_PLANNER_TRANSPORT")
|
||||
),
|
||||
ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
|
||||
dependency_supervisor_enabled=_truthy(
|
||||
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False
|
||||
values,
|
||||
"HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED",
|
||||
mode == "local",
|
||||
),
|
||||
appium_supervised=_truthy(
|
||||
values, "HOST_AGENT_APPIUM_SUPERVISED", mode == "local"
|
||||
),
|
||||
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
|
||||
),
|
||||
@@ -150,12 +160,16 @@ def load_host_agent_config(
|
||||
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
|
||||
).strip()
|
||||
),
|
||||
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").strip()),
|
||||
task_retention_max_count=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
||||
),
|
||||
task_retention_max_age_days=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
|
||||
),
|
||||
skill_sync_interval_seconds=_positive_float(
|
||||
values, "HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS", 300.0
|
||||
),
|
||||
)
|
||||
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
|
||||
raise HostAgentConfigurationError(
|
||||
@@ -173,9 +187,23 @@ 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 "direct"
|
||||
return "cloud"
|
||||
transport = value.strip().lower()
|
||||
if transport not in _AI_PLANNER_TRANSPORTS:
|
||||
raise HostAgentConfigurationError(
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from runtime.planner_config import PlannerConfig
|
||||
from runtime.tool_specs import ACTION_TOOL_SPECS, ToolSpec
|
||||
|
||||
READ_TOOL_SPECS = [
|
||||
ToolSpec("take_screenshot", "Capture the current device screen.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
|
||||
ToolSpec("describe_screen", "Inspect the current screen and UI.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
|
||||
ToolSpec("find_text", "Find visible text on the current screen.", {"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}, "device_id": {"type": "string"}}}),
|
||||
ToolSpec("get_ui_tree", "Read the current accessibility/UI tree.", {"type": "object", "properties": {"device_id": {"type": "string"}, "include_app_info": {"type": "boolean"}}}),
|
||||
ToolSpec("list_devices", "List configured devices.", {"type": "object", "properties": {}}),
|
||||
ToolSpec("device_status", "Read one device status.", {"type": "object", "required": ["device_id"], "properties": {"device_id": {"type": "string"}}}),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatResult:
|
||||
content: str
|
||||
tool_calls: int
|
||||
|
||||
|
||||
class ConversationAgent:
|
||||
"""Small OpenAI-compatible agent loop backed by Host Agent tools."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: PlannerConfig,
|
||||
tools: dict[str, Callable[..., Any]],
|
||||
max_rounds: int = 8,
|
||||
event_logger: Callable[[dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.tools = tools
|
||||
self.max_rounds = max_rounds
|
||||
self.event_logger = event_logger
|
||||
self._client: Any | None = None
|
||||
|
||||
def chat(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
|
||||
if self.config.provider == "anthropic":
|
||||
return self._chat_anthropic(messages, tools=tools)
|
||||
if self.config.provider not in {"openai", "openai_compatible"}:
|
||||
raise RuntimeError("unsupported conversation provider")
|
||||
client = self._get_client()
|
||||
active_tools = tools or self.tools
|
||||
history: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a mobile device assistant. Reply naturally when no "
|
||||
"device action is needed. Call a tool when the user asks you "
|
||||
"to inspect or operate a device. Never claim an action was "
|
||||
"completed unless the tool result confirms it."
|
||||
),
|
||||
},
|
||||
*[_normalize_message(message) for message in messages],
|
||||
]
|
||||
calls = 0
|
||||
for _ in range(self.max_rounds):
|
||||
response = client.chat.completions.create(
|
||||
model=self.config.resolved_model(),
|
||||
messages=history,
|
||||
tools=[_openai_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
|
||||
tool_choice="auto",
|
||||
parallel_tool_calls=False,
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
message = response.choices[0].message
|
||||
text = getattr(message, "content", None)
|
||||
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
|
||||
tool_calls = getattr(message, "tool_calls", None) or []
|
||||
self._log({"type": "llm_response", "content": text, "thinking": thinking,
|
||||
"tool_calls": [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments} for c in tool_calls]})
|
||||
if not tool_calls:
|
||||
self._log({"type": "final_reply", "content": str(text or "")})
|
||||
return ChatResult(content=str(text or ""), tool_calls=calls)
|
||||
history.append(_message_dict(message))
|
||||
for tool_call in tool_calls:
|
||||
name = tool_call.function.name
|
||||
arguments: dict[str, Any] = {}
|
||||
try:
|
||||
decoded_arguments = json.loads(tool_call.function.arguments or "{}")
|
||||
if not isinstance(decoded_arguments, dict):
|
||||
raise ValueError("tool arguments must be an object")
|
||||
arguments = decoded_arguments
|
||||
arguments.pop("purpose", None)
|
||||
arguments.pop("expected_outcome", None)
|
||||
result = active_tools[name](**arguments)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
calls += 1
|
||||
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
|
||||
"tool_name": name, "arguments": arguments, "result": result})
|
||||
history.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": json.dumps(result, ensure_ascii=True, default=str),
|
||||
}
|
||||
)
|
||||
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
|
||||
|
||||
def _chat_anthropic(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
|
||||
import anthropic
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.config.api_key:
|
||||
kwargs["api_key"] = self.config.api_key
|
||||
if self.config.base_url:
|
||||
kwargs["base_url"] = self.config.base_url
|
||||
client = anthropic.Anthropic(**kwargs)
|
||||
history = [_normalize_anthropic_message(message) for message in messages]
|
||||
active_tools = tools or self.tools
|
||||
calls = 0
|
||||
for _ in range(self.max_rounds):
|
||||
response = client.messages.create(
|
||||
model=self.config.resolved_model(),
|
||||
max_tokens=2048,
|
||||
system="You are a mobile device assistant. Reply naturally, or call one device tool when needed. Never claim an action succeeded unless its tool result confirms it.",
|
||||
messages=history,
|
||||
tools=[_anthropic_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
blocks = getattr(response, "content", []) or []
|
||||
text_parts = [getattr(block, "text", "") for block in blocks if getattr(block, "type", None) == "text"]
|
||||
thinking_parts = [getattr(block, "thinking", "") for block in blocks if getattr(block, "type", None) == "thinking"]
|
||||
uses = [block for block in blocks if getattr(block, "type", None) == "tool_use"]
|
||||
self._log({"type": "llm_response", "content": "\n".join(x for x in text_parts if x), "thinking": "\n".join(x for x in thinking_parts if x), "tool_calls": [{"id": u.id, "name": u.name, "arguments": u.input} for u in uses]})
|
||||
if not uses:
|
||||
content = "\n".join(x for x in text_parts if x)
|
||||
self._log({"type": "final_reply", "content": content})
|
||||
return ChatResult(content=content, tool_calls=calls)
|
||||
history.append({"role": "assistant", "content": [_anthropic_block_dict(block) for block in blocks if getattr(block, "type", None) != "thinking"]})
|
||||
results = []
|
||||
for use in uses:
|
||||
arguments = dict(use.input) if isinstance(use.input, dict) else {}
|
||||
try:
|
||||
result = active_tools[use.name](**arguments)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
calls += 1
|
||||
self._log({"type": "tool_result", "tool_call_id": use.id, "tool_name": use.name, "arguments": arguments, "result": result})
|
||||
results.append({"type": "tool_result", "tool_use_id": use.id, "content": json.dumps(result, ensure_ascii=True, default=str)})
|
||||
history.append({"role": "user", "content": results})
|
||||
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
|
||||
|
||||
def chat_for_device(self, device_id: str, messages: list[dict[str, Any]]) -> ChatResult:
|
||||
"""Run a conversation bound to exactly one device."""
|
||||
device_tools = {
|
||||
name: _bind_device(tool, device_id, name=name)
|
||||
for name, tool in self.tools.items()
|
||||
}
|
||||
return self.chat(messages, tools=device_tools)
|
||||
|
||||
def _log(self, event: dict[str, Any]) -> None:
|
||||
if self.event_logger is not None:
|
||||
try:
|
||||
self.event_logger(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
if self._client is None:
|
||||
from openai import OpenAI
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.config.api_key:
|
||||
kwargs["api_key"] = self.config.api_key
|
||||
if self.config.base_url:
|
||||
kwargs["base_url"] = self.config.base_url
|
||||
self._client = OpenAI(**kwargs)
|
||||
return self._client
|
||||
|
||||
|
||||
def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
|
||||
parameters = dict(spec.parameters)
|
||||
properties = dict(parameters.get("properties") or {})
|
||||
properties.setdefault(
|
||||
"device_id",
|
||||
{"type": "string", "description": "Target device ID when needed."},
|
||||
)
|
||||
parameters["properties"] = properties
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
|
||||
return {"name": spec.name, "description": spec.description, "input_schema": spec.parameters}
|
||||
|
||||
|
||||
def _anthropic_block_dict(block: Any) -> dict[str, Any]:
|
||||
block_type = getattr(block, "type", "")
|
||||
if block_type == "text":
|
||||
return {"type": "text", "text": getattr(block, "text", "")}
|
||||
if block_type == "thinking":
|
||||
return {"type": "thinking", "thinking": getattr(block, "thinking", "")}
|
||||
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
|
||||
|
||||
|
||||
def _normalize_anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = _normalize_message(message)
|
||||
content = normalized["content"]
|
||||
if isinstance(content, str):
|
||||
return normalized
|
||||
blocks = []
|
||||
for block in content:
|
||||
if block.get("type") == "text":
|
||||
blocks.append(block)
|
||||
elif block.get("type") == "image_url":
|
||||
url = block.get("image_url", {}).get("url", "")
|
||||
if isinstance(url, str) and url.startswith("data:"):
|
||||
header, data = url.split(",", 1)
|
||||
media_type = header[5:].split(";", 1)[0]
|
||||
blocks.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
|
||||
return {"role": normalized["role"], "content": blocks}
|
||||
|
||||
|
||||
def _message_dict(message: Any) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"role": "assistant"}
|
||||
content = getattr(message, "content", None)
|
||||
if content is not None:
|
||||
result["content"] = content
|
||||
calls = getattr(message, "tool_calls", None) or []
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": call.function.name,
|
||||
"arguments": call.function.arguments,
|
||||
},
|
||||
}
|
||||
for call in calls
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_message(message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Accept OpenAI text/image content blocks and a compact image_base64 form."""
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return {"role": role, "content": content}
|
||||
if isinstance(content, list):
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
||||
blocks.append({"type": "text", "text": block["text"]})
|
||||
elif block.get("type") == "image_url":
|
||||
image_url = block.get("image_url")
|
||||
if isinstance(image_url, str):
|
||||
image_url = {"url": image_url}
|
||||
if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
|
||||
blocks.append({"type": "image_url", "image_url": {"url": image_url["url"]}})
|
||||
if blocks:
|
||||
return {"role": role, "content": blocks}
|
||||
image = message.get("image_base64")
|
||||
if isinstance(image, str) and image:
|
||||
mime = str(message.get("mime_type") or "image/png")
|
||||
text = message.get("text")
|
||||
blocks = []
|
||||
if isinstance(text, str) and text:
|
||||
blocks.append({"type": "text", "text": text})
|
||||
blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}})
|
||||
return {"role": role, "content": blocks}
|
||||
raise ValueError("message content must be text, image blocks, or image_base64")
|
||||
|
||||
|
||||
def _bind_device(tool: Callable[..., Any], device_id: str, *, name: str) -> Callable[..., Any]:
|
||||
def bound(**arguments: Any) -> Any:
|
||||
if name == "list_devices":
|
||||
return tool(**arguments)
|
||||
requested = arguments.get("device_id")
|
||||
if requested is not None and requested != device_id:
|
||||
raise ValueError(f"conversation is bound to device {device_id}")
|
||||
arguments["device_id"] = device_id
|
||||
return tool(**arguments)
|
||||
|
||||
return bound
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConversationLogStore:
|
||||
"""SQLite-backed local audit log for chat and tool activity."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = Lock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)")
|
||||
|
||||
def append(self, event: dict[str, Any]) -> None:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str)))
|
||||
|
||||
def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(row["payload_json"])
|
||||
except (TypeError, ValueError):
|
||||
payload = {}
|
||||
result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload})
|
||||
return result
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_safe(v) for v in value]
|
||||
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
||||
@@ -14,6 +15,8 @@ from runtime.planner_config import PlannerConfig, load_config as load_planner_co
|
||||
from runtime.task import TaskRunner
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
from tools.describe_screen import describe_screen
|
||||
from tools.screenshot import take_screenshot
|
||||
from workflow.runner import WorkflowRunner
|
||||
from workflow.store import WorkflowStore
|
||||
|
||||
@@ -23,6 +26,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(
|
||||
@@ -32,6 +36,7 @@ def create_execution_factories(
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
host_agent_config: HostAgentConfig | None = None,
|
||||
conversation_log: Any | None = None,
|
||||
) -> ExecutionFactories:
|
||||
shared_workflow_store = workflow_store or WorkflowStore()
|
||||
resolved_host_agent_config = host_agent_config
|
||||
@@ -39,10 +44,20 @@ def create_execution_factories(
|
||||
def create_task_runner() -> TaskRunner:
|
||||
return TaskRunner(
|
||||
executor=Executor(tools=default_tool_registry(manager=manager)),
|
||||
observer=lambda device_id: describe_screen(device_id, manager=manager),
|
||||
screenshot_provider=lambda device_id: take_screenshot(
|
||||
device_id, manager=manager
|
||||
),
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
planner=_host_agent_planner(resolved_host_agent_config),
|
||||
planner=_host_agent_planner(
|
||||
resolved_host_agent_config,
|
||||
event_logger=conversation_log.append if conversation_log is not None else None,
|
||||
),
|
||||
planner_config=_host_agent_planner_config(),
|
||||
device_platform_provider=lambda device_id: _device_platform(
|
||||
manager, device_id
|
||||
),
|
||||
)
|
||||
|
||||
def create_workflow_runner() -> WorkflowRunner:
|
||||
@@ -55,6 +70,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,14 +91,15 @@ def _host_agent_planner_config() -> PlannerConfig:
|
||||
|
||||
def _host_agent_planner(
|
||||
host_agent_config: HostAgentConfig | None,
|
||||
*,
|
||||
event_logger: Callable[[dict[str, Any]], None] | None = None,
|
||||
) -> Planner | None:
|
||||
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
|
||||
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
|
||||
instead of a local Anthropic/OpenAI SDK client.
|
||||
|
||||
Returns `None` (letting `TaskRunner` fall back to its own
|
||||
`_default_planner()`) for the `direct` transport, which preserves the
|
||||
existing default-enabled/direct-to-provider behavior unchanged.
|
||||
`_default_planner()`) only for the explicit `direct` transport.
|
||||
"""
|
||||
planner_config = _host_agent_planner_config()
|
||||
if not planner_config.enabled:
|
||||
@@ -90,9 +107,22 @@ def _host_agent_planner(
|
||||
|
||||
resolved_config = host_agent_config or load_host_agent_config()
|
||||
if resolved_config.ai_planner_transport != "cloud":
|
||||
return None
|
||||
return AIPlanner(config=planner_config, event_logger=event_logger)
|
||||
|
||||
return AIPlanner(
|
||||
client=CloudProxyToolCallingClient(resolved_config),
|
||||
config=planner_config,
|
||||
event_logger=event_logger,
|
||||
)
|
||||
|
||||
|
||||
def _device_platform(manager: DeviceManager, device_id: str) -> str | None:
|
||||
for device in manager.list_devices():
|
||||
if device.id != device_id:
|
||||
continue
|
||||
if device.driver_type == "wda":
|
||||
return "ios"
|
||||
if device.driver_type == "uiautomator2":
|
||||
return "android"
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -14,6 +14,8 @@ from host_agent.status import AgentStatusTracker
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
|
||||
|
||||
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
||||
return [
|
||||
@@ -40,6 +42,7 @@ class HeartbeatSynchronizer:
|
||||
on_sync: Callable[[int], None] | None = None,
|
||||
policy_cache: HostPolicyCacheStore | None = None,
|
||||
on_policy_sync: Callable[[int], None] | None = None,
|
||||
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||
) -> None:
|
||||
self.manager = manager
|
||||
self.client = client
|
||||
@@ -50,17 +53,26 @@ class HeartbeatSynchronizer:
|
||||
self.on_sync = on_sync
|
||||
self.policy_cache = policy_cache
|
||||
self.on_policy_sync = on_policy_sync
|
||||
self.mcp_busy_tracker = mcp_busy_tracker
|
||||
self.policy = policy_cache.load() if policy_cache is not None else None
|
||||
self.policy_revision = self.policy.revision if self.policy is not None else 0
|
||||
if self.status_tracker is not None:
|
||||
self.status_tracker.mark_host_policy(self.policy)
|
||||
|
||||
async def sync_once(self) -> HeartbeatResponse:
|
||||
self.probe_connected_devices()
|
||||
self.connect_devices()
|
||||
snapshot = build_device_snapshot(self.manager)
|
||||
mcp_busy_ids = (
|
||||
self.mcp_busy_tracker.busy_device_ids()
|
||||
if self.mcp_busy_tracker is not None
|
||||
else []
|
||||
)
|
||||
response = await self.client.heartbeat(
|
||||
snapshot,
|
||||
address=self.address,
|
||||
policy_revision=self.policy_revision,
|
||||
mcp_busy_device_ids=mcp_busy_ids,
|
||||
)
|
||||
self.policy_revision = response.policy_revision
|
||||
if response.policy is not None:
|
||||
@@ -98,9 +110,19 @@ class HeartbeatSynchronizer:
|
||||
|
||||
def connect_devices(self) -> None:
|
||||
for device in self.manager.list_devices():
|
||||
if device.status != "idle":
|
||||
if device.status not in {"idle", "offline", "error"}:
|
||||
continue
|
||||
try:
|
||||
self.manager.connect(device.id)
|
||||
except DeviceRuntimeError:
|
||||
continue
|
||||
|
||||
def probe_connected_devices(self) -> None:
|
||||
active_id: str | None = None
|
||||
if self.status_tracker is not None:
|
||||
current = self.status_tracker.snapshot().get("current_assignment")
|
||||
if isinstance(current, dict) and isinstance(current.get("device_id"), str):
|
||||
active_id = current["device_id"]
|
||||
for device in self.manager.list_devices():
|
||||
if device.id != active_id and device.status == "busy":
|
||||
self.manager.probe(device.id)
|
||||
|
||||
@@ -55,6 +55,21 @@ class ConsoleHistoryStore:
|
||||
{"revision": revision},
|
||||
)
|
||||
|
||||
def record_task_submission(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
device_id: str | None,
|
||||
) -> None:
|
||||
if device_id:
|
||||
summary = f"task submitted: {task_id} on {device_id}"
|
||||
else:
|
||||
summary = f"task submitted: {task_id} (automatic device)"
|
||||
detail: dict[str, Any] = {"task_id": task_id}
|
||||
if device_id is not None:
|
||||
detail["device_id"] = device_id
|
||||
self._insert("task_submission", summary, detail)
|
||||
|
||||
def list_recent(self, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
effective_limit = limit if limit is not None else self.limit
|
||||
with self._connect() as connection:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class IOSDiscoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def discover_connected_ios_devices(*, timeout_seconds: int = 10) -> list[dict[str, str]]:
|
||||
"""Return paired, currently connected physical iOS devices from CoreDevice."""
|
||||
with tempfile.TemporaryDirectory(prefix="ios-device-discovery-") as temp_dir:
|
||||
output_path = Path(temp_dir) / "devices.json"
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"xcrun",
|
||||
"devicectl",
|
||||
"list",
|
||||
"devices",
|
||||
"--json-output",
|
||||
str(output_path),
|
||||
"--timeout",
|
||||
str(timeout_seconds),
|
||||
"--quiet",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds + 2,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise IOSDiscoveryError("xcrun is unavailable; install Xcode command line tools") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise IOSDiscoveryError("iOS device discovery timed out") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise IOSDiscoveryError(detail or "devicectl failed to discover iOS devices")
|
||||
try:
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise IOSDiscoveryError("devicectl returned invalid device data") from exc
|
||||
|
||||
raw_devices = payload.get("result", {}).get("devices", [])
|
||||
devices: list[dict[str, str]] = []
|
||||
for item in raw_devices if isinstance(raw_devices, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
hardware = item.get("hardwareProperties", {})
|
||||
properties = item.get("deviceProperties", {})
|
||||
connection = item.get("connectionProperties", {})
|
||||
if not all(isinstance(value, dict) for value in (hardware, properties, connection)):
|
||||
continue
|
||||
udid = hardware.get("udid")
|
||||
if (
|
||||
hardware.get("platform") != "iOS"
|
||||
or hardware.get("reality") != "physical"
|
||||
or connection.get("pairingState") != "paired"
|
||||
or connection.get("tunnelState") != "connected"
|
||||
or not isinstance(udid, str)
|
||||
or not udid
|
||||
):
|
||||
continue
|
||||
devices.append(
|
||||
{
|
||||
"udid": udid,
|
||||
"name": str(properties.get("name") or hardware.get("marketingName") or "iPhone"),
|
||||
"model": str(hardware.get("marketingName") or hardware.get("productType") or "iPhone"),
|
||||
"os_version": str(properties.get("osVersionNumber") or ""),
|
||||
"transport": str(connection.get("transportType") or "unknown"),
|
||||
}
|
||||
)
|
||||
return sorted(devices, key=lambda device: (device["name"], device["udid"]))
|
||||
|
||||
@@ -12,6 +12,7 @@ from cloud.internal_api.models import AssignmentModel
|
||||
from host_agent.assignment import AssignmentExecutionResult
|
||||
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
from runtime.task import is_cancellation_reason
|
||||
|
||||
|
||||
class InterruptibleAssignmentExecutor(Protocol):
|
||||
@@ -20,6 +21,7 @@ class InterruptibleAssignmentExecutor(Protocol):
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
stop_reason: Callable[[], str | None] | None = None,
|
||||
) -> AssignmentExecutionResult: ...
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
||||
@@ -36,6 +38,10 @@ class LeaseGuard:
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
@property
|
||||
def is_cancellation(self) -> bool:
|
||||
return is_cancellation_reason(self.reason)
|
||||
|
||||
def is_lost(self) -> bool:
|
||||
return self._lost.is_set()
|
||||
|
||||
@@ -69,6 +75,7 @@ class ActiveAssignmentRunner:
|
||||
self.executor.execute,
|
||||
assignment,
|
||||
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
|
||||
stop_reason=lambda: guard.reason,
|
||||
)
|
||||
)
|
||||
renewal = asyncio.create_task(
|
||||
@@ -108,4 +115,7 @@ class ActiveAssignmentRunner:
|
||||
guard.mark_lost("lease renewal failed after transport retries")
|
||||
return
|
||||
else:
|
||||
if response.cancel_requested:
|
||||
guard.mark_lost("cancellation requested by control plane")
|
||||
return
|
||||
lease_expires_at = response.lease_expires_at
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cloud.internal_api.models import (
|
||||
ClaimResponse,
|
||||
HeartbeatResponse,
|
||||
HostTaskCancellationResponse,
|
||||
HostTaskSubmissionResponse,
|
||||
LeaseRenewalResponse,
|
||||
TerminalResultResponse,
|
||||
)
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
|
||||
class LocalHostAgentClient:
|
||||
"""In-process task broker used when the Host Agent runs without Cloud."""
|
||||
|
||||
def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None:
|
||||
self.host_id = host_id
|
||||
self._device_ids = device_ids
|
||||
self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
|
||||
self._cancelled: set[str] = set()
|
||||
|
||||
async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse:
|
||||
task_id = f"local-{uuid.uuid4().hex}"
|
||||
await self._queue.put((task_id, goal, device_id))
|
||||
return HostTaskSubmissionResponse(task_id=task_id)
|
||||
|
||||
async def claim(self):
|
||||
task_id, goal, requested_device = await self._queue.get()
|
||||
devices = self._device_ids()
|
||||
device_id = requested_device or (devices[0] if devices else "")
|
||||
if not device_id:
|
||||
return None
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
|
||||
return AssignmentModel(
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
lease_id=f"local-lease-{uuid.uuid4().hex}",
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
|
||||
host_id=self.host_id,
|
||||
device_id=device_id,
|
||||
goal=goal,
|
||||
)
|
||||
|
||||
async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse:
|
||||
return HeartbeatResponse(
|
||||
host_id=self.host_id,
|
||||
accepted_devices=len(self._device_ids()),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse:
|
||||
return LeaseRenewalResponse(
|
||||
status="renewed",
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
|
||||
)
|
||||
|
||||
async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse:
|
||||
return TerminalResultResponse(status="recorded")
|
||||
|
||||
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
|
||||
self._cancelled.add(task_id)
|
||||
return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Per-device MCP session-level busy tracker.
|
||||
|
||||
The cloud-side assignment path and the MCP-driven path both drive devices
|
||||
through the same in-process ``DeviceManager``. This tracker records which
|
||||
devices are currently held by an MCP session so that:
|
||||
|
||||
- MCP tool calls against a device held by another session (or by a cloud
|
||||
assignment — checked separately by the caller via ``AgentStatusTracker``)
|
||||
can fail fast with a busy error.
|
||||
- The heartbeat payload can advertise ``mcp_busy_device_ids`` so the cloud
|
||||
scheduler won't dispatch conflicting assignments to the same device.
|
||||
|
||||
Leases expire ``ttl_seconds`` after the last ``renew()`` call (set on every
|
||||
tool call from the holding session). Expired leases are lazy-swept on read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpDeviceLease:
|
||||
device_id: str
|
||||
session_id: str
|
||||
acquired_at: datetime
|
||||
last_seen_at: datetime
|
||||
|
||||
|
||||
class McpBusyTracker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: float = 20.0,
|
||||
now: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._ttl = float(ttl_seconds)
|
||||
self._now = now or (lambda: datetime.now(UTC))
|
||||
self._lock = Lock()
|
||||
# device_id -> McpDeviceLease
|
||||
self._leases: dict[str, McpDeviceLease] = {}
|
||||
|
||||
def acquire(self, device_id: str, session_id: str) -> bool:
|
||||
with self._lock:
|
||||
self._sweep_locked()
|
||||
existing = self._leases.get(device_id)
|
||||
if existing is not None and existing.session_id != session_id:
|
||||
return False
|
||||
now = self._now()
|
||||
lease = McpDeviceLease(
|
||||
device_id=device_id,
|
||||
session_id=session_id,
|
||||
acquired_at=(existing.acquired_at if existing is not None else now),
|
||||
last_seen_at=now,
|
||||
)
|
||||
self._leases[device_id] = lease
|
||||
return True
|
||||
|
||||
def renew(self, device_id: str, session_id: str) -> bool:
|
||||
with self._lock:
|
||||
self._sweep_locked()
|
||||
existing = self._leases.get(device_id)
|
||||
# Tolerate boundary: lease may have been swept, but if the caller
|
||||
# is the legitimate previous holder, re-acquire on their behalf.
|
||||
if existing is None:
|
||||
now = self._now()
|
||||
self._leases[device_id] = McpDeviceLease(
|
||||
device_id=device_id,
|
||||
session_id=session_id,
|
||||
acquired_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
return True
|
||||
if existing.session_id != session_id:
|
||||
return False
|
||||
self._leases[device_id] = McpDeviceLease(
|
||||
device_id=device_id,
|
||||
session_id=session_id,
|
||||
acquired_at=existing.acquired_at,
|
||||
last_seen_at=self._now(),
|
||||
)
|
||||
return True
|
||||
|
||||
def release(self, session_id: str) -> list[str]:
|
||||
with self._lock:
|
||||
freed = [
|
||||
device_id
|
||||
for device_id, lease in self._leases.items()
|
||||
if lease.session_id == session_id
|
||||
]
|
||||
for device_id in freed:
|
||||
del self._leases[device_id]
|
||||
return freed
|
||||
|
||||
def release_device(self, device_id: str, session_id: str) -> bool:
|
||||
with self._lock:
|
||||
existing = self._leases.get(device_id)
|
||||
if existing is None or existing.session_id != session_id:
|
||||
return False
|
||||
del self._leases[device_id]
|
||||
return True
|
||||
|
||||
def busy_device_ids(self) -> list[str]:
|
||||
with self._lock:
|
||||
self._sweep_locked()
|
||||
return sorted(self._leases)
|
||||
|
||||
def snapshot(self) -> list[McpDeviceLease]:
|
||||
with self._lock:
|
||||
self._sweep_locked()
|
||||
return sorted(self._leases.values(), key=lambda lease: lease.device_id)
|
||||
|
||||
def wait_until_usable(
|
||||
self,
|
||||
device_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
timeout: float,
|
||||
poll_interval: float = 1.0,
|
||||
cloud_busy_check: Callable[[], bool] | None = None,
|
||||
) -> bool:
|
||||
"""Block until ``device_id`` is acquirable by ``session_id`` or timeout.
|
||||
|
||||
Reserved capability. MVP callers use try-acquire (``acquire`` -> False
|
||||
means busy). This method exists for future wiring where the cloud
|
||||
assignment path or an explicit MCP tool may opt to wait.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
cloud_busy = cloud_busy_check() if cloud_busy_check else False
|
||||
if not cloud_busy:
|
||||
if self.acquire(device_id, session_id):
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
return False
|
||||
remaining = deadline - time.monotonic()
|
||||
time.sleep(max(0.0, min(poll_interval, remaining)))
|
||||
|
||||
def _sweep_locked(self) -> None:
|
||||
"""Caller holds ``self._lock``. Drops leases past their TTL."""
|
||||
cutoff = self._now()
|
||||
expired = [
|
||||
device_id
|
||||
for device_id, lease in self._leases.items()
|
||||
if (cutoff - lease.last_seen_at).total_seconds() > self._ttl
|
||||
]
|
||||
for device_id in expired:
|
||||
del self._leases[device_id]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Bearer-token persistence for the host-agent MCP server.
|
||||
|
||||
The token is generated on first start and persisted to a JSON file with
|
||||
0o600 permissions (POSIX) alongside the host identity. Rotation = delete
|
||||
the file and restart host-agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
class McpTokenStoreError(RuntimeError):
|
||||
"""Raised when the MCP token file cannot be read or written."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpToken:
|
||||
version: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class McpTokenStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
now: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._path = Path(path)
|
||||
self._now = now or (lambda: datetime.now(UTC))
|
||||
|
||||
def load_or_create(self) -> McpToken:
|
||||
if self._path.exists():
|
||||
return self._read_existing()
|
||||
return self._generate_and_write()
|
||||
|
||||
def verify(self, presented: str) -> bool:
|
||||
try:
|
||||
token = self.load_or_create()
|
||||
except McpTokenStoreError:
|
||||
return False
|
||||
import hmac
|
||||
|
||||
return hmac.compare_digest(token.token, presented)
|
||||
|
||||
def _read_existing(self) -> McpToken:
|
||||
try:
|
||||
data = json.loads(self._path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise McpTokenStoreError(
|
||||
f"cannot read MCP token file {self._path}: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise McpTokenStoreError("MCP token file is not a JSON object")
|
||||
try:
|
||||
return McpToken(
|
||||
version=int(data["version"]),
|
||||
token=str(data["token"]),
|
||||
created_at=datetime.fromisoformat(str(data["created_at"])),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise McpTokenStoreError(f"MCP token file schema invalid: {exc}") from exc
|
||||
|
||||
def _generate_and_write(self) -> McpToken:
|
||||
token = McpToken(
|
||||
version=1,
|
||||
token=secrets.token_urlsafe(_TOKEN_BYTES),
|
||||
created_at=self._now(),
|
||||
)
|
||||
payload = {
|
||||
"version": token.version,
|
||||
"token": token.token,
|
||||
"created_at": token.created_at.isoformat(),
|
||||
}
|
||||
try:
|
||||
self._atomic_write(json.dumps(payload, indent=2))
|
||||
except OSError as exc:
|
||||
raise McpTokenStoreError(
|
||||
f"cannot write MCP token file {self._path}: {exc}"
|
||||
) from exc
|
||||
return token
|
||||
|
||||
def _atomic_write(self, content: str) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic on POSIX; on Windows os.replace is also atomic per docs.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=".host_mcp_token.",
|
||||
suffix=".tmp",
|
||||
dir=str(self._path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
os.chmod(tmp_name, 0o600)
|
||||
os.replace(tmp_name, self._path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -47,8 +47,11 @@ class AssignmentProcessor:
|
||||
self.status_tracker.mark_assignment_started(assignment)
|
||||
try:
|
||||
execution = await self.active_executor.run(assignment)
|
||||
status = "done" if execution.status == "done" else "failed"
|
||||
failure_reason = execution.failure_reason if status == "failed" else None
|
||||
if execution.status in {"done", "cancelled"}:
|
||||
status = execution.status
|
||||
else:
|
||||
status = "failed"
|
||||
failure_reason = execution.failure_reason if status != "done" else None
|
||||
response = await self.client.report_result(
|
||||
assignment,
|
||||
status=status,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Host-agent wiring for skill sync against the Cloud Control Plane.
|
||||
|
||||
Constructs the synced catalog store, the local skill store, the Cloud API
|
||||
sync client, and the :class:`SkillSyncRunner`, then drives them on the host
|
||||
agent's lifecycle: the runner pulls incremental per-host skill deltas into the
|
||||
synced catalog (forking local overrides on revocation, design D9), and a
|
||||
best-effort inventory of the agent's local skills is reported to the Cloud
|
||||
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
|
||||
reasons as :mod:`host_agent.cloud_planner_client`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HostAgentSkillSync:
|
||||
"""Owns the skill stores, sync runner, and inventory reporting thread."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: HostAgentConfig,
|
||||
*,
|
||||
poll_interval: float | None = None,
|
||||
http_client=None,
|
||||
) -> None:
|
||||
from api.skill_sync import CloudApiSkillClient, SkillSyncRunner
|
||||
from storage.local_skills import LocalSkillStore
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
state_dir = config.identity_path.parent
|
||||
self.config = config
|
||||
self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3")
|
||||
self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3")
|
||||
self.client = CloudApiSkillClient(
|
||||
config.control_plane_url,
|
||||
host_token=config.token,
|
||||
client=http_client,
|
||||
)
|
||||
self.runner = SkillSyncRunner(
|
||||
store=self.synced_store,
|
||||
client=self.client,
|
||||
subscriptions=[config.host_id],
|
||||
poll_interval=poll_interval or config.skill_sync_interval_seconds,
|
||||
local_store=self.local_store,
|
||||
)
|
||||
self._inventory_stop = threading.Event()
|
||||
self._inventory_thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the sync poll loop + periodic inventory reporting."""
|
||||
self.runner.start_background()
|
||||
self._inventory_stop.clear()
|
||||
self._inventory_thread = threading.Thread(
|
||||
target=self._report_inventory_forever, daemon=True
|
||||
)
|
||||
self._inventory_thread.start()
|
||||
log.info("skill sync started for host %s", self.config.host_id)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sync loop + inventory thread and close the HTTP client."""
|
||||
self.runner.stop_background()
|
||||
self._inventory_stop.set()
|
||||
if self._inventory_thread is not None:
|
||||
self._inventory_thread.join(timeout=5.0)
|
||||
self._inventory_thread = None
|
||||
self.client.close()
|
||||
|
||||
def report_inventory_once(self) -> None:
|
||||
"""Report the current local-skill inventory to the Cloud (best-effort)."""
|
||||
try:
|
||||
inventory = [
|
||||
{
|
||||
"id": meta.id,
|
||||
"name": meta.name,
|
||||
"kind": meta.kind,
|
||||
"origin": "local",
|
||||
}
|
||||
for meta in self.local_store.list_local()
|
||||
]
|
||||
for override in self.local_store.list_overrides():
|
||||
inventory.append(
|
||||
{
|
||||
"id": override.metadata.id,
|
||||
"name": override.metadata.name,
|
||||
"kind": override.metadata.kind,
|
||||
"origin": "cloud",
|
||||
"locally_overridden": True,
|
||||
}
|
||||
)
|
||||
self.client.report_inventory(self.config.host_id, inventory)
|
||||
except Exception: # best-effort: never impair local operation
|
||||
log.debug("skill inventory report failed", exc_info=True)
|
||||
|
||||
def _report_inventory_forever(self) -> None:
|
||||
# Report once at startup, then on the sync cadence.
|
||||
self.report_inventory_once()
|
||||
interval = self.config.skill_sync_interval_seconds
|
||||
while not self._inventory_stop.wait(interval):
|
||||
self.report_inventory_once()
|
||||
@@ -3,21 +3,34 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import jinja2
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError
|
||||
from device.manager import DeviceManager
|
||||
from driver.registry import build_driver_factory
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentEnrollmentClient
|
||||
from host_agent.client import (
|
||||
HostAgentClient,
|
||||
HostAgentEnrollmentClient,
|
||||
HostAgentAPIError,
|
||||
HostTaskSubmissionUnknownError,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.conversation import ConversationAgent
|
||||
from host_agent.conversation_log import ConversationLogStore
|
||||
from host_agent.devices import register_local_device, unregister_local_device
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.ios_discovery import IOSDiscoveryError, discover_connected_ios_devices
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.auth import (
|
||||
SessionManager,
|
||||
@@ -25,7 +38,11 @@ from host_agent.web.auth import (
|
||||
attempt_login,
|
||||
change_password,
|
||||
)
|
||||
from host_agent.web.mcp_auth import BearerAuthMiddleware
|
||||
from storage.device_config import DeviceConfigStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
|
||||
@@ -34,6 +51,11 @@ CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_FORM_FIELD = "csrf_token"
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
TaskSubmissionCallable = Callable[..., Awaitable[str]]
|
||||
TaskCancellationCallable = Callable[..., Awaitable[Any]]
|
||||
AUTOMATIC_DEVICE_VALUE = "__automatic__"
|
||||
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
||||
|
||||
_ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
@@ -61,9 +83,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))
|
||||
@@ -73,6 +99,88 @@ 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]]:
|
||||
raw_nodes = record.get("ui_tree_results")
|
||||
if not isinstance(raw_nodes, list):
|
||||
return []
|
||||
return [node for node in raw_nodes if isinstance(node, dict)]
|
||||
|
||||
|
||||
def _overlay_payload(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Combined perception elements + screen size for client-side bounding-box
|
||||
overlay and action-effect rendering on the before-screenshot.
|
||||
"""
|
||||
scene = record.get("scene")
|
||||
screen = scene.get("screen") if isinstance(scene, dict) else None
|
||||
width = screen.get("width") if isinstance(screen, dict) else None
|
||||
height = screen.get("height") if isinstance(screen, dict) else None
|
||||
elements = scene.get("elements") if isinstance(scene, dict) else None
|
||||
return {
|
||||
"width": width if isinstance(width, (int, float)) else 0,
|
||||
"height": height if isinstance(height, (int, float)) else 0,
|
||||
"elements": [element for element in elements if isinstance(element, dict)]
|
||||
if isinstance(elements, list)
|
||||
else [],
|
||||
}
|
||||
|
||||
|
||||
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),
|
||||
"overlay": _overlay_payload(record),
|
||||
}
|
||||
|
||||
|
||||
def _safe_submission_error(detail: str) -> str:
|
||||
"""Return a safe, single-line error message for the operator.
|
||||
|
||||
The Cloud response ``detail`` is treated as a static control-plane message;
|
||||
we strip surrounding whitespace and reject empty results so the operator
|
||||
never sees a blank error or, through Jinja autoescape, anything that could
|
||||
carry unrendered HTML.
|
||||
"""
|
||||
cleaned = " ".join(str(detail).split()).strip()
|
||||
return cleaned or "Cloud rejected the task submission."
|
||||
|
||||
|
||||
def _extract_task_id(response: Any) -> str | None:
|
||||
"""Normalize the Host self-submission return value to a Cloud task ID.
|
||||
|
||||
Tests and narrow protocol overrides may return a bare string while the
|
||||
production client returns a pydantic model. Accept either so the rest of
|
||||
the handler can rely on a single string ID.
|
||||
"""
|
||||
candidate: Any = response
|
||||
if hasattr(candidate, "task_id"):
|
||||
candidate = getattr(candidate, "task_id")
|
||||
if not isinstance(candidate, str):
|
||||
return None
|
||||
cleaned = candidate.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _dashboard_texts(*, snapshot: dict[str, Any]) -> dict[str, str]:
|
||||
"""Pre-compute human-readable text strings for the dashboard template."""
|
||||
heartbeat = snapshot.get("last_heartbeat")
|
||||
@@ -120,12 +228,46 @@ def create_console_app(
|
||||
status_tracker: AgentStatusTracker,
|
||||
session_manager: SessionManager,
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
host_client: HostAgentClient | None = None,
|
||||
submit_self_task: TaskSubmissionCallable | None = None,
|
||||
cancel_task: TaskCancellationCallable | None = None,
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
executor: AssignmentExecutor | None = None,
|
||||
mcp_server: FastMCP | None = None,
|
||||
mcp_token_store: McpTokenStore | None = None,
|
||||
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||
conversation_agent: ConversationAgent | None = None,
|
||||
conversation_log: ConversationLogStore | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Host Agent Console")
|
||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||
if submit_self_task is None and host_client is not None:
|
||||
submit_self_task = host_client.submit_self_task
|
||||
if cancel_task is None and host_client is not None:
|
||||
cancel_task = host_client.cancel_task
|
||||
submission_available = submit_self_task is not None
|
||||
mcp_mounted = mcp_server is not None and mcp_token_store is not None
|
||||
if mcp_mounted:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
|
||||
mcp_asgi = mcp_server.streamable_http_app()
|
||||
authed = Starlette(
|
||||
routes=[],
|
||||
middleware=[Middleware(BearerAuthMiddleware, token_store=mcp_token_store)],
|
||||
)
|
||||
authed.router.mount("/", mcp_asgi)
|
||||
app.mount("/mcp", authed)
|
||||
|
||||
def _running_devices() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"id": device.id,
|
||||
"label": device.name or device.id,
|
||||
}
|
||||
for device in manager.list_devices()
|
||||
]
|
||||
|
||||
def _session_token(request: Request) -> str | None:
|
||||
return request.cookies.get(SESSION_COOKIE_NAME)
|
||||
@@ -226,6 +368,10 @@ def create_console_app(
|
||||
for d in manager.list_devices()
|
||||
]
|
||||
texts = _dashboard_texts(snapshot=snapshot)
|
||||
mcp_endpoint = "/mcp" if mcp_mounted else None
|
||||
mcp_busy_devices = (
|
||||
mcp_busy_tracker.busy_device_ids() if mcp_busy_tracker is not None else []
|
||||
)
|
||||
return _render(
|
||||
"dashboard.html",
|
||||
title="Status",
|
||||
@@ -233,6 +379,8 @@ def create_console_app(
|
||||
identity=identity,
|
||||
devices=devices,
|
||||
config=config,
|
||||
mcp_endpoint=mcp_endpoint,
|
||||
mcp_busy_devices=mcp_busy_devices,
|
||||
**texts,
|
||||
)
|
||||
|
||||
@@ -261,7 +409,63 @@ def create_console_app(
|
||||
}
|
||||
for device in manager.list_devices()
|
||||
]
|
||||
return JSONResponse({"status": snapshot, "devices": devices})
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": snapshot,
|
||||
"devices": devices,
|
||||
"mcp_endpoint": "/mcp" if mcp_mounted else None,
|
||||
"mcp_busy_devices": (
|
||||
mcp_busy_tracker.busy_device_ids()
|
||||
if mcp_busy_tracker is not None
|
||||
else []
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def api_chat(
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
if conversation_agent is None:
|
||||
raise HTTPException(status_code=503, detail="chat agent is not configured")
|
||||
payload = await request.json()
|
||||
device_id = payload.get("device_id") if isinstance(payload, dict) else None
|
||||
if not isinstance(device_id, str) or not device_id.strip():
|
||||
raise HTTPException(status_code=400, detail="device_id is required")
|
||||
if device_id not in {device.id for device in manager.list_devices()}:
|
||||
raise HTTPException(status_code=404, detail="unknown device")
|
||||
raw_messages = payload.get("messages") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_messages, list) or not raw_messages:
|
||||
raise HTTPException(status_code=400, detail="messages must be a non-empty list")
|
||||
messages = [
|
||||
{key: value for key, value in item.items() if key in {"role", "content", "image_base64", "mime_type", "text"}}
|
||||
for item in raw_messages
|
||||
if isinstance(item, dict)
|
||||
and item.get("role") in {"user", "assistant"}
|
||||
and (isinstance(item.get("content"), (str, list)) or isinstance(item.get("image_base64"), str))
|
||||
]
|
||||
if not messages:
|
||||
raise HTTPException(status_code=400, detail="messages are invalid")
|
||||
if conversation_log is not None:
|
||||
await asyncio.to_thread(
|
||||
conversation_log.append,
|
||||
{"type": "user_request", "device_id": device_id.strip(), "messages": messages},
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
conversation_agent.chat_for_device, device_id.strip(), messages
|
||||
)
|
||||
except Exception as exc:
|
||||
if conversation_log is not None:
|
||||
await asyncio.to_thread(
|
||||
conversation_log.append,
|
||||
{"type": "agent_error", "device_id": device_id.strip(), "error": str(exc)},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return JSONResponse(
|
||||
{"content": result.content, "tool_calls": result.tool_calls}
|
||||
)
|
||||
|
||||
@app.get("/devices", response_class=HTMLResponse)
|
||||
async def devices_page(
|
||||
@@ -287,6 +491,124 @@ def create_console_app(
|
||||
error=None,
|
||||
)
|
||||
|
||||
@app.post("/api/devices/{device_id}/screenshot")
|
||||
async def api_device_screenshot(
|
||||
device_id: str,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""Capture one on-demand screenshot for a connected local device."""
|
||||
try:
|
||||
screenshot = await asyncio.to_thread(
|
||||
lambda: manager.active_driver(device_id).screenshot()
|
||||
)
|
||||
except DeviceNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except DeviceOfflineError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except DeviceRuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=str(exc) or "failed to capture device screenshot",
|
||||
) from exc
|
||||
|
||||
if not isinstance(screenshot, bytes) or not screenshot:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="device returned an empty screenshot",
|
||||
)
|
||||
return Response(
|
||||
content=screenshot,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/devices/discover-ios")
|
||||
async def api_discover_ios_devices(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
discovered = await asyncio.to_thread(discover_connected_ios_devices)
|
||||
except IOSDiscoveryError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
configured = await asyncio.to_thread(config_store.list)
|
||||
configured_udids = {
|
||||
str(record["connection_info"].get("udid"))
|
||||
for record in configured
|
||||
if record["driver_type"] == "wda"
|
||||
}
|
||||
used_wda_ports = {
|
||||
record["connection_info"].get("wda_local_port") for record in configured
|
||||
}
|
||||
used_mjpeg_ports = {
|
||||
record["connection_info"].get("mjpegServerPort") for record in configured
|
||||
}
|
||||
next_wda_port = 8100
|
||||
next_mjpeg_port = 9100
|
||||
result = []
|
||||
for device in discovered:
|
||||
while next_wda_port in used_wda_ports:
|
||||
next_wda_port += 1
|
||||
while next_mjpeg_port in used_mjpeg_ports:
|
||||
next_mjpeg_port += 1
|
||||
result.append(
|
||||
{
|
||||
**device,
|
||||
"configured": device["udid"] in configured_udids,
|
||||
"suggested_wda_port": next_wda_port,
|
||||
"suggested_mjpeg_port": next_mjpeg_port,
|
||||
}
|
||||
)
|
||||
used_wda_ports.add(next_wda_port)
|
||||
used_mjpeg_ports.add(next_mjpeg_port)
|
||||
next_wda_port += 1
|
||||
next_mjpeg_port += 1
|
||||
return JSONResponse({"devices": result})
|
||||
|
||||
@app.post("/api/devices/test-connection")
|
||||
async def api_device_test_connection(
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
payload = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Request must be an object.")
|
||||
driver_type = str(payload.get("driver_type", "")).strip()
|
||||
connection_info = payload.get("connection_info")
|
||||
if not driver_type or not isinstance(connection_info, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Driver type and connection info are required.",
|
||||
)
|
||||
|
||||
driver = None
|
||||
connected = False
|
||||
try:
|
||||
driver = build_driver_factory(driver_type, connection_info)()
|
||||
await asyncio.to_thread(driver.connect)
|
||||
connected = True
|
||||
await asyncio.to_thread(driver.health_check)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=str(exc) or "device connection test failed",
|
||||
) from exc
|
||||
finally:
|
||||
if connected and driver is not None:
|
||||
try:
|
||||
await asyncio.to_thread(driver.disconnect)
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse({"ok": True, "driver_type": driver_type})
|
||||
|
||||
@app.post("/devices/save")
|
||||
async def devices_save(
|
||||
request: Request,
|
||||
@@ -313,6 +635,26 @@ def create_console_app(
|
||||
else:
|
||||
connection_info = parsed
|
||||
|
||||
if error is None:
|
||||
# The console exposes the common Appium settings as regular form
|
||||
# fields. Advanced JSON remains available for uncommon capabilities.
|
||||
field_map = {
|
||||
"server_url": "server_url",
|
||||
"udid": "udid",
|
||||
"device_name": "device_name",
|
||||
}
|
||||
for form_key, config_key in field_map.items():
|
||||
value = str(form.get(form_key, "")).strip()
|
||||
if value:
|
||||
connection_info[config_key] = value
|
||||
port_key = "wda_local_port" if driver_type == "wda" else "system_port"
|
||||
port_value = str(form.get(port_key, "")).strip()
|
||||
if port_value:
|
||||
try:
|
||||
connection_info[port_key] = int(port_value)
|
||||
except ValueError:
|
||||
error = f"{port_key} must be an integer."
|
||||
|
||||
if error is None:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
@@ -426,25 +768,190 @@ def create_console_app(
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
@app.get("/conversations", response_class=HTMLResponse)
|
||||
async def conversations_page(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
events = await asyncio.to_thread(
|
||||
conversation_log.list_recent if conversation_log is not None else (lambda: [])
|
||||
)
|
||||
return _render("conversations.html", title="Conversations", session=session, events=events)
|
||||
|
||||
def _tasks_list_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
goal_value: str = "",
|
||||
selected_device: str = AUTOMATIC_DEVICE_VALUE,
|
||||
submission_error: str | None = None,
|
||||
submission_notice: str | None = None,
|
||||
submission_unknown: bool = False,
|
||||
status_code: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
devices = _running_devices()
|
||||
return {
|
||||
"title": "Tasks",
|
||||
"session": session,
|
||||
"csrf_token": session.csrf_token,
|
||||
"tasks": metadata_store.list_tasks() if metadata_store is not None else [],
|
||||
"metadata_store_missing": metadata_store is None,
|
||||
"devices": devices,
|
||||
"automatic_device_value": AUTOMATIC_DEVICE_VALUE,
|
||||
"selected_device": selected_device
|
||||
if any(d["id"] == selected_device for d in devices)
|
||||
or selected_device == AUTOMATIC_DEVICE_VALUE
|
||||
else AUTOMATIC_DEVICE_VALUE,
|
||||
"submission_available": submission_available,
|
||||
"goal_value": goal_value,
|
||||
"submission_error": submission_error,
|
||||
"submission_notice": submission_notice,
|
||||
"submission_unknown": submission_unknown,
|
||||
"status_code": status_code,
|
||||
}
|
||||
|
||||
@app.get("/tasks", response_class=HTMLResponse)
|
||||
async def tasks_page(
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
tasks_list = await asyncio.to_thread(metadata_store.list_tasks)
|
||||
notice: str | None = None
|
||||
unknown = False
|
||||
error: str | None = None
|
||||
if request.query_params.get("submitted") == "1":
|
||||
task_id = request.query_params.get("task_id", "")
|
||||
if task_id:
|
||||
notice = (
|
||||
f"Task submitted. Cloud task ID: {task_id}. "
|
||||
"It will appear below when this Host begins executing it."
|
||||
)
|
||||
elif request.query_params.get("outcome") == "unknown":
|
||||
unknown = True
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
submission_notice=notice,
|
||||
submission_unknown=unknown,
|
||||
submission_error=error,
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
title="Tasks",
|
||||
session=session,
|
||||
tasks=tasks_list,
|
||||
status_code=context["status_code"],
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
@app.post("/tasks/submit", response_class=HTMLResponse)
|
||||
async def tasks_submit(
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> Response:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
if submit_self_task is None:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
submission_error=(
|
||||
"Host submission client is not available yet. "
|
||||
"Wait for Host enrollment to complete, then retry."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=503,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
form = await request.form()
|
||||
raw_goal = str(form.get("goal", ""))
|
||||
goal = raw_goal.strip()
|
||||
device_selection = str(form.get("device_id", AUTOMATIC_DEVICE_VALUE)).strip()
|
||||
explicit_device_id: str | None = None
|
||||
if device_selection and device_selection != AUTOMATIC_DEVICE_VALUE:
|
||||
snapshot_ids = {device.id for device in manager.list_devices()}
|
||||
if device_selection not in snapshot_ids:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=(
|
||||
"Selected device is no longer registered. "
|
||||
"Refresh and try again."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=400,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
explicit_device_id = device_selection
|
||||
|
||||
if not goal:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection or AUTOMATIC_DEVICE_VALUE,
|
||||
submission_error="Goal cannot be empty.",
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=400,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await submit_self_task(goal=goal, device_id=explicit_device_id)
|
||||
except HostAgentAPIError as exc:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=_safe_submission_error(str(exc.detail)),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=502,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
except HostTaskSubmissionUnknownError:
|
||||
return RedirectResponse(
|
||||
url="/tasks?outcome=unknown",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
task_id = _extract_task_id(response)
|
||||
if task_id is None:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=(
|
||||
"Host submission client returned an unexpected response."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=502,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
history_store.record_task_submission,
|
||||
task_id=task_id,
|
||||
device_id=explicit_device_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
params = f"submitted=1&task_id={task_id}"
|
||||
return RedirectResponse(url=f"/tasks?{params}", status_code=303)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
async def task_detail_page(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
if metadata_store is None:
|
||||
@@ -458,43 +965,73 @@ 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]
|
||||
can_cancel = (
|
||||
cancel_task is not None
|
||||
and task.get("source_task_id") is not None
|
||||
and task.get("status") not in _TERMINAL_LOCAL_TASK_STATUSES
|
||||
)
|
||||
cancel_notice = (
|
||||
"Cancellation requested. It may take a moment to take effect."
|
||||
if request.query_params.get("cancelled") == "1"
|
||||
else None
|
||||
)
|
||||
cancel_error = (
|
||||
"Failed to request cancellation. Try again."
|
||||
if request.query_params.get("cancel_error") == "1"
|
||||
else None
|
||||
)
|
||||
return _render(
|
||||
"task_detail.html",
|
||||
title=f"Task {task_id}",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
task=task,
|
||||
task_rows=task_rows,
|
||||
timeline_steps=timeline_steps,
|
||||
can_cancel=can_cancel,
|
||||
cancel_notice=cancel_notice,
|
||||
cancel_error=cancel_error,
|
||||
)
|
||||
|
||||
@app.post("/tasks/{task_id}/cancel")
|
||||
async def tasks_cancel(
|
||||
task_id: str,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> Response:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
task = await asyncio.to_thread(metadata_store.get_task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
source_task_id = task.get("source_task_id")
|
||||
if cancel_task is None or source_task_id is None:
|
||||
return RedirectResponse(
|
||||
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
|
||||
)
|
||||
try:
|
||||
await cancel_task(source_task_id)
|
||||
except HostAgentAPIError:
|
||||
return RedirectResponse(
|
||||
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
|
||||
)
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?cancelled=1", status_code=303)
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""FastMCP server builder for the host-agent MCP endpoint.
|
||||
|
||||
Wraps ``api.mcp.tool_handlers(manager=...)`` with:
|
||||
|
||||
- Cloud-busy and MCP-busy checks (per-device, fail-fast on conflict).
|
||||
- Lazy session-level device lock acquire / renew.
|
||||
- Display-status mapping for ``list_devices`` / ``device_status`` so
|
||||
connected-but-idle devices don't appear "busy" (which they do at the
|
||||
``DeviceManager`` layer because an Appium/WDA session is open).
|
||||
|
||||
The builder returns a ``FastMCP`` instance. The caller
|
||||
(``create_console_app``) is responsible for wrapping it in
|
||||
``BearerAuthMiddleware`` and mounting at ``/mcp``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
# Tool names that don't target a specific device — skip busy check.
|
||||
_NON_DEVICE_TOOLS = frozenset({"list_devices", "device_status"})
|
||||
# Tools that report status and should use the display-status mapping.
|
||||
_STATUS_TOOLS = frozenset({"list_devices", "device_status"})
|
||||
# Name of the wrapper kwarg FastMCP injects the live ``Context`` into.
|
||||
# We set ``tool.context_kwarg = _CONTEXT_KWARG`` after swapping the tool's
|
||||
# ``fn`` (see ``build_mcp_server``) so FastMCP passes ``ctx`` into our
|
||||
# wrapper alongside the validated arguments.
|
||||
_CONTEXT_KWARG = "ctx"
|
||||
|
||||
|
||||
class McpDeviceBusyError(Exception):
|
||||
"""Raised by the wrapper when the target device is held by the cloud
|
||||
assignment path or another MCP session."""
|
||||
|
||||
def __init__(self, device_id: str, busy_owner: str) -> None:
|
||||
super().__init__(f"device {device_id} is busy (held by {busy_owner})")
|
||||
self.device_id = device_id
|
||||
self.busy_owner = busy_owner
|
||||
|
||||
|
||||
class FastMcpSdkIncompatibilityError(RuntimeError):
|
||||
"""Raised when the FastMCP SDK layout diverges from what this module
|
||||
expects (e.g. ``Tool.fn`` rename or ``Tool.context_kwarg`` removal)."""
|
||||
|
||||
|
||||
# contextvars fallback used by tests and any call that originates outside a
|
||||
# live FastMCP request lifecycle. Production handlers run inside an MCP
|
||||
# request whose context exposes ``request_id`` and the underlying
|
||||
# ``session``; ``_current_session_id`` reads from that context first and
|
||||
# falls back to this ContextVar.
|
||||
_TEST_SESSION_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"_TEST_SESSION_ID", default=""
|
||||
)
|
||||
|
||||
|
||||
def _current_session_id(ctx: Context | None = None) -> str:
|
||||
"""Extract a stable per-MCP-session identifier from the live context.
|
||||
|
||||
The mcp SDK 1.28.1 ``Context`` exposes ``session`` (a long-lived
|
||||
``ServerSession`` instance per Streamable HTTP session). Its Python
|
||||
object identity (``id(ctx.session)``) is stable across every tool call
|
||||
the same client makes within that session, which is exactly the
|
||||
identity the busy tracker needs to renew leases.
|
||||
|
||||
Falls back to ``_TEST_SESSION_ID`` when no Context is supplied (i.e.
|
||||
when invoked outside a FastMCP request lifecycle, as ``_call_tool_sync``
|
||||
does in tests).
|
||||
"""
|
||||
if ctx is not None:
|
||||
session_obj = getattr(ctx, "session", None)
|
||||
if session_obj is not None:
|
||||
return f"mcp_session:{id(session_obj)}"
|
||||
return _TEST_SESSION_ID.get("")
|
||||
|
||||
|
||||
def build_mcp_server(
|
||||
*,
|
||||
manager: DeviceManager,
|
||||
mcp_busy_tracker: McpBusyTracker,
|
||||
status_tracker: AgentStatusTracker,
|
||||
) -> FastMCP:
|
||||
"""Construct the FastMCP server wrapping ``tool_handlers``."""
|
||||
# Imported lazily to keep the package import graph flat.
|
||||
from api.mcp import tool_handlers
|
||||
|
||||
handlers = tool_handlers(manager=manager)
|
||||
server = FastMCP("apex-host-agent")
|
||||
|
||||
for tool_name, raw_handler in handlers.items():
|
||||
wrapped = _wrap_tool(
|
||||
tool_name,
|
||||
raw_handler,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
status_tracker=status_tracker,
|
||||
)
|
||||
# Register the raw handler so FastMCP captures its signature (the
|
||||
# MCP wire schema is derived from the function signature). Then
|
||||
# swap ``tool.fn`` for our busy-check / status-mapping wrapper.
|
||||
# Using ``*args, **kwargs`` directly breaks the schema, so we have
|
||||
# to keep the signature and only replace the underlying callable.
|
||||
server._tool_manager.add_tool( # type: ignore[attr-defined]
|
||||
raw_handler, name=tool_name
|
||||
)
|
||||
try:
|
||||
tool = server._tool_manager._tools[tool_name] # type: ignore[attr-defined]
|
||||
tool.fn = wrapped
|
||||
# FastMCP injects the live Context into the kwarg named by
|
||||
# ``tool.context_kwarg``. The raw handler doesn't declare one,
|
||||
# so the cached value is None; we override it so the wrapper
|
||||
# receives the Context via its ``ctx`` kwarg.
|
||||
tool.context_kwarg = _CONTEXT_KWARG
|
||||
except AttributeError as exc:
|
||||
raise FastMcpSdkIncompatibilityError(
|
||||
"FastMCP SDK layout changed: cannot swap Tool.fn or set "
|
||||
f"context_kwarg (tool={tool_name!r}). Underlying error: {exc}"
|
||||
) from exc
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _wrap_tool(
|
||||
tool_name: str,
|
||||
handler: Callable[..., Any],
|
||||
*,
|
||||
mcp_busy_tracker: McpBusyTracker,
|
||||
status_tracker: AgentStatusTracker,
|
||||
) -> Callable[..., Any]:
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = kwargs.pop(_CONTEXT_KWARG, None)
|
||||
session_id = _current_session_id(ctx)
|
||||
device_id = kwargs.get("device_id")
|
||||
|
||||
if tool_name in _STATUS_TOOLS:
|
||||
return _with_display_status(handler, status_tracker, *args, **kwargs)
|
||||
|
||||
if device_id is not None and tool_name not in _NON_DEVICE_TOOLS:
|
||||
_check_and_acquire(device_id, session_id, mcp_busy_tracker, status_tracker)
|
||||
|
||||
return handler(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _check_and_acquire(
|
||||
device_id: str,
|
||||
session_id: str,
|
||||
mcp_busy_tracker: McpBusyTracker,
|
||||
status_tracker: AgentStatusTracker,
|
||||
) -> None:
|
||||
cloud_busy = _cloud_busy_device_id(status_tracker)
|
||||
if cloud_busy == device_id:
|
||||
raise McpDeviceBusyError(device_id, "cloud_assignment")
|
||||
if device_id in mcp_busy_tracker.busy_device_ids():
|
||||
existing = next(
|
||||
(
|
||||
lease
|
||||
for lease in mcp_busy_tracker.snapshot()
|
||||
if lease.device_id == device_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing is not None and existing.session_id != session_id:
|
||||
prefix = existing.session_id[:8]
|
||||
raise McpDeviceBusyError(device_id, f"mcp_session:{prefix}")
|
||||
if not mcp_busy_tracker.acquire(device_id, session_id):
|
||||
# Race: someone else got it between check and acquire.
|
||||
raise McpDeviceBusyError(device_id, "another_session")
|
||||
mcp_busy_tracker.renew(device_id, session_id)
|
||||
|
||||
|
||||
def _cloud_busy_device_id(status_tracker: AgentStatusTracker) -> str | None:
|
||||
"""Return the device_id currently bound to the cloud assignment, if any."""
|
||||
snap = status_tracker.snapshot()
|
||||
current = snap.get("current_assignment")
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
device_id = current.get("device_id")
|
||||
return device_id if isinstance(device_id, str) else None
|
||||
|
||||
|
||||
def _with_display_status(
|
||||
handler: Callable[..., Any],
|
||||
status_tracker: AgentStatusTracker,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
busy_device_id = _cloud_busy_device_id(status_tracker)
|
||||
result = handler(*args, **kwargs)
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
if isinstance(item, dict) and "status" in item:
|
||||
item["status"] = _display_status(
|
||||
item["status"], item.get("id"), busy_device_id
|
||||
)
|
||||
return result
|
||||
if isinstance(result, dict) and "status" in result:
|
||||
result["status"] = _display_status(
|
||||
result["status"], result.get("device_id"), busy_device_id
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _display_status(raw: str, device_id: Any, busy_device_id: str | None) -> str:
|
||||
"""Mirror ``host_agent.web.app._device_display_status`` semantics.
|
||||
|
||||
A device that's locally "busy" because it's connected-but-idle reports
|
||||
"connected" instead, unless it's the device currently running a cloud
|
||||
assignment (in which case "busy" is the truthful status).
|
||||
"""
|
||||
if raw == "busy" and device_id != busy_device_id:
|
||||
return "connected"
|
||||
return raw
|
||||
|
||||
|
||||
def _call_tool_sync(
|
||||
server: FastMCP,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
session_id: str,
|
||||
) -> Any:
|
||||
"""Test helper: invoke a registered tool synchronously with a forced
|
||||
``session_id``. Bypasses the HTTP/MCP transport layer (and the live
|
||||
FastMCP Context) so tests don't need an MCP client.
|
||||
|
||||
Walks FastMCP's tool registry (``_tool_manager._tools[tool_name].fn``) —
|
||||
the exact attribute path follows mcp SDK 1.28.1's
|
||||
``ToolManager._tools`` layout.
|
||||
"""
|
||||
token = _TEST_SESSION_ID.set(session_id)
|
||||
try:
|
||||
manager = getattr(server, "_tool_manager", None)
|
||||
if manager is None:
|
||||
raise KeyError(f"tool {tool_name!r} not registered (no tool manager)")
|
||||
registry = getattr(manager, "_tools", None) or getattr(manager, "tools", None)
|
||||
if isinstance(registry, dict):
|
||||
tool = registry.get(tool_name)
|
||||
else:
|
||||
tool = manager.get_tool(tool_name) # type: ignore[union-attr]
|
||||
if tool is None:
|
||||
raise KeyError(f"tool {tool_name!r} not registered")
|
||||
# FastMCP Tool wraps a callable; our wrappers are sync, so unwrap.
|
||||
fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
|
||||
if fn is None:
|
||||
raise KeyError(f"tool {tool_name!r} has no callable")
|
||||
return fn(**arguments)
|
||||
finally:
|
||||
_TEST_SESSION_ID.reset(token)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Bearer-token auth middleware for the MCP sub-app.
|
||||
|
||||
Mounted on the FastMCP ``streamable_http_app()`` (NOT the console FastAPI),
|
||||
so cookie-session auth on console routes is unaffected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
|
||||
|
||||
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, token_store: McpTokenStore) -> None:
|
||||
super().__init__(app)
|
||||
self._store = token_store
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def]
|
||||
header = request.headers.get("Authorization")
|
||||
if not header or not header.lower().startswith("bearer "):
|
||||
return _unauthorized()
|
||||
presented = header.split(" ", 1)[1].strip()
|
||||
if not self._store.verify(presented):
|
||||
return _unauthorized()
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _unauthorized() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "invalid token"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
@@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; }
|
||||
<a href="/tasks">Tasks</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<a href="/conversations">Conversations</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
|
||||
<button type="submit">Logout</button>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Conversations</h1>
|
||||
{% if not events %}<p>No conversation activity recorded yet.</p>{% endif %}
|
||||
{% for event in events %}
|
||||
<article>
|
||||
<h2>{{ event["event_type"] }} <small>{{ event["occurred_at"] }}</small></h2>
|
||||
{% if event.get("content") %}<pre>{{ event["content"] }}</pre>{% endif %}
|
||||
{% if event.get("thinking") %}<details><summary>LLM reasoning</summary><pre>{{ event["thinking"] }}</pre></details>{% endif %}
|
||||
{% if event.get("tool_calls") %}<details open><summary>Tool calls</summary><pre>{{ event["tool_calls"] | tojson(indent=2) }}</pre></details>{% endif %}
|
||||
{% if event.get("tool_name") %}<p><strong>{{ event["tool_name"] }}</strong></p><pre>{{ event.get("arguments") | tojson(indent=2) }}</pre><pre>{{ event.get("result") | tojson(indent=2) }}</pre>{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,24 @@
|
||||
<p id="current-assignment">{{ assignment_text }}</p>
|
||||
<p id="current-progress">{{ progress_text }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>MCP</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>MCP</td>
|
||||
<td>
|
||||
{% if mcp_endpoint %}
|
||||
endpoint <code>{{ mcp_endpoint }}</code>;
|
||||
{% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %}
|
||||
{% else %}
|
||||
not configured
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
<table>
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th>Screenshot</th><th></th></tr></thead>
|
||||
<tbody>{% for device in devices %}
|
||||
<tr>
|
||||
<td>{{ device["device_id"] }}</td>
|
||||
<td>{{ device["name"] or "" }}</td>
|
||||
<td>{{ device["driver_type"] }}</td>
|
||||
<td>{{ device["cloud_device_id"] or "" }}</td>
|
||||
<td>
|
||||
<button type="button" class="screenshot-button" data-device-id="{{ device["device_id"] }}">Get screenshot</button>
|
||||
<div class="screenshot-preview" data-screenshot-preview hidden>
|
||||
<p class="screenshot-status" data-screenshot-status></p>
|
||||
<img alt="Current screen for {{ device["device_id"] }}" data-screenshot-image hidden>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
|
||||
<form class="inline" method="post" action="/devices/remove">
|
||||
@@ -24,14 +31,173 @@
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
|
||||
<button type="button" id="discover-ios">Scan connected iPhones</button>
|
||||
<p id="discovery-status" class="screenshot-status"></p>
|
||||
<div id="discovered-devices"></div>
|
||||
<form method="post" action="/devices/save">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
|
||||
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
|
||||
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br>
|
||||
<label>Connection info (JSON)<br>
|
||||
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
|
||||
<label>Platform and protocol
|
||||
<select name="driver_type" id="driver-type" required>
|
||||
<option value="wda"{% if not edit_record or edit_record["driver_type"] == "wda" %} selected{% endif %}>iOS - Appium / XCUITest (WDA)</option>
|
||||
<option value="uiautomator2"{% if edit_record and edit_record["driver_type"] == "uiautomator2" %} selected{% endif %}>Android - Appium / UiAutomator2</option>
|
||||
</select>
|
||||
</label><br>
|
||||
<label>Appium server URL <input type="url" name="server_url" value="{{ edit_record["connection_info"].get("server_url", "http://127.0.0.1:4723") if edit_record else "http://127.0.0.1:4723" }}" required></label><br>
|
||||
<label>Device UDID <input type="text" name="udid" value="{{ edit_record["connection_info"].get("udid", "") if edit_record else "" }}" required></label><br>
|
||||
<label>Device name <input type="text" name="device_name" value="{{ edit_record["connection_info"].get("device_name", "") if edit_record else "" }}" placeholder="iPhone"></label><br>
|
||||
<label class="ios-setting">WDA local port <input type="number" min="1" max="65535" name="wda_local_port" value="{{ edit_record["connection_info"].get("wda_local_port", "") if edit_record else "" }}" placeholder="8100"></label>
|
||||
<label class="android-setting">UiAutomator2 system port <input type="number" min="1" max="65535" name="system_port" value="{{ edit_record["connection_info"].get("system_port", "") if edit_record else "" }}" placeholder="8200"></label><br>
|
||||
<details>
|
||||
<summary>Advanced connection capabilities (JSON)</summary>
|
||||
<textarea name="connection_info" rows="4" cols="60">{{ connection_info_json }}</textarea>
|
||||
</details>
|
||||
<p id="connection-test-status" class="screenshot-status"></p>
|
||||
<button type="button" id="test-connection">Test connection</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<style>
|
||||
.screenshot-preview { margin-top: 0.5rem; max-width: 260px; }
|
||||
.screenshot-preview img { display: block; width: 100%; height: auto; border: 1px solid #c8d0d6; }
|
||||
.screenshot-status { margin: 0 0 0.35rem; color: #5e6b73; }
|
||||
.screenshot-status.error { color: #b00020; }
|
||||
</style>
|
||||
<script>
|
||||
(() => {
|
||||
const driverType = document.getElementById("driver-type");
|
||||
const syncPlatformFields = () => {
|
||||
const ios = driverType.value === "wda";
|
||||
document.querySelectorAll(".ios-setting").forEach((el) => { el.hidden = !ios; });
|
||||
document.querySelectorAll(".android-setting").forEach((el) => { el.hidden = ios; });
|
||||
};
|
||||
driverType.addEventListener("change", syncPlatformFields);
|
||||
syncPlatformFields();
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput ? csrfInput.value : "";
|
||||
const form = document.querySelector('form[action="/devices/save"]');
|
||||
const discoverButton = document.getElementById("discover-ios");
|
||||
const discoveryStatus = document.getElementById("discovery-status");
|
||||
const discoveredDevices = document.getElementById("discovered-devices");
|
||||
discoverButton.addEventListener("click", async () => {
|
||||
discoverButton.disabled = true;
|
||||
discoveryStatus.classList.remove("error");
|
||||
discoveryStatus.textContent = "Scanning...";
|
||||
discoveredDevices.replaceChildren();
|
||||
try {
|
||||
const response = await fetch("/api/devices/discover-ios");
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.detail || "Discovery failed.");
|
||||
discoveryStatus.textContent = payload.devices.length
|
||||
? `Found ${payload.devices.length} connected iPhone(s).`
|
||||
: "No connected, paired iPhones found.";
|
||||
payload.devices.forEach((device, index) => {
|
||||
const row = document.createElement("p");
|
||||
const description = document.createElement("span");
|
||||
description.textContent = `${device.name} - ${device.model} - iOS ${device.os_version} (${device.transport}) `;
|
||||
const select = document.createElement("button");
|
||||
select.type = "button";
|
||||
select.textContent = device.configured ? "Already added" : "Use this iPhone";
|
||||
select.disabled = device.configured;
|
||||
select.addEventListener("click", () => {
|
||||
form.elements.driver_type.value = "wda";
|
||||
form.elements.udid.value = device.udid;
|
||||
form.elements.device_name.value = device.name;
|
||||
form.elements.wda_local_port.value = device.suggested_wda_port;
|
||||
form.elements.device_id.value ||= `ios-phone-${index + 1}`;
|
||||
form.elements.name.value ||= device.name;
|
||||
const advanced = JSON.parse(form.elements.connection_info.value || "{}");
|
||||
advanced.mjpegServerPort = device.suggested_mjpeg_port;
|
||||
advanced.derivedDataPath = `/tmp/wda-${device.udid}`;
|
||||
form.elements.connection_info.value = JSON.stringify(advanced, null, 2);
|
||||
syncPlatformFields();
|
||||
form.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
row.append(description, select);
|
||||
discoveredDevices.append(row);
|
||||
});
|
||||
} catch (error) {
|
||||
discoveryStatus.classList.add("error");
|
||||
discoveryStatus.textContent = error.message || "Discovery failed.";
|
||||
} finally {
|
||||
discoverButton.disabled = false;
|
||||
}
|
||||
});
|
||||
const testButton = document.getElementById("test-connection");
|
||||
const testStatus = document.getElementById("connection-test-status");
|
||||
const connectionInfo = () => {
|
||||
const data = new FormData(form);
|
||||
let info = {};
|
||||
const advanced = String(data.get("connection_info") || "{}");
|
||||
info = JSON.parse(advanced);
|
||||
["server_url", "udid", "device_name"].forEach((key) => {
|
||||
const value = String(data.get(key) || "").trim();
|
||||
if (value) info[key] = value;
|
||||
});
|
||||
const portKey = data.get("driver_type") === "wda" ? "wda_local_port" : "system_port";
|
||||
const port = String(data.get(portKey) || "").trim();
|
||||
if (port) info[portKey] = Number(port);
|
||||
return { driver_type: data.get("driver_type"), connection_info: info };
|
||||
};
|
||||
testButton.addEventListener("click", async () => {
|
||||
testButton.disabled = true;
|
||||
testStatus.classList.remove("error");
|
||||
testStatus.textContent = "Testing connection...";
|
||||
try {
|
||||
const response = await fetch("/api/devices/test-connection", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
|
||||
body: JSON.stringify(connectionInfo()),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.detail || "Connection test failed.");
|
||||
testStatus.textContent = "Connection successful.";
|
||||
} catch (error) {
|
||||
testStatus.classList.add("error");
|
||||
testStatus.textContent = error.message || "Connection test failed.";
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll(".screenshot-button").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const deviceId = button.dataset.deviceId;
|
||||
const preview = button.parentElement.querySelector("[data-screenshot-preview]");
|
||||
const status = preview.querySelector("[data-screenshot-status]");
|
||||
const image = preview.querySelector("[data-screenshot-image]");
|
||||
const previousUrl = image.dataset.objectUrl;
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
||||
button.disabled = true;
|
||||
preview.hidden = false;
|
||||
image.hidden = true;
|
||||
status.classList.remove("error");
|
||||
status.textContent = "Capturing...";
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/devices/" + encodeURIComponent(deviceId) + "/screenshot",
|
||||
{ method: "POST", headers: { "X-CSRF-Token": csrfToken } }
|
||||
);
|
||||
if (!response.ok) {
|
||||
let detail = "Screenshot failed.";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload.detail) detail = payload.detail;
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
image.src = objectUrl;
|
||||
image.dataset.objectUrl = objectUrl;
|
||||
image.hidden = false;
|
||||
status.textContent = "Captured.";
|
||||
} catch (error) {
|
||||
status.classList.add("error");
|
||||
status.textContent = error.message || "Screenshot failed.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,259 @@
|
||||
{% 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; position: relative; }
|
||||
.screenshot-frame img { display: block; width: 100%; height: auto; }
|
||||
.overlay-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.overlay-svg .overlay-box { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.overlay-svg .overlay-box.source-ui { stroke: #1e88e5; }
|
||||
.overlay-svg .overlay-box.source-ocr { stroke: #fb8c00; }
|
||||
.overlay-svg .overlay-boxes { display: none; }
|
||||
.overlay-svg.show-boxes .overlay-boxes { display: inline; }
|
||||
.overlay-svg .action-tap { fill: #e53935; fill-opacity: 0.25; stroke: #e53935; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.overlay-svg .action-swipe-line { stroke: #e53935; stroke-width: 3; vector-effect: non-scaling-stroke; fill: none; }
|
||||
.overlay-svg .action-swipe-dot { fill: #e53935; }
|
||||
.overlay-toggle { margin-bottom: 1rem; }
|
||||
.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">← 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>
|
||||
</table>
|
||||
{% if cancel_notice %}<p class="notice" id="cancel-notice">{{ cancel_notice }}</p>{% endif %}
|
||||
{% if cancel_error %}<p class="error" id="cancel-error">{{ cancel_error }}</p>{% endif %}
|
||||
{% if can_cancel %}
|
||||
<form method="post" action="/tasks/{{ task['id'] }}/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p><button type="submit">Cancel task</button></p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<h2>Timeline</h2>
|
||||
{% if not timeline_steps %}
|
||||
<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> — {{ 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>
|
||||
{% endfor %}
|
||||
<p class="overlay-toggle">
|
||||
<label><input type="checkbox" id="overlay-boxes-toggle"> Show OCR/UI-tree bounding boxes on before-action screenshots</label>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for step in timeline_steps %}
|
||||
<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">
|
||||
{% if step.overlay.width and step.overlay.height %}
|
||||
<svg class="overlay-svg" data-step-overlay
|
||||
viewBox="0 0 {{ step.overlay.width }} {{ step.overlay.height }}"
|
||||
preserveAspectRatio="none"></svg>
|
||||
<script type="application/json" class="step-overlay-data">{{ {"overlay": step.overlay, "tool_call": step.tool_call} | tojson }}</script>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<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 %}
|
||||
<script>
|
||||
(function () {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function rect(bounds, className) {
|
||||
const el = document.createElementNS(SVG_NS, "rect");
|
||||
el.setAttribute("x", bounds.x);
|
||||
el.setAttribute("y", bounds.y);
|
||||
el.setAttribute("width", bounds.width);
|
||||
el.setAttribute("height", bounds.height);
|
||||
el.setAttribute("class", className);
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildBoxesGroup(elements) {
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "overlay-boxes");
|
||||
(elements || []).forEach(function (element) {
|
||||
const bounds = element.bounds;
|
||||
if (!bounds) return;
|
||||
const source = element.source === "ui" ? "source-ui" : "source-ocr";
|
||||
const box = rect(bounds, "overlay-box " + source);
|
||||
const label = element.text || element.id || "";
|
||||
if (label) {
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = label;
|
||||
box.appendChild(title);
|
||||
}
|
||||
group.appendChild(box);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildActionGroup(toolCall) {
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "overlay-action");
|
||||
if (!toolCall) return group;
|
||||
const action = toolCall.action;
|
||||
const args = toolCall.args || {};
|
||||
if (action === "tap" && isFinite(args.x) && isFinite(args.y)) {
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", args.x);
|
||||
circle.setAttribute("cy", args.y);
|
||||
circle.setAttribute("r", 14);
|
||||
circle.setAttribute("class", "action-tap");
|
||||
group.appendChild(circle);
|
||||
} else if (
|
||||
action === "swipe" &&
|
||||
isFinite(args.start_x) &&
|
||||
isFinite(args.start_y) &&
|
||||
isFinite(args.end_x) &&
|
||||
isFinite(args.end_y)
|
||||
) {
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", args.start_x);
|
||||
line.setAttribute("y1", args.start_y);
|
||||
line.setAttribute("x2", args.end_x);
|
||||
line.setAttribute("y2", args.end_y);
|
||||
line.setAttribute("class", "action-swipe-line");
|
||||
group.appendChild(line);
|
||||
|
||||
const dot = document.createElementNS(SVG_NS, "circle");
|
||||
dot.setAttribute("r", 8);
|
||||
dot.setAttribute("class", "action-swipe-dot");
|
||||
const motion = document.createElementNS(SVG_NS, "animateMotion");
|
||||
motion.setAttribute("dur", "1.2s");
|
||||
motion.setAttribute("repeatCount", "indefinite");
|
||||
motion.setAttribute(
|
||||
"path",
|
||||
"M" + args.start_x + "," + args.start_y + " L" + args.end_x + "," + args.end_y
|
||||
);
|
||||
dot.appendChild(motion);
|
||||
group.appendChild(dot);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
document.querySelectorAll("svg.overlay-svg[data-step-overlay]").forEach(function (svg) {
|
||||
const dataScript = svg.nextElementSibling;
|
||||
if (!dataScript || !dataScript.classList.contains("step-overlay-data")) return;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(dataScript.textContent);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
svg.appendChild(buildBoxesGroup(payload.overlay && payload.overlay.elements));
|
||||
svg.appendChild(buildActionGroup(payload.tool_call));
|
||||
});
|
||||
|
||||
const toggle = document.getElementById("overlay-boxes-toggle");
|
||||
if (toggle) {
|
||||
const STORAGE_KEY = "task-detail-overlay-boxes-visible";
|
||||
|
||||
// 页面加载时恢复之前的选择
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedState !== null) {
|
||||
const shouldShow = savedState === "true";
|
||||
toggle.checked = shouldShow;
|
||||
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
|
||||
svg.classList.toggle("show-boxes", shouldShow);
|
||||
});
|
||||
}
|
||||
|
||||
toggle.addEventListener("change", function () {
|
||||
const isChecked = toggle.checked;
|
||||
// 保存状态到 localStorage
|
||||
localStorage.setItem(STORAGE_KEY, String(isChecked));
|
||||
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
|
||||
svg.classList.toggle("show-boxes", isChecked);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Tasks</h1>
|
||||
{% if not tasks %}
|
||||
<p>No tasks recorded.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Task ID</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("status") or "" }}</td>
|
||||
<td>{{ task.get("device_id") or "" }}</td>
|
||||
<td>{{ task.get("created_at") or "" }}</td>
|
||||
<td>{{ task.get("updated_at") or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<section id="task-submission">
|
||||
<h2>Submit task to current Host</h2>
|
||||
{% if submission_available %}
|
||||
<form method="post" action="/tasks/submit">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>
|
||||
<label for="goal">Goal</label><br>
|
||||
<textarea id="goal" name="goal" rows="4" cols="60" required>{{ goal_value }}</textarea>
|
||||
</p>
|
||||
<p>
|
||||
<label for="device_id">Target device</label><br>
|
||||
<select id="device_id" name="device_id">
|
||||
<option value="{{ automatic_device_value }}"{% if selected_device == automatic_device_value %} selected{% endif %}>Automatic (let Host choose an eligible device)</option>
|
||||
{% for device in devices %}<option value="{{ device.id }}"{% if selected_device == device.id %} selected{% endif %}>{{ device.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</p>
|
||||
{% if submission_error %}<p class="error" id="submission-error">{{ submission_error }}</p>{% endif %}
|
||||
{% if submission_unknown %}<p class="error" id="submission-unknown">Submission outcome is unknown. The task may have been queued. Check the Cloud console before submitting again.</p>{% endif %}
|
||||
{% if submission_notice %}<p class="notice" id="submission-notice">{{ submission_notice }}</p>{% endif %}
|
||||
<p><button type="submit">Submit task</button></p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="error" id="submission-unavailable">Host submission client is not available yet. Wait for Host enrollment to complete, then refresh.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section id="local-tasks">
|
||||
<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 executions recorded yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<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>
|
||||
<td>{{ task.get("updated_at") or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "device-host-agent"
|
||||
version = "0.1.0"
|
||||
description = "Outbound device-host worker for the Device Cloud Platform."
|
||||
requires-python = ">=3.14"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = [
|
||||
"device-agent-runtime==0.1.0",
|
||||
"device-cloud-platform==0.1.0",
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"filelock>=3.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1",
|
||||
"mcp>=1.28,<2",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _humanize_disabled_by_default_in_tests(monkeypatch):
|
||||
"""Humanize defaults ON in production; tests default it OFF so existing
|
||||
exact-coordinate assertions stay deterministic. Tests that want to
|
||||
exercise humanize call ``monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "true")``
|
||||
in their own body, which overrides this fixture (test body runs after
|
||||
fixture setup)."""
|
||||
monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "false")
|
||||
@@ -193,6 +193,9 @@ def make_task_detail_context(
|
||||
task: dict[str, Any] | None = None,
|
||||
task_rows: list[tuple[str, Any]] | None = None,
|
||||
timeline_steps: list[dict[str, Any]] | None = None,
|
||||
can_cancel: bool = False,
|
||||
cancel_notice: str | None = None,
|
||||
cancel_error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if task is None:
|
||||
task = {
|
||||
@@ -216,15 +219,22 @@ def make_task_detail_context(
|
||||
"index": 0,
|
||||
"timestamp": "2026-01-01T00:01:00Z",
|
||||
"prompt": "Tap the Settings icon",
|
||||
"tool_call_text": '{"action": "tap", "x": 100, "y": 200}',
|
||||
"result_text": '{"ok": true}',
|
||||
"screenshot_src": None,
|
||||
"tool_call": {"action": "tap", "x": 100, "y": 200},
|
||||
"result": {"ok": True},
|
||||
"before_screenshot_src": None,
|
||||
"after_screenshot_src": None,
|
||||
"ocr_results": [],
|
||||
"ui_tree_nodes": [],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "Task task-001",
|
||||
"session": session,
|
||||
"csrf_token": session.csrf_token,
|
||||
"task": task,
|
||||
"task_rows": task_rows,
|
||||
"timeline_steps": timeline_steps,
|
||||
"can_cancel": can_cancel,
|
||||
"cancel_notice": cancel_notice,
|
||||
"cancel_error": cancel_error,
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ def test_devices_renders(env, sample_session) -> None:
|
||||
**make_devices_context(sample_session)
|
||||
)
|
||||
assert '<form method="post" action="/devices/save">' in html
|
||||
assert 'class="screenshot-button"' in html
|
||||
|
||||
|
||||
def test_account_renders(env, sample_session) -> None:
|
||||
@@ -77,6 +78,43 @@ def test_task_detail_renders(env, sample_session) -> None:
|
||||
assert "<h2>Timeline</h2>" in html
|
||||
|
||||
|
||||
def test_task_detail_shows_cancel_button_for_non_terminal_task(
|
||||
env, sample_session
|
||||
) -> None:
|
||||
html = env.get_template("task_detail.html").render(
|
||||
**make_task_detail_context(sample_session, can_cancel=True)
|
||||
)
|
||||
assert 'action="/tasks/task-001/cancel"' in html
|
||||
assert "Cancel task" in html
|
||||
|
||||
|
||||
def test_task_detail_hides_cancel_button_for_terminal_task(
|
||||
env, sample_session
|
||||
) -> None:
|
||||
html = env.get_template("task_detail.html").render(
|
||||
**make_task_detail_context(sample_session, can_cancel=False)
|
||||
)
|
||||
assert 'action="/tasks/task-001/cancel"' not in html
|
||||
|
||||
|
||||
def test_task_detail_renders_cancel_notice_and_error(env, sample_session) -> None:
|
||||
html = env.get_template("task_detail.html").render(
|
||||
**make_task_detail_context(
|
||||
sample_session,
|
||||
cancel_notice="Cancellation requested. It may take a moment to take effect.",
|
||||
)
|
||||
)
|
||||
assert 'id="cancel-notice"' in html
|
||||
|
||||
html = env.get_template("task_detail.html").render(
|
||||
**make_task_detail_context(
|
||||
sample_session,
|
||||
cancel_error="Failed to request cancellation. Try again.",
|
||||
)
|
||||
)
|
||||
assert 'id="cancel-error"' in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
|
||||
# string fields set to <script>alert(1)</script>)
|
||||
@@ -170,9 +208,12 @@ def _xss_context(template: str, session: SessionState) -> dict[str, Any]:
|
||||
"index": 0,
|
||||
"timestamp": XSS_PROBE,
|
||||
"prompt": XSS_PROBE,
|
||||
"tool_call_text": XSS_PROBE,
|
||||
"result_text": XSS_PROBE,
|
||||
"screenshot_src": None,
|
||||
"tool_call": {"action": XSS_PROBE, "description": XSS_PROBE},
|
||||
"result": {"ok": XSS_PROBE},
|
||||
"before_screenshot_src": None,
|
||||
"after_screenshot_src": None,
|
||||
"ocr_results": [],
|
||||
"ui_tree_nodes": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from cloud.internal_api.models import (
|
||||
AssignmentModel,
|
||||
@@ -15,10 +16,23 @@ from cloud.internal_api.models import (
|
||||
)
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.app import HostAgentApplication, create_application
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.execution import create_execution_factories
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.app import create_console_app
|
||||
from host_agent.web.auth import SessionManager
|
||||
from host_agent.web.mcp import build_mcp_server
|
||||
from storage.artifact_store import ArtifactStore
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
|
||||
|
||||
def _free_loopback_port() -> int:
|
||||
@@ -653,6 +667,77 @@ def test_create_application_with_independent_identity_paths_coexist(
|
||||
asyncio.run(app_a.client.aclose())
|
||||
|
||||
|
||||
def test_create_application_wires_mcp_components(tmp_path, monkeypatch) -> None:
|
||||
"""create_application produces a console app with /mcp mounted (auth-protected)
|
||||
and persists the host_mcp_token.json file alongside the identity."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = _config()
|
||||
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
|
||||
identity_store = HostIdentityStore(config.identity_path)
|
||||
history_store = ConsoleHistoryStore(
|
||||
tmp_path / "host_console_history.sqlite3",
|
||||
limit=config.console_history_limit,
|
||||
)
|
||||
metadata_store = TaskMetadataStore(db_path=config.task_progress_db_path)
|
||||
timeline = Timeline(ArtifactStore(root=config.task_artifact_dir))
|
||||
status_tracker = AgentStatusTracker()
|
||||
|
||||
application = create_application(
|
||||
config=config,
|
||||
device_config_store=config_store,
|
||||
identity_store=identity_store,
|
||||
manager=DeviceManager(),
|
||||
)
|
||||
|
||||
# Token file must exist after create_application.
|
||||
assert (config.identity_path.parent / "host_mcp_token.json").exists()
|
||||
|
||||
# Heartbeat must hold the in-process McpBusyTracker.
|
||||
assert application.heartbeat.mcp_busy_tracker is not None
|
||||
|
||||
# Build the same console app the production path builds and verify /mcp
|
||||
# is mounted (responds 401, not 404) without a bearer token.
|
||||
mcp_token_store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
|
||||
mcp_token_store.load_or_create()
|
||||
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
|
||||
mcp_server = build_mcp_server(
|
||||
manager=application.heartbeat.manager,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
status_tracker=status_tracker,
|
||||
)
|
||||
console_app = create_console_app(
|
||||
config=config,
|
||||
manager=application.heartbeat.manager,
|
||||
config_store=config_store,
|
||||
local_account_store=LocalAccountStore(config.local_account_path),
|
||||
identity_store=identity_store,
|
||||
history_store=history_store,
|
||||
status_tracker=status_tracker,
|
||||
session_manager=SessionManager(ttl_seconds=config.console_session_ttl_seconds),
|
||||
enrollment_client=None,
|
||||
host_client=application.client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
executor=AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
application.heartbeat.manager,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
host_agent_config=config,
|
||||
),
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
),
|
||||
mcp_server=mcp_server,
|
||||
mcp_token_store=mcp_token_store,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
)
|
||||
with TestClient(console_app) as client:
|
||||
resp = client.post("/mcp/")
|
||||
assert resp.status_code == 401 # auth required, not 404
|
||||
|
||||
asyncio.run(application.client.aclose())
|
||||
|
||||
|
||||
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
identity_path = tmp_path / "host_identity.json"
|
||||
|
||||
@@ -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:
|
||||
@@ -65,6 +78,39 @@ def test_goal_assignment_preserves_runtime_failure_reason() -> None:
|
||||
assert result.failure_reason == "planner unavailable"
|
||||
|
||||
|
||||
def test_goal_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
|
||||
# First should_stop() call is Executor.execute()'s pre-flight check (must pass
|
||||
# through so the runner is actually invoked); the runner's own loop then stops.
|
||||
calls = {"count": 0}
|
||||
|
||||
def should_stop() -> bool:
|
||||
calls["count"] += 1
|
||||
return calls["count"] > 1
|
||||
|
||||
class FakeTaskRunner:
|
||||
def run(self, task: Task, *, should_stop=None, stop_reason=None) -> Task:
|
||||
assert should_stop is not None and should_stop()
|
||||
assert stop_reason is not None
|
||||
task.status = "cancelled"
|
||||
task.failure_reason = stop_reason()
|
||||
return task
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
result = AssignmentExecutor(factories).execute(
|
||||
_assignment(),
|
||||
should_stop=should_stop,
|
||||
stop_reason=lambda: "cancellation requested by control plane",
|
||||
)
|
||||
|
||||
assert result.status == "cancelled"
|
||||
assert result.failure_reason == "cancellation requested by control plane"
|
||||
|
||||
|
||||
def test_workflow_assignment_loads_and_executes_definition() -> None:
|
||||
definition = object()
|
||||
calls: list[tuple[object, str]] = []
|
||||
@@ -96,6 +142,108 @@ def test_workflow_assignment_loads_and_executes_definition() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
|
||||
calls = {"count": 0}
|
||||
|
||||
def should_stop() -> bool:
|
||||
calls["count"] += 1
|
||||
return calls["count"] > 1
|
||||
|
||||
class FakeWorkflowStore:
|
||||
def get_definition(self, definition_id: str):
|
||||
return object() if definition_id == "workflow-a" else None
|
||||
|
||||
class FakeWorkflowRunner:
|
||||
def run(
|
||||
self,
|
||||
loaded_definition,
|
||||
device_id: str,
|
||||
*,
|
||||
should_stop=None,
|
||||
stop_reason=None,
|
||||
):
|
||||
assert should_stop is not None and should_stop()
|
||||
assert stop_reason is not None
|
||||
return SimpleNamespace(
|
||||
id="run-a", status="cancelled", failure_reason=stop_reason()
|
||||
)
|
||||
|
||||
factories = ExecutionFactories(
|
||||
task_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
|
||||
workflow_runner_factory=lambda: FakeWorkflowRunner(), # type: ignore[arg-type,return-value]
|
||||
workflow_store=FakeWorkflowStore(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = AssignmentExecutor(factories).execute(
|
||||
_assignment(goal=None, workflow_definition_id="workflow-a"),
|
||||
should_stop=should_stop,
|
||||
stop_reason=lambda: "cancellation requested by control plane",
|
||||
)
|
||||
|
||||
assert result.status == "cancelled"
|
||||
assert result.metadata == {
|
||||
"workflow_run_id": "run-a",
|
||||
"workflow_status": "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def test_execute_fails_fast_when_mcp_session_holds_device() -> None:
|
||||
"""Cloud assignment arriving for a device currently held by an MCP
|
||||
session must fail immediately rather than fight for the device."""
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
|
||||
tracker = McpBusyTracker()
|
||||
tracker.acquire("phone-1", "sess-mcp")
|
||||
executor = AssignmentExecutor(
|
||||
_build_factories(),
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
assignment = _assignment(device_id="phone-1")
|
||||
result = executor.execute(assignment)
|
||||
assert result.status == "failed"
|
||||
assert "MCP" in (result.failure_reason or "")
|
||||
|
||||
|
||||
def test_execute_skips_check_when_tracker_is_none() -> None:
|
||||
"""Default backward-compat: no tracker → no fail-fast."""
|
||||
executor = AssignmentExecutor(_build_factories())
|
||||
# Without a real workflow store / task runner this test verifies the
|
||||
# entry-point path doesn't raise on the mcp_busy check.
|
||||
# We use a goal + a mock runner factory so execute() runs through.
|
||||
assignment = _assignment()
|
||||
result = executor.execute(assignment)
|
||||
# Should run through normally (not fail on MCP check)
|
||||
assert result.status == "done"
|
||||
|
||||
|
||||
def _build_factories() -> ExecutionFactories:
|
||||
"""Shared factory fixture used by MCP-hold tests."""
|
||||
received: list[Task] = []
|
||||
|
||||
class FakeTaskRunner:
|
||||
def run(self, task: Task) -> Task:
|
||||
received.append(task)
|
||||
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:
|
||||
pass
|
||||
|
||||
return 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]
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_workflow_fails_without_running() -> None:
|
||||
class FakeWorkflowStore:
|
||||
def get_definition(self, definition_id: str):
|
||||
|
||||
@@ -146,3 +146,21 @@ def test_duplicate_instance_exits_with_clear_error(
|
||||
err = capsys.readouterr().err
|
||||
assert "another Host Agent instance" in err
|
||||
assert str(lock_path) in err
|
||||
|
||||
|
||||
def test_mcp_token_subcommand_prints_token(tmp_path, capsys, monkeypatch) -> None:
|
||||
monkeypatch.setenv("HOST_AGENT_IDENTITY_PATH", str(tmp_path / "host_identity.json"))
|
||||
monkeypatch.setenv(
|
||||
"HOST_AGENT_LOCAL_ACCOUNT_PATH", str(tmp_path / "host_local_account.json")
|
||||
)
|
||||
# Also set control plane URL to satisfy config loading
|
||||
monkeypatch.setenv("HOST_AGENT_CONTROL_PLANE_URL", "https://cloud.example")
|
||||
from host_agent.cli import main
|
||||
|
||||
main(["mcp-token"])
|
||||
out = capsys.readouterr().out.strip()
|
||||
assert len(out) >= 40 # token is ~43 chars
|
||||
# Subsequent invocation prints the same token (idempotent).
|
||||
main(["mcp-token"])
|
||||
out2 = capsys.readouterr().out.strip()
|
||||
assert out == out2
|
||||
|
||||
@@ -11,6 +11,8 @@ from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
|
||||
from host_agent.client import (
|
||||
HostAgentClient,
|
||||
HostAgentEnrollmentClient,
|
||||
HostAgentAPIError,
|
||||
HostTaskSubmissionUnknownError,
|
||||
StaleLeaseError,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
@@ -138,6 +140,30 @@ def test_stale_lease_response_raises_typed_error_without_retry() -> None:
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_renew_deserializes_cancel_requested_flag() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "renewed",
|
||||
"lease_expires_at": "2026-07-12T00:05:00Z",
|
||||
"cancel_requested": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
response = await client.renew(_assignment())
|
||||
assert response.status == "renewed"
|
||||
assert response.cancel_requested is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_result_report_retries_identical_payload_after_response_loss() -> None:
|
||||
payloads: list[dict[str, object]] = []
|
||||
|
||||
@@ -172,6 +198,215 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None:
|
||||
assert payloads[0]["failure_reason"] == "planner unavailable"
|
||||
|
||||
|
||||
def test_submit_self_task_posts_once_and_returns_task_id() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"task_id": "task-cloud-1"})
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
response = await client.submit_self_task(
|
||||
goal="open settings",
|
||||
device_id=None,
|
||||
)
|
||||
|
||||
assert response.task_id == "task-cloud-1"
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/internal/v1/hosts/host-a/tasks"
|
||||
assert requests[0].headers["authorization"] == "Bearer host-secret"
|
||||
payload = json.loads(requests[0].content)
|
||||
assert payload == {
|
||||
"host_id": "host-a",
|
||||
"goal": "open settings",
|
||||
"device_id": None,
|
||||
}
|
||||
|
||||
|
||||
def test_submit_self_task_does_not_retry_transport_failure() -> None:
|
||||
attempts = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ConnectError("network down", request=request)
|
||||
|
||||
async def sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_submit_self_task_treats_5xx_as_unknown_outcome_without_retry() -> None:
|
||||
attempts = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(502, json={"detail": "bad gateway"})
|
||||
|
||||
async def sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_submit_self_task_raises_definitive_error_on_4xx_rejection() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(
|
||||
403,
|
||||
json={"detail": "Host self-submission is disabled"},
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
with pytest.raises(HostAgentAPIError) as error:
|
||||
await client.submit_self_task(goal="open settings")
|
||||
assert "host-secret" not in str(error.value)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_submit_self_task_treats_malformed_success_as_unknown() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(201, json={"unexpected": "shape"})
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_submit_self_task_does_not_duplicate_when_response_is_lost() -> None:
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ReadError("response lost", request=request)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(
|
||||
_config(),
|
||||
http_client=http_client,
|
||||
sleep=lambda delay: asyncio.sleep(0),
|
||||
)
|
||||
with pytest.raises(HostTaskSubmissionUnknownError):
|
||||
await client.submit_self_task(goal="open settings")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
def test_heartbeat_includes_mcp_busy_device_ids_in_payload() -> None:
|
||||
"""When mcp_busy_device_ids is passed, the client sends it in the request."""
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"host_id": "host-a",
|
||||
"accepted_devices": 0,
|
||||
"received_at": "2026-07-12T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
await client.heartbeat([], mcp_busy_device_ids=["phone-1"])
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert captured[0]["mcp_busy_device_ids"] == ["phone-1"]
|
||||
|
||||
|
||||
def test_heartbeat_omits_mcp_busy_device_ids_when_empty() -> None:
|
||||
"""Empty list is omitted from the payload (backward compatible)."""
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"host_id": "host-a",
|
||||
"accepted_devices": 0,
|
||||
"received_at": "2026-07-12T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler),
|
||||
base_url="https://control.example",
|
||||
) as http_client:
|
||||
client = HostAgentClient(_config(), http_client=http_client)
|
||||
await client.heartbeat([], mcp_busy_device_ids=[])
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert "mcp_busy_device_ids" not in captured[0]
|
||||
|
||||
|
||||
def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
host_attempts = 0
|
||||
|
||||
@@ -5,7 +5,10 @@ import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
||||
from host_agent.cloud_planner_client import (
|
||||
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
|
||||
CloudProxyToolCallingClient,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.planner_context import PlannerExecutionContext, _context
|
||||
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
|
||||
@@ -33,7 +36,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
|
||||
seen_requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"tool_name": "tap", "arguments": {"x": 1, "y": 2}},
|
||||
json={
|
||||
"tool_name": "tap",
|
||||
"arguments": {"x": 1, "y": 2},
|
||||
"rationale": "The button is visible. Opening it.",
|
||||
"thinking": "A tap should navigate to the next page.",
|
||||
"purpose": "Open the next page.",
|
||||
"expected_outcome": "The next page is visible.",
|
||||
},
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
@@ -46,7 +56,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
assert decision == ToolCallDecision(
|
||||
tool_name="tap",
|
||||
arguments={"x": 1, "y": 2},
|
||||
text_output="The button is visible. Opening it.",
|
||||
thinking="A tap should navigate to the next page.",
|
||||
purpose="Open the next page.",
|
||||
expected_outcome="The next page is visible.",
|
||||
)
|
||||
assert len(seen_requests) == 1
|
||||
request = seen_requests[0]
|
||||
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
|
||||
@@ -79,6 +96,59 @@ def test_decide_base64_encodes_screenshot() -> None:
|
||||
assert body["screenshot_base64"] == "aGVsbG8="
|
||||
|
||||
|
||||
def test_decide_forwards_planner_history() -> None:
|
||||
seen_requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_requests.append(request)
|
||||
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
|
||||
|
||||
client = _client(handler)
|
||||
history = [
|
||||
{
|
||||
"user_prompt": "first screen",
|
||||
"tool_name": "tap",
|
||||
"arguments": {"x": 1, "y": 2},
|
||||
"rationale": "Open it.",
|
||||
"tool_result": {"success": True},
|
||||
}
|
||||
]
|
||||
|
||||
client.decide(
|
||||
system_prompt="sp",
|
||||
user_prompt="next screen",
|
||||
screenshot=None,
|
||||
tools=_TOOLS,
|
||||
timeout=10.0,
|
||||
history=history,
|
||||
)
|
||||
|
||||
assert json.loads(seen_requests[0].content)["history"] == history
|
||||
|
||||
|
||||
def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None:
|
||||
seen_requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_requests.append(request)
|
||||
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
|
||||
|
||||
client = _client(handler)
|
||||
|
||||
client.decide(
|
||||
system_prompt="sp",
|
||||
user_prompt="up",
|
||||
screenshot=None,
|
||||
tools=_TOOLS,
|
||||
timeout=180.0,
|
||||
)
|
||||
|
||||
request = seen_requests[0]
|
||||
body = json.loads(request.content)
|
||||
assert body["timeout_seconds"] == 120.0
|
||||
assert request.extensions["timeout"]["read"] == _CLOUD_PROXY_HTTP_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_decide_includes_bound_assignment_context() -> None:
|
||||
seen_requests: list[httpx.Request] = []
|
||||
|
||||
|
||||
@@ -11,11 +11,31 @@ from host_agent.config import (
|
||||
)
|
||||
|
||||
|
||||
def test_load_host_agent_config_uses_managed_cloud_default() -> None:
|
||||
assert load_host_agent_config({}) == HostAgentConfig(
|
||||
def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
|
||||
config = load_host_agent_config({})
|
||||
|
||||
assert config == HostAgentConfig(
|
||||
control_plane_url="https://amcp.home.jerryyan.top",
|
||||
enrollment_managed=True,
|
||||
)
|
||||
assert config.ai_planner_transport == "cloud"
|
||||
|
||||
|
||||
def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> None:
|
||||
config = load_host_agent_config({"AI_PLANNER_TRANSPORT": "direct"})
|
||||
|
||||
assert config.ai_planner_transport == "direct"
|
||||
|
||||
|
||||
def test_load_host_agent_config_supports_local_mode() -> None:
|
||||
config = load_host_agent_config({"HOST_AGENT_MODE": "local"})
|
||||
|
||||
assert config.mode == "local"
|
||||
assert config.control_plane_url == ""
|
||||
assert config.enrollment_managed is False
|
||||
assert config.ai_planner_transport == "direct"
|
||||
assert config.dependency_supervisor_enabled is True
|
||||
assert config.appium_supervised is True
|
||||
|
||||
|
||||
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
|
||||
@@ -182,9 +202,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
|
||||
|
||||
|
||||
@@ -195,9 +212,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",
|
||||
}
|
||||
)
|
||||
@@ -206,9 +220,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
|
||||
|
||||
|
||||
@@ -216,7 +227,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"},
|
||||
],
|
||||
)
|
||||
@@ -225,3 +235,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
|
||||
|
||||
@@ -47,6 +47,9 @@ class FakeDriver(Driver):
|
||||
def tap(self, x: float, y: float) -> None:
|
||||
self.calls.append(("tap", (x, y)))
|
||||
|
||||
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||
return None
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
start_x: float,
|
||||
@@ -57,6 +60,14 @@ class FakeDriver(Driver):
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def swipe_path(
|
||||
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||
return None
|
||||
|
||||
def input(self, text: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from driver.base import Driver
|
||||
from host_agent import execution
|
||||
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.execution import create_execution_factories
|
||||
@@ -15,6 +17,65 @@ from workflow.runner import WorkflowRunner
|
||||
from workflow.store import WorkflowStore
|
||||
|
||||
|
||||
class FakeDriver(Driver):
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
def connect(self) -> None:
|
||||
self.calls.append(("connect", ()))
|
||||
|
||||
def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
return b"fake-screenshot-bytes"
|
||||
|
||||
def tap(self, x: float, y: float) -> None:
|
||||
return None
|
||||
|
||||
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||
return None
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
duration_ms: int = 500,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def swipe_path(
|
||||
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||
return None
|
||||
|
||||
def input(self, text: str) -> None:
|
||||
return None
|
||||
|
||||
def launch(self, app_id: str) -> None:
|
||||
return None
|
||||
|
||||
def terminate(self, app_id: str) -> None:
|
||||
return None
|
||||
|
||||
def tree(self):
|
||||
return None
|
||||
|
||||
def home(self) -> None:
|
||||
return None
|
||||
|
||||
def lock(self) -> None:
|
||||
return None
|
||||
|
||||
def unlock(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) -> None:
|
||||
manager = DeviceManager()
|
||||
workflow_store = WorkflowStore(tmp_path / "workflows.sqlite3")
|
||||
@@ -32,6 +93,72 @@ def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) ->
|
||||
assert isinstance(workflow_runner.task_runner_factory(), TaskRunner)
|
||||
|
||||
|
||||
def test_created_task_runner_screenshot_provider_uses_configured_manager(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Regression test: `create_task_runner()` must thread the Host Agent's
|
||||
own `manager` into `screenshot_provider`. Omitting `manager=` makes the
|
||||
tool fall back to the process-global `DEFAULT_MANAGER` singleton, which
|
||||
never has this device registered, so it raises `DeviceNotFoundError` even
|
||||
though the device is connected on the manager actually in use.
|
||||
"""
|
||||
manager = DeviceManager()
|
||||
manager.register_device("phone-1", lambda: FakeDriver())
|
||||
manager.connect("phone-1")
|
||||
factories = create_execution_factories(
|
||||
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
|
||||
)
|
||||
|
||||
task_runner = factories.task_runner_factory()
|
||||
|
||||
assert task_runner.screenshot_provider("phone-1") == b"fake-screenshot-bytes"
|
||||
|
||||
|
||||
def test_created_task_runner_observer_uses_configured_manager(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression test: `create_task_runner()` must thread the Host Agent's
|
||||
own `manager` into `observer` the same way it does for
|
||||
`screenshot_provider` -- see the test above for the failure mode this
|
||||
guards against.
|
||||
"""
|
||||
manager = DeviceManager()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_describe_screen(device_id, *, manager=None):
|
||||
seen["device_id"] = device_id
|
||||
seen["manager"] = manager
|
||||
return "scene-stub"
|
||||
|
||||
monkeypatch.setattr(execution, "describe_screen", fake_describe_screen)
|
||||
factories = create_execution_factories(
|
||||
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
|
||||
)
|
||||
|
||||
task_runner = factories.task_runner_factory()
|
||||
|
||||
assert task_runner.observer("phone-1") == "scene-stub"
|
||||
assert seen == {"device_id": "phone-1", "manager": manager}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("driver_type", "expected_platform"),
|
||||
[("wda", "ios"), ("uiautomator2", "android")],
|
||||
)
|
||||
def test_created_task_runner_resolves_platform_from_configured_driver(
|
||||
tmp_path, driver_type: str, expected_platform: str
|
||||
) -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device("phone-1", lambda: FakeDriver(), driver_type=driver_type)
|
||||
factories = create_execution_factories(
|
||||
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
|
||||
)
|
||||
task_runner = factories.task_runner_factory()
|
||||
|
||||
assert task_runner.device_platform_provider is not None
|
||||
assert task_runner.device_platform_provider("phone-1") == expected_platform
|
||||
|
||||
|
||||
def test_created_task_runner_defaults_to_ai_planner(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -60,8 +187,9 @@ def test_created_task_runner_honors_explicit_ai_planner_opt_out(
|
||||
assert type(task_runner.planner) is Planner
|
||||
|
||||
|
||||
def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
@pytest.mark.parametrize("transport", [None, "cloud"])
|
||||
def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
|
||||
) -> None:
|
||||
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
|
||||
manager = DeviceManager()
|
||||
@@ -69,7 +197,7 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
|
||||
control_plane_url="https://control-plane.example",
|
||||
host_id="host-a",
|
||||
token="token-a",
|
||||
ai_planner_transport="cloud",
|
||||
**({} if transport is None else {"ai_planner_transport": transport}),
|
||||
)
|
||||
factories = create_execution_factories(
|
||||
manager,
|
||||
@@ -84,9 +212,8 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
|
||||
assert task_runner.planner.client.config is host_agent_config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transport", [None, "direct"])
|
||||
def test_direct_transport_preserves_existing_local_provider_construction(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
|
||||
def test_explicit_direct_transport_preserves_local_provider_construction(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
|
||||
manager = DeviceManager()
|
||||
@@ -94,7 +221,7 @@ def test_direct_transport_preserves_existing_local_provider_construction(
|
||||
control_plane_url="https://control-plane.example",
|
||||
host_id="host-a",
|
||||
token="token-a",
|
||||
**({} if transport is None else {"ai_planner_transport": transport}),
|
||||
ai_planner_transport="direct",
|
||||
)
|
||||
factories = create_execution_factories(
|
||||
manager,
|
||||
|
||||
@@ -8,6 +8,7 @@ from cloud.internal_api.models import HostGovernancePolicyModel
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
|
||||
@@ -16,6 +17,9 @@ class ConnectableDriver:
|
||||
def connect(self) -> None:
|
||||
return None
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
return b"ok"
|
||||
|
||||
|
||||
def _config() -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
@@ -60,7 +64,9 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
|
||||
calls: list[list[str]] = []
|
||||
|
||||
class FakeClient:
|
||||
async def heartbeat(self, devices, *, address=None, policy_revision=0):
|
||||
async def heartbeat(
|
||||
self, devices, *, address=None, policy_revision=0, **kwargs
|
||||
):
|
||||
calls.append([device.device_id for device in devices])
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
@@ -86,6 +92,33 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
|
||||
assert manager.status("device-a") == "busy"
|
||||
|
||||
|
||||
def test_offline_device_is_retried_on_next_heartbeat_cycle() -> None:
|
||||
class FlakyDriver(ConnectableDriver):
|
||||
def __init__(self, fail: bool) -> None:
|
||||
self.fail = fail
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
if self.fail:
|
||||
raise RuntimeError("WDA disconnected")
|
||||
return b"ok"
|
||||
|
||||
instances: list[FlakyDriver] = []
|
||||
|
||||
def factory() -> FlakyDriver:
|
||||
driver = FlakyDriver(not instances)
|
||||
instances.append(driver)
|
||||
return driver
|
||||
|
||||
manager = DeviceManager()
|
||||
manager.register_device("device-a", factory) # type: ignore[arg-type]
|
||||
manager.connect("device-a")
|
||||
sync = HeartbeatSynchronizer(manager, object(), _config()) # type: ignore[arg-type]
|
||||
sync.probe_connected_devices()
|
||||
assert manager.status("device-a") == "offline"
|
||||
sync.connect_devices()
|
||||
assert manager.status("device-a") == "busy"
|
||||
|
||||
|
||||
def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
@@ -98,7 +131,9 @@ def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> No
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
async def heartbeat(self, devices, *, address=None, policy_revision=0):
|
||||
async def heartbeat(
|
||||
self, devices, *, address=None, policy_revision=0, **kwargs
|
||||
):
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
accepted_devices=len(devices),
|
||||
@@ -133,7 +168,9 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
|
||||
revisions: list[int] = []
|
||||
|
||||
class UpdatingClient:
|
||||
async def heartbeat(self, devices, *, address=None, policy_revision=0):
|
||||
async def heartbeat(
|
||||
self, devices, *, address=None, policy_revision=0, **kwargs
|
||||
):
|
||||
revisions.append(policy_revision)
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
@@ -175,6 +212,63 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert revisions == [0]
|
||||
assert '"token":' not in (
|
||||
tmp_path / "host_policy.json"
|
||||
).read_text(encoding="utf-8")
|
||||
assert '"token":' not in (tmp_path / "host_policy.json").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_sync_once_passes_mcp_busy_device_ids_to_client() -> None:
|
||||
"""When mcp_busy_tracker has a lease, sync_once relays the device_ids."""
|
||||
manager = DeviceManager()
|
||||
tracker = McpBusyTracker()
|
||||
assert tracker.acquire("phone-1", "sess-a")
|
||||
last_kwargs: dict[str, object] = {}
|
||||
|
||||
class FakeClient:
|
||||
async def heartbeat(
|
||||
self, devices, *, address=None, policy_revision=0, **kwargs
|
||||
):
|
||||
last_kwargs.update(kwargs)
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
accepted_devices=len(devices),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
sync = HeartbeatSynchronizer(
|
||||
manager,
|
||||
FakeClient(), # type: ignore[arg-type]
|
||||
_config(),
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
await sync.sync_once()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert last_kwargs.get("mcp_busy_device_ids") == ["phone-1"]
|
||||
|
||||
|
||||
def test_sync_once_passes_empty_when_tracker_is_none() -> None:
|
||||
"""Default: no tracker → no busy device ids forwarded."""
|
||||
manager = DeviceManager()
|
||||
last_kwargs: dict[str, object] = {}
|
||||
|
||||
class FakeClient:
|
||||
async def heartbeat(
|
||||
self, devices, *, address=None, policy_revision=0, **kwargs
|
||||
):
|
||||
last_kwargs.update(kwargs)
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
accepted_devices=len(devices),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
sync = HeartbeatSynchronizer(
|
||||
manager,
|
||||
FakeClient(), # type: ignore[arg-type]
|
||||
_config(),
|
||||
)
|
||||
await sync.sync_once()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert not last_kwargs.get("mcp_busy_device_ids")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
@@ -67,3 +68,67 @@ def test_history_store_uses_injected_now_for_occurred_at(tmp_path) -> None:
|
||||
entries = store.list_recent()
|
||||
|
||||
assert entries[0]["occurred_at"] == fixed_now.isoformat()
|
||||
|
||||
|
||||
def test_history_store_records_task_submission_with_device(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-1", device_id="device-cloud-a")
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["kind"] == "task_submission"
|
||||
assert entries[0]["summary"] == "task submitted: task-cloud-1 on device-cloud-a"
|
||||
assert entries[0]["detail"] == {
|
||||
"task_id": "task-cloud-1",
|
||||
"device_id": "device-cloud-a",
|
||||
}
|
||||
|
||||
|
||||
def test_history_store_records_task_submission_without_device(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-2", device_id=None)
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert entries[0]["summary"] == ("task submitted: task-cloud-2 (automatic device)")
|
||||
assert entries[0]["detail"] == {"task_id": "task-cloud-2"}
|
||||
|
||||
|
||||
def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
secret_goal = (
|
||||
"rotate secret-XYZ-abcdef-very-secret cookie=session-abc; lease=lease-stale"
|
||||
)
|
||||
|
||||
store.record_task_submission(task_id="task-cloud-3", device_id="device-cloud-a")
|
||||
store.record_heartbeat(device_count=1)
|
||||
|
||||
entries = store.list_recent()
|
||||
rendered = "\n".join(
|
||||
repr(entry["summary"]) + " " + json.dumps(entry["detail"]) for entry in entries
|
||||
)
|
||||
|
||||
assert secret_goal not in rendered
|
||||
assert "session-abc" not in rendered
|
||||
assert "lease-stale" not in rendered
|
||||
|
||||
|
||||
def test_history_store_task_submissions_prune_beyond_limit(tmp_path) -> None:
|
||||
store = ConsoleHistoryStore(tmp_path / "history.sqlite3", limit=3)
|
||||
|
||||
for index in range(5):
|
||||
store.record_task_submission(
|
||||
task_id=f"task-{index}", device_id=f"device-{index}"
|
||||
)
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert len(entries) == 3
|
||||
assert [entry["detail"]["task_id"] for entry in entries] == [
|
||||
"task-4",
|
||||
"task-3",
|
||||
"task-2",
|
||||
]
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_lease_renews_while_execution_is_active() -> None:
|
||||
renewed = asyncio.Event()
|
||||
|
||||
class BlockingExecutor:
|
||||
def execute(self, assignment, *, should_stop=None):
|
||||
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||
execution_started.set()
|
||||
release_execution.wait(timeout=2)
|
||||
return AssignmentExecutionResult(status="done")
|
||||
@@ -61,13 +61,13 @@ def test_lease_renews_while_execution_is_active() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stale_lease_stops_later_interruptible_actions() -> None:
|
||||
def test_cancel_requested_renewal_stops_execution_with_cancelled_status() -> None:
|
||||
async def scenario() -> None:
|
||||
first_action_started = Event()
|
||||
actions: list[str] = []
|
||||
|
||||
class CooperativeExecutor:
|
||||
def execute(self, assignment, *, should_stop=None):
|
||||
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||
assert should_stop is not None
|
||||
actions.append("first")
|
||||
first_action_started.set()
|
||||
@@ -76,9 +76,59 @@ def test_stale_lease_stops_later_interruptible_actions() -> None:
|
||||
Event().wait(0.001)
|
||||
if not should_stop():
|
||||
actions.append("second")
|
||||
reason = stop_reason() if stop_reason is not None else None
|
||||
return AssignmentExecutionResult(
|
||||
status="failed",
|
||||
failure_reason="execution interrupted",
|
||||
status="cancelled" if reason and "cancel" in reason else "failed",
|
||||
failure_reason=reason,
|
||||
)
|
||||
|
||||
def latest_progress(self):
|
||||
return None
|
||||
|
||||
class CancellingClient:
|
||||
async def renew(self, assignment, *, progress=None):
|
||||
assert await asyncio.to_thread(first_action_started.wait, 1)
|
||||
return LeaseRenewalResponse(
|
||||
status="renewed",
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
|
||||
cancel_requested=True,
|
||||
)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
ActiveAssignmentRunner(
|
||||
CancellingClient(), # type: ignore[arg-type]
|
||||
CooperativeExecutor(),
|
||||
).run(_assignment()),
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert result.status == "cancelled"
|
||||
assert result.failure_reason == "cancellation requested by control plane"
|
||||
assert actions == ["first"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stale_lease_stops_later_interruptible_actions() -> None:
|
||||
async def scenario() -> None:
|
||||
first_action_started = Event()
|
||||
actions: list[str] = []
|
||||
|
||||
class CooperativeExecutor:
|
||||
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||
assert should_stop is not None
|
||||
actions.append("first")
|
||||
first_action_started.set()
|
||||
assert first_action_started.wait(timeout=1)
|
||||
while not should_stop():
|
||||
Event().wait(0.001)
|
||||
if not should_stop():
|
||||
actions.append("second")
|
||||
reason = stop_reason() if stop_reason is not None else None
|
||||
assert reason == "lease rejected by control plane"
|
||||
return AssignmentExecutionResult(
|
||||
status="cancelled" if reason and "cancel" in reason else "failed",
|
||||
failure_reason=reason,
|
||||
)
|
||||
|
||||
def latest_progress(self):
|
||||
@@ -108,7 +158,7 @@ def test_renewal_loop_exits_when_execution_finishes() -> None:
|
||||
renew_calls = 0
|
||||
|
||||
class ImmediateExecutor:
|
||||
def execute(self, assignment, *, should_stop=None):
|
||||
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||
return AssignmentExecutionResult(status="done")
|
||||
|
||||
def latest_progress(self):
|
||||
@@ -142,7 +192,7 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
|
||||
execution_started = Event()
|
||||
|
||||
class CooperativeExecutor:
|
||||
def execute(self, assignment, *, should_stop=None):
|
||||
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||
assert should_stop is not None
|
||||
execution_started.set()
|
||||
while not should_stop():
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
from host_agent.web.mcp_auth import BearerAuthMiddleware
|
||||
|
||||
|
||||
def _make_client(tmp_path: Path) -> tuple[TestClient, str]:
|
||||
store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
token = store.load_or_create().token
|
||||
|
||||
async def hello(request): # type: ignore[no-untyped-def]
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
inner = Starlette(routes=[])
|
||||
inner.router.add_route("/", hello, methods=["GET"])
|
||||
wrapped = Starlette()
|
||||
wrapped.add_middleware(BearerAuthMiddleware, token_store=store)
|
||||
wrapped.mount("/", inner)
|
||||
return TestClient(wrapped), token
|
||||
|
||||
|
||||
def test_no_header_returns_401(tmp_path: Path) -> None:
|
||||
client, _ = _make_client(tmp_path)
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["WWW-Authenticate"] == "Bearer"
|
||||
assert resp.json() == {"error": "invalid token"}
|
||||
|
||||
|
||||
def test_wrong_token_returns_401(tmp_path: Path) -> None:
|
||||
client, _ = _make_client(tmp_path)
|
||||
resp = client.get("/", headers={"Authorization": "Bearer wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_correct_token_passes_through(tmp_path: Path) -> None:
|
||||
client, token = _make_client(tmp_path)
|
||||
resp = client.get("/", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_non_bearer_scheme_returns_401(tmp_path: Path) -> None:
|
||||
client, token = _make_client(tmp_path)
|
||||
resp = client.get("/", headers={"Authorization": f"Basic {token}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_header_case_insensitive(tmp_path: Path) -> None:
|
||||
client, token = _make_client(tmp_path)
|
||||
resp = client.get("/", headers={"authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
|
||||
|
||||
def _tracker_with_now() -> tuple[McpBusyTracker, list[datetime]]:
|
||||
times: list[datetime] = []
|
||||
|
||||
def now() -> datetime:
|
||||
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
tracker = McpBusyTracker(ttl_seconds=60.0, now=now)
|
||||
return tracker, times
|
||||
|
||||
|
||||
def test_acquire_succeeds_on_empty() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
assert tracker.acquire("phone-1", "sess-a") is True
|
||||
assert "phone-1" in tracker.busy_device_ids()
|
||||
|
||||
|
||||
def test_acquire_fails_when_held_by_other_session() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
assert tracker.acquire("phone-1", "sess-a") is True
|
||||
assert tracker.acquire("phone-1", "sess-b") is False
|
||||
|
||||
|
||||
def test_acquire_is_idempotent_for_same_session() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
assert tracker.acquire("phone-1", "sess-a") is True
|
||||
# Same session re-acquiring is allowed (acts as renew).
|
||||
assert tracker.acquire("phone-1", "sess-a") is True
|
||||
|
||||
|
||||
def test_renew_refreshes_last_seen() -> None:
|
||||
tracker, times = _tracker_with_now()
|
||||
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
initial = tracker.snapshot()[0]
|
||||
times.append(datetime(2026, 1, 1, 12, 0, 30, tzinfo=UTC))
|
||||
assert tracker.renew("phone-1", "sess-a") is True
|
||||
refreshed = tracker.snapshot()[0]
|
||||
assert refreshed.last_seen_at > initial.last_seen_at
|
||||
|
||||
|
||||
def test_renew_fails_when_held_by_other() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
assert tracker.renew("phone-1", "sess-b") is False
|
||||
|
||||
|
||||
def test_release_returns_freed_device_ids() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
tracker.acquire("phone-2", "sess-a")
|
||||
freed = tracker.release("sess-a")
|
||||
assert sorted(freed) == ["phone-1", "phone-2"]
|
||||
assert tracker.busy_device_ids() == []
|
||||
|
||||
|
||||
def test_release_only_frees_caller_session() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
tracker.acquire("phone-1", "sess-b") # fails
|
||||
freed = tracker.release("sess-b")
|
||||
assert freed == []
|
||||
assert "phone-1" in tracker.busy_device_ids()
|
||||
|
||||
|
||||
def test_ttl_sweeps_expired_leases() -> None:
|
||||
tracker, times = _tracker_with_now()
|
||||
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
# Advance past TTL without renew.
|
||||
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # 61s later
|
||||
assert tracker.busy_device_ids() == []
|
||||
|
||||
|
||||
def test_renew_after_ttl_tolerates_same_session() -> None:
|
||||
"""Scene 10: lease expired but session_id matches -> re-acquire."""
|
||||
tracker, times = _tracker_with_now()
|
||||
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # expired
|
||||
# renew from the same session should succeed (re-acquire).
|
||||
assert tracker.renew("phone-1", "sess-a") is True
|
||||
assert "phone-1" in tracker.busy_device_ids()
|
||||
|
||||
|
||||
def test_snapshot_matches_busy_device_ids() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
tracker.acquire("phone-2", "sess-a")
|
||||
snap = tracker.snapshot()
|
||||
assert {lease.device_id for lease in snap} == set(tracker.busy_device_ids())
|
||||
|
||||
|
||||
def test_wait_until_usable_succeeds_when_free() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
ok = tracker.wait_until_usable("phone-1", "sess-a", timeout=1.0, poll_interval=0.01)
|
||||
assert ok is True
|
||||
assert "phone-1" in tracker.busy_device_ids()
|
||||
|
||||
|
||||
def test_wait_until_usable_returns_false_on_timeout() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
ok = tracker.wait_until_usable("phone-1", "sess-b", timeout=0.1, poll_interval=0.02)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_wait_until_usable_blocks_then_succeeds_when_released() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
tracker.acquire("phone-1", "sess-a")
|
||||
|
||||
def releaser() -> None:
|
||||
import time
|
||||
|
||||
time.sleep(0.05)
|
||||
tracker.release("sess-a")
|
||||
|
||||
t = threading.Thread(target=releaser)
|
||||
t.start()
|
||||
try:
|
||||
ok = tracker.wait_until_usable(
|
||||
"phone-1", "sess-b", timeout=2.0, poll_interval=0.02
|
||||
)
|
||||
assert ok is True
|
||||
finally:
|
||||
t.join()
|
||||
|
||||
|
||||
def test_wait_until_usable_blocks_then_fails_when_cloud_remains_busy() -> None:
|
||||
tracker, _ = _tracker_with_now()
|
||||
ok = tracker.wait_until_usable(
|
||||
"phone-1",
|
||||
"sess-a",
|
||||
timeout=0.1,
|
||||
poll_interval=0.02,
|
||||
cloud_busy_check=lambda: True,
|
||||
)
|
||||
assert ok is False
|
||||
assert tracker.busy_device_ids() == []
|
||||
|
||||
|
||||
def test_default_ttl_is_20_seconds() -> None:
|
||||
"""The McpBusyTracker default TTL is 20s: short enough to recover from a
|
||||
dead MCP session within a heartbeat interval without an explicit release
|
||||
callback (mcp SDK 1.28.1 has no per-session shutdown hook), but long
|
||||
enough that an actively-busy session does not lose its lease during
|
||||
normal operator pauses."""
|
||||
tracker = McpBusyTracker()
|
||||
assert tracker._ttl == 20.0
|
||||
|
||||
|
||||
def test_default_ttl_recovers_dead_session_within_one_window() -> None:
|
||||
"""With the 20s default, a session that never renews its lease is
|
||||
reaped within one TTL window on the next read. This is the
|
||||
concrete fallback behavior for I1 (no FastMCP session-end hook)."""
|
||||
times: list[datetime] = []
|
||||
|
||||
def now() -> datetime:
|
||||
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
tracker = McpBusyTracker(now=now) # default 20s TTL
|
||||
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
||||
assert tracker.acquire("phone-1", "dead-session") is True
|
||||
# No renew: advance 21s. Lease should be swept on next read.
|
||||
times.append(datetime(2026, 1, 1, 12, 0, 21, tzinfo=UTC))
|
||||
assert tracker.busy_device_ids() == []
|
||||
# New session can now acquire cleanly (no stale-busy contamination).
|
||||
assert tracker.acquire("phone-1", "new-session") is True
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from host_agent.mcp_token import McpToken, McpTokenStore, McpTokenStoreError
|
||||
|
||||
|
||||
def test_load_or_create_generates_when_missing(tmp_path: Path) -> None:
|
||||
store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
token = store.load_or_create()
|
||||
assert token.version == 1
|
||||
assert len(token.token) >= 40 # secrets.token_urlsafe(32) -> ~43 chars
|
||||
assert isinstance(token.created_at, datetime)
|
||||
# File now exists.
|
||||
assert (tmp_path / "host_mcp_token.json").exists()
|
||||
|
||||
|
||||
def test_load_or_create_is_idempotent(tmp_path: Path) -> None:
|
||||
store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
first = store.load_or_create()
|
||||
second = McpTokenStore(tmp_path / "host_mcp_token.json").load_or_create()
|
||||
assert first.token == second.token
|
||||
|
||||
|
||||
def test_load_or_create_writes_json_schema(tmp_path: Path) -> None:
|
||||
path = tmp_path / "host_mcp_token.json"
|
||||
McpTokenStore(path).load_or_create()
|
||||
data = json.loads(path.read_text())
|
||||
assert set(data) == {"version", "token", "created_at"}
|
||||
assert data["version"] == 1
|
||||
assert isinstance(data["token"], str)
|
||||
# created_at is ISO 8601.
|
||||
datetime.fromisoformat(data["created_at"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perms only")
|
||||
def test_load_or_create_sets_posix_permissions(tmp_path: Path) -> None:
|
||||
path = tmp_path / "host_mcp_token.json"
|
||||
McpTokenStore(path).load_or_create()
|
||||
mode = stat.S_IMODE(os.fstat(os.open(path, os.O_RDONLY)).st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_verify_accepts_correct_token(tmp_path: Path) -> None:
|
||||
store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
token = store.load_or_create()
|
||||
assert store.verify(token.token) is True
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_token(tmp_path: Path) -> None:
|
||||
store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
store.load_or_create()
|
||||
assert store.verify("wrong") is False
|
||||
|
||||
|
||||
def test_load_or_create_raises_on_corrupt_json(tmp_path: Path) -> None:
|
||||
path = tmp_path / "host_mcp_token.json"
|
||||
path.write_text("{not valid json")
|
||||
with pytest.raises(McpTokenStoreError):
|
||||
McpTokenStore(path).load_or_create()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod enforcement only")
|
||||
def test_load_or_create_raises_on_unwritable_dir(tmp_path: Path) -> None:
|
||||
unwritable = tmp_path / "ro"
|
||||
unwritable.mkdir()
|
||||
os.chmod(unwritable, 0o500) # r-x for owner
|
||||
try:
|
||||
with pytest.raises(McpTokenStoreError):
|
||||
McpTokenStore(unwritable / "host_mcp_token.json").load_or_create()
|
||||
finally:
|
||||
os.chmod(unwritable, 0o700) # restore so cleanup works
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX atomic-rename semantics only",
|
||||
)
|
||||
def test_load_or_create_concurrent_calls_do_not_corrupt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two store instances racing to create: both end up reading the same token."""
|
||||
import threading
|
||||
|
||||
path = tmp_path / "host_mcp_token.json"
|
||||
results: list[McpToken] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def worker() -> None:
|
||||
barrier.wait()
|
||||
store = McpTokenStore(path)
|
||||
results.append(store.load_or_create())
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert len(results) == 2
|
||||
assert results[0].token == results[1].token
|
||||
@@ -88,6 +88,38 @@ def test_processor_preserves_runtime_failure_reason() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_processor_reports_cancelled_status_with_reason() -> None:
|
||||
async def scenario() -> None:
|
||||
reports: list[dict[str, object]] = []
|
||||
|
||||
class CancelledExecutor:
|
||||
async def run(self, assignment):
|
||||
return AssignmentExecutionResult(
|
||||
status="cancelled",
|
||||
failure_reason="cancellation requested by control plane",
|
||||
metadata={"runtime_status": "cancelled"},
|
||||
)
|
||||
|
||||
class RecordingClient:
|
||||
async def report_result(self, assignment, **kwargs):
|
||||
reports.append(kwargs)
|
||||
return TerminalResultResponse(status="recorded")
|
||||
|
||||
result = await AssignmentProcessor(
|
||||
RecordingClient(), # type: ignore[arg-type]
|
||||
CancelledExecutor(),
|
||||
).process(_assignment())
|
||||
|
||||
assert result.report_status == "recorded"
|
||||
assert reports[0] == {
|
||||
"status": "cancelled",
|
||||
"failure_reason": "cancellation requested by control plane",
|
||||
"result": {"runtime_status": "cancelled"},
|
||||
}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_status_tracker_sees_started_then_finished_even_on_raise() -> None:
|
||||
async def scenario() -> None:
|
||||
tracker = AgentStatusTracker()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the host-agent skill sync wiring (§7.2/7.4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.skill_sync import HostAgentSkillSync
|
||||
|
||||
|
||||
def _config(tmp_path) -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
control_plane_url="https://cloud.example",
|
||||
host_id="host-1",
|
||||
token="host-token",
|
||||
identity_path=tmp_path / "identity.json",
|
||||
skill_sync_interval_seconds=0.01,
|
||||
)
|
||||
|
||||
|
||||
def test_skill_sync_pulls_delta_and_reports_inventory(tmp_path):
|
||||
seen_inventory = {"host": None, "body": None}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.endswith("/skills/sync"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skills": [
|
||||
{
|
||||
"id": "c1",
|
||||
"name": "Cloud Skill",
|
||||
"kind": "knowledge",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"revision": 1,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"content": "body",
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
"removed_ids": [],
|
||||
"latest_version": 1,
|
||||
"is_full_replace": True,
|
||||
},
|
||||
)
|
||||
if path.endswith("/skills/inventory"):
|
||||
seen_inventory["host"] = request.url.path.split("/")[4]
|
||||
seen_inventory["body"] = request.read()
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# One manual tick applies the cloud skill to the synced store.
|
||||
outcomes = sync.runner.tick()
|
||||
assert outcomes["host-1"].success is True
|
||||
visible = sync.synced_store.list_skills({"host-1"})
|
||||
assert [m.name for m in visible] == ["Cloud Skill"]
|
||||
|
||||
# Inventory report of an authored local skill is best-effort and payload-shaped.
|
||||
from skills_learning.models import KnowledgeSkill, SkillMetadata
|
||||
|
||||
sync.local_store.create_local(
|
||||
KnowledgeSkill(metadata=SkillMetadata(name="Local Note", kind="knowledge"), content="x")
|
||||
)
|
||||
sync.report_inventory_once()
|
||||
assert seen_inventory["host"] == "host-1"
|
||||
assert b"Local Note" in seen_inventory["body"]
|
||||
|
||||
# start/stop lifecycle does not raise.
|
||||
sync.start()
|
||||
sync.stop()
|
||||
|
||||
|
||||
def test_skill_sync_inventory_failure_is_isolated(tmp_path):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/skills/inventory"):
|
||||
return httpx.Response(500)
|
||||
return httpx.Response(200, json={"skills": [], "removed_ids": [], "latest_version": 0, "is_full_replace": True})
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# A failed inventory report must not raise.
|
||||
sync.report_inventory_once()
|
||||
sync.client.close()
|
||||
@@ -1,25 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from core.models import Task
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.client import HostAgentAPIError, HostTaskSubmissionUnknownError
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.mcp_token import McpTokenStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
|
||||
from host_agent.web.auth import SessionManager
|
||||
from host_agent.web.mcp import build_mcp_server
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
|
||||
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||
|
||||
TaskSubmissionCallable = Callable[[str, str | None], Awaitable[str]]
|
||||
TaskCancellationCallable = Callable[[str], Awaitable[Any]]
|
||||
|
||||
def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient, dict]:
|
||||
|
||||
def _build_client(
|
||||
tmp_path,
|
||||
*,
|
||||
create_account: bool = True,
|
||||
submit_self_task: TaskSubmissionCallable | None = None,
|
||||
cancel_task: TaskCancellationCallable | None = None,
|
||||
include_metadata_store: bool = True,
|
||||
mcp_server: FastMCP | None = None,
|
||||
mcp_token_store: McpTokenStore | None = None,
|
||||
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||
) -> tuple[TestClient, dict]:
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
@@ -36,6 +58,9 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
|
||||
status_tracker = AgentStatusTracker()
|
||||
session_manager = SessionManager(ttl_seconds=3600.0)
|
||||
metadata_store: TaskMetadataStore | None = None
|
||||
if include_metadata_store:
|
||||
metadata_store = TaskMetadataStore(db_path=tmp_path / "task_metadata.sqlite3")
|
||||
|
||||
app = create_console_app(
|
||||
config=config,
|
||||
@@ -47,6 +72,12 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
status_tracker=status_tracker,
|
||||
session_manager=session_manager,
|
||||
enrollment_client=None,
|
||||
submit_self_task=submit_self_task,
|
||||
cancel_task=cancel_task,
|
||||
metadata_store=metadata_store,
|
||||
mcp_server=mcp_server,
|
||||
mcp_token_store=mcp_token_store,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
)
|
||||
client = TestClient(app)
|
||||
context = {
|
||||
@@ -56,6 +87,7 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient,
|
||||
"history_store": history_store,
|
||||
"session_manager": session_manager,
|
||||
"status_tracker": status_tracker,
|
||||
"metadata_store": metadata_store,
|
||||
}
|
||||
return client, context
|
||||
|
||||
@@ -210,6 +242,147 @@ def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None:
|
||||
assert [device.id for device in context["manager"].list_devices()] == ["device-a"]
|
||||
|
||||
|
||||
def test_devices_page_captures_screenshot_only_when_button_endpoint_is_called(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
class ScreenshotDriver:
|
||||
def __init__(self) -> None:
|
||||
self.capture_count = 0
|
||||
|
||||
def connect(self) -> None:
|
||||
pass
|
||||
|
||||
def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
self.capture_count += 1
|
||||
return b"fake-png"
|
||||
|
||||
driver = ScreenshotDriver()
|
||||
client, context = _build_client(tmp_path)
|
||||
context["config_store"].add(
|
||||
device_id="device-a",
|
||||
name="Lab iPhone",
|
||||
driver_type="wda",
|
||||
connection_info={},
|
||||
)
|
||||
context["manager"].register_device("device-a", lambda: driver)
|
||||
context["manager"].connect("device-a")
|
||||
csrf_token = _login(client)
|
||||
|
||||
page = client.get("/devices")
|
||||
assert page.status_code == 200
|
||||
assert 'class="screenshot-button"' in page.text
|
||||
assert 'data-device-id="device-a"' in page.text
|
||||
assert driver.capture_count == 0
|
||||
|
||||
response = client.post(
|
||||
"/api/devices/device-a/screenshot",
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"fake-png"
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert driver.capture_count == 1
|
||||
|
||||
|
||||
def test_device_screenshot_requires_csrf_and_connected_device(tmp_path) -> None:
|
||||
class ScreenshotDriver:
|
||||
def connect(self) -> None:
|
||||
pass
|
||||
|
||||
def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
return b"fake-png"
|
||||
|
||||
client, context = _build_client(tmp_path)
|
||||
context["manager"].register_device("device-a", ScreenshotDriver)
|
||||
csrf_token = _login(client)
|
||||
|
||||
missing_csrf = client.post("/api/devices/device-a/screenshot")
|
||||
assert missing_csrf.status_code == 403
|
||||
|
||||
offline = client.post(
|
||||
"/api/devices/device-a/screenshot",
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
assert offline.status_code == 503
|
||||
|
||||
|
||||
def test_device_connection_test_connects_checks_health_and_disconnects(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class ProbeDriver:
|
||||
def connect(self) -> None:
|
||||
events.append("connect")
|
||||
|
||||
def health_check(self) -> None:
|
||||
events.append("health")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
events.append("disconnect")
|
||||
|
||||
def factory(driver_type, connection_info):
|
||||
assert driver_type == "wda"
|
||||
assert connection_info["udid"] == "ios-udid"
|
||||
return ProbeDriver
|
||||
|
||||
monkeypatch.setattr("host_agent.web.app.build_driver_factory", factory)
|
||||
client, context = _build_client(tmp_path)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/devices/test-connection",
|
||||
json={"driver_type": "wda", "connection_info": {"udid": "ios-udid"}},
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "driver_type": "wda"}
|
||||
assert events == ["connect", "health", "disconnect"]
|
||||
assert context["config_store"].list() == []
|
||||
|
||||
|
||||
def test_ios_discovery_returns_connected_devices_and_unique_ports(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"host_agent.web.app.discover_connected_ios_devices",
|
||||
lambda: [
|
||||
{
|
||||
"udid": "ios-new",
|
||||
"name": "New iPhone",
|
||||
"model": "iPhone 15",
|
||||
"os_version": "18.0",
|
||||
"transport": "wired",
|
||||
}
|
||||
],
|
||||
)
|
||||
client, context = _build_client(tmp_path)
|
||||
context["config_store"].add(
|
||||
device_id="existing",
|
||||
driver_type="wda",
|
||||
connection_info={"udid": "ios-old", "wda_local_port": 8100, "mjpegServerPort": 9100},
|
||||
)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/devices/discover-ios")
|
||||
|
||||
assert response.status_code == 200
|
||||
device = response.json()["devices"][0]
|
||||
assert device["udid"] == "ios-new"
|
||||
assert device["configured"] is False
|
||||
assert device["suggested_wda_port"] == 8101
|
||||
assert device["suggested_mjpeg_port"] == 9101
|
||||
|
||||
|
||||
def test_remove_device_unregisters_from_manager(tmp_path) -> None:
|
||||
client, context = _build_client(tmp_path)
|
||||
csrf_token = _login(client)
|
||||
@@ -309,3 +482,757 @@ def test_logout_invalidates_session_so_subsequent_request_redirects_to_login(
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def _make_submission_recorder(
|
||||
*,
|
||||
task_id: str = "task-cloud-1",
|
||||
raise_api_error: HostAgentAPIError | None = None,
|
||||
raise_unknown: bool = False,
|
||||
history_store: ConsoleHistoryStore | None = None,
|
||||
history_fail: bool = False,
|
||||
) -> tuple[TaskSubmissionCallable, dict]:
|
||||
captured: dict = {}
|
||||
|
||||
async def submit(goal: str, device_id: str | None) -> str:
|
||||
captured["goal"] = goal
|
||||
captured["device_id"] = device_id
|
||||
if raise_unknown:
|
||||
raise HostTaskSubmissionUnknownError("transport failure")
|
||||
if raise_api_error is not None:
|
||||
raise raise_api_error
|
||||
return task_id
|
||||
|
||||
async def record(*, task_id: str, device_id: str | None) -> None:
|
||||
captured.setdefault("history_calls", []).append(
|
||||
{"task_id": task_id, "device_id": device_id}
|
||||
)
|
||||
if history_fail:
|
||||
raise RuntimeError("history store unavailable")
|
||||
|
||||
if history_store is not None:
|
||||
original = history_store.record_task_submission
|
||||
|
||||
def _proxy(*, task_id: str, device_id: str | None) -> None:
|
||||
try:
|
||||
asyncio_run(record(task_id=task_id, device_id=device_id))
|
||||
except RuntimeError:
|
||||
pass
|
||||
return original(task_id=task_id, device_id=device_id)
|
||||
|
||||
history_store.record_task_submission = _proxy # type: ignore[method-assign]
|
||||
return submit, captured
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
import asyncio
|
||||
|
||||
return (
|
||||
asyncio.get_event_loop().run_until_complete(coro)
|
||||
if asyncio.get_event_loop().is_running()
|
||||
else asyncio.run(coro)
|
||||
)
|
||||
|
||||
|
||||
def test_tasks_page_renders_submission_form_with_device_options(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder()
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/tasks")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Submit task to current Host" in response.text
|
||||
assert 'action="/tasks/submit"' in response.text
|
||||
assert "Automatic (let Host choose" in response.text
|
||||
assert "Lab iPhone" in response.text
|
||||
|
||||
|
||||
def test_authenticated_automatic_submission_invokes_submit_with_none_device(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
submit, captured = _make_submission_recorder(task_id="task-cloud-auto")
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-auto"
|
||||
assert captured == {"goal": "open settings", "device_id": None}
|
||||
|
||||
|
||||
def test_authenticated_explicit_device_submission_uses_runtime_id(tmp_path) -> None:
|
||||
submit, captured = _make_submission_recorder(task_id="task-cloud-explicit")
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-cloud-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Cloud iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open mail",
|
||||
"device_id": "device-cloud-a",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-explicit"
|
||||
)
|
||||
assert captured == {"goal": "open mail", "device_id": "device-cloud-a"}
|
||||
|
||||
|
||||
def test_redirected_tasks_page_renders_task_id_confirmation(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder()
|
||||
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/tasks?submitted=1&task_id=task-cloud-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "task-cloud-1" in response.text
|
||||
assert "Cloud task ID" in response.text
|
||||
|
||||
|
||||
def test_confirmed_submission_records_history_audit(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder(task_id="task-cloud-audit")
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-cloud-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Cloud iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "audit me",
|
||||
"device_id": "device-cloud-a",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
entries = context["history_store"].list_recent()
|
||||
assert entries[0]["kind"] == "task_submission"
|
||||
assert entries[0]["detail"] == {
|
||||
"task_id": "task-cloud-audit",
|
||||
"device_id": "device-cloud-a",
|
||||
}
|
||||
|
||||
|
||||
def test_unauthenticated_submission_redirects_to_login_without_calling_client(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
submit, captured = _make_submission_recorder()
|
||||
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={"goal": "open settings", "device_id": "__automatic__"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_csrf_invalid_submission_is_rejected(tmp_path) -> None:
|
||||
submit, captured = _make_submission_recorder()
|
||||
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
||||
_login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": "wrong-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_blank_goal_rejected_before_client_invocation(tmp_path) -> None:
|
||||
submit, captured = _make_submission_recorder()
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": " ",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Goal cannot be empty" in response.text
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_stale_device_selection_rejected_before_client_invocation(tmp_path) -> None:
|
||||
submit, captured = _make_submission_recorder()
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value],
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "device-that-was-removed",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "no longer registered" in response.text
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_cloud_definitive_rejection_renders_safe_error_without_calling_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
submit, captured = _make_submission_recorder(
|
||||
raise_api_error=HostAgentAPIError(403, "Host self-submission is disabled"),
|
||||
)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "Host self-submission is disabled" in response.text
|
||||
entries = context["history_store"].list_recent()
|
||||
assert all(entry["kind"] != "task_submission" for entry in entries)
|
||||
assert captured == {"goal": "open settings", "device_id": None}
|
||||
|
||||
|
||||
def test_transport_uncertain_response_redirects_to_unknown_outcome(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
submit, captured = _make_submission_recorder(raise_unknown=True)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/tasks?outcome=unknown"
|
||||
entries = context["history_store"].list_recent()
|
||||
assert all(entry["kind"] != "task_submission" for entry in entries)
|
||||
assert captured == {"goal": "open settings", "device_id": None}
|
||||
|
||||
|
||||
def test_unknown_outcome_query_shows_safe_message(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder()
|
||||
client, _ = _build_client(tmp_path, submit_self_task=submit)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/tasks?outcome=unknown")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Submission outcome is unknown" in response.text
|
||||
assert "Check the Cloud console" in response.text
|
||||
|
||||
|
||||
def test_submission_form_absent_when_client_missing(tmp_path) -> None:
|
||||
client, _ = _build_client(tmp_path, submit_self_task=None)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/tasks")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'action="/tasks/submit"' not in response.text
|
||||
assert "submission client is not available yet" in response.text
|
||||
|
||||
|
||||
def test_submit_when_client_missing_returns_503_without_history(tmp_path) -> None:
|
||||
client, context = _build_client(tmp_path, submit_self_task=None)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
entries = context["history_store"].list_recent()
|
||||
assert all(entry["kind"] != "task_submission" for entry in entries)
|
||||
|
||||
|
||||
def test_audit_failure_does_not_break_successful_submission(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder(
|
||||
task_id="task-cloud-audit-fail",
|
||||
history_store=None,
|
||||
)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
|
||||
def boom(*, task_id: str, device_id: str | None) -> None:
|
||||
raise RuntimeError("audit DB offline")
|
||||
|
||||
context["history_store"].record_task_submission = boom # type: ignore[method-assign]
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "/tasks?submitted=1&task_id=task-cloud-audit-fail"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_submission_never_writes_successful_audit(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder(
|
||||
raise_api_error=HostAgentAPIError(422, "ownership mismatch"),
|
||||
)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "open settings",
|
||||
"device_id": "device-runtime-a",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
entries = context["history_store"].list_recent()
|
||||
assert all(entry["kind"] != "task_submission" for entry in entries)
|
||||
|
||||
|
||||
def test_tasks_page_autoescapes_goal_device_and_error_text(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder(
|
||||
raise_api_error=HostAgentAPIError(403, "<img src=x onerror=alert(1)>"),
|
||||
)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"<script>alert('x')</script>",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
name='"><img src=x onerror=alert(1)>',
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": "<script>alert('x')</script>",
|
||||
"device_id": "<script>alert('x')</script>",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
html = response.text
|
||||
assert "<script>alert('x')</script>" in html
|
||||
assert "<img src=x onerror=alert(1)>" not in html
|
||||
assert "<script>alert('x')</script>" not in html
|
||||
assert "<script>alert" in html
|
||||
assert "alert(1)" in html # auto-escaped as text, not executable
|
||||
|
||||
|
||||
def test_submitted_redirect_does_not_include_goal_text(tmp_path) -> None:
|
||||
submit, _ = _make_submission_recorder(task_id="task-cloud-clean")
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
"device-runtime-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value],
|
||||
name="Lab iPhone",
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
secret_goal = "rotate bearer-token-deadbeef-very-secret-12345"
|
||||
|
||||
response = client.post(
|
||||
"/tasks/submit",
|
||||
data={
|
||||
"goal": secret_goal,
|
||||
"device_id": "__automatic__",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert secret_goal not in response.headers["location"]
|
||||
assert "bearer-token-deadbeef" not in response.headers["location"]
|
||||
follow = client.get(response.headers["location"])
|
||||
assert secret_goal not in follow.text
|
||||
|
||||
|
||||
def _make_cancellation_recorder(
|
||||
*,
|
||||
raise_api_error: HostAgentAPIError | None = None,
|
||||
) -> tuple[TaskCancellationCallable, dict]:
|
||||
captured: dict = {}
|
||||
|
||||
async def cancel(task_id: str) -> None:
|
||||
captured["task_id"] = task_id
|
||||
if raise_api_error is not None:
|
||||
raise raise_api_error
|
||||
|
||||
return cancel, captured
|
||||
|
||||
|
||||
def _seed_local_task(
|
||||
metadata_store: TaskMetadataStore,
|
||||
*,
|
||||
status: str = "running",
|
||||
source_task_id: str | None = "cloud-task-1",
|
||||
) -> str:
|
||||
task = Task(goal="open settings", device_id="dev-1", status=status)
|
||||
metadata_store.create_task(task, source_task_id=source_task_id, source_attempt=1)
|
||||
return task.id
|
||||
|
||||
|
||||
def test_task_detail_page_shows_cancel_button_for_non_terminal_task(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cancel, _ = _make_cancellation_recorder()
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
||||
_login(client)
|
||||
|
||||
response = client.get(f"/tasks/{execution_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'action="/tasks/{execution_id}/cancel"' in response.text
|
||||
assert "Cancel task" in response.text
|
||||
|
||||
|
||||
def test_task_detail_page_hides_cancel_button_for_terminal_task(tmp_path) -> None:
|
||||
cancel, _ = _make_cancellation_recorder()
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="completed")
|
||||
_login(client)
|
||||
|
||||
response = client.get(f"/tasks/{execution_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
|
||||
|
||||
|
||||
def test_task_detail_page_hides_cancel_button_when_client_unavailable(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
client, context = _build_client(tmp_path, cancel_task=None)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
||||
_login(client)
|
||||
|
||||
response = client.get(f"/tasks/{execution_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
|
||||
|
||||
|
||||
def test_cancel_task_success_calls_client_with_cloud_task_id_and_redirects(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cancel, captured = _make_cancellation_recorder()
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(
|
||||
context["metadata_store"], status="running", source_task_id="cloud-task-99"
|
||||
)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
f"/tasks/{execution_id}/cancel",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/tasks/{execution_id}?cancelled=1"
|
||||
assert captured == {"task_id": "cloud-task-99"}
|
||||
|
||||
follow = client.get(response.headers["location"])
|
||||
assert "cancel-notice" in follow.text
|
||||
|
||||
|
||||
def test_cancel_task_client_error_redirects_with_cancel_error(tmp_path) -> None:
|
||||
cancel, _ = _make_cancellation_recorder(
|
||||
raise_api_error=HostAgentAPIError(502, "control plane unavailable")
|
||||
)
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
f"/tasks/{execution_id}/cancel",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/tasks/{execution_id}?cancel_error=1"
|
||||
|
||||
follow = client.get(response.headers["location"])
|
||||
assert "cancel-error" in follow.text
|
||||
|
||||
|
||||
def test_cancel_task_unknown_execution_id_returns_404(tmp_path) -> None:
|
||||
cancel, captured = _make_cancellation_recorder()
|
||||
client, _ = _build_client(tmp_path, cancel_task=cancel)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/does-not-exist/cancel",
|
||||
data={"csrf_token": csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_unauthenticated_cancel_redirects_to_login_without_calling_client(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cancel, captured = _make_cancellation_recorder()
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
||||
|
||||
response = client.post(
|
||||
f"/tasks/{execution_id}/cancel",
|
||||
data={},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_cancel_task_without_csrf_token_is_rejected(tmp_path) -> None:
|
||||
cancel, captured = _make_cancellation_recorder()
|
||||
client, context = _build_client(tmp_path, cancel_task=cancel)
|
||||
execution_id = _seed_local_task(context["metadata_store"], status="running")
|
||||
_login(client)
|
||||
|
||||
response = client.post(
|
||||
f"/tasks/{execution_id}/cancel",
|
||||
data={"csrf_token": "wrong-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert captured == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP mount + /api/status fields + dashboard row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_mcp_components(tmp_path) -> tuple[FastMCP, McpTokenStore, McpBusyTracker]:
|
||||
manager = DeviceManager()
|
||||
status_tracker = AgentStatusTracker()
|
||||
tracker = McpBusyTracker()
|
||||
token_store = McpTokenStore(tmp_path / "host_mcp_token.json")
|
||||
server = build_mcp_server(
|
||||
manager=manager,
|
||||
mcp_busy_tracker=tracker,
|
||||
status_tracker=status_tracker,
|
||||
)
|
||||
return server, token_store, tracker
|
||||
|
||||
|
||||
def test_console_app_mounts_mcp_when_all_components_provided(tmp_path) -> None:
|
||||
server, token_store, tracker = _build_mcp_components(tmp_path)
|
||||
client, _ = _build_client(
|
||||
tmp_path,
|
||||
mcp_server=server,
|
||||
mcp_token_store=token_store,
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
# Without auth, the bearer middleware should respond 401 — not 404.
|
||||
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
||||
assert resp.status_code != 404
|
||||
|
||||
|
||||
def test_console_app_does_not_mount_mcp_when_components_missing(tmp_path) -> None:
|
||||
client, _ = _build_client(tmp_path)
|
||||
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_api_status_includes_mcp_busy_devices(tmp_path) -> None:
|
||||
server, token_store, tracker = _build_mcp_components(tmp_path)
|
||||
# Acquire a lease without going through HTTP — tracker exposes a direct API.
|
||||
tracker.acquire("phone-1", "test-session")
|
||||
client, _ = _build_client(
|
||||
tmp_path,
|
||||
mcp_server=server,
|
||||
mcp_token_store=token_store,
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "mcp_busy_devices" in body
|
||||
assert "phone-1" in body["mcp_busy_devices"]
|
||||
assert body["mcp_endpoint"] == "/mcp"
|
||||
|
||||
|
||||
def test_api_status_omits_mcp_fields_when_components_missing(tmp_path) -> None:
|
||||
client, _ = _build_client(tmp_path)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["mcp_busy_devices"] == []
|
||||
assert body["mcp_endpoint"] is None
|
||||
|
||||
|
||||
def test_dashboard_renders_mcp_status_row(tmp_path) -> None:
|
||||
server, token_store, tracker = _build_mcp_components(tmp_path)
|
||||
tracker.acquire("phone-1", "test-session")
|
||||
client, _ = _build_client(
|
||||
tmp_path,
|
||||
mcp_server=server,
|
||||
mcp_token_store=token_store,
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert "<td>MCP</td>" in text
|
||||
assert "/mcp" in text
|
||||
assert "phone-1" in text
|
||||
|
||||
|
||||
def test_dashboard_renders_mcp_not_configured_when_components_missing(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
client, _ = _build_client(tmp_path)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert "<td>MCP</td>" in text
|
||||
assert "not configured" in text
|
||||
|
||||
|
||||
def test_mcp_endpoint_unauthorized_without_bearer_token(tmp_path) -> None:
|
||||
server, token_store, tracker = _build_mcp_components(tmp_path)
|
||||
client, _ = _build_client(
|
||||
tmp_path,
|
||||
mcp_server=server,
|
||||
mcp_token_store=token_store,
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_mcp_endpoint_rejects_invalid_bearer_token(tmp_path) -> None:
|
||||
server, token_store, tracker = _build_mcp_components(tmp_path)
|
||||
client, _ = _build_client(
|
||||
tmp_path,
|
||||
mcp_server=server,
|
||||
mcp_token_store=token_store,
|
||||
mcp_busy_tracker=tracker,
|
||||
)
|
||||
resp = client.post(
|
||||
"/mcp/",
|
||||
headers={"Authorization": "Bearer not-the-real-token"},
|
||||
json={"jsonrpc": "2.0", "method": "ping", "id": 1},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from device.manager import DeviceManager
|
||||
from driver.base import Driver
|
||||
from host_agent.mcp_lock import McpBusyTracker
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.mcp import (
|
||||
McpDeviceBusyError,
|
||||
_call_tool_sync,
|
||||
_current_session_id,
|
||||
build_mcp_server,
|
||||
)
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.shared.context import RequestContext
|
||||
|
||||
|
||||
class _FakeDriver(Driver):
|
||||
"""Minimal driver. connect/screenshot/tap are exercised; remaining abstract
|
||||
methods are stubbed to satisfy Driver's ABC contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.taps: list[tuple[float, float]] = []
|
||||
|
||||
def connect(self) -> None:
|
||||
return None
|
||||
|
||||
def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
return b"fake"
|
||||
|
||||
def tap(self, x: float, y: float) -> None:
|
||||
self.taps.append((x, y))
|
||||
|
||||
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||
return None
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
duration_ms: int = 500,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def swipe_path(
|
||||
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||
return None
|
||||
|
||||
def input(self, text: str) -> None:
|
||||
return None
|
||||
|
||||
def launch(self, app_id: str) -> None:
|
||||
return None
|
||||
|
||||
def terminate(self, app_id: str) -> None:
|
||||
return None
|
||||
|
||||
def tree(self) -> Any:
|
||||
return None
|
||||
|
||||
def home(self) -> None:
|
||||
return None
|
||||
|
||||
def lock(self) -> None:
|
||||
return None
|
||||
|
||||
def unlock(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_manager_with_device(device_id: str = "phone-1") -> DeviceManager:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
device_id=device_id,
|
||||
driver_factory=lambda: _FakeDriver(),
|
||||
name=device_id,
|
||||
)
|
||||
manager.connect(device_id)
|
||||
return manager
|
||||
|
||||
|
||||
def test_build_mcp_server_returns_fastmcp_instance() -> None:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
assert isinstance(server, FastMCP)
|
||||
|
||||
|
||||
def test_call_tool_succeeds_when_device_is_free() -> None:
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
result = _call_tool_sync(
|
||||
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert "phone-1" in tracker.busy_device_ids()
|
||||
|
||||
|
||||
def test_call_tool_fails_when_cloud_uses_device() -> None:
|
||||
"""AgentStatusTracker.current_assignment.device_id matches -> busy."""
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
status.mark_assignment_started(
|
||||
AssignmentModel(
|
||||
task_id="t1",
|
||||
attempt=1,
|
||||
lease_id="l1",
|
||||
lease_expires_at=datetime.now(UTC),
|
||||
host_id="h1",
|
||||
device_id="phone-1",
|
||||
goal="cloud task",
|
||||
)
|
||||
)
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
with pytest.raises(McpDeviceBusyError) as exc:
|
||||
_call_tool_sync(
|
||||
server,
|
||||
"take_screenshot",
|
||||
{"device_id": "phone-1"},
|
||||
session_id="sess-a",
|
||||
)
|
||||
assert exc.value.device_id == "phone-1"
|
||||
assert exc.value.busy_owner == "cloud_assignment"
|
||||
|
||||
|
||||
def test_call_tool_fails_when_another_mcp_session_holds_device() -> None:
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
# Pre-acquire as a different session.
|
||||
tracker.acquire("phone-1", "sess-other")
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
with pytest.raises(McpDeviceBusyError) as exc:
|
||||
_call_tool_sync(
|
||||
server,
|
||||
"take_screenshot",
|
||||
{"device_id": "phone-1"},
|
||||
session_id="sess-a",
|
||||
)
|
||||
assert exc.value.busy_owner.startswith("mcp_session:")
|
||||
|
||||
|
||||
def test_call_tool_renews_when_same_session_already_holds() -> None:
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
_call_tool_sync(
|
||||
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
|
||||
)
|
||||
# Second call from the same session should succeed.
|
||||
result = _call_tool_sync(
|
||||
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_list_devices_uses_display_status() -> None:
|
||||
"""Connected-but-idle devices report as 'connected', not 'busy'."""
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
result = _call_tool_sync(server, "list_devices", {}, session_id="sess-a")
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["status"] == "connected"
|
||||
|
||||
|
||||
def test_unknown_device_returns_semantic_error_dict() -> None:
|
||||
"""take_screenshot against an unknown device returns the api-errors semantic
|
||||
error dict (``ok=False, error="device not found"``) rather than raising.
|
||||
|
||||
Note: this test adapts the brief's exception-assertion semantics to the
|
||||
actual behavior of ``call_with_semantic_errors`` in ``api/errors.py`` —
|
||||
the brief's expectation that an exception is raised here is incorrect for
|
||||
the current handler implementation."""
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
result = _call_tool_sync(
|
||||
server,
|
||||
"take_screenshot",
|
||||
{"device_id": "does-not-exist"},
|
||||
session_id="sess-a",
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert result["ok"] is False
|
||||
assert "device" in result["error"].lower()
|
||||
|
||||
|
||||
def test_manager_required_for_build_mcp_server() -> None:
|
||||
"""Reinforces D12 — build_mcp_server requires manager as keyword-only."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(build_mcp_server)
|
||||
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
|
||||
|
||||
|
||||
def _fake_ctx(session_obj: object) -> Context:
|
||||
"""Build a Context whose ``session`` attribute returns ``session_obj``.
|
||||
|
||||
Context's ``session`` is a property backed by ``request_context.session``;
|
||||
we construct a minimal ``RequestContext`` and set it as the private
|
||||
``_request_context`` field. The pydantic public API doesn't expose a
|
||||
setter for ``session``, so we use ``object.__setattr__`` on the private
|
||||
backing field.
|
||||
"""
|
||||
ctx = Context.model_construct()
|
||||
request_ctx = RequestContext(
|
||||
request_id="req-test",
|
||||
meta=None,
|
||||
session=session_obj,
|
||||
lifespan_context=None,
|
||||
)
|
||||
object.__setattr__(ctx, "_request_context", request_ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
def test_current_session_id_is_stable_across_calls_same_session() -> None:
|
||||
"""Production-path identity: two tool calls from the same MCP session
|
||||
must yield the same session_id so the busy tracker can renew the lease.
|
||||
|
||||
This exercises the ``Context.session`` code path (NOT the
|
||||
``_TEST_SESSION_ID`` fallback used by ``_call_tool_sync``)."""
|
||||
sentinel_session = object()
|
||||
ctx = _fake_ctx(sentinel_session)
|
||||
first = _current_session_id(ctx)
|
||||
second = _current_session_id(ctx)
|
||||
assert first == second
|
||||
assert first.startswith("mcp_session:")
|
||||
# Object identity of the underlying ServerSession is the key — verifies
|
||||
# we use id(ctx.session) rather than e.g. ctx.request_id.
|
||||
assert first == f"mcp_session:{id(sentinel_session)}"
|
||||
|
||||
|
||||
def test_current_session_id_differs_across_sessions() -> None:
|
||||
"""Two different MCP sessions (distinct ServerSession objects) must
|
||||
produce distinct session_ids so the busy tracker can isolate them."""
|
||||
sess_a = object()
|
||||
sess_b = object()
|
||||
assert _current_session_id(_fake_ctx(sess_a)) != _current_session_id(
|
||||
_fake_ctx(sess_b)
|
||||
)
|
||||
|
||||
|
||||
def test_current_session_id_falls_back_when_no_context() -> None:
|
||||
"""When no Context is available (e.g. outside a FastMCP request lifecycle,
|
||||
or via ``_call_tool_sync`` which omits the ctx kwarg), the test
|
||||
contextvars override provides the session_id."""
|
||||
token = None
|
||||
try:
|
||||
from host_agent.web import mcp as mcp_mod
|
||||
|
||||
token = mcp_mod._TEST_SESSION_ID.set("test-session-xyz")
|
||||
assert _current_session_id(None) == "test-session-xyz"
|
||||
finally:
|
||||
if token is not None:
|
||||
from host_agent.web import mcp as mcp_mod
|
||||
|
||||
mcp_mod._TEST_SESSION_ID.reset(token)
|
||||
|
||||
|
||||
def test_wrapped_tool_accepts_context_kwarg() -> None:
|
||||
"""The wrapper registered on FastMCP must declare a ``ctx`` parameter so
|
||||
FastMCP injects the live Context (and ``tool.context_kwarg`` is set to
|
||||
``"ctx"``). Without this, FastMCP never injects context and we fall
|
||||
back to the empty test default — the production bug this PR fixes."""
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
tool_manager = server._tool_manager # type: ignore[attr-defined]
|
||||
tool = tool_manager.get_tool("take_screenshot")
|
||||
assert tool is not None
|
||||
assert tool.context_kwarg == "ctx"
|
||||
|
||||
|
||||
def test_busy_error_wire_shape_is_calltoolresult_iserror() -> None:
|
||||
"""Regression test for spec §7 — busy errors must be visible on the wire.
|
||||
|
||||
mcp SDK 1.28.1's ``Tool.run`` wraps every non-``UrlElicitationRequiredError``
|
||||
exception (including ``McpError`` and our ``McpDeviceBusyError``) into
|
||||
``ToolError`` (see ``mcp/server/fastmcp/tools/base.py``). The lowlevel
|
||||
``call_tool`` handler then builds a ``CallToolResult(isError=True,
|
||||
content=[TextContent(...)])`` (see
|
||||
``mcp/server/lowlevel/server.py::_make_error_result``). There is no public
|
||||
path that surfaces JSON-RPC ``-32000`` + structured ``data.busy_owner`` from
|
||||
a tool call site — the SDK's wire contract for tool errors is the
|
||||
``isError=true`` flag plus text content. This test pins the wire shape so
|
||||
any future SDK upgrade that exposes a true JSON-RPC error path is caught."""
|
||||
import asyncio
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
tracker.acquire("phone-1", "sess-other") # different session holds the device
|
||||
|
||||
server: FastMCP = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
|
||||
assert tool is not None
|
||||
|
||||
sentinel_session = object()
|
||||
ctx = _fake_ctx(sentinel_session)
|
||||
|
||||
with pytest.raises(ToolError) as tool_exc:
|
||||
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
|
||||
|
||||
# ToolError text carries the original exception message verbatim,
|
||||
# which is what the lowlevel handler copies into TextContent.
|
||||
message = str(tool_exc.value)
|
||||
assert "phone-1" in message
|
||||
assert "busy" in message
|
||||
assert "mcp_session:sess-oth" in message # truncated busy_owner
|
||||
|
||||
# The lowlevel handler converts any exception into a CallToolResult
|
||||
# with isError=True (mcp SDK 1.28.1 — not a JSON-RPC error envelope).
|
||||
# We invoke the SDK helper directly to lock the wire contract.
|
||||
from mcp.server.lowlevel.server import Server as LowlevelServer
|
||||
|
||||
lowlevel = LowlevelServer("test-lowlevel")
|
||||
error_result = lowlevel._make_error_result(message) # type: ignore[attr-defined]
|
||||
inner = error_result.root
|
||||
assert isinstance(inner, types.CallToolResult)
|
||||
assert inner.isError is True
|
||||
assert len(inner.content) == 1
|
||||
text_block = inner.content[0]
|
||||
assert isinstance(text_block, types.TextContent)
|
||||
assert text_block.text == message
|
||||
# And confirm the wire shape is NOT a JSON-RPC error envelope — that
|
||||
# would require code=-32000 + data.busy_owner, which is not exposed
|
||||
# in mcp SDK 1.28.1 for tool-call errors.
|
||||
assert not hasattr(inner, "code")
|
||||
assert inner.structuredContent is None
|
||||
|
||||
|
||||
def test_busy_error_text_includes_cloud_assignment_owner() -> None:
|
||||
"""Same wire-shape test for the cloud_assignment branch — verifies the
|
||||
human-readable busy_owner value (the only place to surface it given the
|
||||
SDK forces tool errors into CallToolResult.isError=true) is correct."""
|
||||
import asyncio
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
status.mark_assignment_started(
|
||||
AssignmentModel(
|
||||
task_id="t1",
|
||||
attempt=1,
|
||||
lease_id="l1",
|
||||
lease_expires_at=datetime.now(UTC),
|
||||
host_id="h1",
|
||||
device_id="phone-1",
|
||||
goal="cloud task",
|
||||
)
|
||||
)
|
||||
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
|
||||
assert tool is not None
|
||||
|
||||
sentinel_session = object()
|
||||
ctx = _fake_ctx(sentinel_session)
|
||||
|
||||
with pytest.raises(ToolError) as tool_exc:
|
||||
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
|
||||
assert "cloud_assignment" in str(tool_exc.value)
|
||||
assert "phone-1" in str(tool_exc.value)
|
||||
assert "busy" in str(tool_exc.value)
|
||||
@@ -27,6 +27,12 @@ budgets are enforced only for Hosts reporting `AI_PLANNER_TRANSPORT=cloud`;
|
||||
direct-provider Hosts are labelled **unmetered** rather than budget compliant.
|
||||
The configured proxy reservation ceiling must fit within any daily budget.
|
||||
|
||||
Task readers can inspect full per-step LLM interaction history for
|
||||
cloud-transport tasks in the task detail view. This history includes prompts
|
||||
and resolved tool calls, excludes screenshot bytes, and is unavailable by
|
||||
design for direct-provider Hosts. Retention is configured on the Cloud API via
|
||||
`CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` and its prune interval.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
Boxes,
|
||||
BookOpen,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
|
||||
import PluginsView from "./views/PluginsView.vue";
|
||||
import UsersView from "./views/UsersView.vue";
|
||||
import LlmProvidersView from "./views/LlmProvidersView.vue";
|
||||
import SkillsView from "./views/SkillsView.vue";
|
||||
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
|
||||
|
||||
const activeView = ref<ViewId>("tasks");
|
||||
const currentUser = ref<CloudUser | null>(null);
|
||||
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
|
||||
currentUser.value?.scopes.includes("governance:admin")),
|
||||
);
|
||||
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
|
||||
const canAdminSkills = computed(() => hasScope(currentUser.value, "skills:admin"));
|
||||
const isAuthenticated = computed(() => currentUser.value !== null);
|
||||
const currentUserLabel = computed(() =>
|
||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
||||
@@ -70,6 +73,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
|
||||
if (canAdminProviders.value) {
|
||||
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||
}
|
||||
if (canAdminSkills.value) {
|
||||
items.push({ id: "skills", label: "Skills", icon: BookOpen });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
|
||||
return UsersView;
|
||||
case "providers":
|
||||
return LlmProvidersView;
|
||||
case "skills":
|
||||
return SkillsView;
|
||||
default:
|
||||
return TasksView;
|
||||
}
|
||||
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
|
||||
:can-admin-governance="canAdminGovernance"
|
||||
/>
|
||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
||||
<SkillsView v-else-if="activeView === 'skills'" :can-admin="canAdminSkills" />
|
||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,18 @@ import type {
|
||||
PluginRecord,
|
||||
PluginRegistrationPayload,
|
||||
TaskAttempt,
|
||||
TaskCancellationResponse,
|
||||
TaskListResponse,
|
||||
TaskSubmissionPayload,
|
||||
TaskStatus,
|
||||
TokenUsageEvent,
|
||||
UserListResponse,
|
||||
UserSubmissionPolicy,
|
||||
CloudSkill,
|
||||
CloudSkillListResponse,
|
||||
CloudSkillEntitlementsResponse,
|
||||
HostSkillInventoryResponse,
|
||||
CloudSkillKind,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
@@ -234,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function cancelTask(taskId: string): Promise<TaskCancellationResponse> {
|
||||
return request<TaskCancellationResponse>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskPlannerDecisions(
|
||||
taskId: string,
|
||||
attempt: number,
|
||||
@@ -328,3 +341,76 @@ export function deleteLlmProviderProfile(
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface CloudSkillPayload {
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function listCloudSkills(): Promise<CloudSkillListResponse> {
|
||||
return request<CloudSkillListResponse>("/v1/skills");
|
||||
}
|
||||
|
||||
export function createCloudSkill(payload: CloudSkillPayload): Promise<CloudSkill> {
|
||||
return request<CloudSkill>("/v1/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCloudSkill(
|
||||
skillId: string,
|
||||
payload: CloudSkillPayload,
|
||||
): Promise<CloudSkill> {
|
||||
return request<CloudSkill>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCloudSkill(skillId: string): Promise<void> {
|
||||
return request<void>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listCloudSkillEntitlements(
|
||||
skillId: string,
|
||||
): Promise<CloudSkillEntitlementsResponse> {
|
||||
return request<CloudSkillEntitlementsResponse>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements`,
|
||||
);
|
||||
}
|
||||
|
||||
export function grantCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostSkillInventory(
|
||||
hostId: string,
|
||||
): Promise<HostSkillInventoryResponse> {
|
||||
return request<HostSkillInventoryResponse>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/skill-inventory`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canCancelTask } from "./taskCancellation";
|
||||
import type { TaskStatus } from "./types";
|
||||
|
||||
describe("canCancelTask", () => {
|
||||
it.each<TaskStatus>(["queued", "assigned", "dispatched"])(
|
||||
"allows cancelling a %s task when the caller can submit",
|
||||
(status) => {
|
||||
expect(canCancelTask(status, true)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<TaskStatus>(["done", "failed", "cancelled"])(
|
||||
"refuses to cancel a terminal %s task even when the caller can submit",
|
||||
(status) => {
|
||||
expect(canCancelTask(status, true)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses to cancel a cancellable task when the caller lacks submit permission", () => {
|
||||
expect(canCancelTask("assigned", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TaskStatus } from "./types";
|
||||
|
||||
/**
|
||||
* Statuses for which cancellation is still meaningful: the task has not yet
|
||||
* reached a terminal state. `cancelled` itself is excluded so a task can't be
|
||||
* cancelled twice through the UI.
|
||||
*/
|
||||
const CANCELLABLE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||
"queued",
|
||||
"assigned",
|
||||
"dispatched",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether the Cancel action should be shown/enabled for a task, given the
|
||||
* caller's submit permission and the task's current status.
|
||||
*/
|
||||
export function canCancelTask(status: TaskStatus, canSubmit: boolean): boolean {
|
||||
return canSubmit && CANCELLABLE_STATUSES.has(status);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ export type TaskStatus =
|
||||
| "assigned"
|
||||
| "dispatched"
|
||||
| "done"
|
||||
| "failed";
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface TaskListItem {
|
||||
id: string;
|
||||
@@ -41,6 +42,11 @@ export interface TaskListResponse {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface TaskCancellationResponse {
|
||||
task_id: string;
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
task_id: string;
|
||||
attempt: number;
|
||||
@@ -190,9 +196,44 @@ export interface PlannerDecisionItem {
|
||||
user_prompt: string;
|
||||
tool_name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
rationale?: string | null;
|
||||
thinking?: string | null;
|
||||
purpose?: string | null;
|
||||
expected_outcome?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlannerDecisionListResponse {
|
||||
items: PlannerDecisionItem[];
|
||||
}
|
||||
|
||||
export type CloudSkillKind = "knowledge" | "flow_template";
|
||||
|
||||
export interface CloudSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface CloudSkillListResponse {
|
||||
items: CloudSkill[];
|
||||
}
|
||||
|
||||
export interface CloudSkillEntitlementsResponse {
|
||||
skill_id: string;
|
||||
host_ids: string[];
|
||||
}
|
||||
|
||||
export interface HostSkillInventoryResponse {
|
||||
host_id: string;
|
||||
payload: Record<string, unknown>[];
|
||||
reported_at: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
||||
import {
|
||||
createCloudSkill,
|
||||
deleteCloudSkill,
|
||||
grantCloudSkillEntitlement,
|
||||
listCloudSkillEntitlements,
|
||||
listCloudSkills,
|
||||
revokeCloudSkillEntitlement,
|
||||
updateCloudSkill,
|
||||
getHostSkillInventory,
|
||||
type CloudSkillPayload,
|
||||
} from "../api";
|
||||
import type { CloudSkill, CloudSkillKind, HostSkillInventoryResponse } from "../types";
|
||||
|
||||
defineProps<{ canAdmin: boolean }>();
|
||||
|
||||
const skills = ref<CloudSkill[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const entitlementsFor = ref<Record<string, string[]>>({});
|
||||
const entitlementHostInput = ref<Record<string, string>>({});
|
||||
|
||||
const inventoryHostId = ref("");
|
||||
const inventory = ref<HostSkillInventoryResponse | null>(null);
|
||||
const inventoryLoading = ref(false);
|
||||
|
||||
const form = reactive<CloudSkillPayload>({
|
||||
name: "",
|
||||
kind: "knowledge",
|
||||
description: "",
|
||||
tags: [],
|
||||
content: "",
|
||||
steps: [],
|
||||
parameters: {},
|
||||
});
|
||||
const tagsInput = ref("");
|
||||
const stepsJson = ref("[]");
|
||||
const parametersJson = ref("{}");
|
||||
const formError = ref("");
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.name = "";
|
||||
form.kind = "knowledge";
|
||||
form.description = "";
|
||||
form.tags = [];
|
||||
form.content = "";
|
||||
form.steps = [];
|
||||
form.parameters = {};
|
||||
tagsInput.value = "";
|
||||
stepsJson.value = "[]";
|
||||
parametersJson.value = "{}";
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await listCloudSkills();
|
||||
skills.value = response.items;
|
||||
await Promise.all(skills.value.map(loadEntitlements));
|
||||
} catch (error) {
|
||||
showError(error, "failed to load skills");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlements(skill: CloudSkill) {
|
||||
try {
|
||||
const resp = await listCloudSkillEntitlements(skill.id);
|
||||
entitlementsFor.value[skill.id] = resp.host_ids;
|
||||
} catch {
|
||||
entitlementsFor.value[skill.id] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function editSkill(skill: CloudSkill) {
|
||||
editingId.value = skill.id;
|
||||
form.name = skill.name;
|
||||
form.kind = skill.kind;
|
||||
form.description = skill.description;
|
||||
form.tags = [...skill.tags];
|
||||
tagsInput.value = skill.tags.join(", ");
|
||||
form.content = skill.content;
|
||||
form.steps = skill.steps;
|
||||
form.parameters = skill.parameters;
|
||||
stepsJson.value = JSON.stringify(skill.steps, null, 2);
|
||||
parametersJson.value = JSON.stringify(skill.parameters, null, 2);
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
}
|
||||
|
||||
function buildPayload(): CloudSkillPayload | null {
|
||||
if (!form.name.trim()) {
|
||||
formError.value = "name is required";
|
||||
return null;
|
||||
}
|
||||
let steps: Record<string, unknown>[] = [];
|
||||
let parameters: Record<string, Record<string, unknown>> = {};
|
||||
if (form.kind === "flow_template") {
|
||||
try {
|
||||
steps = JSON.parse(stepsJson.value || "[]");
|
||||
parameters = JSON.parse(parametersJson.value || "{}");
|
||||
} catch {
|
||||
formError.value = "steps/parameters must be valid JSON";
|
||||
return null;
|
||||
}
|
||||
} else if (!form.content.trim()) {
|
||||
formError.value = "knowledge skill content is required";
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
kind: form.kind as CloudSkillKind,
|
||||
description: form.description,
|
||||
tags: tagsInput.value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
content: form.content,
|
||||
steps,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
formError.value = "";
|
||||
const payload = buildPayload();
|
||||
if (payload === null) return;
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateCloudSkill(editingId.value, payload);
|
||||
successMessage.value = "skill updated";
|
||||
} else {
|
||||
await createCloudSkill(payload);
|
||||
successMessage.value = "skill created";
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to save skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSkill(skill: CloudSkill) {
|
||||
try {
|
||||
await deleteCloudSkill(skill.id);
|
||||
successMessage.value = "skill deleted";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to delete skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function addHost(skillId: string) {
|
||||
const hostId = (entitlementHostInput.value[skillId] || "").trim();
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await grantCloudSkillEntitlement(skillId, hostId);
|
||||
entitlementHostInput.value[skillId] = "";
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to grant entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHost(skillId: string, hostId: string) {
|
||||
try {
|
||||
await revokeCloudSkillEntitlement(skillId, hostId);
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to revoke entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventory() {
|
||||
const hostId = inventoryHostId.value.trim();
|
||||
if (!hostId) return;
|
||||
inventoryLoading.value = true;
|
||||
try {
|
||||
inventory.value = await getHostSkillInventory(hostId);
|
||||
} catch (error) {
|
||||
showError(error, "failed to load host inventory");
|
||||
} finally {
|
||||
inventoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="skills-view">
|
||||
<header class="row">
|
||||
<h2>Skills</h2>
|
||||
<button :disabled="loading" @click="refresh">
|
||||
<RefreshCw :size="14" /> Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="success">{{ successMessage }}</p>
|
||||
|
||||
<form v-if="canAdmin" class="skill-form" @submit.prevent="saveSkill">
|
||||
<h3>{{ editingId ? "Edit skill" : "New skill" }}</h3>
|
||||
<label>name <input v-model="form.name" /></label>
|
||||
<label>kind
|
||||
<select v-model="form.kind">
|
||||
<option value="knowledge">knowledge</option>
|
||||
<option value="flow_template">flow_template</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>description <input v-model="form.description" /></label>
|
||||
<label>tags (comma-separated) <input v-model="tagsInput" /></label>
|
||||
<label v-if="form.kind === 'knowledge'">content
|
||||
<textarea v-model="form.content" rows="4"></textarea>
|
||||
</label>
|
||||
<template v-else>
|
||||
<label>steps (JSON)
|
||||
<textarea v-model="stepsJson" rows="4"></textarea>
|
||||
</label>
|
||||
<label>parameters (JSON)
|
||||
<textarea v-model="parametersJson" rows="4"></textarea>
|
||||
</label>
|
||||
</template>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<div class="row">
|
||||
<button type="submit"><Plus :size="14" /> {{ editingId ? "Save" : "Create" }}</button>
|
||||
<button type="button" @click="resetForm"><X :size="14" /> Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<LoaderCircle v-if="loading" class="spin" :size="20" />
|
||||
<ul v-else class="skill-list">
|
||||
<li v-for="skill in skills" :key="skill.id">
|
||||
<div class="skill-head">
|
||||
<strong>{{ skill.name }}</strong>
|
||||
<span class="badge">{{ skill.kind }}</span>
|
||||
<span class="muted">rev {{ skill.revision }}</span>
|
||||
<div class="row">
|
||||
<button v-if="canAdmin" @click="editSkill(skill)"><Pencil :size="12" /> edit</button>
|
||||
<button v-if="canAdmin" @click="removeSkill(skill)"><Trash2 :size="12" /> delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">{{ skill.description }}</p>
|
||||
<div class="entitlements">
|
||||
<span>entitled hosts:</span>
|
||||
<span v-for="host in entitlementsFor[skill.id] || []" :key="host" class="chip">
|
||||
{{ host }}
|
||||
<button v-if="canAdmin" @click="removeHost(skill.id, host)"><X :size="10" /></button>
|
||||
</span>
|
||||
<template v-if="canAdmin">
|
||||
<input
|
||||
v-model="entitlementHostInput[skill.id]"
|
||||
placeholder="host id"
|
||||
@keyup.enter="addHost(skill.id)"
|
||||
/>
|
||||
<button @click="addHost(skill.id)">grant</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section class="inventory">
|
||||
<h3>Host local-skill inventory</h3>
|
||||
<div class="row">
|
||||
<input v-model="inventoryHostId" placeholder="host id" @keyup.enter="loadInventory" />
|
||||
<button :disabled="inventoryLoading" @click="loadInventory">view</button>
|
||||
</div>
|
||||
<p v-if="inventory && inventory.payload.length === 0" class="muted">no local skills reported</p>
|
||||
<ul v-if="inventory && inventory.payload.length">
|
||||
<li v-for="(item, idx) in inventory.payload" :key="idx">
|
||||
{{ item.name }} ({{ item.kind }}) — origin {{ item.origin }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="inventory?.reported_at" class="muted">reported {{ inventory.reported_at }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skills-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.skill-form { display: flex; flex-direction: column; gap: 0.5rem; border: 1px solid var(--border, #ccc); padding: 1rem; border-radius: 6px; }
|
||||
.skill-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
|
||||
.skill-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.skill-list li { border: 1px solid var(--border, #ccc); padding: 0.75rem; border-radius: 6px; }
|
||||
.skill-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.skill-head .row { margin-left: auto; }
|
||||
.badge { font-size: 0.7rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
.muted { color: var(--muted, #888); font-size: 0.8rem; }
|
||||
.entitlements { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 0.5rem; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 10px; font-size: 0.75rem; }
|
||||
.chip button { border: none; background: none; cursor: pointer; padding: 0; display: flex; }
|
||||
input, select, textarea { padding: 0.3rem; border: 1px solid var(--border, #ccc); border-radius: 4px; }
|
||||
button { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error { color: #c00; } .success { color: #070; }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
cancelTask,
|
||||
getTaskAttempts,
|
||||
getTaskPlannerDecisions,
|
||||
listTasks,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
PlannerDecisionItem,
|
||||
} from "../types";
|
||||
import { formatTaskProgress } from "../taskProgress";
|
||||
import { canCancelTask } from "../taskCancellation";
|
||||
import { computePlannerHistoryState } from "../plannerHistory";
|
||||
|
||||
const props = defineProps<{ canSubmit: boolean }>();
|
||||
@@ -30,6 +32,7 @@ const STATUSES: TaskStatus[] = [
|
||||
"dispatched",
|
||||
"done",
|
||||
"failed",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
const statusFilter = ref<TaskStatus | "">("");
|
||||
@@ -55,6 +58,7 @@ const devices = ref<DeviceRecord[]>([]);
|
||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||
const plannerLoading = ref(false);
|
||||
const plannerError = ref("");
|
||||
const cancelling = ref(false);
|
||||
const availableDevices = computed(() =>
|
||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||
);
|
||||
@@ -83,6 +87,8 @@ async function refresh() {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
} else {
|
||||
await selectTask(stillPresent);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -185,6 +191,21 @@ async function selectTask(task: TaskListItem) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSelectedTask() {
|
||||
if (!selectedTask.value) return;
|
||||
cancelling.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await cancelTask(selectedTask.value.id);
|
||||
selectedTask.value = { ...selectedTask.value, status: response.status };
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "failed to cancel task");
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err: unknown, fallback: string) {
|
||||
if (err instanceof CloudApiError) {
|
||||
errorMessage.value = err.message;
|
||||
@@ -257,6 +278,11 @@ const selectedTaskProgress = computed(() =>
|
||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||
);
|
||||
|
||||
const canCancelSelectedTask = computed(
|
||||
() =>
|
||||
!!selectedTask.value && canCancelTask(selectedTask.value.status, props.canSubmit),
|
||||
);
|
||||
|
||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||
if (!selectedTask.value?.assigned_host_id) return null;
|
||||
const host = hosts.value.find(
|
||||
@@ -417,6 +443,13 @@ function formatArguments(args: Record<string, unknown>): string {
|
||||
<h2>
|
||||
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
||||
</h2>
|
||||
<button
|
||||
v-if="canCancelSelectedTask"
|
||||
:disabled="cancelling"
|
||||
@click="cancelSelectedTask"
|
||||
>
|
||||
{{ cancelling ? "Cancelling…" : "Cancel" }}
|
||||
</button>
|
||||
<button @click="clearSelection">Back to list</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
@@ -504,6 +537,22 @@ function formatArguments(args: Record<string, unknown>): string {
|
||||
<summary>Arguments</summary>
|
||||
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.purpose">
|
||||
<summary>Action purpose</summary>
|
||||
<pre class="planner-prompt">{{ decision.purpose }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.expected_outcome">
|
||||
<summary>Expected outcome</summary>
|
||||
<pre class="planner-prompt">{{ decision.expected_outcome }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.rationale">
|
||||
<summary>Rationale</summary>
|
||||
<pre class="planner-prompt">{{ decision.rationale }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.thinking">
|
||||
<summary>Thinking</summary>
|
||||
<pre class="planner-prompt">{{ decision.thinking }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
||||
|
||||
@@ -27,6 +27,10 @@ services:
|
||||
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
|
||||
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY}
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096}
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300}
|
||||
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600}
|
||||
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}
|
||||
ports:
|
||||
- "${CLOUD_API_PORT:-8001}:8001"
|
||||
depends_on:
|
||||
|
||||
@@ -28,6 +28,10 @@ services:
|
||||
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
|
||||
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY}
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096}
|
||||
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300}
|
||||
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600}
|
||||
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}
|
||||
ports:
|
||||
- "${CLOUD_API_PORT:-8001}:8001"
|
||||
depends_on:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -1,46 +0,0 @@
|
||||
# Apex Agent Console
|
||||
|
||||
Independent Vue 3 + Vite SPA for the operator console.
|
||||
|
||||
## Run Locally
|
||||
|
||||
Start the backend from the repository root:
|
||||
|
||||
```bash
|
||||
uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Start the frontend from this directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`.
|
||||
Copy `.env.example` to `.env.local` if the backend runs on another host or port.
|
||||
|
||||
The backend mounts `/console/*` routes and enables permissive CORS in `create_app()`
|
||||
for local frontend development.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Same-Origin, Single-Process Mode
|
||||
|
||||
For an edge/dev setup where running a separate `npm run dev` process is too heavy,
|
||||
the backend can serve the built console directly from the same process:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL= npm run build
|
||||
RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works
|
||||
same-origin without CORS. The console is then served at `/ui/` (with `/`
|
||||
redirecting there); `/console/*` remains the JSON API used by both this mode
|
||||
and local `npm run dev`. Rebuild (`npm run build`) after frontend changes —
|
||||
this mode does not hot-reload.
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Apex Agent Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1211
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "apex-agent-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.23.0",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vue-tsc": "^3.3.6"
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
MonitorSmartphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
getConfig,
|
||||
getTask,
|
||||
getTimeline,
|
||||
listDevices,
|
||||
listTasks,
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
updateConfig,
|
||||
} from "./api";
|
||||
import type { Device, TaskRecord, TimelineRecord } from "./types";
|
||||
|
||||
type ViewId = "dashboard" | "tasks" | "config";
|
||||
|
||||
const navItems: { id: ViewId; label: string; icon: Component }[] = [
|
||||
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
|
||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||
{ id: "config", label: "Config", icon: Settings2 },
|
||||
];
|
||||
|
||||
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
|
||||
|
||||
const activeView = ref<ViewId>("dashboard");
|
||||
const loading = ref(false);
|
||||
const refreshError = ref("");
|
||||
const devices = ref<Device[]>([]);
|
||||
const tasks = ref<TaskRecord[]>([]);
|
||||
const selectedTask = ref<TaskRecord | null>(null);
|
||||
const timeline = ref<TimelineRecord[]>([]);
|
||||
const selectedStepIndex = ref(0);
|
||||
const deviceFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
const deviceError = ref("");
|
||||
const configError = ref("");
|
||||
const configSaved = ref("");
|
||||
const maxSteps = ref(20);
|
||||
const deviceForm = reactive({
|
||||
name: "",
|
||||
driver_type: "wda",
|
||||
server_url: "http://127.0.0.1:4723",
|
||||
udid: "",
|
||||
wda_local_port: "",
|
||||
});
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
const currentStep = computed<TimelineRecord | null>(() => {
|
||||
if (!timeline.value.length) {
|
||||
return null;
|
||||
}
|
||||
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
|
||||
});
|
||||
|
||||
const runningTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "running").length,
|
||||
);
|
||||
|
||||
const failedTasks = computed(
|
||||
() => tasks.value.filter((task) => task.status === "failed").length,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
refreshError.value = "";
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([refreshDevices(), refreshTasks()]);
|
||||
} catch (error) {
|
||||
refreshError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices(): Promise<void> {
|
||||
devices.value = await listDevices();
|
||||
}
|
||||
|
||||
async function refreshTasks(): Promise<void> {
|
||||
tasks.value = await listTasks({
|
||||
deviceId: deviceFilter.value,
|
||||
status: statusFilter.value,
|
||||
});
|
||||
if (selectedTask.value) {
|
||||
await openTask(selectedTask.value.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig(): Promise<void> {
|
||||
const config = await getConfig();
|
||||
maxSteps.value = config.max_steps;
|
||||
}
|
||||
|
||||
async function applyTaskFilters(): Promise<void> {
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
async function openTask(taskId: string, switchView = true): Promise<void> {
|
||||
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
|
||||
selectedTask.value = task;
|
||||
timeline.value = records;
|
||||
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
|
||||
if (switchView) {
|
||||
activeView.value = "tasks";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDevice(): Promise<void> {
|
||||
deviceError.value = "";
|
||||
const connectionInfo: Record<string, unknown> = {};
|
||||
if (deviceForm.server_url.trim()) {
|
||||
connectionInfo.server_url = deviceForm.server_url.trim();
|
||||
}
|
||||
if (deviceForm.udid.trim()) {
|
||||
connectionInfo.udid = deviceForm.udid.trim();
|
||||
}
|
||||
if (deviceForm.wda_local_port.trim()) {
|
||||
const port = Number(deviceForm.wda_local_port);
|
||||
if (!Number.isFinite(port)) {
|
||||
deviceError.value = "wda_local_port must be a number";
|
||||
return;
|
||||
}
|
||||
connectionInfo.wda_local_port = port;
|
||||
}
|
||||
|
||||
try {
|
||||
await registerDevice({
|
||||
driver_type: deviceForm.driver_type,
|
||||
name: deviceForm.name.trim() || null,
|
||||
connection_info: connectionInfo,
|
||||
});
|
||||
deviceForm.name = "";
|
||||
deviceForm.udid = "";
|
||||
deviceForm.wda_local_port = "";
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(device: Device): Promise<void> {
|
||||
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
|
||||
return;
|
||||
}
|
||||
deviceError.value = "";
|
||||
try {
|
||||
await unregisterDevice(device.id);
|
||||
await refreshDevices();
|
||||
} catch (error) {
|
||||
deviceError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
configError.value = "";
|
||||
configSaved.value = "";
|
||||
try {
|
||||
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
|
||||
maxSteps.value = updated.max_steps;
|
||||
configSaved.value = "Saved";
|
||||
} catch (error) {
|
||||
configError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep(): void {
|
||||
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
|
||||
}
|
||||
|
||||
function nextStep(): void {
|
||||
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
|
||||
}
|
||||
|
||||
function displayDeviceName(device: Device): string {
|
||||
return device.name || device.id;
|
||||
}
|
||||
|
||||
function findDeviceName(deviceId: string): string {
|
||||
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Request failed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<MonitorSmartphone :size="22" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>{{ API_BASE_URL }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
class="nav-button"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
@click="activeView = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="18" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
|
||||
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
|
||||
</div>
|
||||
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
|
||||
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
|
||||
<RefreshCw v-else :size="17" aria-hidden="true" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
|
||||
|
||||
<section v-if="activeView === 'dashboard'" class="view-grid">
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ devices.length }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ runningTasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failedTasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
<div v-if="!devices.length" class="empty-state">
|
||||
<MonitorSmartphone :size="34" aria-hidden="true" />
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
<ul v-else class="device-list">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill" :class="device.status">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'tasks'" class="tasks-layout">
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>
|
||||
Device
|
||||
<select v-model="deviceFilter" @change="applyTaskFilters">
|
||||
<option value="">All devices</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ displayDeviceName(device) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="statusFilter" @change="applyTaskFilters">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
|
||||
{{ statusName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!tasks.length" class="empty-state compact">
|
||||
<ListChecks :size="30" aria-hidden="true" />
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
v-else
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ selected: selectedTask?.id === task.id }"
|
||||
type="button"
|
||||
@click="openTask(task.id)"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="task.status">{{ task.status }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>Task Detail</h2>
|
||||
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
|
||||
{{ selectedTask.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedTask" class="empty-state">
|
||||
<ListChecks :size="34" aria-hidden="true" />
|
||||
<span>Select a task to inspect its timeline.</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-detail">
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Goal</dt>
|
||||
<dd>{{ selectedTask.goal }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="selectedTask.failure_reason">
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ selectedTask.failure_reason }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Previous step"
|
||||
:disabled="selectedStepIndex === 0"
|
||||
@click="previousStep"
|
||||
>
|
||||
<ChevronLeft :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="Next step"
|
||||
:disabled="selectedStepIndex >= timeline.length - 1"
|
||||
@click="nextStep"
|
||||
>
|
||||
<ChevronRight :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentStep" class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
<div v-else class="timeline-stage">
|
||||
<div class="screenshot-frame">
|
||||
<img
|
||||
v-if="currentStep.image_base64"
|
||||
:src="`data:image/png;base64,${currentStep.image_base64}`"
|
||||
alt="Task step screenshot"
|
||||
/>
|
||||
<span v-else>No screenshot</span>
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Tool Call</h3>
|
||||
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Result</h3>
|
||||
<pre>{{ prettyJson(currentStep.result) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="activeView === 'config'" class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
<form class="form-grid" @submit.prevent="submitDevice">
|
||||
<label>
|
||||
Name
|
||||
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select v-model="deviceForm.driver_type">
|
||||
<option value="wda">wda</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input v-model="deviceForm.server_url" type="url" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input v-model="deviceForm.udid" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<Plus :size="17" aria-hidden="true" />
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
|
||||
|
||||
<ul class="device-list managed">
|
||||
<li v-for="device in devices" :key="device.id" class="device-row">
|
||||
<div>
|
||||
<strong>{{ displayDeviceName(device) }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button danger"
|
||||
type="button"
|
||||
title="Remove device"
|
||||
@click="removeDevice(device)"
|
||||
>
|
||||
<Trash2 :size="17" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
<form class="settings-form" @submit.prevent="saveConfig">
|
||||
<label>
|
||||
Max steps
|
||||
<input v-model.number="maxSteps" type="number" min="1" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<Save :size="17" aria-hidden="true" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="configError" class="alert error">{{ configError }}</p>
|
||||
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,96 +0,0 @@
|
||||
import type {
|
||||
Device,
|
||||
RegisterDevicePayload,
|
||||
RuntimeConfig,
|
||||
TaskRecord,
|
||||
TimelineRecord,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
export const API_BASE_URL = (
|
||||
configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
message = payload.detail;
|
||||
} else if (payload.detail) {
|
||||
message = JSON.stringify(payload.detail);
|
||||
}
|
||||
} catch {
|
||||
message = await response.text();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function listDevices(): Promise<Device[]> {
|
||||
return request<Device[]>("/console/devices");
|
||||
}
|
||||
|
||||
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
|
||||
return request<Device>("/console/devices", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterDevice(deviceId: string): Promise<void> {
|
||||
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listTasks(filters: {
|
||||
deviceId?: string;
|
||||
status?: string;
|
||||
}): Promise<TaskRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.deviceId) {
|
||||
params.set("device_id", filters.deviceId);
|
||||
}
|
||||
if (filters.status) {
|
||||
params.set("status", filters.status);
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export function getTask(taskId: string): Promise<TaskRecord> {
|
||||
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
|
||||
return request<TimelineRecord[]>(
|
||||
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config");
|
||||
}
|
||||
|
||||
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
|
||||
return request<RuntimeConfig>("/console/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -1,552 +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;
|
||||
}
|
||||
|
||||
.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 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;
|
||||
}
|
||||
|
||||
.tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
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.selected {
|
||||
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;
|
||||
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.screenshot-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 360px;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9dde5;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tasks-layout,
|
||||
.timeline-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-browser {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.workspace {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.section-title,
|
||||
.device-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.device-row,
|
||||
.settings-form {
|
||||
flex-direction: column;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.filters,
|
||||
.form-grid,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
export type DeviceStatus = "idle" | "busy" | "offline" | "error";
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string | null;
|
||||
status: DeviceStatus;
|
||||
driver_type: string;
|
||||
connection_info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TaskStatus =
|
||||
| "created"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
goal: string;
|
||||
device_id: string;
|
||||
status: TaskStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export interface TimelineRecord {
|
||||
index: number;
|
||||
scene: Record<string, unknown>;
|
||||
prompt: string;
|
||||
tool_call: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
screenshot_path?: string | null;
|
||||
image_base64?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
max_steps: number;
|
||||
}
|
||||
|
||||
export interface RegisterDevicePayload {
|
||||
driver_type: string;
|
||||
name?: string | null;
|
||||
connection_info: Record<string, unknown>;
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/ui/",
|
||||
});
|
||||
+80
-3
@@ -71,6 +71,41 @@ class Device:
|
||||
}
|
||||
|
||||
|
||||
_STATE_FIELDS = ("enabled", "clickable", "selected", "checked", "focused")
|
||||
_COLOR_FIELDS = ("foreground_color", "background_color")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveApp:
|
||||
"""Native identifier for the application currently in the foreground."""
|
||||
|
||||
platform: str
|
||||
bundle_id: str | None = None
|
||||
package: str | None = None
|
||||
activity: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
data = {"platform": self.platform}
|
||||
for field_name in ("bundle_id", "package", "activity"):
|
||||
value = getattr(self, field_name)
|
||||
if value is not None:
|
||||
data[field_name] = value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ActiveApp":
|
||||
def text(key: str) -> str | None:
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
return cls(
|
||||
platform=text("platform") or "unknown",
|
||||
bundle_id=text("bundle_id"),
|
||||
package=text("package"),
|
||||
activity=text("activity"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneElement:
|
||||
id: str
|
||||
@@ -79,6 +114,18 @@ class SceneElement:
|
||||
text: str | None = None
|
||||
confidence: float | None = None
|
||||
source: str | None = None
|
||||
# Accessibility-tree interaction state, when the platform reports it.
|
||||
# None means "not reported by this platform/element", not "false".
|
||||
enabled: bool | None = None
|
||||
clickable: bool | None = None
|
||||
selected: bool | None = None
|
||||
checked: bool | None = None
|
||||
focused: bool | None = None
|
||||
# Text/background color sampled from the screenshot pixels under the OCR
|
||||
# box ("#rrggbb"). None means unavailable (not OCR-sourced, or sampling
|
||||
# failed), not "no color".
|
||||
foreground_color: str | None = None
|
||||
background_color: str | None = None
|
||||
|
||||
@property
|
||||
def center(self) -> tuple[float, float]:
|
||||
@@ -94,6 +141,10 @@ class SceneElement:
|
||||
}
|
||||
if self.source:
|
||||
data["source"] = self.source
|
||||
for field_name in _STATE_FIELDS + _COLOR_FIELDS:
|
||||
value = getattr(self, field_name)
|
||||
if value is not None:
|
||||
data[field_name] = value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
@@ -105,6 +156,13 @@ class SceneElement:
|
||||
bounds=Bounds.from_dict(data["bounds"]),
|
||||
confidence=data.get("confidence"),
|
||||
source=data.get("source"),
|
||||
enabled=data.get("enabled"),
|
||||
clickable=data.get("clickable"),
|
||||
selected=data.get("selected"),
|
||||
checked=data.get("checked"),
|
||||
focused=data.get("focused"),
|
||||
foreground_color=data.get("foreground_color"),
|
||||
background_color=data.get("background_color"),
|
||||
)
|
||||
|
||||
|
||||
@@ -113,22 +171,42 @@ class Scene:
|
||||
width: int
|
||||
height: int
|
||||
elements: list[SceneElement] = field(default_factory=list)
|
||||
# Keep raw OCR observations for local execution evidence without duplicating
|
||||
# them in the normalized, LLM-facing scene payload.
|
||||
ocr_elements: list[SceneElement] = field(default_factory=list)
|
||||
# The foreground app is supplied by the Driver, separately from the
|
||||
# accessibility tree, and is absent for drivers that cannot query it.
|
||||
# Kept last to preserve Scene's existing positional constructor arguments.
|
||||
active_app: ActiveApp | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
data: dict[str, Any] = {
|
||||
"screen": {"width": self.width, "height": self.height},
|
||||
"elements": [element.to_dict() for element in self.elements],
|
||||
}
|
||||
if self.active_app is not None:
|
||||
data["app"] = self.active_app.to_dict()
|
||||
return data
|
||||
|
||||
def ocr_results_to_dict(self) -> list[dict[str, Any]]:
|
||||
return [element.to_dict() for element in self.ocr_elements]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Scene":
|
||||
screen = data.get("screen") or {}
|
||||
raw_app = data.get("app")
|
||||
return cls(
|
||||
width=int(screen.get("width") or data.get("width") or 0),
|
||||
height=int(screen.get("height") or data.get("height") or 0),
|
||||
elements=[
|
||||
SceneElement.from_dict(element) for element in data.get("elements", [])
|
||||
],
|
||||
active_app=ActiveApp.from_dict(raw_app)
|
||||
if isinstance(raw_app, dict)
|
||||
else None,
|
||||
ocr_elements=[
|
||||
SceneElement.from_dict(element)
|
||||
for element in data.get("elements", [])
|
||||
for element in data.get("ocr_elements", [])
|
||||
],
|
||||
)
|
||||
|
||||
@@ -179,4 +257,3 @@ class Step:
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,26 @@ class DeviceManager:
|
||||
self._drivers.pop(device_id, None)
|
||||
self._set_status(device_id, "offline" if offline else "error")
|
||||
|
||||
def probe(self, device_id: str) -> bool:
|
||||
"""Check whether an established driver is still reachable."""
|
||||
with self._lock:
|
||||
self._device(device_id)
|
||||
driver = self._drivers.get(device_id)
|
||||
if driver is None:
|
||||
return False
|
||||
try:
|
||||
health_check = getattr(driver, "health_check", None)
|
||||
if callable(health_check):
|
||||
health_check()
|
||||
else:
|
||||
# Compatibility for drivers implemented before health_check
|
||||
# existed. Built-in drivers use the non-screen health check.
|
||||
driver.screenshot()
|
||||
except Exception:
|
||||
self.mark_error(device_id, offline=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
def active_driver(self, device_id: str | None = None) -> Driver:
|
||||
with self._lock:
|
||||
if device_id is None:
|
||||
|
||||
+102
-51
@@ -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,33 +383,21 @@ 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.
|
||||
Provide provider credentials before deploying:
|
||||
With `AI_PLANNER_TRANSPORT` unset, the Host Agent uses the **`cloud`**
|
||||
transport. Configure an active Cloud Provider profile before deploying; without
|
||||
one, every planning step raises immediately and the task fails on its first
|
||||
step (no silent fallback to the stub planner). Set `AI_PLANNER_ENABLED=false`
|
||||
to opt back out to the deterministic stub planner (e.g. for offline/dev hosts
|
||||
with no provider credentials).
|
||||
|
||||
```text
|
||||
AI_PLANNER_PROVIDER=anthropic
|
||||
AI_PLANNER_MODEL=claude-sonnet-5
|
||||
AI_PLANNER_TIMEOUT_SECONDS=30
|
||||
ANTHROPIC_API_KEY=<secret manager reference>
|
||||
```
|
||||
### Cloud-proxy transport (default)
|
||||
|
||||
Without a valid API key, every planning step raises immediately and the task
|
||||
fails on its first step (no silent fallback to the stub planner). Set
|
||||
`AI_PLANNER_ENABLED=false` to opt back out to the deterministic stub planner
|
||||
(e.g. for offline/dev hosts with no provider credentials).
|
||||
|
||||
For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through
|
||||
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. This is the **`direct`
|
||||
transport** (the default): the Host Agent holds provider credentials and
|
||||
calls Anthropic/OpenAI itself.
|
||||
|
||||
### Cloud-proxy transport (`AI_PLANNER_TRANSPORT=cloud`)
|
||||
|
||||
Set `AI_PLANNER_TRANSPORT=cloud` on the Host Agent to instead route every
|
||||
planning decision through the Cloud API's
|
||||
With `AI_PLANNER_TRANSPORT` unset or set to `cloud`, every planning decision
|
||||
routes through the Cloud API's
|
||||
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
|
||||
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
|
||||
mode:
|
||||
@@ -416,8 +407,11 @@ mode:
|
||||
sign in to `/console/` as an administrator and create an active entry under
|
||||
**LLM providers**. Provider API keys are encrypted in the database and are
|
||||
never returned by the API or Console. Edge Hosts do not hold Provider keys.
|
||||
The Cloud API does not read `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`,
|
||||
`AI_PLANNER_TIMEOUT_SECONDS`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`.
|
||||
The profile's provider, model, base URL, and timeout (1 to 120 seconds) are
|
||||
the authority for every Cloud-proxy call. The Cloud API does not read
|
||||
`AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`,
|
||||
`ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`; those variables apply only to the
|
||||
explicit `direct` transport.
|
||||
- **Profile types:** choose **Anthropic** for native Anthropic tool use, or
|
||||
**OpenAI-compatible** for the OpenAI Chat Completions tool-calling protocol.
|
||||
Both accept an optional absolute HTTP(S) Base URL; leave it blank for the
|
||||
@@ -425,9 +419,9 @@ mode:
|
||||
provider's existing request schema and authentication; custom headers or
|
||||
incompatible parameter dialects are not supported by this path.
|
||||
- **Activation is immediate:** a newly activated enabled profile becomes the
|
||||
Provider/model for the next Cloud-proxy planner decision. A Cloud-planner
|
||||
request fails closed until one enabled profile is active; it never falls back
|
||||
to a Cloud API environment credential.
|
||||
Provider/model/timeout for the next Cloud-proxy planner decision. A
|
||||
Cloud-planner request fails closed until one enabled profile is active; it
|
||||
never falls back to a Cloud API environment credential.
|
||||
- **Trade-offs to accept before enabling:**
|
||||
- *Latency*: every planning step now makes a round trip to the Cloud API in
|
||||
addition to the LLM provider call.
|
||||
@@ -435,13 +429,37 @@ mode:
|
||||
Cloud API outages via retry/backoff), a planning step fails immediately if
|
||||
the Cloud API or its configured provider is unreachable -- there is no
|
||||
fallback to the stub planner or to a local direct call.
|
||||
- *Expanded data path*: goal/scene prompts and screenshots now transit the
|
||||
Cloud API. The endpoint logs only metadata (host id, resolved tool name,
|
||||
latency, error class) and never prompt text or screenshot bytes, but the
|
||||
request bodies themselves do cross the network to the control plane.
|
||||
- *Expanded data path and retained history*: goal/scene prompts and
|
||||
screenshots transit the Cloud API. Application logs retain only metadata
|
||||
(host id, resolved tool name, latency, error class), but every successful
|
||||
Cloud-proxy decision with task context is also stored as system prompt,
|
||||
user prompt, resolved tool name, arguments, and step index. The Cloud
|
||||
Console task detail exposes that history to authorized task readers. The
|
||||
decision log never stores screenshot bytes; direct-transport Hosts produce
|
||||
no Cloud-side LLM history.
|
||||
- *Retention*: `CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` defaults to `7`.
|
||||
The Cloud prunes a terminal task's decision rows after that window;
|
||||
`CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS` defaults to `3600`.
|
||||
Prompt retention is therefore a deliberate operational and data-handling
|
||||
choice, not merely transient request processing.
|
||||
|
||||
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing
|
||||
direct-to-provider behavior with no change.
|
||||
### Direct transport (explicit opt-out)
|
||||
|
||||
Set `AI_PLANNER_TRANSPORT=direct` only for Hosts that must call a provider
|
||||
without the Cloud proxy. Those Hosts hold their own provider credentials:
|
||||
|
||||
```text
|
||||
AI_PLANNER_TRANSPORT=direct
|
||||
AI_PLANNER_PROVIDER=anthropic
|
||||
AI_PLANNER_MODEL=claude-sonnet-5
|
||||
AI_PLANNER_TIMEOUT_SECONDS=30
|
||||
ANTHROPIC_API_KEY=<secret manager reference>
|
||||
```
|
||||
|
||||
For OpenAI, set `AI_PLANNER_PROVIDER=openai` and provide `OPENAI_API_KEY`.
|
||||
Direct Hosts are not covered by Cloud token budgets or Cloud-side Provider key
|
||||
rotation. `AI_PLANNER_TIMEOUT_SECONDS` controls the provider call only in this
|
||||
direct mode.
|
||||
|
||||
### Host governance and Cloud-proxy token budgets
|
||||
|
||||
@@ -458,8 +476,10 @@ current UTC day. Set `CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS` (default
|
||||
`300`) to bound an unknown-usage reservation after provider/transport failure.
|
||||
The daily Host budget must accommodate the reservation ceiling; otherwise the
|
||||
proxy rejects before calling the provider. On a provider response, the
|
||||
reservation is settled to reported usage and the Console retains only timestamp,
|
||||
provider/model, token counts, and optional task/attempt identifiers.
|
||||
reservation is settled to reported usage and the separate usage ledger retains
|
||||
only timestamp, provider/model, token counts, and optional task/attempt
|
||||
identifiers. This ledger is distinct from the bounded planner-decision history
|
||||
described above.
|
||||
|
||||
Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as
|
||||
**unmetered**. Cloud cannot enforce or verify their provider token use. Do not
|
||||
@@ -476,6 +496,29 @@ legacy Provider environment configuration, while a rollback to `direct`
|
||||
transport requires valid provider credentials on that Host; preserve usage and
|
||||
policy rows rather than deleting accounting history.
|
||||
|
||||
## Skill Management
|
||||
|
||||
Cloud-origin Skills are administrator-managed through the Cloud Console's
|
||||
**Skills** view (requires the `skills:admin` scope, which administrators
|
||||
hold via the `*` scope). Create/edit/delete skills (knowledge or flow-template
|
||||
kinds), and grant or revoke per-host entitlement — an agent only ever sees the
|
||||
cloud skills entitled to its own host.
|
||||
|
||||
Agents pull their entitled cloud skills incrementally from the Cloud API
|
||||
(`GET /internal/v1/hosts/{host_id}/skills/sync`) on a configurable cadence
|
||||
(`HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS`, default 300s) and cache them in a
|
||||
local SQLite file (`tasks/skills.sqlite3`). Agents may also author their own
|
||||
**local skills** (persisted in a separate `tasks/local_skills.sqlite3`) and
|
||||
**override** a cloud skill locally via the MCP authoring tools; an override
|
||||
shadows the cloud skill until removed, and forks into a standalone local skill
|
||||
if the cloud entitlement is revoked. Agents report a best-effort read-only
|
||||
inventory of their local skills to the Cloud so the Console can display them
|
||||
per host.
|
||||
|
||||
The cloud skill store, entitlement mapping, per-host sync versioning, and
|
||||
inventory readback live in the Cloud platform database (migration
|
||||
`0010_skill_management`).
|
||||
|
||||
## Operational Limitations
|
||||
|
||||
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
|
||||
@@ -497,6 +540,14 @@ device workflows to tolerate repeated actions when the target operation allows
|
||||
it. Do not use this release for operations that require a transactional
|
||||
exactly-once guarantee across the cloud database and an external device.
|
||||
|
||||
Task cancellation is collaborative, not instantaneous, for tasks that have
|
||||
already left the queue. Cancelling a `queued` task takes effect immediately.
|
||||
Cancelling an `assigned`/`dispatched` task only records the request; the
|
||||
owning Host Agent learns about it at its next lease renewal (at most roughly
|
||||
one third of `lease_duration_seconds`, the same interval used for lease-loss
|
||||
detection) and then stops at the next cooperative checkpoint. As with lease
|
||||
loss, an action already sent to a device cannot be rolled back mid-flight.
|
||||
|
||||
## Shutdown And Rollback
|
||||
|
||||
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
|
||||
|
||||
@@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
|
||||
explicit workspace source; the Runtime distribution must never depend on or
|
||||
package `cloud`.
|
||||
|
||||
All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
|
||||
remains outside the Python workspace and keeps its independent npm lifecycle.
|
||||
All Python members share the committed root `uv.lock`. 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
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Host Agent Local Console Task Submission
|
||||
|
||||
The Host Agent local Console adds a **Submit task to current Host** form on
|
||||
the authenticated Tasks page. The form lets an operator who is already
|
||||
signed in to the loopback Console enqueue a goal task for the current Host
|
||||
without going through the Cloud Console or the public SDK.
|
||||
|
||||
The submission reuses the existing Host self-submission contract (a single
|
||||
authenticated `POST /internal/v1/hosts/{host_id}/tasks` against the control
|
||||
plane). It does not introduce a new Cloud endpoint, a public task scope, or
|
||||
a cross-Host target.
|
||||
|
||||
## Automatic vs explicit device
|
||||
|
||||
The form offers two submission modes for the device target:
|
||||
|
||||
- **Automatic** — the form omits the `device_id` field. The Cloud scheduler
|
||||
picks any eligible device currently registered for the authenticated
|
||||
Host. The Console labels this option *Automatic (let Host choose an
|
||||
eligible device)*.
|
||||
- **Explicit device** — the operator selects one of the device IDs that are
|
||||
currently registered in the running `DeviceManager`. In
|
||||
enrollment-managed deployments these are the Cloud device IDs assigned by
|
||||
the Host enrollment flow (the same IDs the heartbeat sends to the
|
||||
control plane), not the local configuration IDs.
|
||||
|
||||
The Console revalidates the selected device against a fresh
|
||||
`DeviceManager.list_devices()` snapshot before issuing the outbound
|
||||
request. If the device was removed between page render and POST, the
|
||||
Console returns a validation error and never calls the Host client.
|
||||
|
||||
## Required state and policy
|
||||
|
||||
- The Console session must be valid (the same local account login used for
|
||||
the rest of the Console). The submission form is rendered only for
|
||||
authenticated sessions, and the POST handler requires both a valid
|
||||
session and the matching CSRF token.
|
||||
- The Host must have completed self-enrollment. The Host self-submission
|
||||
client is wired in by `host_agent.app.create_application` from the same
|
||||
asynchronous `HostAgentClient` instance that drives heartbeat, claim,
|
||||
lease renewal, and result reporting, so the submission form disappears
|
||||
with a clear *submission client is not available yet* message until
|
||||
enrollment is complete.
|
||||
- The Cloud control plane must have `self_submission_enabled = true` in
|
||||
the Host governance policy. A disabled policy is returned to the
|
||||
operator as a definitive 4xx rejection ("Host self-submission is
|
||||
disabled") without recording a successful audit entry; the cached
|
||||
policy is *not* used for local authorization, so a stale cache never
|
||||
silently denies a newly enabled Host.
|
||||
|
||||
## Cloud queue semantics
|
||||
|
||||
- Task creation is **not** idempotent. After the POST reaches the control
|
||||
plane, the Cloud scheduler assigns a task ID. The Console never retries
|
||||
the creation call. Even when the response is lost, a 5xx is returned, or
|
||||
the success payload is malformed, the Console only reports the outcome
|
||||
as **unknown** and instructs the operator to check the Cloud console
|
||||
before submitting again. This avoids the known failure mode where one
|
||||
operator click duplicates a task in the Cloud queue.
|
||||
- The local Console does not show queue progress for a freshly submitted
|
||||
task. After a successful submission the form redirect carries the Cloud
|
||||
task ID in the URL and the Tasks page shows *Task submitted. Cloud task
|
||||
ID: \<id\>*; execution progress, lease state, and the terminal result
|
||||
continue to appear on the local Tasks page only after the Cloud
|
||||
scheduler assigns the task to this Host and the assignment is processed
|
||||
locally.
|
||||
|
||||
## Outcome-unknown procedure
|
||||
|
||||
The Console intentionally distinguishes the **unknown outcome** case from
|
||||
both a confirmed submission and a definitive rejection:
|
||||
|
||||
- A transport failure (DNS, connect, read timeout, dropped connection),
|
||||
any 5xx response, or a malformed 2xx payload makes the local client
|
||||
raise `HostTaskSubmissionUnknownError`. The Console catches it and
|
||||
redirects the operator to `GET /tasks?outcome=unknown`, which renders a
|
||||
*Submission outcome is unknown. The task may have been queued. Check
|
||||
the Cloud console before submitting again.* notice. No history entry is
|
||||
written and no success confirmation is shown.
|
||||
- A definitive 4xx rejection (for example, a disabled self-submission
|
||||
policy or a target device that the control plane does not recognize as
|
||||
owned by this Host) raises `HostAgentAPIError`. The Console renders the
|
||||
Cloud's `detail` message safely through Jinja autoescape without
|
||||
echoing the goal text or any credentials. No history entry is written
|
||||
and no task ID is shown.
|
||||
|
||||
## Auditing
|
||||
|
||||
- Confirmed submissions write a `task_submission` history row that records
|
||||
the Cloud task ID and, when applicable, the target device ID. The row
|
||||
deliberately does **not** persist the goal, the Host token, the
|
||||
session cookie, the lease secret, or any other operator credential. The
|
||||
row is bounded by the existing `console_history_limit` and survives
|
||||
process restarts.
|
||||
- A local audit write failure is best-effort: it does not turn a
|
||||
Cloud-confirmed submission into a retryable failure. A `task_submission`
|
||||
history row is only written after the Cloud returns a 2xx with a usable
|
||||
task ID, so a rejection or unknown outcome can never produce a
|
||||
misleading successful audit entry.
|
||||
|
||||
## Operator checklist
|
||||
|
||||
1. Sign in to the loopback Console with a valid local account.
|
||||
2. Open **Tasks**.
|
||||
3. Type a non-empty goal in the textarea.
|
||||
4. Choose **Automatic** to let the Cloud pick a device, or pick a listed
|
||||
device ID for an explicit target.
|
||||
5. Click **Submit task**.
|
||||
6. On confirmation, the page shows *Task submitted. Cloud task ID: \<id\>*.
|
||||
Track the task from the Cloud console; the local Tasks list will fill
|
||||
in once the scheduler assigns the task to this Host.
|
||||
7. If the page shows the *outcome is unknown* notice, check the Cloud
|
||||
console for a matching task before submitting again.
|
||||
8. If the page shows a Cloud rejection, fix the underlying issue (policy,
|
||||
device ownership, lease state) and submit again. The page does not
|
||||
preserve the goal text in the URL, in the error message, or in the
|
||||
history; retype the goal when retrying.
|
||||
+64
-129
@@ -102,7 +102,7 @@ export WDA_BUNDLE_ID="com.<your-name>.WebDriverAgentRunner"
|
||||
|
||||
### 4.1 Homebrew、Python 和 Node.js
|
||||
|
||||
本项目当前在 `pyproject.toml` 中要求 Python `>=3.14`。Appium 3.1 要求 Node.js
|
||||
本项目当前在 `pyproject.toml` 中要求 Python `>=3.13,<3.14`。Appium 3.1 要求 Node.js
|
||||
`^20.19.0 || ^22.12.0 || >=24.0.0`,并要求 npm `>=10`。
|
||||
|
||||
已安装 Homebrew 时执行:
|
||||
@@ -123,7 +123,7 @@ npm --version
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
uv python install 3.14
|
||||
uv python install 3.13
|
||||
uv sync --locked --all-packages
|
||||
uv run --package device-agent-runtime python --version
|
||||
uv run --package device-cloud-platform python -c "import cloud"
|
||||
@@ -153,9 +153,13 @@ uv pip install \
|
||||
```
|
||||
|
||||
这种降级安装不包含 OCR。`find_text` 和基于 OCR 的 screen description 可能返回空
|
||||
结果,但 WDA 基础控制不受影响。不要为了兼容 PaddleOCR 擅自降到 Python 3.13,
|
||||
因为当前项目元数据明确要求 Python 3.14;如需降级,应先作为独立兼容性变更修改
|
||||
和验证 `pyproject.toml`。
|
||||
结果,但 WDA 基础控制不受影响。
|
||||
|
||||
项目已固定使用 Python 3.13(见 `pyproject.toml` 的 `requires-python`),原因是
|
||||
`paddlepaddle` 在 PyPI 上尚未发布 Python 3.14 (cp314) 的 wheel,3.14 环境下无法
|
||||
安装 `paddlepaddle`,会导致 OCR 相关功能在运行时报 `RuntimeError`。注意
|
||||
`paddlepaddle` 本身并未作为 `paddleocr` 的声明依赖被 `uv sync` 自动安装,需要
|
||||
在 3.13 环境下手动执行 `uv pip install paddlepaddle` 才能让 OCR 引擎真正可用。
|
||||
|
||||
### 4.3 安装 Appium 和 XCUITest Driver
|
||||
|
||||
@@ -269,81 +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 确认坐标,避免
|
||||
误操作。
|
||||
|
||||
如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
|
||||
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
|
||||
操作只写入配置,不会自动 connect。
|
||||
|
||||
如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
|
||||
Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
|
||||
Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
|
||||
指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
|
||||
`npm run build`,不支持热更新。
|
||||
真机连接和任务执行都由下一节的受管 Host Agent 完成。保持 §6 的 Appium 服务
|
||||
可用,然后启动 Host Agent;本机执行记录、截图、OCR 和 UI 树均从其已鉴权的
|
||||
Console 查看。
|
||||
|
||||
## 9. 启动云端受管 Host Agent
|
||||
|
||||
@@ -385,17 +323,29 @@ uv run --package device-host-agent device-host-agent setup
|
||||
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
|
||||
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
|
||||
|
||||
Host Agent 的 Planner transport 默认是 `cloud`。启动前在 Cloud Console 创建并激活
|
||||
LLM Provider profile;Provider API key 只由 Cloud API 加密保存,边缘 Host 不需要也
|
||||
不应配置厂商 API key。`cloud` transport 下的 provider、model、base URL 与 timeout
|
||||
均以激活 Profile 为准,Host 上的 `AI_PLANNER_*` Provider/model/timeout 配置不会覆盖
|
||||
云端 Profile。
|
||||
|
||||
```bash
|
||||
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
|
||||
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
|
||||
|
||||
export AI_PLANNER_ENABLED="true"
|
||||
export AI_PLANNER_PROVIDER="anthropic"
|
||||
export ANTHROPIC_API_KEY="<secret>"
|
||||
|
||||
uv run --package device-host-agent device-host-agent
|
||||
```
|
||||
|
||||
只有需要绕过 Cloud API 时,才显式设置
|
||||
`AI_PLANNER_TRANSPORT=direct`,并在该 Host 上配置
|
||||
`AI_PLANNER_PROVIDER`、`AI_PLANNER_MODEL` 与对应的厂商 API key。
|
||||
|
||||
Cloud transport 的成功规划决策会在云端按任务保存 system/user prompt、工具调用和步骤序号,
|
||||
供有任务读取权限的 Cloud Console 用户排障;截图不会写入该云端决策记录。默认在任务终态
|
||||
7 天后清理,具体配置和数据处理边界见 `docs/CLOUD_DEPLOYMENT.md` 的 Runtime AI Planner 一节。
|
||||
|
||||
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
|
||||
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
|
||||
Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json` 和
|
||||
@@ -447,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
|
||||
# 任务进度持久化路径(默认值,可通过环境变量覆盖)
|
||||
@@ -465,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. 多设备与端口
|
||||
|
||||
@@ -606,6 +527,20 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每
|
||||
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
|
||||
wheel 与 Apple Silicon 兼容性。
|
||||
|
||||
## MCP server (Hermes Agent integration)
|
||||
|
||||
Host-agent now exposes an MCP server on the same port as the local
|
||||
console (`127.0.0.1:8765/mcp`). To drive your iPhone from Hermes Agent
|
||||
or any MCP-compatible client:
|
||||
|
||||
1. Start host-agent normally.
|
||||
2. Get the bearer token: `device-host-agent mcp-token`.
|
||||
3. Configure Hermes per `docs/MCP_INTEGRATION.md`.
|
||||
|
||||
The MCP path reuses the same WDA session that the cloud worker uses.
|
||||
Per-device locking prevents both sides from driving the same device at
|
||||
once; see `docs/MCP_INTEGRATION.md` for the full concurrency model.
|
||||
|
||||
## 12. 完成检查表
|
||||
|
||||
- [ ] Xcode 能看到已解锁的 iPhone。
|
||||
@@ -617,16 +552,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,以及受控环境中的真机验收脚本。
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Host-Agent MCP Server Integration
|
||||
|
||||
The host-agent process exposes a Streamable HTTP MCP server on the same
|
||||
port as the local console (default `127.0.0.1:8765`), at path `/mcp`. This
|
||||
lets any MCP-compatible client — Hermes Agent, Claude Desktop, custom
|
||||
scripts using the `mcp` Python SDK — drive devices directly through the
|
||||
same `DeviceManager` the cloud worker uses.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Host-agent built from this repo (see `docs/MACOS_IPHONE_SETUP.md`).
|
||||
- An MCP client that supports the Streamable HTTP transport (mcp SDK
|
||||
1.20+ on the client side).
|
||||
|
||||
## Get the bearer token
|
||||
|
||||
The first time host-agent starts after this feature ships, it generates
|
||||
a random bearer token and writes it to:
|
||||
|
||||
<identity_path.parent>/host_mcp_token.json
|
||||
|
||||
(Default: `tasks/host_mcp_token.json` next to `host_identity.json`.)
|
||||
|
||||
To print it for copy/paste:
|
||||
|
||||
device-host-agent mcp-token
|
||||
|
||||
To rotate: delete the file and restart host-agent. Old tokens stop
|
||||
working immediately.
|
||||
|
||||
## Hermes Agent configuration
|
||||
|
||||
Add to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
apex_device:
|
||||
url: "http://127.0.0.1:8765/mcp"
|
||||
headers:
|
||||
Authorization: "Bearer <paste-token-here>"
|
||||
```
|
||||
|
||||
Start (or restart) Hermes. Verify by asking Hermes to list devices:
|
||||
|
||||
> Use the apex_device MCP to list connected devices.
|
||||
|
||||
## Tools exposed
|
||||
|
||||
All 11 device tools from `api/mcp.py`:
|
||||
|
||||
- `take_screenshot(device_id?)`
|
||||
- `tap(x, y, device_id?)`
|
||||
- `swipe(start_x, start_y, end_x, end_y, duration_ms?, device_id?)`
|
||||
- `input_text(text, device_id?)`
|
||||
- `launch_app(app_id, device_id?)`
|
||||
- `find_text(query, device_id?)`
|
||||
- `find_icon(name, device_id?)`
|
||||
- `get_ui_tree(device_id?, include_app_info?)`
|
||||
- `describe_screen(device_id?)`
|
||||
- `list_devices()`
|
||||
- `device_status(device_id)`
|
||||
|
||||
## Concurrency model
|
||||
|
||||
- The cloud worker and MCP clients share the same `DeviceManager`.
|
||||
- Per-device, session-level locking: the first caller (cloud or MCP) to
|
||||
touch a device holds it; the other side sees a busy error.
|
||||
- MCP sessions hold their lock until **20 seconds of inactivity**
|
||||
(the `McpBusyTracker` default TTL). The mcp SDK 1.28.1 does not expose
|
||||
a per-session shutdown callback, so a clean Hermes disconnect is also
|
||||
recovered via the 20s TTL sweep — see the implementation note in
|
||||
spec §6.5. Cloud assignments hold theirs until the assignment
|
||||
terminates.
|
||||
- The cloud scheduler is told about MCP-held devices via the heartbeat
|
||||
`mcp_busy_device_ids` field, so it normally won't even try to dispatch
|
||||
to them. A 30-second window exists between an MCP acquire and the next
|
||||
heartbeat; during that window cloud may dispatch, and the host-agent
|
||||
will fail-fast the assignment with `failure_reason="device held by an
|
||||
active MCP session"`.
|
||||
|
||||
## Network binding
|
||||
|
||||
The MCP endpoint is bound to the same address as the local console. By
|
||||
default this is `127.0.0.1` (loopback only). To expose on a different
|
||||
interface, set `HOST_AGENT_CONSOLE_BIND_HOST` AND
|
||||
`HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=true` — both are required. This
|
||||
is the same escape hatch the local console uses; there is no MCP-only
|
||||
override.
|
||||
|
||||
## Error responses
|
||||
|
||||
The mcp SDK 1.28.1 forces tool errors into `CallToolResult(isError=true,
|
||||
content=[TextContent(message)])` — there is no public path that surfaces
|
||||
JSON-RPC `-32000` with a structured `data.busy_owner` field from a tool
|
||||
call site. The busy-owner value lives inside the text content (full
|
||||
string for `cloud_assignment`, truncated session_id prefix for
|
||||
`mcp_session:` collisions).
|
||||
|
||||
| Condition | JSON-RPC envelope | `result.content[0].text` |
|
||||
|---|---|---|
|
||||
| Missing/wrong bearer token | HTTP 401 (transport-level) | `{"error": "invalid token"}` + `WWW-Authenticate: Bearer` |
|
||||
| Device busy (cloud) | `result.isError = true` | `"device <X> is busy (held by cloud assignment)"` |
|
||||
| Device busy (other MCP) | `result.isError = true` | `"device <X> is busy (held by mcp_session:<8-char-prefix>)"` |
|
||||
| Unknown device | `result.isError = false` | JSON `{"ok": false, "error": "device not found: <X>"}` |
|
||||
| Tool error | `result.isError = true` | `"Error executing tool <name>: <original-message>"` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`list_devices` returns `[]`**: no devices registered. Use the local
|
||||
console at `http://127.0.0.1:8765/` to add one (Login → Devices).
|
||||
- **`device X is busy` even when cloud console says device is idle**:
|
||||
check whether another MCP session is holding it. The local console
|
||||
dashboard shows active MCP sessions and held device_ids.
|
||||
- **Token verification fails after restart**: confirm you copied the
|
||||
token from the current `host_mcp_token.json`, not an older one.
|
||||
Rotation = delete file + restart.
|
||||
|
||||
## Out of scope (current version)
|
||||
|
||||
- `wait_until_usable` MCP tool: implemented internally but not exposed.
|
||||
MVP callers must handle busy errors themselves.
|
||||
- MCP call history in the local console: only current state is surfaced,
|
||||
not a call log.
|
||||
- Token rotation CLI: use delete-and-restart for now.
|
||||
- Non-loopback binding without explicit opt-in.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user