Compare commits
106
Commits
a46f7d02a1
...
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 |
@@ -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,
|
||||
@@ -219,6 +273,11 @@ def create_application(
|
||||
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(
|
||||
@@ -234,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
|
||||
),
|
||||
@@ -263,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,
|
||||
@@ -271,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()
|
||||
@@ -319,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,
|
||||
@@ -167,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())
|
||||
|
||||
@@ -219,6 +224,17 @@ class HostAgentClient:
|
||||
"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/{task_id}/cancel",
|
||||
json={"host_id": self.config.host_id},
|
||||
headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
)
|
||||
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(
|
||||
"POST",
|
||||
|
||||
@@ -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
|
||||
@@ -25,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(
|
||||
@@ -34,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
|
||||
@@ -47,8 +50,14 @@ def create_execution_factories(
|
||||
),
|
||||
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:
|
||||
@@ -61,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,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:
|
||||
@@ -96,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)
|
||||
|
||||
@@ -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()
|
||||
@@ -5,13 +5,15 @@ 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 (
|
||||
HostAgentClient,
|
||||
@@ -20,10 +22,15 @@ from host_agent.client import (
|
||||
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,
|
||||
@@ -31,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
|
||||
|
||||
@@ -41,7 +52,9 @@ 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"),
|
||||
@@ -70,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))
|
||||
@@ -82,6 +99,60 @@ 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.
|
||||
|
||||
@@ -159,15 +230,35 @@ def create_console_app(
|
||||
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 [
|
||||
@@ -277,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",
|
||||
@@ -284,6 +379,8 @@ def create_console_app(
|
||||
identity=identity,
|
||||
devices=devices,
|
||||
config=config,
|
||||
mcp_endpoint=mcp_endpoint,
|
||||
mcp_busy_devices=mcp_busy_devices,
|
||||
**texts,
|
||||
)
|
||||
|
||||
@@ -312,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(
|
||||
@@ -338,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,
|
||||
@@ -364,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(
|
||||
@@ -477,6 +768,15 @@ 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,
|
||||
*,
|
||||
@@ -492,9 +792,7 @@ def create_console_app(
|
||||
"title": "Tasks",
|
||||
"session": session,
|
||||
"csrf_token": session.csrf_token,
|
||||
"tasks": metadata_store.list_tasks()
|
||||
if metadata_store is not None
|
||||
else [],
|
||||
"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,
|
||||
@@ -527,7 +825,7 @@ def create_console_app(
|
||||
if task_id:
|
||||
notice = (
|
||||
f"Task submitted. Cloud task ID: {task_id}. "
|
||||
"Track it from the Cloud console for execution progress."
|
||||
"It will appear below when this Host begins executing it."
|
||||
)
|
||||
elif request.query_params.get("outcome") == "unknown":
|
||||
unknown = True
|
||||
@@ -603,9 +901,7 @@ def create_console_app(
|
||||
)
|
||||
|
||||
try:
|
||||
response = await submit_self_task(
|
||||
goal=goal, device_id=explicit_device_id
|
||||
)
|
||||
response = await submit_self_task(goal=goal, device_id=explicit_device_id)
|
||||
except HostAgentAPIError as exc:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
@@ -655,6 +951,7 @@ def create_console_app(
|
||||
@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:
|
||||
@@ -668,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 %}
|
||||
|
||||
@@ -29,17 +29,19 @@
|
||||
</section>
|
||||
|
||||
<section id="local-tasks">
|
||||
<h2>Local Runtime tasks</h2>
|
||||
<h2>Executed tasks on this Host</h2>
|
||||
{% if metadata_store_missing %}
|
||||
<p class="error">Task metadata store is not configured.</p>
|
||||
{% elif not tasks %}
|
||||
<p>No tasks recorded.</p>
|
||||
<p>No executions recorded yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Execution ID</th><th>Cloud task</th><th>Attempt</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>{% for task in tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
|
||||
<td>{{ task.get("source_task_id") or "" }}</td>
|
||||
<td>{{ task.get("source_attempt") if task.get("source_attempt") is not none else "" }}</td>
|
||||
<td>{{ task.get("status") or "" }}</td>
|
||||
<td>{{ task.get("device_id") or "" }}</td>
|
||||
<td>{{ task.get("created_at") or "" }}</td>
|
||||
@@ -49,4 +51,4 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,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]] = []
|
||||
|
||||
@@ -329,6 +353,60 @@ def test_submit_self_task_does_not_duplicate_when_response_is_lost() -> None:
|
||||
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
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ class FakeDriver(Driver):
|
||||
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,
|
||||
@@ -43,6 +46,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
|
||||
|
||||
@@ -130,6 +141,24 @@ def test_created_task_runner_observer_uses_configured_manager(
|
||||
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:
|
||||
@@ -158,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()
|
||||
@@ -167,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,
|
||||
@@ -182,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()
|
||||
@@ -192,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")
|
||||
|
||||
@@ -93,17 +93,14 @@ def test_history_store_records_task_submission_without_device(tmp_path) -> None:
|
||||
|
||||
entries = store.list_recent()
|
||||
|
||||
assert entries[0]["summary"] == (
|
||||
"task submitted: task-cloud-2 (automatic device)"
|
||||
)
|
||||
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"
|
||||
"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")
|
||||
@@ -111,8 +108,7 @@ def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> Non
|
||||
|
||||
entries = store.list_recent()
|
||||
rendered = "\n".join(
|
||||
repr(entry["summary"]) + " " + json.dumps(entry["detail"])
|
||||
for entry in entries
|
||||
repr(entry["summary"]) + " " + json.dumps(entry["detail"]) for entry in entries
|
||||
)
|
||||
|
||||
assert secret_goal not in rendered
|
||||
@@ -135,4 +131,4 @@ def test_history_store_task_submissions_prune_beyond_limit(tmp_path) -> None:
|
||||
"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()
|
||||
@@ -3,25 +3,32 @@ 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(
|
||||
@@ -29,7 +36,11 @@ def _build_client(
|
||||
*,
|
||||
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",
|
||||
@@ -49,9 +60,7 @@ def _build_client(
|
||||
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"
|
||||
)
|
||||
metadata_store = TaskMetadataStore(db_path=tmp_path / "task_metadata.sqlite3")
|
||||
|
||||
app = create_console_app(
|
||||
config=config,
|
||||
@@ -64,7 +73,11 @@ def _build_client(
|
||||
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 = {
|
||||
@@ -229,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)
|
||||
@@ -373,7 +527,11 @@ def _make_submission_recorder(
|
||||
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)
|
||||
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:
|
||||
@@ -444,8 +602,7 @@ def test_authenticated_explicit_device_submission_uses_runtime_id(tmp_path) -> N
|
||||
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "/tasks?submitted=1&task_id=task-cloud-explicit"
|
||||
response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-explicit"
|
||||
)
|
||||
assert captured == {"goal": "open mail", "device_id": "device-cloud-a"}
|
||||
|
||||
@@ -577,9 +734,7 @@ 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"
|
||||
),
|
||||
raise_api_error=HostAgentAPIError(403, "Host self-submission is disabled"),
|
||||
)
|
||||
client, context = _build_client(tmp_path, submit_self_task=submit)
|
||||
context["manager"].register_device(
|
||||
@@ -796,4 +951,288 @@ def test_submitted_redirect_does_not_include_goal_text(tmp_path) -> None:
|
||||
assert "bearer-token-deadbeef" not in response.headers["location"]
|
||||
follow = client.get(response.headers["location"])
|
||||
assert secret_goal not in follow.text
|
||||
assert "bearer-token-deadbeef" 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
|
||||
|
||||
|
||||
+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.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user