Compare commits
72
Commits
7f439f0db5
...
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 |
@@ -60,6 +60,83 @@ operator view for the tasks that actually execute on that Host, including
|
|||||||
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
|
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.
|
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
|
## Project Direction
|
||||||
|
|
||||||
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
|
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
|
||||||
|
|||||||
+41
-30
@@ -3,7 +3,7 @@ from collections.abc import Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.errors import call_with_semantic_errors
|
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.describe_screen import describe_screen
|
||||||
from tools.find_icon import find_icon_on_screen
|
from tools.find_icon import find_icon_on_screen
|
||||||
from tools.find_text import find_text_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(
|
def tool_handlers(
|
||||||
*,
|
*,
|
||||||
manager: DeviceManager | None = None,
|
manager: DeviceManager,
|
||||||
) -> dict[str, Callable[..., Any]]:
|
) -> dict[str, Callable[..., Any]]:
|
||||||
device_manager = manager or DEFAULT_MANAGER
|
|
||||||
|
|
||||||
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"image_base64": base64.b64encode(image).decode("ascii"),
|
"image_base64": base64.b64encode(image).decode("ascii"),
|
||||||
@@ -39,62 +38,65 @@ def tool_handlers(
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
device_id=device_id,
|
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": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: (
|
||||||
swipe,
|
call_with_semantic_errors(
|
||||||
start_x,
|
swipe,
|
||||||
start_y,
|
start_x,
|
||||||
end_x,
|
start_y,
|
||||||
end_y,
|
end_x,
|
||||||
duration_ms=duration_ms,
|
end_y,
|
||||||
device_id=device_id,
|
duration_ms=duration_ms,
|
||||||
manager=device_manager,
|
device_id=device_id,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"input_text": lambda text, device_id=None: call_with_semantic_errors(
|
"input_text": lambda text, device_id=None: call_with_semantic_errors(
|
||||||
input_text,
|
input_text,
|
||||||
text,
|
text,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
|
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
|
||||||
launch_app,
|
launch_app,
|
||||||
app_id,
|
app_id,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"find_text": lambda query, device_id=None: call_with_semantic_errors(
|
"find_text": lambda query, device_id=None: call_with_semantic_errors(
|
||||||
find_text_on_screen,
|
find_text_on_screen,
|
||||||
query,
|
query,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
|
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
|
||||||
find_icon_on_screen,
|
find_icon_on_screen,
|
||||||
name,
|
name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
|
"get_ui_tree": lambda device_id=None, include_app_info=False: (
|
||||||
get_ui_tree,
|
call_with_semantic_errors(
|
||||||
device_id,
|
get_ui_tree,
|
||||||
manager=device_manager,
|
device_id,
|
||||||
|
manager=manager,
|
||||||
|
include_app_info=include_app_info,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"describe_screen": lambda device_id=None: call_with_semantic_errors(
|
"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: [
|
"list_devices": lambda: [device.to_dict() for device in manager.list_devices()],
|
||||||
device.to_dict() for device in device_manager.list_devices()
|
|
||||||
],
|
|
||||||
"device_status": lambda device_id: call_with_semantic_errors(
|
"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(
|
def create_mcp_server(
|
||||||
*,
|
*,
|
||||||
manager: DeviceManager | None = None,
|
manager: DeviceManager,
|
||||||
skill_catalog_store: Any | None = None,
|
skill_catalog_store: Any | None = None,
|
||||||
skill_active_subscriptions: set[str] | None = None,
|
skill_active_subscriptions: set[str] | None = None,
|
||||||
skill_local_store: Any | None = None,
|
skill_local_store: Any | None = None,
|
||||||
@@ -104,6 +106,9 @@ def create_mcp_server(
|
|||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError("mcp SDK is not installed") from 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)
|
handlers = tool_handlers(manager=manager)
|
||||||
server = FastMCP("apex-agent")
|
server = FastMCP("apex-agent")
|
||||||
|
|
||||||
@@ -150,8 +155,14 @@ def create_mcp_server(
|
|||||||
return handlers["find_icon"](name=name, device_id=device_id)
|
return handlers["find_icon"](name=name, device_id=device_id)
|
||||||
|
|
||||||
@server.tool(name="get_ui_tree")
|
@server.tool(name="get_ui_tree")
|
||||||
def _get_ui_tree(device_id: str | None = None) -> Any:
|
def _get_ui_tree(
|
||||||
return handlers["get_ui_tree"](device_id=device_id)
|
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")
|
@server.tool(name="describe_screen")
|
||||||
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
|
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ from device.manager import DeviceManager
|
|||||||
from host_agent.assignment import AssignmentExecutor
|
from host_agent.assignment import AssignmentExecutor
|
||||||
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
||||||
from host_agent.config import HostAgentConfig, load_host_agent_config
|
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.dependency_supervisor import DependencySupervisor
|
||||||
from host_agent.devices import register_local_device
|
from host_agent.devices import register_local_device
|
||||||
from host_agent.enrollment import resolve_host_identity
|
from host_agent.enrollment import resolve_host_identity
|
||||||
@@ -21,6 +24,9 @@ from host_agent.identity import HostIdentityStore
|
|||||||
from host_agent.instance_lock import InstanceLock
|
from host_agent.instance_lock import InstanceLock
|
||||||
from host_agent.lease import ActiveAssignmentRunner
|
from host_agent.lease import ActiveAssignmentRunner
|
||||||
from host_agent.local_account import LocalAccountStore
|
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.policy_cache import HostPolicyCacheStore
|
||||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||||
from host_agent.retention import prune_task_history
|
from host_agent.retention import prune_task_history
|
||||||
@@ -28,6 +34,9 @@ from host_agent.skill_sync import HostAgentSkillSync
|
|||||||
from host_agent.status import AgentStatusTracker
|
from host_agent.status import AgentStatusTracker
|
||||||
from host_agent.web.app import create_console_app
|
from host_agent.web.app import create_console_app
|
||||||
from host_agent.web.auth import SessionManager
|
from host_agent.web.auth import SessionManager
|
||||||
|
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.artifact_store import ArtifactStore
|
||||||
from storage.device_config import DeviceConfigStore
|
from storage.device_config import DeviceConfigStore
|
||||||
from storage.task_metadata import TaskMetadataStore
|
from storage.task_metadata import TaskMetadataStore
|
||||||
@@ -169,25 +178,37 @@ def create_application(
|
|||||||
startup_config.identity_path
|
startup_config.identity_path
|
||||||
)
|
)
|
||||||
owned_enrollment_client = enrollment_client is None
|
owned_enrollment_client = enrollment_client is None
|
||||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(
|
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
||||||
startup_config
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
resolved_config = resolve_host_identity(
|
if startup_config.mode == "local":
|
||||||
startup_config,
|
resolved_config = replace(
|
||||||
identity_store=resolved_identity_store,
|
startup_config, control_plane_url="", host_id="local-host"
|
||||||
client=bootstrap_client,
|
)
|
||||||
)
|
resolved_manager = manager or _configured_device_manager(
|
||||||
bootstrap_client.config = resolved_config
|
config_store, config=resolved_config, enrollment_client=None
|
||||||
resolved_manager = manager or _configured_device_manager(
|
)
|
||||||
config_store,
|
else:
|
||||||
config=resolved_config,
|
resolved_config = resolve_host_identity(
|
||||||
enrollment_client=bootstrap_client,
|
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:
|
finally:
|
||||||
if owned_enrollment_client:
|
if owned_enrollment_client:
|
||||||
bootstrap_client.close()
|
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(
|
history_store = ConsoleHistoryStore(
|
||||||
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
||||||
@@ -201,13 +222,40 @@ def create_application(
|
|||||||
db_path=resolved_config.task_progress_db_path
|
db_path=resolved_config.task_progress_db_path
|
||||||
)
|
)
|
||||||
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
|
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(
|
executor = AssignmentExecutor(
|
||||||
create_execution_factories(
|
create_execution_factories(
|
||||||
resolved_manager,
|
resolved_manager,
|
||||||
metadata_store=metadata_store,
|
metadata_store=metadata_store,
|
||||||
timeline=timeline,
|
timeline=timeline,
|
||||||
host_agent_config=resolved_config,
|
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(
|
console_app = create_console_app(
|
||||||
config=resolved_config,
|
config=resolved_config,
|
||||||
@@ -225,6 +273,11 @@ def create_application(
|
|||||||
metadata_store=metadata_store,
|
metadata_store=metadata_store,
|
||||||
timeline=timeline,
|
timeline=timeline,
|
||||||
executor=executor,
|
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(
|
console_server = _EmbeddedConsoleServer(
|
||||||
uvicorn.Config(
|
uvicorn.Config(
|
||||||
@@ -240,6 +293,7 @@ def create_application(
|
|||||||
client,
|
client,
|
||||||
resolved_config,
|
resolved_config,
|
||||||
status_tracker=status_tracker,
|
status_tracker=status_tracker,
|
||||||
|
mcp_busy_tracker=mcp_busy_tracker,
|
||||||
on_sync=lambda device_count: history_store.record_heartbeat(
|
on_sync=lambda device_count: history_store.record_heartbeat(
|
||||||
device_count=device_count
|
device_count=device_count
|
||||||
),
|
),
|
||||||
@@ -329,7 +383,7 @@ def _configured_device_manager(
|
|||||||
config_store: DeviceConfigStore,
|
config_store: DeviceConfigStore,
|
||||||
*,
|
*,
|
||||||
config: HostAgentConfig,
|
config: HostAgentConfig,
|
||||||
enrollment_client: HostAgentEnrollmentClient,
|
enrollment_client: HostAgentEnrollmentClient | None,
|
||||||
) -> DeviceManager:
|
) -> DeviceManager:
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
for device in config_store.list():
|
for device in config_store.list():
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from core.models import Task
|
from core.models import Task
|
||||||
from host_agent.execution import ExecutionFactories
|
from host_agent.execution import ExecutionFactories
|
||||||
from host_agent.planner_context import bind_planner_execution_context
|
from host_agent.planner_context import bind_planner_execution_context
|
||||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -19,9 +23,15 @@ class AssignmentExecutionResult:
|
|||||||
|
|
||||||
|
|
||||||
class AssignmentExecutor:
|
class AssignmentExecutor:
|
||||||
def __init__(self, factories: ExecutionFactories) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
factories: ExecutionFactories,
|
||||||
|
*,
|
||||||
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||||
|
) -> None:
|
||||||
self.factories = factories
|
self.factories = factories
|
||||||
self._progress = TaskProgressHolder()
|
self._progress = TaskProgressHolder()
|
||||||
|
self._mcp_busy_tracker = mcp_busy_tracker
|
||||||
|
|
||||||
def latest_progress(self) -> TaskProgressSnapshot | None:
|
def latest_progress(self) -> TaskProgressSnapshot | None:
|
||||||
"""Latest step progress reported by the currently-running assignment."""
|
"""Latest step progress reported by the currently-running assignment."""
|
||||||
@@ -32,18 +42,33 @@ class AssignmentExecutor:
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None = None,
|
should_stop: Callable[[], bool] | None = None,
|
||||||
|
stop_reason: Callable[[], str | None] | None = None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
self._progress.clear()
|
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):
|
with bind_planner_execution_context(assignment):
|
||||||
if should_stop is not None and should_stop():
|
if should_stop is not None and should_stop():
|
||||||
|
reason = stop_reason() if stop_reason is not None else None
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="failed",
|
status="cancelled" if is_cancellation_reason(reason) else "failed",
|
||||||
failure_reason="execution interrupted",
|
failure_reason=reason or "execution interrupted",
|
||||||
)
|
)
|
||||||
if assignment.workflow_definition_id is not None:
|
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:
|
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(
|
return AssignmentExecutionResult(
|
||||||
status="failed",
|
status="failed",
|
||||||
failure_reason="assignment has neither goal nor workflow definition",
|
failure_reason="assignment has neither goal nor workflow definition",
|
||||||
@@ -54,6 +79,7 @@ class AssignmentExecutor:
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None,
|
should_stop: Callable[[], bool] | None,
|
||||||
|
stop_reason: Callable[[], str | None] | None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
||||||
if self.factories.metadata_store is not None:
|
if self.factories.metadata_store is not None:
|
||||||
@@ -67,9 +93,11 @@ class AssignmentExecutor:
|
|||||||
if should_stop is None:
|
if should_stop is None:
|
||||||
completed = runner.run(task)
|
completed = runner.run(task)
|
||||||
else:
|
else:
|
||||||
completed = runner.run(task, should_stop=should_stop)
|
completed = runner.run(
|
||||||
|
task, should_stop=should_stop, stop_reason=stop_reason
|
||||||
|
)
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="done" if completed.status == "completed" else "failed",
|
status=_terminal_status(completed.status),
|
||||||
failure_reason=completed.failure_reason,
|
failure_reason=completed.failure_reason,
|
||||||
metadata={
|
metadata={
|
||||||
"runtime_task_id": completed.id,
|
"runtime_task_id": completed.id,
|
||||||
@@ -82,6 +110,7 @@ class AssignmentExecutor:
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None,
|
should_stop: Callable[[], bool] | None,
|
||||||
|
stop_reason: Callable[[], str | None] | None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
definition_id = assignment.workflow_definition_id or ""
|
definition_id = assignment.workflow_definition_id or ""
|
||||||
definition = self.factories.workflow_store.get_definition(definition_id)
|
definition = self.factories.workflow_store.get_definition(definition_id)
|
||||||
@@ -98,9 +127,10 @@ class AssignmentExecutor:
|
|||||||
definition,
|
definition,
|
||||||
device_id=assignment.device_id,
|
device_id=assignment.device_id,
|
||||||
should_stop=should_stop,
|
should_stop=should_stop,
|
||||||
|
stop_reason=stop_reason,
|
||||||
)
|
)
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="done" if run.status == "completed" else "failed",
|
status=_terminal_status(run.status),
|
||||||
failure_reason=(
|
failure_reason=(
|
||||||
None if run.status == "completed" else f"workflow ended as {run.status}"
|
None if run.status == "completed" else f"workflow ended as {run.status}"
|
||||||
),
|
),
|
||||||
@@ -109,3 +139,11 @@ class AssignmentExecutor:
|
|||||||
"workflow_status": run.status,
|
"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 argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import replace
|
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.config import load_host_agent_config
|
||||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||||
from host_agent.local_account import LocalAccountStore
|
from host_agent.local_account import LocalAccountStore
|
||||||
|
from host_agent.mcp_token import McpTokenStore
|
||||||
|
|
||||||
|
|
||||||
class LocalAccountSetupError(RuntimeError):
|
class LocalAccountSetupError(RuntimeError):
|
||||||
@@ -17,11 +20,20 @@ class LocalAccountSetupError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> None:
|
def main(argv: Sequence[str] | None = None) -> None:
|
||||||
|
_load_dotenv()
|
||||||
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
subparsers.add_parser("setup", help="Create the local operator account")
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.command == "mcp-token":
|
||||||
|
_print_mcp_token()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.command == "setup":
|
if args.command == "setup":
|
||||||
_run_setup()
|
_run_setup()
|
||||||
@@ -54,6 +66,12 @@ def _run_setup() -> None:
|
|||||||
print(f"Local account '{account.username}' created.")
|
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():
|
def _resolve_config_with_local_account():
|
||||||
config = load_host_agent_config()
|
config = load_host_agent_config()
|
||||||
store = LocalAccountStore(config.local_account_path)
|
store = LocalAccountStore(config.local_account_path)
|
||||||
@@ -81,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
|
|||||||
if password != confirm:
|
if password != confirm:
|
||||||
raise LocalAccountSetupError("passwords do not match")
|
raise LocalAccountSetupError("passwords do not match")
|
||||||
return store.create(username, password)
|
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,
|
DeviceSnapshotModel,
|
||||||
HeartbeatResponse,
|
HeartbeatResponse,
|
||||||
HostEnrollmentResponse,
|
HostEnrollmentResponse,
|
||||||
|
HostTaskCancellationResponse,
|
||||||
HostTaskSubmissionResponse,
|
HostTaskSubmissionResponse,
|
||||||
LeaseRenewalResponse,
|
LeaseRenewalResponse,
|
||||||
TaskProgressModel,
|
TaskProgressModel,
|
||||||
@@ -167,17 +168,21 @@ class HostAgentClient:
|
|||||||
*,
|
*,
|
||||||
address: str | None = None,
|
address: str | None = None,
|
||||||
policy_revision: int = 0,
|
policy_revision: int = 0,
|
||||||
|
mcp_busy_device_ids: list[str] | None = None,
|
||||||
) -> HeartbeatResponse:
|
) -> 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(
|
response = await self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
|
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
|
||||||
json={
|
json=payload,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return HeartbeatResponse.model_validate(response.json())
|
return HeartbeatResponse.model_validate(response.json())
|
||||||
|
|
||||||
@@ -219,6 +224,17 @@ class HostAgentClient:
|
|||||||
"control plane returned malformed success payload"
|
"control plane returned malformed success payload"
|
||||||
) from exc
|
) 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:
|
async def claim(self) -> AssignmentModel | None:
|
||||||
response = await self._request(
|
response = await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ class CloudProxyToolCallingClient:
|
|||||||
screenshot: bytes | None,
|
screenshot: bytes | None,
|
||||||
tools: list[ToolSpec],
|
tools: list[ToolSpec],
|
||||||
timeout: float,
|
timeout: float,
|
||||||
|
history: list[dict[str, Any]] | None = None,
|
||||||
) -> ToolCallDecision:
|
) -> ToolCallDecision:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"host_id": self.config.host_id,
|
"host_id": self.config.host_id,
|
||||||
"system_prompt": system_prompt,
|
"system_prompt": system_prompt,
|
||||||
"user_prompt": user_prompt,
|
"user_prompt": user_prompt,
|
||||||
|
"history": history or [],
|
||||||
"screenshot_base64": (
|
"screenshot_base64": (
|
||||||
base64.b64encode(screenshot).decode("ascii")
|
base64.b64encode(screenshot).decode("ascii")
|
||||||
if screenshot is not None
|
if screenshot is not None
|
||||||
@@ -124,6 +126,10 @@ class CloudProxyToolCallingClient:
|
|||||||
)
|
)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
text_output=decoded.rationale,
|
||||||
|
thinking=decoded.thinking,
|
||||||
|
purpose=decoded.purpose,
|
||||||
|
expected_outcome=decoded.expected_outcome,
|
||||||
)
|
)
|
||||||
raise ToolCallUnavailable(_error_detail(response))
|
raise ToolCallUnavailable(_error_detail(response))
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class HostAgentConfigurationError(ValueError):
|
|||||||
|
|
||||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||||
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
||||||
|
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
|
||||||
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
||||||
"HOST_AGENT_RUNTIME_SUPERVISED",
|
"HOST_AGENT_RUNTIME_SUPERVISED",
|
||||||
"HOST_AGENT_RUNTIME_HOST",
|
"HOST_AGENT_RUNTIME_HOST",
|
||||||
@@ -22,7 +23,8 @@ _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class HostAgentConfig:
|
class HostAgentConfig:
|
||||||
control_plane_url: str
|
control_plane_url: str = ""
|
||||||
|
mode: str = "cloud"
|
||||||
host_id: str = ""
|
host_id: str = ""
|
||||||
token: str = field(default="", repr=False)
|
token: str = field(default="", repr=False)
|
||||||
identity_path: Path = Path("tasks/host_identity.json")
|
identity_path: Path = Path("tasks/host_identity.json")
|
||||||
@@ -47,6 +49,7 @@ class HostAgentConfig:
|
|||||||
dependency_restart_max_attempts: int = 5
|
dependency_restart_max_attempts: int = 5
|
||||||
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
||||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
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_count: int = 50
|
||||||
task_retention_max_age_days: int = 7
|
task_retention_max_age_days: int = 7
|
||||||
skill_sync_interval_seconds: float = 300.0
|
skill_sync_interval_seconds: float = 300.0
|
||||||
@@ -57,16 +60,19 @@ def load_host_agent_config(
|
|||||||
) -> HostAgentConfig:
|
) -> HostAgentConfig:
|
||||||
values = os.environ if env is None else env
|
values = os.environ if env is None else env
|
||||||
_reject_removed_runtime_supervision_settings(values)
|
_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 = (
|
control_plane_url = (
|
||||||
values.get(
|
values.get(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL",
|
"HOST_AGENT_CONTROL_PLANE_URL",
|
||||||
"https://amcp.home.jerryyan.top",
|
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
|
||||||
)
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.rstrip("/")
|
.rstrip("/")
|
||||||
)
|
)
|
||||||
parsed_url = urlparse(control_plane_url)
|
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(
|
raise HostAgentConfigurationError(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
||||||
)
|
)
|
||||||
@@ -82,9 +88,10 @@ def load_host_agent_config(
|
|||||||
|
|
||||||
config = HostAgentConfig(
|
config = HostAgentConfig(
|
||||||
control_plane_url=control_plane_url,
|
control_plane_url=control_plane_url,
|
||||||
|
mode=mode,
|
||||||
identity_path=identity_path,
|
identity_path=identity_path,
|
||||||
local_account_path=local_account_path,
|
local_account_path=local_account_path,
|
||||||
enrollment_managed=True,
|
enrollment_managed=mode == "cloud",
|
||||||
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
||||||
heartbeat_interval_seconds=_positive_float(
|
heartbeat_interval_seconds=_positive_float(
|
||||||
values,
|
values,
|
||||||
@@ -128,13 +135,15 @@ def load_host_agent_config(
|
|||||||
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
|
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
|
||||||
200,
|
200,
|
||||||
),
|
),
|
||||||
ai_planner_transport=_parse_ai_planner_transport(
|
ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
|
||||||
values.get("AI_PLANNER_TRANSPORT")
|
|
||||||
),
|
|
||||||
dependency_supervisor_enabled=_truthy(
|
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_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
|
||||||
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
|
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
|
||||||
dependency_restart_max_attempts=_positive_int(
|
dependency_restart_max_attempts=_positive_int(
|
||||||
@@ -151,6 +160,7 @@ def load_host_agent_config(
|
|||||||
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
|
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
|
||||||
).strip()
|
).strip()
|
||||||
),
|
),
|
||||||
|
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").strip()),
|
||||||
task_retention_max_count=_positive_int(
|
task_retention_max_count=_positive_int(
|
||||||
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
|
||||||
@@ -35,6 +36,7 @@ def create_execution_factories(
|
|||||||
metadata_store: TaskMetadataStore | None = None,
|
metadata_store: TaskMetadataStore | None = None,
|
||||||
timeline: Timeline | None = None,
|
timeline: Timeline | None = None,
|
||||||
host_agent_config: HostAgentConfig | None = None,
|
host_agent_config: HostAgentConfig | None = None,
|
||||||
|
conversation_log: Any | None = None,
|
||||||
) -> ExecutionFactories:
|
) -> ExecutionFactories:
|
||||||
shared_workflow_store = workflow_store or WorkflowStore()
|
shared_workflow_store = workflow_store or WorkflowStore()
|
||||||
resolved_host_agent_config = host_agent_config
|
resolved_host_agent_config = host_agent_config
|
||||||
@@ -48,8 +50,14 @@ def create_execution_factories(
|
|||||||
),
|
),
|
||||||
metadata_store=metadata_store,
|
metadata_store=metadata_store,
|
||||||
timeline=timeline,
|
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(),
|
planner_config=_host_agent_planner_config(),
|
||||||
|
device_platform_provider=lambda device_id: _device_platform(
|
||||||
|
manager, device_id
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_workflow_runner() -> WorkflowRunner:
|
def create_workflow_runner() -> WorkflowRunner:
|
||||||
@@ -83,6 +91,8 @@ def _host_agent_planner_config() -> PlannerConfig:
|
|||||||
|
|
||||||
def _host_agent_planner(
|
def _host_agent_planner(
|
||||||
host_agent_config: HostAgentConfig | None,
|
host_agent_config: HostAgentConfig | None,
|
||||||
|
*,
|
||||||
|
event_logger: Callable[[dict[str, Any]], None] | None = None,
|
||||||
) -> Planner | None:
|
) -> Planner | None:
|
||||||
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
|
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
|
||||||
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
|
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
|
||||||
@@ -97,9 +107,22 @@ def _host_agent_planner(
|
|||||||
|
|
||||||
resolved_config = host_agent_config or load_host_agent_config()
|
resolved_config = host_agent_config or load_host_agent_config()
|
||||||
if resolved_config.ai_planner_transport != "cloud":
|
if resolved_config.ai_planner_transport != "cloud":
|
||||||
return None
|
return AIPlanner(config=planner_config, event_logger=event_logger)
|
||||||
|
|
||||||
return AIPlanner(
|
return AIPlanner(
|
||||||
client=CloudProxyToolCallingClient(resolved_config),
|
client=CloudProxyToolCallingClient(resolved_config),
|
||||||
config=planner_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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from host_agent.mcp_lock import McpBusyTracker
|
||||||
|
|
||||||
|
|
||||||
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
||||||
return [
|
return [
|
||||||
@@ -40,6 +42,7 @@ class HeartbeatSynchronizer:
|
|||||||
on_sync: Callable[[int], None] | None = None,
|
on_sync: Callable[[int], None] | None = None,
|
||||||
policy_cache: HostPolicyCacheStore | None = None,
|
policy_cache: HostPolicyCacheStore | None = None,
|
||||||
on_policy_sync: Callable[[int], None] | None = None,
|
on_policy_sync: Callable[[int], None] | None = None,
|
||||||
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.client = client
|
self.client = client
|
||||||
@@ -50,17 +53,26 @@ class HeartbeatSynchronizer:
|
|||||||
self.on_sync = on_sync
|
self.on_sync = on_sync
|
||||||
self.policy_cache = policy_cache
|
self.policy_cache = policy_cache
|
||||||
self.on_policy_sync = on_policy_sync
|
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 = 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
|
self.policy_revision = self.policy.revision if self.policy is not None else 0
|
||||||
if self.status_tracker is not None:
|
if self.status_tracker is not None:
|
||||||
self.status_tracker.mark_host_policy(self.policy)
|
self.status_tracker.mark_host_policy(self.policy)
|
||||||
|
|
||||||
async def sync_once(self) -> HeartbeatResponse:
|
async def sync_once(self) -> HeartbeatResponse:
|
||||||
|
self.probe_connected_devices()
|
||||||
|
self.connect_devices()
|
||||||
snapshot = build_device_snapshot(self.manager)
|
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(
|
response = await self.client.heartbeat(
|
||||||
snapshot,
|
snapshot,
|
||||||
address=self.address,
|
address=self.address,
|
||||||
policy_revision=self.policy_revision,
|
policy_revision=self.policy_revision,
|
||||||
|
mcp_busy_device_ids=mcp_busy_ids,
|
||||||
)
|
)
|
||||||
self.policy_revision = response.policy_revision
|
self.policy_revision = response.policy_revision
|
||||||
if response.policy is not None:
|
if response.policy is not None:
|
||||||
@@ -98,9 +110,19 @@ class HeartbeatSynchronizer:
|
|||||||
|
|
||||||
def connect_devices(self) -> None:
|
def connect_devices(self) -> None:
|
||||||
for device in self.manager.list_devices():
|
for device in self.manager.list_devices():
|
||||||
if device.status != "idle":
|
if device.status not in {"idle", "offline", "error"}:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
self.manager.connect(device.id)
|
self.manager.connect(device.id)
|
||||||
except DeviceRuntimeError:
|
except DeviceRuntimeError:
|
||||||
continue
|
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.assignment import AssignmentExecutionResult
|
||||||
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
||||||
from host_agent.progress import TaskProgressSnapshot
|
from host_agent.progress import TaskProgressSnapshot
|
||||||
|
from runtime.task import is_cancellation_reason
|
||||||
|
|
||||||
|
|
||||||
class InterruptibleAssignmentExecutor(Protocol):
|
class InterruptibleAssignmentExecutor(Protocol):
|
||||||
@@ -20,6 +21,7 @@ class InterruptibleAssignmentExecutor(Protocol):
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None = None,
|
should_stop: Callable[[], bool] | None = None,
|
||||||
|
stop_reason: Callable[[], str | None] | None = None,
|
||||||
) -> AssignmentExecutionResult: ...
|
) -> AssignmentExecutionResult: ...
|
||||||
|
|
||||||
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
||||||
@@ -36,6 +38,10 @@ class LeaseGuard:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._reason
|
return self._reason
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellation(self) -> bool:
|
||||||
|
return is_cancellation_reason(self.reason)
|
||||||
|
|
||||||
def is_lost(self) -> bool:
|
def is_lost(self) -> bool:
|
||||||
return self._lost.is_set()
|
return self._lost.is_set()
|
||||||
|
|
||||||
@@ -69,6 +75,7 @@ class ActiveAssignmentRunner:
|
|||||||
self.executor.execute,
|
self.executor.execute,
|
||||||
assignment,
|
assignment,
|
||||||
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
|
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
|
||||||
|
stop_reason=lambda: guard.reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
renewal = asyncio.create_task(
|
renewal = asyncio.create_task(
|
||||||
@@ -108,4 +115,7 @@ class ActiveAssignmentRunner:
|
|||||||
guard.mark_lost("lease renewal failed after transport retries")
|
guard.mark_lost("lease renewal failed after transport retries")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
if response.cancel_requested:
|
||||||
|
guard.mark_lost("cancellation requested by control plane")
|
||||||
|
return
|
||||||
lease_expires_at = response.lease_expires_at
|
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)
|
self.status_tracker.mark_assignment_started(assignment)
|
||||||
try:
|
try:
|
||||||
execution = await self.active_executor.run(assignment)
|
execution = await self.active_executor.run(assignment)
|
||||||
status = "done" if execution.status == "done" else "failed"
|
if execution.status in {"done", "cancelled"}:
|
||||||
failure_reason = execution.failure_reason if status == "failed" else None
|
status = execution.status
|
||||||
|
else:
|
||||||
|
status = "failed"
|
||||||
|
failure_reason = execution.failure_reason if status != "done" else None
|
||||||
response = await self.client.report_result(
|
response = await self.client.report_result(
|
||||||
assignment,
|
assignment,
|
||||||
status=status,
|
status=status,
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import base64
|
|||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
|
||||||
|
from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
|
from driver.registry import build_driver_factory
|
||||||
from host_agent.assignment import AssignmentExecutor
|
from host_agent.assignment import AssignmentExecutor
|
||||||
from host_agent.client import (
|
from host_agent.client import (
|
||||||
HostAgentClient,
|
HostAgentClient,
|
||||||
@@ -20,10 +22,15 @@ from host_agent.client import (
|
|||||||
HostTaskSubmissionUnknownError,
|
HostTaskSubmissionUnknownError,
|
||||||
)
|
)
|
||||||
from host_agent.config import HostAgentConfig
|
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.devices import register_local_device, unregister_local_device
|
||||||
from host_agent.history import ConsoleHistoryStore
|
from host_agent.history import ConsoleHistoryStore
|
||||||
from host_agent.identity import HostIdentityStore
|
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.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.status import AgentStatusTracker
|
||||||
from host_agent.web.auth import (
|
from host_agent.web.auth import (
|
||||||
SessionManager,
|
SessionManager,
|
||||||
@@ -31,7 +38,11 @@ from host_agent.web.auth import (
|
|||||||
attempt_login,
|
attempt_login,
|
||||||
change_password,
|
change_password,
|
||||||
)
|
)
|
||||||
|
from host_agent.web.mcp_auth import BearerAuthMiddleware
|
||||||
from storage.device_config import DeviceConfigStore
|
from storage.device_config import DeviceConfigStore
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
from storage.task_metadata import TaskMetadataStore
|
from storage.task_metadata import TaskMetadataStore
|
||||||
from storage.timeline import Timeline
|
from storage.timeline import Timeline
|
||||||
|
|
||||||
@@ -41,7 +52,9 @@ CSRF_FORM_FIELD = "csrf_token"
|
|||||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||||
|
|
||||||
TaskSubmissionCallable = Callable[..., Awaitable[str]]
|
TaskSubmissionCallable = Callable[..., Awaitable[str]]
|
||||||
|
TaskCancellationCallable = Callable[..., Awaitable[Any]]
|
||||||
AUTOMATIC_DEVICE_VALUE = "__automatic__"
|
AUTOMATIC_DEVICE_VALUE = "__automatic__"
|
||||||
|
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
||||||
|
|
||||||
_ENV = jinja2.Environment(
|
_ENV = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||||
@@ -217,15 +230,35 @@ def create_console_app(
|
|||||||
enrollment_client: HostAgentEnrollmentClient | None,
|
enrollment_client: HostAgentEnrollmentClient | None,
|
||||||
host_client: HostAgentClient | None = None,
|
host_client: HostAgentClient | None = None,
|
||||||
submit_self_task: TaskSubmissionCallable | None = None,
|
submit_self_task: TaskSubmissionCallable | None = None,
|
||||||
|
cancel_task: TaskCancellationCallable | None = None,
|
||||||
metadata_store: TaskMetadataStore | None = None,
|
metadata_store: TaskMetadataStore | None = None,
|
||||||
timeline: Timeline | None = None,
|
timeline: Timeline | None = None,
|
||||||
executor: AssignmentExecutor | 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:
|
) -> FastAPI:
|
||||||
app = FastAPI(title="Host Agent Console")
|
app = FastAPI(title="Host Agent Console")
|
||||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||||
if submit_self_task is None and host_client is not None:
|
if submit_self_task is None and host_client is not None:
|
||||||
submit_self_task = host_client.submit_self_task
|
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
|
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]]:
|
def _running_devices() -> list[dict[str, str]]:
|
||||||
return [
|
return [
|
||||||
@@ -335,6 +368,10 @@ def create_console_app(
|
|||||||
for d in manager.list_devices()
|
for d in manager.list_devices()
|
||||||
]
|
]
|
||||||
texts = _dashboard_texts(snapshot=snapshot)
|
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(
|
return _render(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
title="Status",
|
title="Status",
|
||||||
@@ -342,6 +379,8 @@ def create_console_app(
|
|||||||
identity=identity,
|
identity=identity,
|
||||||
devices=devices,
|
devices=devices,
|
||||||
config=config,
|
config=config,
|
||||||
|
mcp_endpoint=mcp_endpoint,
|
||||||
|
mcp_busy_devices=mcp_busy_devices,
|
||||||
**texts,
|
**texts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -370,7 +409,63 @@ def create_console_app(
|
|||||||
}
|
}
|
||||||
for device in manager.list_devices()
|
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)
|
@app.get("/devices", response_class=HTMLResponse)
|
||||||
async def devices_page(
|
async def devices_page(
|
||||||
@@ -396,6 +491,124 @@ def create_console_app(
|
|||||||
error=None,
|
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")
|
@app.post("/devices/save")
|
||||||
async def devices_save(
|
async def devices_save(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -422,6 +635,26 @@ def create_console_app(
|
|||||||
else:
|
else:
|
||||||
connection_info = parsed
|
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:
|
if error is None:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -535,6 +768,15 @@ def create_console_app(
|
|||||||
entries=entries,
|
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(
|
def _tasks_list_context(
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
*,
|
*,
|
||||||
@@ -709,6 +951,7 @@ def create_console_app(
|
|||||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||||
async def task_detail_page(
|
async def task_detail_page(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
request: Request,
|
||||||
session: SessionState = Depends(require_session),
|
session: SessionState = Depends(require_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
if metadata_store is None:
|
if metadata_store is None:
|
||||||
@@ -738,13 +981,57 @@ def create_console_app(
|
|||||||
if task.get(key) is not None
|
if task.get(key) is not None
|
||||||
]
|
]
|
||||||
timeline_steps = [_timeline_step_context(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(
|
return _render(
|
||||||
"task_detail.html",
|
"task_detail.html",
|
||||||
title=f"Task {task_id}",
|
title=f"Task {task_id}",
|
||||||
session=session,
|
session=session,
|
||||||
|
csrf_token=session.csrf_token,
|
||||||
task=task,
|
task=task,
|
||||||
task_rows=task_rows,
|
task_rows=task_rows,
|
||||||
timeline_steps=timeline_steps,
|
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
|
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="/tasks">Tasks</a>
|
||||||
<a href="/account">Account</a>
|
<a href="/account">Account</a>
|
||||||
<a href="/history">History</a>
|
<a href="/history">History</a>
|
||||||
|
<a href="/conversations">Conversations</a>
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
|
||||||
<button type="submit">Logout</button>
|
<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-assignment">{{ assignment_text }}</p>
|
||||||
<p id="current-progress">{{ progress_text }}</p>
|
<p id="current-progress">{{ progress_text }}</p>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Devices</h2>
|
<h2>Devices</h2>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -5,13 +5,20 @@
|
|||||||
<p class="error">{{ error }}</p>
|
<p class="error">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table>
|
<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 %}
|
<tbody>{% for device in devices %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ device["device_id"] }}</td>
|
<td>{{ device["device_id"] }}</td>
|
||||||
<td>{{ device["name"] or "" }}</td>
|
<td>{{ device["name"] or "" }}</td>
|
||||||
<td>{{ device["driver_type"] }}</td>
|
<td>{{ device["driver_type"] }}</td>
|
||||||
<td>{{ device["cloud_device_id"] or "" }}</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>
|
<td>
|
||||||
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
|
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
|
||||||
<form class="inline" method="post" action="/devices/remove">
|
<form class="inline" method="post" action="/devices/remove">
|
||||||
@@ -24,14 +31,173 @@
|
|||||||
{% endfor %}</tbody>
|
{% endfor %}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
|
<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">
|
<form method="post" action="/devices/save">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<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>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>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>Platform and protocol
|
||||||
<label>Connection info (JSON)<br>
|
<select name="driver_type" id="driver-type" required>
|
||||||
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
|
<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><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>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
<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>
|
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
|
||||||
</table>
|
</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>
|
<h2>Timeline</h2>
|
||||||
{% if not timeline_steps %}
|
{% if not timeline_steps %}
|
||||||
<p>No timeline records.</p>
|
<p>No timeline records.</p>
|
||||||
@@ -225,9 +233,24 @@
|
|||||||
|
|
||||||
const toggle = document.getElementById("overlay-boxes-toggle");
|
const toggle = document.getElementById("overlay-boxes-toggle");
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.addEventListener("change", function () {
|
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) {
|
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
|
||||||
svg.classList.toggle("show-boxes", toggle.checked);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"filelock>=3.0",
|
"filelock>=3.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
|
"mcp>=1.28,<2",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"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: dict[str, Any] | None = None,
|
||||||
task_rows: list[tuple[str, Any]] | None = None,
|
task_rows: list[tuple[str, Any]] | None = None,
|
||||||
timeline_steps: list[dict[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]:
|
) -> dict[str, Any]:
|
||||||
if task is None:
|
if task is None:
|
||||||
task = {
|
task = {
|
||||||
@@ -227,7 +230,11 @@ def make_task_detail_context(
|
|||||||
return {
|
return {
|
||||||
"title": "Task task-001",
|
"title": "Task task-001",
|
||||||
"session": session,
|
"session": session,
|
||||||
|
"csrf_token": session.csrf_token,
|
||||||
"task": task,
|
"task": task,
|
||||||
"task_rows": task_rows,
|
"task_rows": task_rows,
|
||||||
"timeline_steps": timeline_steps,
|
"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)
|
**make_devices_context(sample_session)
|
||||||
)
|
)
|
||||||
assert '<form method="post" action="/devices/save">' in html
|
assert '<form method="post" action="/devices/save">' in html
|
||||||
|
assert 'class="screenshot-button"' in html
|
||||||
|
|
||||||
|
|
||||||
def test_account_renders(env, sample_session) -> None:
|
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
|
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
|
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
|
||||||
# string fields set to <script>alert(1)</script>)
|
# string fields set to <script>alert(1)</script>)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from cloud.internal_api.models import (
|
from cloud.internal_api.models import (
|
||||||
AssignmentModel,
|
AssignmentModel,
|
||||||
@@ -15,10 +16,23 @@ from cloud.internal_api.models import (
|
|||||||
)
|
)
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.app import HostAgentApplication, create_application
|
from host_agent.app import HostAgentApplication, create_application
|
||||||
|
from host_agent.assignment import AssignmentExecutor
|
||||||
from host_agent.config import HostAgentConfig
|
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.identity import HostIdentityStore
|
||||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
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.device_config import DeviceConfigStore
|
||||||
|
from storage.task_metadata import TaskMetadataStore
|
||||||
|
from storage.timeline import Timeline
|
||||||
|
|
||||||
|
|
||||||
def _free_loopback_port() -> int:
|
def _free_loopback_port() -> int:
|
||||||
@@ -653,6 +667,77 @@ def test_create_application_with_independent_identity_paths_coexist(
|
|||||||
asyncio.run(app_a.client.aclose())
|
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:
|
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
identity_path = tmp_path / "host_identity.json"
|
identity_path = tmp_path / "host_identity.json"
|
||||||
|
|||||||
@@ -78,6 +78,39 @@ def test_goal_assignment_preserves_runtime_failure_reason() -> None:
|
|||||||
assert result.failure_reason == "planner unavailable"
|
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:
|
def test_workflow_assignment_loads_and_executes_definition() -> None:
|
||||||
definition = object()
|
definition = object()
|
||||||
calls: list[tuple[object, str]] = []
|
calls: list[tuple[object, str]] = []
|
||||||
@@ -109,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:
|
def test_unknown_workflow_fails_without_running() -> None:
|
||||||
class FakeWorkflowStore:
|
class FakeWorkflowStore:
|
||||||
def get_definition(self, definition_id: str):
|
def get_definition(self, definition_id: str):
|
||||||
|
|||||||
@@ -146,3 +146,21 @@ def test_duplicate_instance_exits_with_clear_error(
|
|||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert "another Host Agent instance" in err
|
assert "another Host Agent instance" in err
|
||||||
assert str(lock_path) 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
|
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:
|
def test_result_report_retries_identical_payload_after_response_loss() -> None:
|
||||||
payloads: list[dict[str, object]] = []
|
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
|
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:
|
def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None:
|
||||||
requests: list[httpx.Request] = []
|
requests: list[httpx.Request] = []
|
||||||
host_attempts = 0
|
host_attempts = 0
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
|
|||||||
seen_requests.append(request)
|
seen_requests.append(request)
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
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)
|
client = _client(handler)
|
||||||
@@ -49,7 +56,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
|
|||||||
timeout=30.0,
|
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
|
assert len(seen_requests) == 1
|
||||||
request = seen_requests[0]
|
request = seen_requests[0]
|
||||||
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
|
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
|
||||||
@@ -82,6 +96,36 @@ def test_decide_base64_encodes_screenshot() -> None:
|
|||||||
assert body["screenshot_base64"] == "aGVsbG8="
|
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:
|
def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None:
|
||||||
seen_requests: list[httpx.Request] = []
|
seen_requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> No
|
|||||||
assert 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:
|
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
|
||||||
config = load_host_agent_config(
|
config = load_host_agent_config(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class FakeDriver(Driver):
|
|||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
self.calls.append(("tap", (x, y)))
|
self.calls.append(("tap", (x, y)))
|
||||||
|
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
start_x: float,
|
start_x: float,
|
||||||
@@ -57,6 +60,14 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return 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:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class FakeDriver(Driver):
|
|||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
start_x: float,
|
start_x: float,
|
||||||
@@ -43,6 +46,14 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return 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:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -130,6 +141,24 @@ def test_created_task_runner_observer_uses_configured_manager(
|
|||||||
assert seen == {"device_id": "phone-1", "manager": 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(
|
def test_created_task_runner_defaults_to_ai_planner(
|
||||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from cloud.internal_api.models import HostGovernancePolicyModel
|
|||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
|
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.policy_cache import HostPolicyCacheStore
|
||||||
from host_agent.status import AgentStatusTracker
|
from host_agent.status import AgentStatusTracker
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ class ConnectableDriver:
|
|||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def screenshot(self) -> bytes:
|
||||||
|
return b"ok"
|
||||||
|
|
||||||
|
|
||||||
def _config() -> HostAgentConfig:
|
def _config() -> HostAgentConfig:
|
||||||
return HostAgentConfig(
|
return HostAgentConfig(
|
||||||
@@ -60,7 +64,9 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
|
|||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
class FakeClient:
|
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])
|
calls.append([device.device_id for device in devices])
|
||||||
return HeartbeatResponse(
|
return HeartbeatResponse(
|
||||||
host_id="host-a",
|
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"
|
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:
|
def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None:
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
manager.register_device(
|
manager.register_device(
|
||||||
@@ -98,7 +131,9 @@ def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
class FakeClient:
|
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(
|
return HeartbeatResponse(
|
||||||
host_id="host-a",
|
host_id="host-a",
|
||||||
accepted_devices=len(devices),
|
accepted_devices=len(devices),
|
||||||
@@ -133,7 +168,9 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
|
|||||||
revisions: list[int] = []
|
revisions: list[int] = []
|
||||||
|
|
||||||
class UpdatingClient:
|
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)
|
revisions.append(policy_revision)
|
||||||
return HeartbeatResponse(
|
return HeartbeatResponse(
|
||||||
host_id="host-a",
|
host_id="host-a",
|
||||||
@@ -175,6 +212,63 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
|
|||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
assert revisions == [0]
|
assert revisions == [0]
|
||||||
assert '"token":' not in (
|
assert '"token":' not in (tmp_path / "host_policy.json").read_text(encoding="utf-8")
|
||||||
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")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_lease_renews_while_execution_is_active() -> None:
|
|||||||
renewed = asyncio.Event()
|
renewed = asyncio.Event()
|
||||||
|
|
||||||
class BlockingExecutor:
|
class BlockingExecutor:
|
||||||
def execute(self, assignment, *, should_stop=None):
|
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||||
execution_started.set()
|
execution_started.set()
|
||||||
release_execution.wait(timeout=2)
|
release_execution.wait(timeout=2)
|
||||||
return AssignmentExecutionResult(status="done")
|
return AssignmentExecutionResult(status="done")
|
||||||
@@ -61,13 +61,13 @@ def test_lease_renews_while_execution_is_active() -> None:
|
|||||||
asyncio.run(scenario())
|
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:
|
async def scenario() -> None:
|
||||||
first_action_started = Event()
|
first_action_started = Event()
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
class CooperativeExecutor:
|
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
|
assert should_stop is not None
|
||||||
actions.append("first")
|
actions.append("first")
|
||||||
first_action_started.set()
|
first_action_started.set()
|
||||||
@@ -76,9 +76,59 @@ def test_stale_lease_stops_later_interruptible_actions() -> None:
|
|||||||
Event().wait(0.001)
|
Event().wait(0.001)
|
||||||
if not should_stop():
|
if not should_stop():
|
||||||
actions.append("second")
|
actions.append("second")
|
||||||
|
reason = stop_reason() if stop_reason is not None else None
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="failed",
|
status="cancelled" if reason and "cancel" in reason else "failed",
|
||||||
failure_reason="execution interrupted",
|
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):
|
def latest_progress(self):
|
||||||
@@ -108,7 +158,7 @@ def test_renewal_loop_exits_when_execution_finishes() -> None:
|
|||||||
renew_calls = 0
|
renew_calls = 0
|
||||||
|
|
||||||
class ImmediateExecutor:
|
class ImmediateExecutor:
|
||||||
def execute(self, assignment, *, should_stop=None):
|
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||||
return AssignmentExecutionResult(status="done")
|
return AssignmentExecutionResult(status="done")
|
||||||
|
|
||||||
def latest_progress(self):
|
def latest_progress(self):
|
||||||
@@ -142,7 +192,7 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
|
|||||||
execution_started = Event()
|
execution_started = Event()
|
||||||
|
|
||||||
class CooperativeExecutor:
|
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
|
assert should_stop is not None
|
||||||
execution_started.set()
|
execution_started.set()
|
||||||
while not should_stop():
|
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())
|
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:
|
def test_status_tracker_sees_started_then_finished_even_on_raise() -> None:
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
tracker = AgentStatusTracker()
|
tracker = AgentStatusTracker()
|
||||||
|
|||||||
@@ -3,25 +3,32 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
|
from core.models import Task
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.client import HostAgentAPIError, HostTaskSubmissionUnknownError
|
from host_agent.client import HostAgentAPIError, HostTaskSubmissionUnknownError
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
from host_agent.history import ConsoleHistoryStore
|
from host_agent.history import ConsoleHistoryStore
|
||||||
from host_agent.identity import HostIdentityStore
|
from host_agent.identity import HostIdentityStore
|
||||||
from host_agent.local_account import LocalAccountStore
|
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.status import AgentStatusTracker
|
||||||
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
|
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
|
||||||
from host_agent.web.auth import SessionManager
|
from host_agent.web.auth import SessionManager
|
||||||
|
from host_agent.web.mcp import build_mcp_server
|
||||||
from storage.device_config import DeviceConfigStore
|
from storage.device_config import DeviceConfigStore
|
||||||
from storage.task_metadata import TaskMetadataStore
|
from storage.task_metadata import TaskMetadataStore
|
||||||
|
|
||||||
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||||
|
|
||||||
TaskSubmissionCallable = Callable[[str, str | None], Awaitable[str]]
|
TaskSubmissionCallable = Callable[[str, str | None], Awaitable[str]]
|
||||||
|
TaskCancellationCallable = Callable[[str], Awaitable[Any]]
|
||||||
|
|
||||||
|
|
||||||
def _build_client(
|
def _build_client(
|
||||||
@@ -29,7 +36,11 @@ def _build_client(
|
|||||||
*,
|
*,
|
||||||
create_account: bool = True,
|
create_account: bool = True,
|
||||||
submit_self_task: TaskSubmissionCallable | None = None,
|
submit_self_task: TaskSubmissionCallable | None = None,
|
||||||
|
cancel_task: TaskCancellationCallable | None = None,
|
||||||
include_metadata_store: bool = True,
|
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]:
|
) -> tuple[TestClient, dict]:
|
||||||
config = HostAgentConfig(
|
config = HostAgentConfig(
|
||||||
control_plane_url="https://control.example",
|
control_plane_url="https://control.example",
|
||||||
@@ -62,7 +73,11 @@ def _build_client(
|
|||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
enrollment_client=None,
|
enrollment_client=None,
|
||||||
submit_self_task=submit_self_task,
|
submit_self_task=submit_self_task,
|
||||||
|
cancel_task=cancel_task,
|
||||||
metadata_store=metadata_store,
|
metadata_store=metadata_store,
|
||||||
|
mcp_server=mcp_server,
|
||||||
|
mcp_token_store=mcp_token_store,
|
||||||
|
mcp_busy_tracker=mcp_busy_tracker,
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
context = {
|
context = {
|
||||||
@@ -227,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"]
|
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:
|
def test_remove_device_unregisters_from_manager(tmp_path) -> None:
|
||||||
client, context = _build_client(tmp_path)
|
client, context = _build_client(tmp_path)
|
||||||
csrf_token = _login(client)
|
csrf_token = _login(client)
|
||||||
@@ -795,4 +951,288 @@ def test_submitted_redirect_does_not_include_goal_text(tmp_path) -> None:
|
|||||||
assert "bearer-token-deadbeef" not in response.headers["location"]
|
assert "bearer-token-deadbeef" not in response.headers["location"]
|
||||||
follow = client.get(response.headers["location"])
|
follow = client.get(response.headers["location"])
|
||||||
assert secret_goal not in follow.text
|
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)
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
PluginRecord,
|
PluginRecord,
|
||||||
PluginRegistrationPayload,
|
PluginRegistrationPayload,
|
||||||
TaskAttempt,
|
TaskAttempt,
|
||||||
|
TaskCancellationResponse,
|
||||||
TaskListResponse,
|
TaskListResponse,
|
||||||
TaskSubmissionPayload,
|
TaskSubmissionPayload,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
@@ -239,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
|||||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
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(
|
export function getTaskPlannerDecisions(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
attempt: number,
|
attempt: number,
|
||||||
|
|||||||
@@ -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"
|
| "assigned"
|
||||||
| "dispatched"
|
| "dispatched"
|
||||||
| "done"
|
| "done"
|
||||||
| "failed";
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +42,11 @@ export interface TaskListResponse {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskCancellationResponse {
|
||||||
|
task_id: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskAttempt {
|
export interface TaskAttempt {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
@@ -190,6 +196,10 @@ export interface PlannerDecisionItem {
|
|||||||
user_prompt: string;
|
user_prompt: string;
|
||||||
tool_name: string;
|
tool_name: string;
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>;
|
||||||
|
rationale?: string | null;
|
||||||
|
thinking?: string | null;
|
||||||
|
purpose?: string | null;
|
||||||
|
expected_outcome?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
|||||||
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||||
import {
|
import {
|
||||||
CloudApiError,
|
CloudApiError,
|
||||||
|
cancelTask,
|
||||||
getTaskAttempts,
|
getTaskAttempts,
|
||||||
getTaskPlannerDecisions,
|
getTaskPlannerDecisions,
|
||||||
listTasks,
|
listTasks,
|
||||||
@@ -20,6 +21,7 @@ import type {
|
|||||||
PlannerDecisionItem,
|
PlannerDecisionItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { formatTaskProgress } from "../taskProgress";
|
import { formatTaskProgress } from "../taskProgress";
|
||||||
|
import { canCancelTask } from "../taskCancellation";
|
||||||
import { computePlannerHistoryState } from "../plannerHistory";
|
import { computePlannerHistoryState } from "../plannerHistory";
|
||||||
|
|
||||||
const props = defineProps<{ canSubmit: boolean }>();
|
const props = defineProps<{ canSubmit: boolean }>();
|
||||||
@@ -30,6 +32,7 @@ const STATUSES: TaskStatus[] = [
|
|||||||
"dispatched",
|
"dispatched",
|
||||||
"done",
|
"done",
|
||||||
"failed",
|
"failed",
|
||||||
|
"cancelled",
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusFilter = ref<TaskStatus | "">("");
|
const statusFilter = ref<TaskStatus | "">("");
|
||||||
@@ -55,6 +58,7 @@ const devices = ref<DeviceRecord[]>([]);
|
|||||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||||
const plannerLoading = ref(false);
|
const plannerLoading = ref(false);
|
||||||
const plannerError = ref("");
|
const plannerError = ref("");
|
||||||
|
const cancelling = ref(false);
|
||||||
const availableDevices = computed(() =>
|
const availableDevices = computed(() =>
|
||||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||||
);
|
);
|
||||||
@@ -83,6 +87,8 @@ async function refresh() {
|
|||||||
selectedTask.value = null;
|
selectedTask.value = null;
|
||||||
attempts.value = [];
|
attempts.value = [];
|
||||||
plannerDecisions.value = [];
|
plannerDecisions.value = [];
|
||||||
|
} else {
|
||||||
|
await selectTask(stillPresent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
function handleError(err: unknown, fallback: string) {
|
||||||
if (err instanceof CloudApiError) {
|
if (err instanceof CloudApiError) {
|
||||||
errorMessage.value = err.message;
|
errorMessage.value = err.message;
|
||||||
@@ -257,6 +278,11 @@ const selectedTaskProgress = computed(() =>
|
|||||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canCancelSelectedTask = computed(
|
||||||
|
() =>
|
||||||
|
!!selectedTask.value && canCancelTask(selectedTask.value.status, props.canSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||||
if (!selectedTask.value?.assigned_host_id) return null;
|
if (!selectedTask.value?.assigned_host_id) return null;
|
||||||
const host = hosts.value.find(
|
const host = hosts.value.find(
|
||||||
@@ -417,6 +443,13 @@ function formatArguments(args: Record<string, unknown>): string {
|
|||||||
<h2>
|
<h2>
|
||||||
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="canCancelSelectedTask"
|
||||||
|
:disabled="cancelling"
|
||||||
|
@click="cancelSelectedTask"
|
||||||
|
>
|
||||||
|
{{ cancelling ? "Cancelling…" : "Cancel" }}
|
||||||
|
</button>
|
||||||
<button @click="clearSelection">Back to list</button>
|
<button @click="clearSelection">Back to list</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@@ -504,6 +537,22 @@ function formatArguments(args: Record<string, unknown>): string {
|
|||||||
<summary>Arguments</summary>
|
<summary>Arguments</summary>
|
||||||
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
||||||
</details>
|
</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>
|
</div>
|
||||||
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
||||||
|
|||||||
+70
-1
@@ -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
|
@dataclass
|
||||||
class SceneElement:
|
class SceneElement:
|
||||||
id: str
|
id: str
|
||||||
@@ -79,6 +114,18 @@ class SceneElement:
|
|||||||
text: str | None = None
|
text: str | None = None
|
||||||
confidence: float | None = None
|
confidence: float | None = None
|
||||||
source: str | 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
|
@property
|
||||||
def center(self) -> tuple[float, float]:
|
def center(self) -> tuple[float, float]:
|
||||||
@@ -94,6 +141,10 @@ class SceneElement:
|
|||||||
}
|
}
|
||||||
if self.source:
|
if self.source:
|
||||||
data["source"] = 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
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -105,6 +156,13 @@ class SceneElement:
|
|||||||
bounds=Bounds.from_dict(data["bounds"]),
|
bounds=Bounds.from_dict(data["bounds"]),
|
||||||
confidence=data.get("confidence"),
|
confidence=data.get("confidence"),
|
||||||
source=data.get("source"),
|
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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,12 +174,19 @@ class Scene:
|
|||||||
# Keep raw OCR observations for local execution evidence without duplicating
|
# Keep raw OCR observations for local execution evidence without duplicating
|
||||||
# them in the normalized, LLM-facing scene payload.
|
# them in the normalized, LLM-facing scene payload.
|
||||||
ocr_elements: list[SceneElement] = field(default_factory=list)
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
data: dict[str, Any] = {
|
||||||
"screen": {"width": self.width, "height": self.height},
|
"screen": {"width": self.width, "height": self.height},
|
||||||
"elements": [element.to_dict() for element in self.elements],
|
"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]]:
|
def ocr_results_to_dict(self) -> list[dict[str, Any]]:
|
||||||
return [element.to_dict() for element in self.ocr_elements]
|
return [element.to_dict() for element in self.ocr_elements]
|
||||||
@@ -129,12 +194,16 @@ class Scene:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "Scene":
|
def from_dict(cls, data: dict[str, Any]) -> "Scene":
|
||||||
screen = data.get("screen") or {}
|
screen = data.get("screen") or {}
|
||||||
|
raw_app = data.get("app")
|
||||||
return cls(
|
return cls(
|
||||||
width=int(screen.get("width") or data.get("width") or 0),
|
width=int(screen.get("width") or data.get("width") or 0),
|
||||||
height=int(screen.get("height") or data.get("height") or 0),
|
height=int(screen.get("height") or data.get("height") or 0),
|
||||||
elements=[
|
elements=[
|
||||||
SceneElement.from_dict(element) for element in data.get("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=[
|
ocr_elements=[
|
||||||
SceneElement.from_dict(element)
|
SceneElement.from_dict(element)
|
||||||
for element in data.get("ocr_elements", [])
|
for element in data.get("ocr_elements", [])
|
||||||
|
|||||||
@@ -122,6 +122,26 @@ class DeviceManager:
|
|||||||
self._drivers.pop(device_id, None)
|
self._drivers.pop(device_id, None)
|
||||||
self._set_status(device_id, "offline" if offline else "error")
|
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:
|
def active_driver(self, device_id: str | None = None) -> Driver:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if device_id is None:
|
if device_id is None:
|
||||||
|
|||||||
@@ -540,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
|
it. Do not use this release for operations that require a transactional
|
||||||
exactly-once guarantee across the cloud database and an external device.
|
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
|
## Shutdown And Rollback
|
||||||
|
|
||||||
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
|
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
|
||||||
|
|||||||
@@ -527,6 +527,20 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每
|
|||||||
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
|
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
|
||||||
wheel 与 Apple Silicon 兼容性。
|
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. 完成检查表
|
## 12. 完成检查表
|
||||||
|
|
||||||
- [ ] Xcode 能看到已解锁的 iPhone。
|
- [ ] Xcode 能看到已解锁的 iPhone。
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,158 @@
|
|||||||
|
# 原子手势扩展 + 拟人化抖动 设计
|
||||||
|
|
||||||
|
- 日期:2026-07-15
|
||||||
|
- 状态:Draft(待用户 review)
|
||||||
|
- 作者:Jerry Yan + Claude
|
||||||
|
- 关联代码区:`runtime/tool_specs.py`、`runtime/executor.py`、`runtime/planner_prompts.py`、`driver/base.py`、`driver/wda_driver.py`、`driver/android_driver.py`、`tools/`
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
当前 Runtime 暴露给 AI Planner 的设备动作只有 5 个:`tap` / `swipe` / `input_text` / `launch_app` / `terminate_app`(见 `runtime/tool_specs.py:42-145`)。Driver 抽象(`driver/base.py`)与两个实现(WDA / Android UiAutomator2)同样没有长按、双击。
|
||||||
|
|
||||||
|
两个缺口:
|
||||||
|
|
||||||
|
1. **原子手势不全**:长按(long press)、双击(double tap)无法表达。AI Planner 无法完成依赖这两类手势的任务。
|
||||||
|
2. **动作过于"机器化"**:`swipe` 在 driver 层走 2 点命令(WDA `mobile: dragFromToForDuration`、Android `mobile: dragGesture`),轨迹是完美直线;`tap` 每次落在精确坐标。这种确定性容易被目标 app 的反自动化检测识别。
|
||||||
|
|
||||||
|
本设计同时解决两者:新增两个原子手势,并引入一个集中的拟人化(humanize)层,对所有指针类动作注入坐标偏移、路径曲线、时序抖动。
|
||||||
|
|
||||||
|
## 2. 目标 / 非目标
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 新增 `long_press`、`double_tap` 两个工具,端到端贯通(driver → tool → tool_spec → executor registry → planner prompt)。
|
||||||
|
- 新增 `tools/humanize.py`,提供坐标抖动、滑动 waypoint 生成、时长抖动三类纯函数。
|
||||||
|
- `swipe` 在 humanize 开启时改走 W3C Actions 多点曲线;`double_tap` 始终走 W3C Actions(两 tap + 可调间隔)。
|
||||||
|
- 通过 `APEX_HUMANIZE_ENABLED` env 开关(默认开)控制;关闭时现有 `tap`/`swipe` 行为零回归、现有精确坐标断言零改动。
|
||||||
|
|
||||||
|
### 非目标
|
||||||
|
- 不做捏合、多指、3D Touch 等更复杂的手势(YAGNI,等真有需求)。
|
||||||
|
- 不重构现有 `swipe` 关闭路径(保留 `mobile: dragGesture`/`dragFromToForDuration`)。
|
||||||
|
- 不给 `tap`/`input_text` 等加新的可调参数(只加抖动,不改语义)。
|
||||||
|
- 不引入时序相关的全局 sleep/节流策略。
|
||||||
|
- 不做反检测的"高级"维度(设备指纹、传感器模拟等),只做指针轨迹层面的拟人。
|
||||||
|
|
||||||
|
## 3. 平台命令矩阵
|
||||||
|
|
||||||
|
实现期必须按 `android-driver` 变更 task 1.1 的先例,对照已安装的 appium-python-client 源码/官方文档核实命令名,不得凭记忆猜测。
|
||||||
|
|
||||||
|
| 手势 | iOS (WDA / XCUITest) | Android (UiAutomator2) | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `long_press` | `mobile: touchAndHold` `{x, y, duration}` | `mobile: longClickGesture` `{x, y}` | Android 时长系统固定、不可控;`duration_ms` 参数在 Android 实现里被忽略并记入 Risks |
|
||||||
|
| `double_tap` | **W3C Actions**(`pointerDown→up→pause→down→up`) | **W3C Actions**(同左) | 不用 `mobile: doubleTap`/`doubleClickGesture`,理由见 §4.3 |
|
||||||
|
| `swipe`(humanize 开) | **W3C Actions** 多点曲线 | **W3C Actions** 多点曲线 | 新增 `Driver.swipe_path` |
|
||||||
|
| `swipe`(humanize 关) | `mobile: dragFromToForDuration`(不变) | `mobile: dragGesture`(不变) | 保留原 `Driver.swipe` |
|
||||||
|
|
||||||
|
## 4. 设计
|
||||||
|
|
||||||
|
### 4.1 新原子手势
|
||||||
|
|
||||||
|
**`long_press`**
|
||||||
|
- 参数:`x`、`y`、`duration_ms?=1200`(加 `purpose`/`expected_outcome` 元数据,见 `tool_specs.py:13-26` 的 `ACTION_METADATA_PROPERTIES`)
|
||||||
|
- 实现:driver 新增 `Driver.long_press(x, y, duration_ms)` 抽象方法;WDA 透传 `duration`,Android 忽略 `duration_ms`
|
||||||
|
- 工具:`tools/long_press.py`,薄封装,humanize 抖动坐标(+ WDA 时长)
|
||||||
|
|
||||||
|
**`double_tap`**
|
||||||
|
- 参数:`x`、`y`、`interval_ms?=80`
|
||||||
|
- 实现:driver 新增 `Driver.double_tap(x, y, interval_ms)` 抽象方法,两个驱动统一用 W3C Actions 实现(§4.3)
|
||||||
|
- 工具:`tools/double_tap.py`,薄封装,humanize 抖动坐标 + 间隔
|
||||||
|
|
||||||
|
### 4.2 `tools/humanize.py`(新)
|
||||||
|
|
||||||
|
集中式拟人模块,纯函数 + 一个 dataclass config。无 driver 依赖。
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HumanizeConfig:
|
||||||
|
enabled: bool = True # 读 APEX_HUMANIZE_ENABLED,默认 true
|
||||||
|
tap_radius_px: float = 5.0 # APEX_HUMANIZE_TAP_RADIUS_PX
|
||||||
|
swipe_curvature: float = 0.15 # 垂直噪声幅度占路径长度的比例
|
||||||
|
swipe_waypoints: int = 8 # 中间点数
|
||||||
|
duration_spread: float = 0.15 # 时长 ±15%
|
||||||
|
|
||||||
|
def jitter_point(x, y, *, radius, rng) -> tuple[float, float]: ...
|
||||||
|
def swipe_waypoints(start, end, *, curvature, n, rng) -> list[tuple[float,float]]: ...
|
||||||
|
def jitter_duration(value_ms, *, spread, rng) -> int: ...
|
||||||
|
def jitter_interval(value_ms, *, spread, rng) -> int: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 随机源:函数接受 `rng: random.Random`。模块级单例 `_rng` 默认 `random.Random()`;测试可注入固定种子构造的 `random.Random(seed)` 以可复现。
|
||||||
|
- `swipe_waypoints`:在起止两点间用贝塞尔/线性插值生成 `n` 个点,每个点沿路径法线方向加高斯噪声(幅度 = 路径长度 × `curvature`)。保证首末点等于(抖动后的)起止点。
|
||||||
|
- config 加载沿用仓库既有 `load_config` 模式(参考 `runtime/planner_config.py`),env 名统一前缀 `APEX_HUMANIZE_*`。
|
||||||
|
|
||||||
|
### 4.3 W3C Actions:`Driver.swipe_path` 与 `double_tap` 的共享实现
|
||||||
|
|
||||||
|
Appium Python Client 的 `ActionBuilder` / W3C `pointer` action 在 iOS 与 Android 两侧 API 一致,逐点 `pointer_move` 即可。为避免两个驱动各写一遍,在 driver 层新增一个共享辅助:
|
||||||
|
|
||||||
|
- `driver/base.py` 新增抽象方法:
|
||||||
|
- `swipe_path(self, waypoints: list[tuple[float, float]], duration_ms: int) -> None`
|
||||||
|
- `double_tap(self, x: float, y: float, interval_ms: int) -> None`
|
||||||
|
- `long_press(self, x: float, y: float, duration_ms: int) -> None`
|
||||||
|
- 共享的 W3C Actions 构造逻辑放 `driver/_w3c_actions.py`(模块级函数,接收 appium `client` + 参数),两个驱动的 `swipe_path`/`double_tap` 实现各自调它。这样:
|
||||||
|
- driver 仍是薄适配器(只持 client、调辅助、包 `DriverError`)
|
||||||
|
- W3C 序列化逻辑不重复
|
||||||
|
|
||||||
|
**`swipe_path` 序列**:`pointerDown(start) → move(p1) → move(p2) → … → move(end) → pointerUp`,总时长按 waypoint 数均分(或按段长加权)。
|
||||||
|
|
||||||
|
**`double_tap` 序列**:`pointerDown(x,y) → pointerUp → pause(interval_ms) → pointerDown(x,y) → pointerUp`,单次 HTTP 往返,`interval_ms` 完全可控。
|
||||||
|
|
||||||
|
为什么 `double_tap` 不用原生 `mobile: doubleTap`/`doubleClickGesture`:它们是固定间隔、一次性机器手势,最易被反检测识别;W3C 两 tap + 高斯抖动间隔反而更像人,且和 `swipe_path` 共用同一套基础设施。
|
||||||
|
|
||||||
|
### 4.4 应用点(humanize 开/关矩阵)
|
||||||
|
|
||||||
|
| 动作 | humanize 开 | humanize 关 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tap` | `jitter_point` 后调 `driver.tap` | 原样(零变化) |
|
||||||
|
| `swipe` | `swipe_waypoints` 后调 `driver.swipe_path` | 原样调 `driver.swipe` |
|
||||||
|
| `long_press` | `jitter_point`(+WDA `jitter_duration`) 后调 `driver.long_press` | 直接调 `driver.long_press` |
|
||||||
|
| `double_tap` | `jitter_point` + `jitter_interval` 后调 `driver.double_tap` | 直接调 `driver.double_tap`(仍走 W3C,但不抖动) |
|
||||||
|
| `input_text`/`launch_app`/`terminate_app` | 不动 | 不动 |
|
||||||
|
|
||||||
|
关键保证:**humanize 关闭时,现有 `tap`/`swipe` 走与今天完全相同的 driver 方法与命令**,现有精确坐标断言零改动。`long_press`/`double_tap` 是新工具,无"原行为"可回归。
|
||||||
|
|
||||||
|
### 4.5 tool_specs 与 executor
|
||||||
|
|
||||||
|
- `runtime/tool_specs.py` 新增 `LONG_PRESS_SPEC`、`DOUBLE_TAP_SPEC`,加入 `ACTION_TOOL_SPECS`(`ALL_TOOL_SPECS` 自动带上,prompt 的 tool 列表自动生效)。
|
||||||
|
- `runtime/executor.py::default_tool_registry` 注册 `"long_press"`、`"double_tap"`。
|
||||||
|
|
||||||
|
### 4.6 planner prompt
|
||||||
|
|
||||||
|
`runtime/planner_prompts.py:18-22` 那段工具枚举("You must then call exactly one tool: - One of `tap`, `swipe`, …")补 `long_press`、`double_tap`,并各加一句使用场景提示(长按用于长按菜单/拖拽预备;双击用于缩放/选中)。仍保持"每回合恰好一个工具"的单步约束不变(多步规划是另一条独立议题,本次不动)。
|
||||||
|
|
||||||
|
## 5. 配置(env)
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `APEX_HUMANIZE_ENABLED` | `true` | 总开关;`false` 时所有抖动关闭,tap/swipe 走原路径 |
|
||||||
|
| `APEX_HUMANIZE_TAP_RADIUS_PX` | `5.0` | tap/long_press/double_tap 坐标高斯偏移半径(px) |
|
||||||
|
| `APEX_HUMANIZE_SWIPE_CURVATURE` | `0.15` | 滑动垂直噪声占路径长度比例 |
|
||||||
|
| `APEX_HUMANIZE_SWIPE_WAYPOINTS` | `8` | 滑动中间点数 |
|
||||||
|
| `APEX_HUMANIZE_DURATION_SPREAD` | `0.15` | 时长/间隔 ±比例 |
|
||||||
|
|
||||||
|
未设置时全部走默认;任一缺失不影响其它。测试通过在 fixture 里设 `APEX_HUMANIZE_ENABLED=false`(或注入固定种子 rng)获得确定性。
|
||||||
|
|
||||||
|
## 6. 默认值
|
||||||
|
|
||||||
|
- `long_press.duration_ms = 1200`
|
||||||
|
- `double_tap.interval_ms = 80`
|
||||||
|
- `swipe.duration_ms = 500`(不变)
|
||||||
|
|
||||||
|
## 7. 测试策略
|
||||||
|
|
||||||
|
1. **humanize 单测**(`tests/test_humanize.py`):注入 `random.Random(42)`,断言 `jitter_point` 偏移在半径内、`swipe_waypoints` 首末点等于端点且点数正确、中间点存在垂直偏移、`jitter_duration` 在 ±spread 内。
|
||||||
|
2. **driver 新方法单测**(humanize 关闭):mock appium client,断言 `swipe_path` 发出的 W3C action 序列含 N 个 pointer_move、`double_tap` 序列含两段 pointerDown/Up + 一个 pause、`long_press` 调对正确 mobile 命令(WDA `touchAndHold` 带 duration、Android `longClickGesture`)。
|
||||||
|
3. **tool 集成**:`tools/long_press.py`/`tools/double_tap.py` 在 fake driver 上跑通;`tools/swipe.py` 在 humanize 开启时断言调用的是 `swipe_path` 且 waypoints > 2,关闭时调用的是 `swipe`。
|
||||||
|
4. **回归**:现有 `tap`/`swipe` 相关测试在 humanize 关闭下全部不变;在 `tests/test_executor.py` 补两个新动作的 dispatch。
|
||||||
|
5. **spec 校验**:`runtime/tool_specs.py` 的新 spec 满足 `_action_parameters` 要求(含 `purpose`/`expected_outcome`)。
|
||||||
|
|
||||||
|
## 8. 风险
|
||||||
|
|
||||||
|
- **R1(中)**:W3C Actions 在某些 Appium/驱动版本上对 `pause` 的时长支持不一致。缓解:`double_tap` 的 interval 先用 `pause(duration_ms)`,实现期在真实 Appium 上验证;若不可靠,退化为两次 `mobile: tap` + `time.sleep`(记入 Open Questions)。
|
||||||
|
- **R2(低)**:Android `longClickGesture` 时长不可控,与 WDA 行为不对称。接受:参数保留,Android 忽略并文档化。
|
||||||
|
- **R3(低)**:W3C Actions 曲线滑动比原生 2 点命令慢(多点序列化 + 执行)。接受:反检测优先于延迟,且单次滑动仍是一次 HTTP 往返。
|
||||||
|
- **R4(低)**:humanize 默认开可能让未设 env 的既有测试出现非确定坐标。缓解:现有 `tap`/`swipe` 测试若断言精确坐标,需确认它们跑在 `APEX_HUMANIZE_ENABLED=false` 下;实现期 grep 评估影响面,必要时在测试 conftest 默认关 humanize。
|
||||||
|
|
||||||
|
## 9. 开放问题(实现期核实)
|
||||||
|
|
||||||
|
- OQ1:`mobile: touchAndHold`(WDA)与 `mobile: longClickGesture`(Android)的精确参数名/duration 单位——对照已安装 appium-python-client 核实。
|
||||||
|
- OQ2:W3C `pause` action 在目标 Appium 版本是否可靠传递 interval——真机验证,必要时按 R1 回退。
|
||||||
|
- OQ3:是否需要在 `Driver` 抽象为 `swipe_path`/`double_tap`/`long_press` 提供 fake/mock 默认实现(现有 `driver/registry.py` 不注册 mock driver,保持一致——只做抽象方法 + 两个真实实现 + 单测里的 mock)。
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
# Host-Agent MCP Server — Design Spec
|
||||||
|
|
||||||
|
- **Date**: 2026-07-21
|
||||||
|
- **Status**: Draft, pending user review
|
||||||
|
- **Owner**: Jerry Yan
|
||||||
|
- **Target package**: `apps/device-host-agent`(主)+ `packages/cloud-platform`(schema/scheduler 扩展)+ `api/mcp.py`(一处签名收紧)
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
在 host-agent 进程内挂载 Streamable HTTP MCP server,让 Hermes Agent(或任意 MCP 客户端)作为外部大脑直接驱动 host-agent 管理的设备,与既有的 Cloud Control Plane 长轮询 worker 路径并存。
|
||||||
|
|
||||||
|
**一句话**:Hermes 当大脑,host-agent 当手;同一个 host-agent 进程同时服务两条职责,per-device 互斥,先到先得。
|
||||||
|
|
||||||
|
## 2. Background & Motivation
|
||||||
|
|
||||||
|
- 当前 host-agent 只能通过 Cloud Control Plane 派发任务驱动;操作员想直接用本地 Hermes Agent 临时操控设备时,必须先在 cloud 侧创建 task,路径长、延迟高、依赖网络。
|
||||||
|
- `api/mcp.py::create_mcp_server()` 已经用 FastMCP 暴露了 11 个设备工具(`take_screenshot`/`tap`/`swipe`/`input_text`/`launch_app`/`find_text`/`find_icon`/`get_ui_tree`/`describe_screen`/`list_devices`/`device_status`),但只服务于 Runtime 进程(port 8000),且没有可运行入口被 host-agent 复用。
|
||||||
|
- Hermes Agent 官方支持 HTTP MCP client(见 [Hermes MCP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp)),配置长这样:
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
apex_device:
|
||||||
|
url: "http://127.0.0.1:8765/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer <token>"
|
||||||
|
```
|
||||||
|
- uv.lock 已锁定 `mcp==1.28.1`,支持 Streamable HTTP transport。
|
||||||
|
- 既有的 `host-agent-local-console` 已立先例:host-agent 进程内 always-on 跑 FastAPI + uvicorn(默认 port 8765,loopback only),本次 MCP server 沿用同一 server、同一端口,仅新增一个 `/mcp` mount。
|
||||||
|
|
||||||
|
## 3. Locked Decisions(来自 grill 阶段)
|
||||||
|
|
||||||
|
| # | 决策 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | Transport = Streamable HTTP | mcp SDK 1.28.1 支持 |
|
||||||
|
| D2 | Mount 路径 = `/mcp`,与 console 同端口(默认 8765) | FastAPI `app.mount()` |
|
||||||
|
| D3 | 网络 = loopback only,沿用 `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` 双保险 | 不新增 bind 配置 |
|
||||||
|
| D4 | 鉴权 = 独立 MCP bearer token,存 `host_mcp_token.json` | 文件路径 = `config.identity_path.parent / "host_mcp_token.json"` |
|
||||||
|
| D5 | 并发锁 = per-device,先到先得 + fail-fast | 不做 wait 队列 |
|
||||||
|
| D6 | 设备状态映射 = 走 `_device_display_status()` 同款逻辑 | 避免"所有连上的设备看起来都 busy" |
|
||||||
|
| D7 | Cloud worker 与 MCP server 在同一 host-agent 进程并存 | 不互斥,共享 `DeviceManager` |
|
||||||
|
| D8 | Cloud ↔ MCP 协调 = 心跳上报 `mcp_busy_device_ids`,cloud scheduler 跳过 | 心跳 schema 扩展,cloud 侧 _matches() 一处改动 |
|
||||||
|
| D9 | MCP session 级 lazy acquire 锁,20s TTL 兜底(mcp SDK 1.28.1 无 session-end callback,见 §6.5) | session_id 来自 FastMCP 上下文 |
|
||||||
|
| D10 | Skill catalog 工具 MVP 不暴露,保留 `create_mcp_server(skill_catalog_store=...)` 参数化挂载点 | 未来可加 mutating 工具 |
|
||||||
|
| D11 | `wait_until_usable` 方法实现 + 单测,但调用方不接入 | 预留能力,MVP 全部 fail-fast |
|
||||||
|
| D12 | `tool_handlers(manager)` 改为必传 | 已 grep 确认无调用方依赖 None 默认,根治 `DeviceNotFoundError` 类静默回退地雷 |
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
### 4.1 总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────── host-agent 进程 ────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ run_async() 主循环 │
|
||||||
|
│ ├─ HeartbeatSynchronizer ──reads──→ McpBusyTracker ──┐ │
|
||||||
|
│ │ (已有) payload.mcp_busy_device_ids │ │
|
||||||
|
│ ├─ AssignmentProcessor ──reads──→ AgentStatusTracker │ │
|
||||||
|
│ │ (已有,cloud 路径) .current_assignment │
|
||||||
|
│ │ └─ AssignmentExecutor.execute() 启动前 fail-fast │ │
|
||||||
|
│ │ 检查 mcp_busy_tracker.busy_device_ids() │ │
|
||||||
|
│ └─ Console uvicorn server(已有 port 8765) │ │
|
||||||
|
│ └─ FastAPI app │ │
|
||||||
|
│ ├─ /login, /devices, /tasks, ... (已有) │ │
|
||||||
|
│ └─ Mount("/mcp") │ │
|
||||||
|
│ └─ FastMCP.streamable_http_app() │ │
|
||||||
|
│ ├─ BearerAuthMiddleware ──verify──→ McpTokenStore │
|
||||||
|
│ └─ 11 tools(包装层) │ │
|
||||||
|
│ ├─ busy check ────────────────────┘ │
|
||||||
|
│ │ (cloud 占用? McpBusyTracker 占用?) │
|
||||||
|
│ ├─ acquire device lock(首次调用时) │
|
||||||
|
│ ├─ tool_handlers(manager=<host_agent_manager>)[name]│
|
||||||
|
│ └─ release on session end / TTL │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
│ HTTP + Bearer │ HTTP heartbeat
|
||||||
|
│ │ + mcp_busy_device_ids
|
||||||
|
┌───────────┴───────────┐ ┌─────────┴─────────┐
|
||||||
|
│ Hermes Agent (client) │ │ Cloud Control │
|
||||||
|
│ ~/.hermes/config.yaml │ │ Plane (server) │
|
||||||
|
│ mcp_servers.apex.url │ │ scheduler skips │
|
||||||
|
│ = http://127.0.0.1 │ │ mcp-busy devices │
|
||||||
|
│ :8765/mcp │ │ │
|
||||||
|
└────────────────────────┘ └────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 关键不变量
|
||||||
|
|
||||||
|
- `DeviceManager` 单例;cloud 路径和 MCP 路径都通过显式参数注入。包装层在 `manager is None` 时抛错而非回退 `DEFAULT_MANAGER`(D12)。
|
||||||
|
- 同一设备同一时刻只能被一边占用:cloud 占用 → MCP busy 错误;MCP 占用 → 心跳上报 → cloud scheduler 跳过;窗口期内冲突由 AssignmentExecutor 启动前 fail-fast 兜底。
|
||||||
|
- `runtime/` / `api/` 包边界不破坏:所有新代码在 `host_agent/`;`api/mcp.py` 只做一处签名收紧。
|
||||||
|
|
||||||
|
## 5. Components
|
||||||
|
|
||||||
|
### 5.1 `host_agent/mcp_lock.py`(新)— `McpBusyTracker`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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=None) -> None: ...
|
||||||
|
def acquire(self, device_id: str, session_id: str) -> bool: ...
|
||||||
|
def renew(self, device_id: str, session_id: str) -> bool: ...
|
||||||
|
def release(self, session_id: str) -> list[str]: ...
|
||||||
|
def release_device(self, device_id: str, session_id: str) -> bool: ...
|
||||||
|
def busy_device_ids(self) -> list[str]: ... # lazy sweep
|
||||||
|
def snapshot(self) -> list[McpDeviceLease]: ... # lazy sweep, for UI
|
||||||
|
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: ... # 预留,MVP 不被调用
|
||||||
|
```
|
||||||
|
|
||||||
|
- 进程内单实例,`threading.Lock` 保护。
|
||||||
|
- TTL sweep 在 `busy_device_ids()` / `snapshot()` 调用时 lazy 执行。
|
||||||
|
- Cloud 占用检查**不放这里**——保持单一职责;调用方在 acquire 前查 `AgentStatusTracker`。
|
||||||
|
|
||||||
|
### 5.2 `host_agent/mcp_token.py`(新)— `McpTokenStore`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class McpToken:
|
||||||
|
version: int # =1
|
||||||
|
token: str # secrets.token_urlsafe(32)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class McpTokenStore:
|
||||||
|
def __init__(self, path: Path, *, now=None) -> None: ...
|
||||||
|
def load_or_create(self) -> McpToken: ... # atomic, 0o600
|
||||||
|
def verify(self, presented: str) -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 文件路径:`config.identity_path.parent / "host_mcp_token.json"`。
|
||||||
|
- JSON 格式:`{"version": 1, "token": "...", "created_at": "2026-07-21T..."}`。
|
||||||
|
- 写文件用 `tempfile + os.replace` 原子重命名;权限 0o600。
|
||||||
|
- 文件损坏或不可写 → `McpTokenStoreError`,不静默重新生成。
|
||||||
|
|
||||||
|
### 5.3 `host_agent/web/mcp_auth.py`(新)— Bearer Auth Middleware
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, token_store: McpTokenStore) -> None: ...
|
||||||
|
# 401 + WWW-Authenticate: Bearer + JSON {"error":"invalid token"} on fail
|
||||||
|
```
|
||||||
|
|
||||||
|
- 挂在 FastMCP `streamable_http_app()` 外层(不是 console FastAPI 外层)。
|
||||||
|
- 不写失败日志(避免暴力枚举刷屏);首次成功鉴权写 INFO。
|
||||||
|
|
||||||
|
### 5.4 `host_agent/web/mcp.py`(新)— `build_mcp_server()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_mcp_server(
|
||||||
|
*,
|
||||||
|
manager: DeviceManager,
|
||||||
|
mcp_busy_tracker: McpBusyTracker,
|
||||||
|
status_tracker: AgentStatusTracker,
|
||||||
|
) -> FastMCP:
|
||||||
|
"""Wraps api.mcp.tool_handlers(manager=manager) with:
|
||||||
|
- busy check (cloud OR mcp-busy → JSON-RPC -32000)
|
||||||
|
- lazy device lock acquire on first call
|
||||||
|
- lease renew on every call
|
||||||
|
- status mapping for list_devices / device_status (display_status)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- 单一 decorator 包装所有 11 个工具,避免重复。
|
||||||
|
- `session_id` 来自 FastMCP streamable HTTP 上下文。
|
||||||
|
- `list_devices` / `device_status` 不走 busy 检查(无 device_id 参数),但走 display status 映射。
|
||||||
|
|
||||||
|
### 5.5 改动的现有模块
|
||||||
|
|
||||||
|
| 模块 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `host_agent/app.py::create_application()` | 构造 `McpTokenStore` / `McpBusyTracker` / `build_mcp_server()`,传给 `create_console_app()`;InstanceLock acquire 后、heartbeat 启动前完成 token 生成 |
|
||||||
|
| `host_agent/web/app.py::create_console_app()` | 新参数 `mcp_server` / `mcp_token_store` / `mcp_busy_tracker`;`app.mount("/mcp", auth_wrapped(server.streamable_http_app()))`;`/api/status` 加 `mcp_busy_devices` 字段 |
|
||||||
|
| `host_agent/web/templates/dashboard.html` | 一行 MCP 状态显示 |
|
||||||
|
| `host_agent/cli.py` | 新子命令 `mcp-token` 打印当前 token(不存在则生成) |
|
||||||
|
| `host_agent/heartbeat.py` | payload 加 `mcp_busy_device_ids` 字段,值来自 `mcp_busy_tracker.busy_device_ids()` |
|
||||||
|
| `host_agent/assignment.py::AssignmentExecutor` | 构造参数加 `mcp_busy_tracker: McpBusyTracker \| None = None`(None 时跳过检查,保持向后兼容;host-agent 装配时必传);`execute()` 启动前(`_execute_goal` / `_execute_workflow` 实际跑之前)fail-fast 检查 `mcp_busy_tracker.busy_device_ids()`,命中则返回 `status="failed"` + `failure_reason="device held by active MCP session"` |
|
||||||
|
| `cloud/internal_api/models.py` | Heartbeat payload schema 加 `mcp_busy_device_ids: list[str] = []` |
|
||||||
|
| `cloud/scheduler.py::_matches()` | 心跳里的 `mcp_busy_device_ids` 内的 device 视为非 idle |
|
||||||
|
| `api/mcp.py::tool_handlers` | 签名改为 `tool_handlers(*, manager: DeviceManager)`(必传,D12 根治) |
|
||||||
|
|
||||||
|
## 6. Data Flows
|
||||||
|
|
||||||
|
### 6.1 启动
|
||||||
|
|
||||||
|
```
|
||||||
|
create_application()
|
||||||
|
├─ InstanceLock acquire(已有)
|
||||||
|
├─ resolve_host_identity()(已有)
|
||||||
|
├─ DeviceManager 构造 + 设备注册(已有)
|
||||||
|
├─ McpTokenStore(identity_path.parent / "host_mcp_token.json").load_or_create()
|
||||||
|
│ └─ 首次:生成 token、atomic 写文件、INFO 日志 "MCP token generated at <path>"
|
||||||
|
├─ McpBusyTracker(ttl_seconds=60)
|
||||||
|
├─ build_mcp_server(manager=manager, mcp_busy_tracker=..., status_tracker=...)
|
||||||
|
└─ create_console_app(..., mcp_server=server, mcp_token_store=..., mcp_busy_tracker=...)
|
||||||
|
└─ app.mount("/mcp", auth_wrapped(server.streamable_http_app()))
|
||||||
|
|
||||||
|
run_async() 主循环启动(行为不变):
|
||||||
|
├─ heartbeat_task: 每 30s 读 mcp_busy_tracker.busy_device_ids() → payload
|
||||||
|
├─ claim loop: 不变(仍会 claim cloud 任务)
|
||||||
|
└─ console_server: 现在同时服务 / * (HTML) 和 /mcp (Streamable HTTP)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Hermes 第一次 MCP 调用(lazy acquire)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Hermes → POST /mcp (JSON-RPC tools/call, name=take_screenshot, args={device_id: "phone-1"})
|
||||||
|
2. BearerAuthMiddleware: verify token → pass
|
||||||
|
3. FastMCP route → 包装层 decorator:
|
||||||
|
a. session_id = ctx.session_id
|
||||||
|
b. busy check:
|
||||||
|
- cloud 占用? → status_tracker.snapshot()["current_assignment"]["device_id"] == "phone-1"? NO
|
||||||
|
- mcp 占用? → mcp_busy_tracker.busy_device_ids() 包含 "phone-1"? NO
|
||||||
|
c. acquire: mcp_busy_tracker.acquire("phone-1", session_id) → True
|
||||||
|
d. tool_handlers(manager=...)["take_screenshot"](device_id="phone-1") → screenshot
|
||||||
|
e. renew: mcp_busy_tracker.renew("phone-1", session_id)
|
||||||
|
f. return screenshot_base64
|
||||||
|
4. 下一次心跳(≤30s): payload.mcp_busy_device_ids = ["phone-1"]
|
||||||
|
5. Cloud scheduler 收到 → 把 phone-1 视为非 idle → 不派任务给它
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 同 session 第二次调用(锁已持有)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Hermes → POST /mcp (tap, device_id: "phone-1")
|
||||||
|
2-3a. 同上
|
||||||
|
3b. mcp_busy_tracker.busy_device_ids() 包含 "phone-1",但持有者就是当前 session_id → 通过
|
||||||
|
3c. acquire 已持有,no-op(或 assert)
|
||||||
|
3d-f. 同上
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Cloud 任务抢占的预防
|
||||||
|
|
||||||
|
```
|
||||||
|
场景:Hermes 正在用 phone-1,cloud 这时派任务给 phone-1
|
||||||
|
|
||||||
|
正常路径:
|
||||||
|
1. Cloud scheduler 选设备:phone-1 在最近心跳的 mcp_busy_device_ids 内 → 视为非 idle → 跳过
|
||||||
|
2. Cloud 选 phone-2 或等其他设备 idle
|
||||||
|
|
||||||
|
窗口期兜底(心跳 30s 内 cloud 基于旧心跳派任务):
|
||||||
|
1. AssignmentProcessor.process(assignment) → AssignmentExecutor.execute(assignment)
|
||||||
|
2. execute() 启动前检查:
|
||||||
|
if assignment.device_id in mcp_busy_tracker.busy_device_ids():
|
||||||
|
return AssignmentExecutionResult(
|
||||||
|
status="failed",
|
||||||
|
failure_reason="device held by active MCP session"
|
||||||
|
)
|
||||||
|
3. client.report_result(...) 上报 fail → cloud attempt+1 或派给别的 host
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Session 结束 / TTL 过期
|
||||||
|
|
||||||
|
```
|
||||||
|
正常:Hermes 主动断开 → mcp SDK 1.28.1 没有 per-session shutdown hook
|
||||||
|
→ 该 session 的 lease 进入 TTL 倒计时
|
||||||
|
→ 20s TTL 到期 → 下次 busy_device_ids() 或 snapshot() 调用时 lazy sweep
|
||||||
|
→ lease 清理 → 下次心跳 payload 不再包含 → cloud 重新视为 idle
|
||||||
|
|
||||||
|
异常:Hermes 崩溃 / 网络断 → 同上,无 shutdown callback
|
||||||
|
→ 20s TTL 到期 → 下次 busy_device_ids() 或 snapshot() 调用时 lazy sweep
|
||||||
|
→ lease 清理 → 下次心跳 payload 不再包含 → cloud 重新视为 idle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation note (2026-07-21 fix wave):** mcp SDK 1.28.1 exposes
|
||||||
|
only a server-level `lifespan` hook; `ServerSession.__aexit__` and
|
||||||
|
`StreamableHTTPSessionManager` do not surface a per-session
|
||||||
|
shutdown callback. The spec originally described a release-on-clean-
|
||||||
|
disconnect path that the SDK cannot deliver today. The fallback is
|
||||||
|
the 20-second TTL sweep — short enough that a normal heartbeat
|
||||||
|
interval (30s) catches the recovery before the cloud scheduler
|
||||||
|
notices, long enough that an actively-busy session does not lose its
|
||||||
|
lease during normal operator pauses. Explicit release on session end
|
||||||
|
remains a future enhancement if/when the SDK exposes the hook.
|
||||||
|
|
||||||
|
## 7. Error Handling Matrix
|
||||||
|
|
||||||
|
| # | 触发条件 | 返回语义 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `Authorization` 缺失/不匹配 | HTTP 401 + `WWW-Authenticate: Bearer` + JSON `{"error":"invalid token"}` | 不写失败日志;首次成功鉴权写 INFO |
|
||||||
|
| 2 | Cloud 占用目标设备 | `CallToolResult(isError=true, content=[TextContent("device phone-1 is busy (held by cloud assignment)")])` | DEBUG 日志 |
|
||||||
|
| 3 | 另一 MCP session 占用 | `CallToolResult(isError=true, content=[TextContent("device phone-1 is busy (held by mcp_session:<8-char-prefix>)")])` | DEBUG 日志 |
|
||||||
|
| 4 | `device_id` 不存在 | `CallToolResult(isError=false)` + JSON `{"ok": false, "error": "device not found: phone-1"}` | 复用 `call_with_semantic_errors`;语义错误不抛 |
|
||||||
|
| 5 | 工具底层异常 | `CallToolResult(isError=true, content=[TextContent("Error executing tool <name>: <orig-msg>")])` | WARNING + exc_info |
|
||||||
|
| 6 | Cloud assignment 启动前命中 MCP 占用 | `AssignmentExecutionResult(status="failed", failure_reason="device held by active MCP session")` | INFO 一次 |
|
||||||
|
| 7 | Token 文件损坏 JSON | host-agent 启动失败,stderr 提示 | 不静默重新生成 |
|
||||||
|
| 8 | Token 文件不可写 | host-agent 启动失败 | 同上 |
|
||||||
|
| 9 | MCP session 异常断开 | lease 进入 TTL 倒计时 | 20s 后 lazy sweep |
|
||||||
|
| 10 | TTL 过期瞬间 Hermes 重连 | renew 容忍边界:session_id 匹配 → 重新 acquire 而非报错 | 无感 |
|
||||||
|
| 11 | 同 session 并发不同设备 | 各自独立 acquire | per-device 设计 |
|
||||||
|
| 12 | 同 session 并发同一设备 | 第一个 acquire;第二个 renew(同 session_id) | 并发 safe |
|
||||||
|
| 13 | FastMCP 提取不到 session_id | `CallToolResult(isError=true, content=[TextContent("cannot determine MCP session")])` | ERROR + exc_info |
|
||||||
|
| 14 | host-agent 关停时有 active MCP session | lease 随进程退出消失 | 不需显式清理 |
|
||||||
|
|
||||||
|
### 错误返回格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "<request-id>",
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "device phone-1 is busy (held by cloud assignment)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation note (2026-07-21 fix wave):** mcp SDK 1.28.1's
|
||||||
|
`Tool.run` wraps every non-`UrlElicitationRequiredError` exception
|
||||||
|
(including `McpError` with a typed `ErrorData`) into `ToolError`.
|
||||||
|
The lowlevel `call_tool` handler then serializes any exception as
|
||||||
|
`CallToolResult(isError=true, content=[TextContent(message)])` via
|
||||||
|
`_make_error_result`. There is no public path that surfaces JSON-RPC
|
||||||
|
`-32000` with a structured `data.busy_owner` field from a tool call
|
||||||
|
site — the SDK's wire contract for tool errors is the `isError=true`
|
||||||
|
flag plus text content. The busy-owner value lives in the text
|
||||||
|
content (truncated session_id for `mcp_session:` collisions, full
|
||||||
|
string for `cloud_assignment`). Unknown-device and other semantic
|
||||||
|
errors are returned as normal `ok=False` payloads inside a successful
|
||||||
|
`CallToolResult(isError=false)` (see `api/mcp.py::call_with_semantic_errors`).
|
||||||
|
|
||||||
|
## 8. Testing Strategy
|
||||||
|
|
||||||
|
### 8.1 单元测试
|
||||||
|
|
||||||
|
- **`McpBusyTracker`**(~10 用例):acquire/renew/release/busy_device_ids/snapshot/wait_until_usable 的成功/冲突/并发/TTL sweep
|
||||||
|
- **`McpTokenStore`**(~6 用例):首次创建/二次复用/并发 atomic/verify timing-safe/损坏 JSON 抛错/不可写抛错
|
||||||
|
- **`BearerAuthMiddleware`**(~5 用例):无 header/错误 token/正确 token/非 Bearer scheme/大小写
|
||||||
|
- **包装层 decorator**(~10 用例,**重点**):
|
||||||
|
- 显式 `manager=` 传入 → 工具调用成功(**回归 D12 地雷**)
|
||||||
|
- `manager=None` → 包装层断言失败(**新约束**)
|
||||||
|
- cloud 占用 → `-32000` busy(mock AgentStatusTracker)
|
||||||
|
- 另一 MCP session 占用 → `-32000` busy
|
||||||
|
- 同 session 已持有 → renew 后通过
|
||||||
|
- acquire/renew 调用顺序
|
||||||
|
- 底层 `DeviceNotFoundError` → `-32602`
|
||||||
|
- 底层 `DriverError` → `-32000`
|
||||||
|
- `list_devices` 不 busy check 但走 display status
|
||||||
|
- `device_status` 同上
|
||||||
|
|
||||||
|
### 8.2 集成测试
|
||||||
|
|
||||||
|
- **Mount wiring**(~4 用例):`/mcp` mount 存在、不继承 cookie session、`/api/status` 加字段、dashboard HTML 含状态行
|
||||||
|
- **心跳 payload**(~3 用例):空 / 含 phone-1 / TTL 过期后空
|
||||||
|
- **AssignmentExecutor fail-fast**(~2 用例):命中 MCP 占用立即 fail、不命中正常执行
|
||||||
|
- **Cloud 侧**(~4 用例):Heartbeat model 向后兼容空 list、含 device、`_matches()` 跳过 mcp_busy、旧 host-agent 不发字段时行为不变
|
||||||
|
|
||||||
|
### 8.3 端到端(1 个,可选)
|
||||||
|
|
||||||
|
- MCP 客户端模拟(`mcp` SDK client + ASGI TestClient)→ host-agent 进程内 → FakeDriver
|
||||||
|
- 覆盖:鉴权 → list_devices → screenshot → tap → 中途 cloud assignment fail-fast → session 断开 release
|
||||||
|
- 如果 SDK client + TestClient 组合有坑,降级为直接调用 FastMCP tool registry
|
||||||
|
|
||||||
|
### 8.4 已有测试不破坏
|
||||||
|
|
||||||
|
- `test_app.py` 9 处 `create_application()` 调用:新组件在 `create_application()` 内部构造,外部 API 不变
|
||||||
|
- `test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`:所有新代码在 `host_agent/`,不动 `runtime/` / `api/` 包边界(`api/mcp.py` 签名收紧不算破坏)
|
||||||
|
- 根非集成测试套件:跑一遍无回归
|
||||||
|
|
||||||
|
### 8.5 人工/真机(不在自动化覆盖)
|
||||||
|
|
||||||
|
- 真实 Hermes Agent CLI 接入(写 SOUL.md、跑真实对话)
|
||||||
|
- 真机 iPhone/Android MCP 工具调用
|
||||||
|
|
||||||
|
## 9. Scope
|
||||||
|
|
||||||
|
### 9.1 In Scope
|
||||||
|
|
||||||
|
见第 5 节 Components 表。共:
|
||||||
|
- 4 个新模块(`mcp_lock.py` / `mcp_token.py` / `web/mcp_auth.py` / `web/mcp.py`)
|
||||||
|
- 8 个改动的现有模块
|
||||||
|
- 2 个文档(`docs/MCP_INTEGRATION.md` 新增 + `docs/MACOS_IPHONE_SETUP.md` 加小节)
|
||||||
|
- 单元 + 集成测试全覆盖;e2e 视成本而定
|
||||||
|
|
||||||
|
### 9.2 Non-Goals
|
||||||
|
|
||||||
|
- `wait_until_usable` 的调用方接入(方法实现 + 单测,但 AssignmentExecutor 和 MCP 工具包装层都不调用)
|
||||||
|
- 暴露 `wait_until_device_usable` 成独立 MCP 工具
|
||||||
|
- `device-host-agent mcp-rotate-token` CLI(MVP 轮换走"删文件 + 重启")
|
||||||
|
- MCP 调用进 `ConsoleHistoryStore`(MVP 不记录调用历史)
|
||||||
|
- Skill catalog 工具暴露给 Hermes(保留参数化挂载点)
|
||||||
|
- Token rate limiting / 失败计数
|
||||||
|
- 多 Hermes 实例协调(技术允许但不专门测试)
|
||||||
|
- 远程访问(非 loopback)支持
|
||||||
|
- Hermes 侧 SOUL.md / 配置自动化
|
||||||
|
- MCP over WebSocket
|
||||||
|
- Cloud scheduler 改造为支持"MCP 优先级"/队列
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
|
||||||
|
- **Q1(已答)**:`tool_handlers(manager)` 是否改为必传?→ **是**。Grep 全仓库确认所有调用方(`api/mcp.py:110`、`tests/test_mcp.py:13,56`、`tests/test_skill_catalog_e2e.py:287`)都已显式传 `manager=manager`,改动面为 0。
|
||||||
|
- **Q2**:`mcp-token` CLI 是否需要鉴权?→ MVP 不鉴权,假定能访问宿主机的操作者可信(与 `setup` 子命令同款)。后续可加 `--password` 校验 local_account。
|
||||||
|
- **Q3**:MCP 工具调用是否需要 wall-clock 超时?→ MVP 不加,依赖底层超时;如有"Hermes 调用挂死"报告再加。
|
||||||
|
- **Q4**:心跳扩展是 Alembic migration 还是仅 schema 字段?→ MVP 仅 transient 字段(heartbeat 接收 → scheduler 用 → 丢弃),无需 migration。
|
||||||
|
- **Q5(已答,2026-07-21 fix wave)**:TTL 从 60s 降到 20s,因为 mcp SDK 1.28.1 没有 per-session shutdown callback(见 §6.5)。20s 仍能容许正常 operator 暂停,但能在一次 30s 心跳窗口内回收崩盘 session 的锁;新 `test_default_ttl_is_20_seconds` 和 `test_default_ttl_recovers_dead_session_within_one_window` 锁定该值。如有"Hermes 长操作横跨 20s 静默"报告再调高。
|
||||||
|
|
||||||
|
## 11. Risks
|
||||||
|
|
||||||
|
- **R1**:Cloud 心跳窗口期(30s)冲突。缓解:AssignmentExecutor 启动前 fail-fast。残留:cloud 可能基于过期心跳派任务、host-agent fail、cloud 重试——浪费 attempt 配额。**接受**。
|
||||||
|
- **R2**:Hermes 长时间占用设备导致 cloud 任务反复 fail。MVP 无自动缓解;用户手动管控;未来启用 `wait_until_usable`。**接受**。
|
||||||
|
- **R3**:Hermes 崩溃后 ≤20s 设备不可用(TTL 兜底,但窗口存在)。窗口仍 ≤30s 心跳间隔,cloud 侧下次心跳能学到。**接受,记入 Q5**。
|
||||||
|
- **R4(已消除)**:`tool_handlers` 签名改动影响面。Grep 确认 0 调用方依赖 None 默认。
|
||||||
|
- **R5**:FastMCP `session_id` 提取依赖 `mcp` SDK 内部 API。缓解:e2e 测试覆盖;SDK 升级 CI 能及时暴露。
|
||||||
|
- **R6**:`mcp` SDK 需作为 device-host-agent 直接依赖(目前通过 Runtime 传递)。需加进 `apps/device-host-agent/pyproject.toml`(与 `filelock` 直接化先例一致)。
|
||||||
|
- **R7**:Token 文件首次生成的竞态。缓解:InstanceLock 前置保护;`tempfile + os.replace` 原子重命名。
|
||||||
|
|
||||||
|
## 12. Coordination with Existing Changes
|
||||||
|
|
||||||
|
- **`host-agent-single-instance-lock`**:本次依赖 InstanceLock,token 文件生成在 InstanceLock acquire 之后,安全。无需修改。
|
||||||
|
- **`host-agent-dependency-supervisor`**:本次不引入新的外部进程依赖(FastMCP 是库)。不冲突。
|
||||||
|
- **`task-execution-progress-visibility`**:本次不改 `TaskMetadataStore` / `Timeline`。不冲突。
|
||||||
|
- **`host-agent-local-console`**:本次复用其 FastAPI + uvicorn 设施,新增一个 mount 点。Console 现有 cookie session 鉴权**不**继承到 `/mcp`(鉴权走独立 bearer middleware)。
|
||||||
|
|
||||||
|
## 13. Hermes 侧配置示例(参考,非 host-agent 代码范围)
|
||||||
|
|
||||||
|
`~/.hermes/config.yaml`:
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
apex_device:
|
||||||
|
url: "http://127.0.0.1:8765/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer <token-from-host_mcp_token.json>"
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动 host-agent 后,从 `tasks/host_mcp_token.json`(或 `HOST_AGENT_IDENTITY_PATH` 同目录)读 token,或跑 `device-host-agent mcp-token` 打印。
|
||||||
|
|
||||||
|
Hermes 的 `SOUL.md`(profile 级别)建议补充:
|
||||||
|
- 工具调用前先 `list_devices` 看可用设备
|
||||||
|
- 设备 `busy` 时等待或换设备
|
||||||
|
- iOS 设备坐标是 points(非 pixels),Android 是 pixels
|
||||||
|
- OCR/UI 树 bounds 与 screenshot 像素已对齐(perception 层已处理)
|
||||||
|
|
||||||
|
## 14. Implementation Order(建议,writing-plans 阶段细化)
|
||||||
|
|
||||||
|
1. `api/mcp.py::tool_handlers` 签名收紧(D12)+ 同步更新调用方 docstring/类型
|
||||||
|
2. `McpTokenStore` + 单测
|
||||||
|
3. `McpBusyTracker`(含 `wait_until_usable`)+ 单测
|
||||||
|
4. `BearerAuthMiddleware` + 单测
|
||||||
|
5. `build_mcp_server` 包装层 + 单测(覆盖 D12 回归)
|
||||||
|
6. `create_console_app` mount wiring + 集成测试
|
||||||
|
7. `create_application` 装配
|
||||||
|
8. 心跳 payload 扩展 + cloud scheduler 改动 + 集成测试
|
||||||
|
9. `AssignmentExecutor` fail-fast 检查 + 测试
|
||||||
|
10. CLI `mcp-token` 子命令
|
||||||
|
11. Dashboard 状态行
|
||||||
|
12. 文档(`MCP_INTEGRATION.md` + `MACOS_IPHONE_SETUP.md`)
|
||||||
|
13. e2e 测试(可选)
|
||||||
|
14. 全量非集成测试回归 + ruff + compileall + openspec strict validation
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
POINTER_ID = "finger1"
|
||||||
|
|
||||||
|
|
||||||
|
def _pointer(*actions: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "pointer",
|
||||||
|
"id": POINTER_ID,
|
||||||
|
"parameters": {"pointerType": "touch"},
|
||||||
|
"actions": list(actions),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_swipe_actions(
|
||||||
|
waypoints: list[tuple[float, float]], total_duration_ms: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""W3C actions moving through ``waypoints`` in one continuous touch."""
|
||||||
|
if len(waypoints) < 2:
|
||||||
|
raise ValueError("swipe path requires at least two waypoints")
|
||||||
|
segments = len(waypoints) - 1
|
||||||
|
per_segment = max(1, total_duration_ms // segments)
|
||||||
|
sx, sy = waypoints[0]
|
||||||
|
actions: list[dict[str, Any]] = [
|
||||||
|
{"type": "pointerMove", "duration": 0, "x": int(sx), "y": int(sy)},
|
||||||
|
{"type": "pointerDown", "button": 0},
|
||||||
|
]
|
||||||
|
for point in waypoints[1:]:
|
||||||
|
actions.append(
|
||||||
|
{
|
||||||
|
"type": "pointerMove",
|
||||||
|
"duration": per_segment,
|
||||||
|
"x": int(point[0]),
|
||||||
|
"y": int(point[1]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ex, ey = waypoints[-1]
|
||||||
|
actions.append(
|
||||||
|
{"type": "pointerUp", "button": 0, "x": int(ex), "y": int(ey)}
|
||||||
|
)
|
||||||
|
return _pointer(*actions)
|
||||||
|
|
||||||
|
|
||||||
|
def build_double_tap_actions(
|
||||||
|
x: float, y: float, interval_ms: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
actions = [
|
||||||
|
{"type": "pointerMove", "duration": 0, "x": int(x), "y": int(y)},
|
||||||
|
{"type": "pointerDown", "button": 0},
|
||||||
|
{"type": "pointerUp", "button": 0},
|
||||||
|
{"type": "pause", "duration": max(1, interval_ms)},
|
||||||
|
{"type": "pointerDown", "button": 0},
|
||||||
|
{"type": "pointerUp", "button": 0},
|
||||||
|
]
|
||||||
|
return _pointer(*actions)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_actions(client: Any, actions: list[dict[str, Any]]) -> None:
|
||||||
|
"""Send a W3C actions payload via the Appium/Selenium command seam.
|
||||||
|
|
||||||
|
Import is lazy so the pure payload builders stay importable without
|
||||||
|
selenium on the path (used by unit tests).
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.remote.command import Command
|
||||||
|
|
||||||
|
client.execute(Command.W3C_ACTIONS, {"actions": actions})
|
||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.errors import DeviceOfflineError, DriverError
|
from core.errors import DeviceOfflineError, DriverError
|
||||||
|
from core.models import ActiveApp
|
||||||
from driver.base import Driver
|
from driver.base import Driver
|
||||||
|
|
||||||
# Android KeyEvent.KEYCODE_HOME. Kept as a literal rather than importing the
|
# Android KeyEvent.KEYCODE_HOME. Kept as a literal rather than importing the
|
||||||
@@ -80,6 +81,13 @@ class AndroidDriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("screenshot failed") from exc
|
raise DriverError("screenshot failed") from exc
|
||||||
|
|
||||||
|
def health_check(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
client.get_status()
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("health check failed") from exc
|
||||||
|
|
||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
@@ -87,6 +95,26 @@ class AndroidDriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("tap failed") from exc
|
raise DriverError("tap failed") from exc
|
||||||
|
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
# Android ``mobile: longClickGesture`` uses a system-fixed hold
|
||||||
|
# duration; the caller's ``duration_ms`` is intentionally ignored
|
||||||
|
# (see design R2). Verify on real Appium against the running device
|
||||||
|
# driver — see plan task 2.
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
client.execute_script("mobile: longClickGesture", {"x": x, "y": y})
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("long press failed") from exc
|
||||||
|
|
||||||
|
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||||
|
from driver._w3c_actions import build_double_tap_actions, perform_actions
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
perform_actions(client, build_double_tap_actions(x, y, interval_ms))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("double tap failed") from exc
|
||||||
|
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
start_x: float,
|
start_x: float,
|
||||||
@@ -118,6 +146,17 @@ class AndroidDriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("text input failed") from exc
|
raise DriverError("text input failed") from exc
|
||||||
|
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
from driver._w3c_actions import build_swipe_actions, perform_actions
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
perform_actions(client, build_swipe_actions(waypoints, duration_ms))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("swipe_path failed") from exc
|
||||||
|
|
||||||
def launch(self, app_id: str) -> None:
|
def launch(self, app_id: str) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
@@ -139,6 +178,19 @@ class AndroidDriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("ui tree retrieval failed") from exc
|
raise DriverError("ui tree retrieval failed") from exc
|
||||||
|
|
||||||
|
def active_app(self) -> ActiveApp | None:
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
package = client.current_package
|
||||||
|
activity = client.current_activity
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("active app retrieval failed") from exc
|
||||||
|
package = package if isinstance(package, str) and package else None
|
||||||
|
activity = activity if isinstance(activity, str) and activity else None
|
||||||
|
if package is None and activity is None:
|
||||||
|
return None
|
||||||
|
return ActiveApp(platform="android", package=package, activity=activity)
|
||||||
|
|
||||||
def home(self) -> None:
|
def home(self) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
|
|||||||
+32
-1
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from core.models import ActiveApp
|
||||||
|
|
||||||
|
|
||||||
class Driver(ABC):
|
class Driver(ABC):
|
||||||
"""Driver-independent device capability interface.
|
"""Driver-independent device capability interface.
|
||||||
@@ -23,10 +25,26 @@ class Driver(ABC):
|
|||||||
def screenshot(self) -> bytes:
|
def screenshot(self) -> bytes:
|
||||||
"""Return the current screen as image bytes."""
|
"""Return the current screen as image bytes."""
|
||||||
|
|
||||||
|
def health_check(self) -> None:
|
||||||
|
"""Verify the live session without reading the device screen.
|
||||||
|
|
||||||
|
Drivers with a transport-level status endpoint should override this
|
||||||
|
method. The default is a no-op for legacy drivers that do not expose
|
||||||
|
a separate health check.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
"""Tap the screen at the given coordinates."""
|
"""Tap the screen at the given coordinates."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
"""Press and hold at the given coordinates for ``duration_ms``.
|
||||||
|
|
||||||
|
Some platforms ignore ``duration_ms`` (fixed system hold duration).
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
@@ -38,6 +56,16 @@ class Driver(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Swipe between two screen coordinates."""
|
"""Swipe between two screen coordinates."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
"""Swipe through a sequence of waypoints in one continuous touch."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||||
|
"""Tap twice at the given coordinates with ``interval_ms`` between."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def input(self, text: str) -> None:
|
def input(self, text: str) -> None:
|
||||||
"""Input text into the current focused field."""
|
"""Input text into the current focused field."""
|
||||||
@@ -54,6 +82,10 @@ class Driver(ABC):
|
|||||||
def tree(self) -> Any:
|
def tree(self) -> Any:
|
||||||
"""Return the raw UI tree from the device driver."""
|
"""Return the raw UI tree from the device driver."""
|
||||||
|
|
||||||
|
def active_app(self) -> ActiveApp | None:
|
||||||
|
"""Return foreground application metadata when the driver supports it."""
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def home(self) -> None:
|
def home(self) -> None:
|
||||||
"""Press the device home button."""
|
"""Press the device home button."""
|
||||||
@@ -65,4 +97,3 @@ class Driver(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def unlock(self) -> None:
|
def unlock(self) -> None:
|
||||||
"""Unlock the device."""
|
"""Unlock the device."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.errors import DeviceOfflineError, DriverError
|
from core.errors import DeviceOfflineError, DriverError
|
||||||
|
from core.models import ActiveApp
|
||||||
from driver.base import Driver
|
from driver.base import Driver
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +71,13 @@ class WDADriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("screenshot failed") from exc
|
raise DriverError("screenshot failed") from exc
|
||||||
|
|
||||||
|
def health_check(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
client.get_status()
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("health check failed") from exc
|
||||||
|
|
||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
@@ -77,6 +85,28 @@ class WDADriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("tap failed") from exc
|
raise DriverError("tap failed") from exc
|
||||||
|
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
# ``mobile: touchAndHold`` is an XCUITest WDA endpoint; ``duration`` is
|
||||||
|
# in seconds (float). Verify on real Appium against the running device
|
||||||
|
# driver — see plan task 2.
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
client.execute_script(
|
||||||
|
"mobile: touchAndHold",
|
||||||
|
{"x": x, "y": y, "duration": duration_ms / 1000},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("long press failed") from exc
|
||||||
|
|
||||||
|
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
|
||||||
|
from driver._w3c_actions import build_double_tap_actions, perform_actions
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
perform_actions(client, build_double_tap_actions(x, y, interval_ms))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("double tap failed") from exc
|
||||||
|
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
start_x: float,
|
start_x: float,
|
||||||
@@ -107,6 +137,17 @@ class WDADriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("text input failed") from exc
|
raise DriverError("text input failed") from exc
|
||||||
|
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
from driver._w3c_actions import build_swipe_actions, perform_actions
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
perform_actions(client, build_swipe_actions(waypoints, duration_ms))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("swipe_path failed") from exc
|
||||||
|
|
||||||
def launch(self, app_id: str) -> None:
|
def launch(self, app_id: str) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
@@ -128,6 +169,19 @@ class WDADriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("ui tree retrieval failed") from exc
|
raise DriverError("ui tree retrieval failed") from exc
|
||||||
|
|
||||||
|
def active_app(self) -> ActiveApp | None:
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
response = client.execute_script("mobile: activeAppInfo")
|
||||||
|
except Exception as exc:
|
||||||
|
raise DriverError("active app retrieval failed") from exc
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
return None
|
||||||
|
bundle_id = response.get("bundleId")
|
||||||
|
if not isinstance(bundle_id, str) or not bundle_id:
|
||||||
|
return None
|
||||||
|
return ActiveApp(platform="ios", bundle_id=bundle_id)
|
||||||
|
|
||||||
def home(self) -> None:
|
def home(self) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ Constraints:
|
|||||||
|
|
||||||
**Non-Goals:**
|
**Non-Goals:**
|
||||||
- Not adding a dedicated reflection LLM call (explicit method B) — method C (pre-tool text block) achieves the intent within the existing call budget.
|
- Not adding a dedicated reflection LLM call (explicit method B) — method C (pre-tool text block) achieves the intent within the existing call budget.
|
||||||
- Not changing `CloudProxyToolCallingClient` — it transparently proxies and is not aware of thinking/text blocks.
|
|
||||||
- Not changing the `scene_summary` field in `WorldEvent` for stored/displayed task history in the Console — only the prompt-construction path (`_history_summary()`) is changed.
|
- Not changing the `scene_summary` field in `WorldEvent` for stored/displayed task history in the Console — only the prompt-construction path (`_history_summary()`) is changed.
|
||||||
- Not surfacing thinking/rationale in the Cloud Console UI (that's a follow-on concern).
|
|
||||||
- Not making `thinking_budget_tokens` configurable per-task at runtime (only via environment variable).
|
- Not making `thinking_budget_tokens` configurable per-task at runtime (only via environment variable).
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
@@ -112,13 +110,42 @@ Constraints:
|
|||||||
|
|
||||||
**Why separate columns rather than a JSON blob**: The existing table uses discrete columns for `system_prompt`, `user_prompt`, `tool_name`, `tool_arguments` — consistency favours discrete columns. Both fields are optional (cloud-proxy path only; `direct` transport never produces cloud decision records).
|
**Why separate columns rather than a JSON blob**: The existing table uses discrete columns for `system_prompt`, `user_prompt`, `tool_name`, `tool_arguments` — consistency favours discrete columns. Both fields are optional (cloud-proxy path only; `direct` transport never produces cloud decision records).
|
||||||
|
|
||||||
|
### D9: Device-action tool calls carry required purpose and expected outcome
|
||||||
|
|
||||||
|
**Decision**: Every device-action schema (`tap`, `swipe`, `input_text`,
|
||||||
|
`launch_app`, and `terminate_app`) adds required non-empty `purpose` and
|
||||||
|
`expected_outcome` string fields. The tool-call response parser removes these
|
||||||
|
metadata fields from executable arguments and assigns them to
|
||||||
|
`ToolCallDecision`; `AIPlanner` carries them on `PlannedStep`. The completion
|
||||||
|
signal remains unchanged because it already has a required terminal `reason`.
|
||||||
|
|
||||||
|
The Cloud planner response returns rationale, thinking, purpose, and expected
|
||||||
|
outcome to the Host. The Cloud decision log persists the four values as
|
||||||
|
nullable, additive columns so historic rows and non-AI callers remain
|
||||||
|
readable. `WorldEvent` retains the executable action arguments together with
|
||||||
|
purpose and expected outcome; its compact history summary includes that action
|
||||||
|
record. Timeline records and learned `FlowStep` instances retain the same
|
||||||
|
arguments and metadata; flow embedding text includes available semantics so
|
||||||
|
retrieval can use them.
|
||||||
|
|
||||||
|
**Why structured tool arguments rather than rationale**: the pre-tool text
|
||||||
|
block is optional by API design and can be suppressed by a forced retry. Tool
|
||||||
|
schemas are the provider-enforced structured-output boundary, so requiring
|
||||||
|
purpose and expected outcome there makes them available for every submitted
|
||||||
|
device action without relying on free-form rationale.
|
||||||
|
|
||||||
|
**Why strip metadata before execution**: device tool functions only accept
|
||||||
|
their physical-action arguments. Keeping metadata off `step.args` preserves
|
||||||
|
their API contracts while still making it available to execution history,
|
||||||
|
verification, Cloud audit, and skill synthesis.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- **AI may not always output a text block**: even with `tool_choice: "auto"` (D8), the model is not guaranteed to prefix a text block before the tool call. When absent, `text_output` is `None` and `rationale` is `None`. History degrades gracefully to `{page, rationale: null, action, success}`.
|
- **AI may not always output a text block**: even with `tool_choice: "auto"` (D8), the model is not guaranteed to prefix a text block before the tool call. When absent, `text_output` is `None` and `rationale` is `None`. History degrades gracefully to `{page, rationale: null, action, success}`.
|
||||||
- **`tool_choice: "auto"` occasionally yields no tool call at all**: unlike forced `tool_choice`, `"auto"` permits the model to respond with text only and no tool call. D8's forced retry (no thinking, no rationale on that path) guards this case so a step never stalls; this trades away rationale/thinking for that single step, not overall reliability.
|
- **`tool_choice: "auto"` occasionally yields no tool call at all**: unlike forced `tool_choice`, `"auto"` permits the model to respond with text only and no tool call. D8's forced retry (no thinking, no rationale on that path) guards this case so a step never stalls; this trades away rationale/thinking for that single step, not overall reliability.
|
||||||
- **Extended thinking increases latency**: `budget_tokens` directly adds to minimum response time. This is opt-in and accepted by the operator who enables it.
|
- **Extended thinking increases latency**: `budget_tokens` directly adds to minimum response time. This is opt-in and accepted by the operator who enables it.
|
||||||
- **`WorldEvent` schema divergence from stored data**: Existing `WorldEvent` instances in memory or serialised timelines lack `rationale`/`thinking`. The `to_dict()` method will emit `null` for these fields; downstream consumers should treat `null` as absent, not as a failure.
|
- **`WorldEvent` schema divergence from stored data**: Existing `WorldEvent` instances in memory or serialised timelines lack `rationale`/`thinking`. The `to_dict()` method will emit `null` for these fields; downstream consumers should treat `null` as absent, not as a failure.
|
||||||
- **Cloud-proxy transport never produces thinking/rationale at the client layer**: The proxy returns only `tool_name`/`arguments`. `ToolCallDecision.thinking` and `.text_output` will always be `None` for cloud-transport tasks. The `planner_decision_log` on the cloud side will be populated from the cloud-proxied call itself (D7), which does see the full LLM response.
|
- **Cloud/Host deployment order**: a new Host requires a Cloud API that returns the additive metadata fields to preserve them locally. The Cloud response models remain nullable so an old peer remains readable during a rolling deployment, but it cannot provide the new semantic records.
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ The AI planner's execution history currently stores raw `scene_summary` (full UI
|
|||||||
- **Rationale capture**: `ToolCallDecision` captures the AI's pre-tool text output (`text_output`) and, when extended thinking is enabled, the thinking block (`thinking`). Both flow through `PlannedStep` into `WorldEvent`.
|
- **Rationale capture**: `ToolCallDecision` captures the AI's pre-tool text output (`text_output`) and, when extended thinking is enabled, the thinking block (`thinking`). Both flow through `PlannedStep` into `WorldEvent`.
|
||||||
- **Extended thinking support**: `AnthropicToolCallingClient` gains optional `thinking_budget_tokens` config. When set, the Anthropic API is called with `thinking` enabled (interleaved thinking beta); the thinking block is extracted and stored.
|
- **Extended thinking support**: `AnthropicToolCallingClient` gains optional `thinking_budget_tokens` config. When set, the Anthropic API is called with `thinking` enabled (interleaved thinking beta); the thinking block is extracted and stored.
|
||||||
- **OpenAI reasoning capture**: `OpenAIToolCallingClient` extracts `reasoning_content` from responses when present (o-series models).
|
- **OpenAI reasoning capture**: `OpenAIToolCallingClient` extracts `reasoning_content` from responses when present (o-series models).
|
||||||
- **WorldEvent schema change**: `scene_summary` is replaced by `rationale` (from `text_output`) and `thinking` (from thinking block), plus `current_page` from `WorldState` for minimal page-level verification context. This reduces per-step history token cost by an order of magnitude.
|
- **Required action metadata**: every device-action tool call must return a concise `purpose` and observable `expected_outcome`; the Runtime stores them separately from executable tool arguments so successful executions can be reused as semantically meaningful flows.
|
||||||
|
- **WorldEvent schema change**: `scene_summary` is replaced by rationale/thinking plus the action name, executable arguments, purpose, expected outcome, and `current_page` from `WorldState`, giving later planning and skill reuse a compact but complete action record.
|
||||||
- **History format**: `_history_summary()` in `ai_planner.py` switches from full `WorldEvent.to_dict()` to a compact `{page, rationale, action, success}` format.
|
- **History format**: `_history_summary()` in `ai_planner.py` switches from full `WorldEvent.to_dict()` to a compact `{page, rationale, action, success}` format.
|
||||||
- **planner_decision_log extension**: The Cloud-side decision log table adds `thinking` and `rationale` columns to persist these fields alongside existing prompt/tool records.
|
- **planner_decision_log extension**: The Cloud-side decision log table adds `thinking` and `rationale` columns to persist these fields alongside existing prompt/tool records.
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ The AI planner's execution history currently stores raw `scene_summary` (full UI
|
|||||||
|
|
||||||
- `runtime/tool_calling_client.py` — `ToolCallDecision`, `AnthropicToolCallingClient`, `OpenAIToolCallingClient`, response parsers
|
- `runtime/tool_calling_client.py` — `ToolCallDecision`, `AnthropicToolCallingClient`, `OpenAIToolCallingClient`, response parsers
|
||||||
- `runtime/planner.py` — `PlannedStep`
|
- `runtime/planner.py` — `PlannedStep`
|
||||||
|
- `runtime/tool_specs.py` — required purpose/expected-outcome fields for device actions
|
||||||
- `runtime/ai_planner.py` — `AIPlanner.plan()`, `_history_summary()`
|
- `runtime/ai_planner.py` — `AIPlanner.plan()`, `_history_summary()`
|
||||||
- `runtime/planner_prompts.py` — `PLANNER_SYSTEM_PROMPT`, `planner_user_prompt`
|
- `runtime/planner_prompts.py` — `PLANNER_SYSTEM_PROMPT`, `planner_user_prompt`
|
||||||
- `runtime/planner_config.py` — new `thinking_budget_tokens` field
|
- `runtime/planner_config.py` — new `thinking_budget_tokens` field
|
||||||
@@ -35,7 +37,8 @@ The AI planner's execution history currently stores raw `scene_summary` (full UI
|
|||||||
- `world/model.py` — `_append_history()`
|
- `world/model.py` — `_append_history()`
|
||||||
- `packages/cloud-platform/cloud/db_models.py` — `planner_decision_log` table
|
- `packages/cloud-platform/cloud/db_models.py` — `planner_decision_log` table
|
||||||
- `packages/cloud-platform/cloud/schema.py` — Alembic migration
|
- `packages/cloud-platform/cloud/schema.py` — Alembic migration
|
||||||
- `packages/cloud-platform/cloud/internal_api/api.py` — `record_planner_decision()`
|
- `packages/cloud-platform/cloud/internal_api/api.py` — `record_planner_decision()` and planner decision response
|
||||||
- `packages/cloud-platform/cloud/internal_api/models.py` — `PlannerDecisionRecord`
|
- `packages/cloud-platform/cloud/internal_api/models.py` — planner decision transport models
|
||||||
- No changes to `CloudProxyToolCallingClient` — thinking/text are surfaced at the local client layer only; the cloud proxy is transparent to them.
|
- `apps/device-host-agent/host_agent/cloud_planner_client.py` — return reflection and action metadata from the Cloud proxy
|
||||||
|
- `skills_learning/` — retain action purpose/expected outcome in synthesized flow steps
|
||||||
- No new external dependencies; Anthropic extended thinking uses existing SDK via beta header.
|
- No new external dependencies; Anthropic extended thinking uses existing SDK via beta header.
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ The system SHALL provide a Planner implementation that, given a goal, the curren
|
|||||||
- **WHEN** the LLM response contains only a tool call with no preceding text block
|
- **WHEN** the LLM response contains only a tool call with no preceding text block
|
||||||
- **THEN** `PlannedStep.rationale` is `None` and the step is returned normally
|
- **THEN** `PlannedStep.rationale` is `None` and the step is returned normally
|
||||||
|
|
||||||
|
#### Scenario: Device action includes reusable purpose and expected outcome
|
||||||
|
- **WHEN** the AI Planner selects a device action (`tap`, `swipe`, `input_text`, `launch_app`, or `terminate_app`)
|
||||||
|
- **THEN** its tool call requires non-empty `purpose` and `expected_outcome` values, and the returned `PlannedStep` carries both separately from the executable action arguments
|
||||||
|
|
||||||
### Requirement: Pluggable dual-provider tool-calling abstraction
|
### Requirement: Pluggable dual-provider tool-calling abstraction
|
||||||
The system SHALL support at least two interchangeable LLM providers (Anthropic native tool use and OpenAI function calling) for the AI Planner's decision calls, selectable via configuration, with both providers constrained to return exactly one tool call per request. The Anthropic client SHALL additionally support optional extended thinking via a configurable `thinking_budget_tokens` value. The OpenAI client SHALL capture `reasoning_content` from responses when present. Independently of provider selection, the system SHALL support at least two transports for making that decision call — direct-to-provider and cloud-proxy — selectable via configuration without requiring any change to `AIPlanner`'s own decision logic.
|
The system SHALL support at least two interchangeable LLM providers (Anthropic native tool use and OpenAI function calling) for the AI Planner's decision calls, selectable via configuration, with both providers constrained to return exactly one tool call per request. The Anthropic client SHALL additionally support optional extended thinking via a configurable `thinking_budget_tokens` value. The OpenAI client SHALL capture `reasoning_content` from responses when present. Independently of provider selection, the system SHALL support at least two transports for making that decision call — direct-to-provider and cloud-proxy — selectable via configuration without requiring any change to `AIPlanner`'s own decision logic.
|
||||||
|
|
||||||
@@ -50,6 +54,6 @@ The system SHALL support at least two interchangeable LLM providers (Anthropic n
|
|||||||
- **WHEN** the Host Agent is configured with the cloud-proxy transport
|
- **WHEN** the Host Agent is configured with the cloud-proxy transport
|
||||||
- **THEN** its tool-calling client sends the decision request to the Cloud Control Plane's planner-decision endpoint instead of constructing a local Anthropic or OpenAI SDK client
|
- **THEN** its tool-calling client sends the decision request to the Cloud Control Plane's planner-decision endpoint instead of constructing a local Anthropic or OpenAI SDK client
|
||||||
|
|
||||||
#### Scenario: Cloud-proxy transport returns None for thinking and text_output
|
#### Scenario: Cloud-proxy transport returns planner metadata
|
||||||
- **WHEN** the Host Agent uses cloud-proxy transport
|
- **WHEN** the Host Agent uses cloud-proxy transport and the Cloud Planner returns a decision
|
||||||
- **THEN** `ToolCallDecision.thinking` and `ToolCallDecision.text_output` are `None` because the proxy surface does not expose them
|
- **THEN** the proxy returns its rationale, thinking, purpose, and expected outcome alongside the tool name and executable arguments
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
## MODIFIED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: Cloud Console displays a task's full LLM interaction history
|
### Requirement: Cloud Console displays a task's full LLM interaction history
|
||||||
Cloud Console SHALL provide a view, for a given task, listing each persisted planner decision in step order, including its full prompt, resulting decision, and — when available — the AI's rationale (pre-tool text reflection) and thinking (extended thinking block), sourced from the Cloud Control Plane's persisted planner-decision log.
|
Cloud Console SHALL provide a view, for a given task, listing each persisted planner decision in step order, including its full prompt, resulting decision, and — when available — the AI's rationale (pre-tool text reflection), thinking (extended thinking block), action purpose, and expected outcome, sourced from the Cloud Control Plane's persisted planner-decision log.
|
||||||
|
|
||||||
#### Scenario: Task has persisted planner decisions with rationale
|
#### Scenario: Task has persisted planner decisions with rationale
|
||||||
- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions with non-null rationale
|
- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions with non-null rationale
|
||||||
@@ -20,7 +20,7 @@ Cloud Console SHALL provide a view, for a given task, listing each persisted pla
|
|||||||
- **THEN** Cloud Console indicates that no LLM interaction history is available because the Host does not report it, rather than showing an empty history with no explanation
|
- **THEN** Cloud Console indicates that no LLM interaction history is available because the Host does not report it, rather than showing an empty history with no explanation
|
||||||
|
|
||||||
### Requirement: Cloud Control Plane persists rationale and thinking in the planner decision log
|
### Requirement: Cloud Control Plane persists rationale and thinking in the planner decision log
|
||||||
The Cloud Control Plane's planner-decision log SHALL store the AI's rationale and thinking fields alongside the existing prompt and tool-call fields for each persisted decision. Both fields SHALL be nullable; absence of either field SHALL NOT prevent a decision record from being stored or queried.
|
The Cloud Control Plane's planner-decision log SHALL store the AI's rationale, thinking, action purpose, and expected outcome fields alongside the existing prompt and tool-call fields for each persisted decision. These fields SHALL be nullable for backward compatibility; absence of a legacy or non-AI value SHALL NOT prevent a decision record from being stored or queried.
|
||||||
|
|
||||||
#### Scenario: Decision record includes rationale
|
#### Scenario: Decision record includes rationale
|
||||||
- **WHEN** the Host Agent reports a planner decision with a non-null rationale
|
- **WHEN** the Host Agent reports a planner decision with a non-null rationale
|
||||||
@@ -34,6 +34,10 @@ The Cloud Control Plane's planner-decision log SHALL store the AI's rationale an
|
|||||||
- **WHEN** the Host Agent reports a planner decision with null rationale and null thinking (e.g., cloud-proxy transport where these are not surfaced)
|
- **WHEN** the Host Agent reports a planner decision with null rationale and null thinking (e.g., cloud-proxy transport where these are not surfaced)
|
||||||
- **THEN** the persisted row stores NULL for both columns without error
|
- **THEN** the persisted row stores NULL for both columns without error
|
||||||
|
|
||||||
|
#### Scenario: Decision record includes reusable action metadata
|
||||||
|
- **WHEN** the Host reports a device-action planner decision with a purpose and expected outcome
|
||||||
|
- **THEN** the persisted row stores both values separately from the executable tool arguments and the task API returns them to authorized readers
|
||||||
|
|
||||||
#### Scenario: Existing decision records without rationale or thinking remain readable
|
#### Scenario: Existing decision records without rationale or thinking remain readable
|
||||||
- **WHEN** the system queries a `planner_decision_log` row created before this migration
|
- **WHEN** the system queries a `planner_decision_log` row created before this migration
|
||||||
- **THEN** both `rationale` and `thinking` read as NULL, and the row is returned normally
|
- **THEN** both `rationale` and `thinking` read as NULL, and the row is returned normally
|
||||||
|
|||||||
+17
-2
@@ -34,6 +34,17 @@ The system SHALL extract and preserve the AI model's thinking block (when extend
|
|||||||
- **WHEN** the LLM response contains only a tool call block (no thinking, no text)
|
- **WHEN** the LLM response contains only a tool call block (no thinking, no text)
|
||||||
- **THEN** `ToolCallDecision.thinking` and `ToolCallDecision.text_output` are both `None`, and the decision is returned normally
|
- **THEN** `ToolCallDecision.thinking` and `ToolCallDecision.text_output` are both `None`, and the decision is returned normally
|
||||||
|
|
||||||
|
### Requirement: Device actions return required reusable metadata
|
||||||
|
The system SHALL require each device-action tool call to include concise, non-empty `purpose` and `expected_outcome` strings in its structured arguments. The Runtime SHALL preserve these values as planner metadata while excluding them from the arguments supplied to the physical device tool.
|
||||||
|
|
||||||
|
#### Scenario: Tool schema requires purpose and expected outcome
|
||||||
|
- **WHEN** the Planner sends an action tool schema to an LLM provider
|
||||||
|
- **THEN** each device-action schema requires `purpose` and `expected_outcome` in addition to its physical-action arguments
|
||||||
|
|
||||||
|
#### Scenario: Action metadata is not passed to the device tool
|
||||||
|
- **WHEN** a planned action is executed
|
||||||
|
- **THEN** the device tool receives only its physical-action arguments while the purpose and expected outcome remain available on the planned step and execution record
|
||||||
|
|
||||||
### Requirement: Extended thinking is opt-in via configuration
|
### Requirement: Extended thinking is opt-in via configuration
|
||||||
The system SHALL support enabling Anthropic extended thinking for the AI planner via a `thinking_budget_tokens` configuration value. When not configured, the planner SHALL operate identically to its pre-existing behavior.
|
The system SHALL support enabling Anthropic extended thinking for the AI planner via a `thinking_budget_tokens` configuration value. When not configured, the planner SHALL operate identically to its pre-existing behavior.
|
||||||
|
|
||||||
@@ -50,12 +61,16 @@ The system SHALL support enabling Anthropic extended thinking for the AI planner
|
|||||||
- **THEN** the OpenAI client does not apply the Anthropic thinking parameter; reasoning content is captured only if the model returns it naturally
|
- **THEN** the OpenAI client does not apply the Anthropic thinking parameter; reasoning content is captured only if the model returns it naturally
|
||||||
|
|
||||||
### Requirement: Execution history uses compact rationale-based representation
|
### Requirement: Execution history uses compact rationale-based representation
|
||||||
The system SHALL construct the AI planner's history prompt from a compact per-step record containing the page context, rationale, action, and success flag — not the full scene JSON. This compact history SHALL be the sole format used when constructing the `history_summary` passed to `planner_user_prompt`.
|
The system SHALL construct the AI planner's history prompt from a compact per-step record containing the page context, rationale, action, executable action arguments, purpose, expected outcome, and success flag — not the full scene JSON. This compact history SHALL be the sole format used when constructing the `history_summary` passed to `planner_user_prompt`.
|
||||||
|
|
||||||
#### Scenario: History prompt uses compact format
|
#### Scenario: History prompt uses compact format
|
||||||
- **WHEN** `_history_summary()` is called with a `WorldState` that has one or more history entries
|
- **WHEN** `_history_summary()` is called with a `WorldState` that has one or more history entries
|
||||||
- **THEN** each entry in the returned list contains `page`, `rationale`, `action`, and `success` fields only, without any scene element data
|
- **THEN** each entry in the returned list contains `page`, `rationale`, `action`, `arguments`, `purpose`, `expected_outcome`, and `success` fields only, without any scene element data
|
||||||
|
|
||||||
#### Scenario: History prompt handles None rationale
|
#### Scenario: History prompt handles None rationale
|
||||||
- **WHEN** a `WorldEvent` in history has `rationale=None`
|
- **WHEN** a `WorldEvent` in history has `rationale=None`
|
||||||
- **THEN** the compact history entry for that step includes `"rationale": null` without omitting the field or raising an error
|
- **THEN** the compact history entry for that step includes `"rationale": null` without omitting the field or raising an error
|
||||||
|
|
||||||
|
#### Scenario: History prompt preserves executed action arguments
|
||||||
|
- **WHEN** a prior action tapped a coordinate or otherwise supplied tool arguments
|
||||||
|
- **THEN** the compact history entry includes those exact executable arguments alongside the action name
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
## MODIFIED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: Bounded history of recent scene/action pairs
|
### Requirement: Bounded history of recent scene/action pairs
|
||||||
The system SHALL maintain `WorldState.history` as a fixed-size, bounded collection of the most recent per-step records, automatically evicting the oldest entry when a new entry is added past the configured bound. Each history record SHALL store the action name, success flag, page context (from `WorldState.current_page` at the time of recording), and optional rationale and thinking fields sourced from the executed `PlannedStep`. The `scene_summary` field SHALL be retained as an optional field for backward compatibility but SHALL NOT be required for new entries.
|
The system SHALL maintain `WorldState.history` as a fixed-size, bounded collection of the most recent per-step records, automatically evicting the oldest entry when a new entry is added past the configured bound. Each history record SHALL store the action name, executable action arguments, success flag, page context (from `WorldState.current_page` at the time of recording), and optional rationale, thinking, purpose, and expected-outcome fields sourced from the executed `PlannedStep`. The `scene_summary` field SHALL be retained as an optional field for backward compatibility but SHALL NOT be required for new entries.
|
||||||
|
|
||||||
#### Scenario: WorldState survives across steps within a task
|
#### Scenario: WorldState survives across steps within a task
|
||||||
- **WHEN** a task executes multiple steps in sequence
|
- **WHEN** a task executes multiple steps in sequence
|
||||||
@@ -23,6 +23,14 @@ The system SHALL maintain `WorldState.history` as a fixed-size, bounded collecti
|
|||||||
- **WHEN** the executed `PlannedStep` carries a non-None `thinking`
|
- **WHEN** the executed `PlannedStep` carries a non-None `thinking`
|
||||||
- **THEN** the resulting `WorldEvent` stores that thinking string
|
- **THEN** the resulting `WorldEvent` stores that thinking string
|
||||||
|
|
||||||
|
#### Scenario: History record includes reusable action metadata
|
||||||
|
- **WHEN** an executed `PlannedStep` carries a purpose and expected outcome
|
||||||
|
- **THEN** the resulting `WorldEvent` stores both values for the next planning turn and later reuse
|
||||||
|
|
||||||
|
#### Scenario: History record includes executed action arguments
|
||||||
|
- **WHEN** a `PlannedStep` executes with action arguments such as a tap's `x` and `y` coordinates
|
||||||
|
- **THEN** the resulting `WorldEvent` stores those executable arguments with the action name
|
||||||
|
|
||||||
#### Scenario: History record captures current page at time of recording
|
#### Scenario: History record captures current page at time of recording
|
||||||
- **WHEN** a step is appended to history and `WorldState.current_page` is non-None at that moment
|
- **WHEN** a step is appended to history and `WorldState.current_page` is non-None at that moment
|
||||||
- **THEN** `WorldEvent.page` is set to that page value
|
- **THEN** `WorldEvent.page` is set to that page value
|
||||||
|
|||||||
@@ -60,3 +60,11 @@
|
|||||||
- [x] 9.6 Add/rename unit tests in `tests/test_tool_calling_client.py` covering: default request uses `tool_choice: "auto"`; retry sequence when first response has no tool call (Anthropic and OpenAI); thinking/`betas` dropped on the forced retry; OpenAI `text_output` capture
|
- [x] 9.6 Add/rename unit tests in `tests/test_tool_calling_client.py` covering: default request uses `tool_choice: "auto"`; retry sequence when first response has no tool call (Anthropic and OpenAI); thinking/`betas` dropped on the forced retry; OpenAI `text_output` capture
|
||||||
- [x] 9.7 Update `design.md` (D1 correction + new D8) and this file to document the bug and fix
|
- [x] 9.7 Update `design.md` (D1 correction + new D8) and this file to document the bug and fix
|
||||||
- [x] 9.8 Re-run `uv run --all-packages pytest -m "not integration"`, `ruff check`/`ruff format --check`, `python -m compileall`, and `openspec validate --strict --change "planner-reflection-history"` after the fix
|
- [x] 9.8 Re-run `uv run --all-packages pytest -m "not integration"`, `ruff check`/`ruff format --check`, `python -m compileall`, and `openspec validate --strict --change "planner-reflection-history"` after the fix
|
||||||
|
|
||||||
|
## 10. Required reusable action metadata (post-completion amendment)
|
||||||
|
|
||||||
|
- [x] 10.1 Require `purpose` and `expected_outcome` in every device-action tool schema; extract them from executable arguments into `ToolCallDecision` and `PlannedStep`.
|
||||||
|
- [x] 10.2 Return rationale, thinking, purpose, and expected outcome through the Cloud planner response; persist the new action metadata in `planner_decision_log` with an additive migration and expose it through the task decision API.
|
||||||
|
- [x] 10.3 Preserve executable arguments, purpose, and expected outcome in `WorldEvent`, Timeline records, and synthesized `FlowStep` values; include available metadata in skill embedding text for semantic retrieval.
|
||||||
|
- [x] 10.4 Render available rationale, thinking, purpose, and expected outcome in Cloud Console planner-decision history.
|
||||||
|
- [x] 10.5 Add focused regression tests and run the relevant Python, frontend, migration, lint/format, and OpenSpec validation checks.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-15
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Task cancellation has no implementation anywhere in the stack today:
|
||||||
|
|
||||||
|
- `core/models.py`'s `TaskStatus` reserves `"cancelled"` but no code path assigns it.
|
||||||
|
`runtime/task.py::TaskRunner._interrupt_task()` marks `should_stop`-triggered
|
||||||
|
interruption as `status="failed", failure_reason="execution interrupted"` — this is the
|
||||||
|
only existing consumer of `should_stop`, and it conflates "lease was lost" with "user
|
||||||
|
asked to stop" (both produce `failed`).
|
||||||
|
- `cloud/scheduler.py`'s `ScheduledTaskStatus` is `queued | assigned | dispatched | done |
|
||||||
|
failed` — no `cancelled`, no cancel operation on `TaskScheduler` or `CloudRepository`.
|
||||||
|
- The internal Host↔Cloud protocol (`cloud/internal_api/models.py`) has exactly one
|
||||||
|
channel from Cloud back to Host during execution: `LeaseRenewalResponse`, currently
|
||||||
|
`{status: Literal["renewed"], lease_expires_at: datetime}`. There is no push channel;
|
||||||
|
the Host Agent is purely outbound (heartbeat, claim long-poll, renew, report result).
|
||||||
|
- `workflow/` already has the target shape: `WorkflowRunStatus` includes `cancelled`,
|
||||||
|
`WorkflowRunner._drive()` checks `should_stop()` at each step boundary and calls
|
||||||
|
`self._checkpoint(run, ..., "cancelled")`. This design generalizes that pattern to
|
||||||
|
`TaskRunner`/`ScheduledTask` rather than inventing a new one.
|
||||||
|
- Three prior changes (`cloud-console`, `cloud-control-plane-integration`,
|
||||||
|
`cloud-console-governance`) explicitly deferred cancellation, citing "no
|
||||||
|
scheduler/repository operation for this exists." This change adds that operation.
|
||||||
|
|
||||||
|
Existing collaborative-stop infrastructure this design reuses instead of replacing:
|
||||||
|
`ActiveAssignmentRunner.run()` (`apps/device-host-agent/host_agent/lease.py`) races
|
||||||
|
execution against a renewal loop; the renewal loop is the only place the Host Agent
|
||||||
|
talks to Cloud while a task is running. `should_stop` already flows
|
||||||
|
`ActiveAssignmentRunner` → `AssignmentExecutor.execute()` →
|
||||||
|
`TaskRunner.run()`/`WorkflowRunner.run()`, checked at step boundaries (never mid-step).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Let an authorized caller cancel a task in `queued`, `assigned`, or `dispatched` state
|
||||||
|
through the public SDK.
|
||||||
|
- For `queued` tasks (never dispatched to a Host), cancellation is synchronous and
|
||||||
|
immediate — no Host round-trip needed.
|
||||||
|
- For `assigned`/`dispatched` tasks (a Host may be actively executing), cancellation is
|
||||||
|
collaborative: the Host learns about it at its next lease renewal (≤ ~1/3 of
|
||||||
|
`lease_duration_seconds`, matching the existing lease-loss detection latency) and stops
|
||||||
|
at the next step boundary, exactly like a lost lease does today.
|
||||||
|
- Make `cancelled` a real, reachable terminal state end-to-end: `core/models.py`'s
|
||||||
|
`TaskStatus`, `cloud/scheduler.py`'s `ScheduledTaskStatus`, the Cloud Console, and the
|
||||||
|
Host Agent local console.
|
||||||
|
- Distinguish "cancelled by request" from "failed" in every layer that currently reports
|
||||||
|
`should_stop`-triggered stops as generic failure, so operators can tell the two apart.
|
||||||
|
- Cancellation is idempotent: cancelling an already-cancelled or already-terminal task is
|
||||||
|
a no-op with a clear response, not an error that implies something changed.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No mid-step interruption. A single in-flight action (a tap, an LLM planning call) is
|
||||||
|
never aborted mid-flight; the existing step-boundary granularity of `should_stop` is
|
||||||
|
unchanged. A slow single step still finishes before a `dispatched` task's cancellation
|
||||||
|
takes effect.
|
||||||
|
- No forced/instant kill of a Host Agent process or its OS-level subprocess tree. This is
|
||||||
|
cooperative cancellation only, consistent with the project's existing lease-loss
|
||||||
|
handling — not a new capability class.
|
||||||
|
- No per-task ownership/ACL model. The Cloud repository does not track which principal
|
||||||
|
submitted a task; cancel authorization reuses the existing `tasks:submit` scope
|
||||||
|
(whoever can submit a task can cancel any task). Adding submitter-scoped authorization
|
||||||
|
is a distinct governance change, out of scope here (parallels
|
||||||
|
`cloud-console-governance`'s existing target-based, not ownership-based, model).
|
||||||
|
- No retry-from-UI or task editing. Only stopping a task early; resubmission remains a
|
||||||
|
separate, already-existing "submit a new task" action.
|
||||||
|
- No cancellation of individual workflow steps independent of the whole run; workflow
|
||||||
|
cancellation continues to mean "stop the run," which `workflow/` already implements.
|
||||||
|
- No change to `TaskDispatcher` (`cloud/dispatch.py`) — confirmed dead/dev-only code per
|
||||||
|
prior investigation; not worth extending for cancellation.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: `LeaseRenewalResponse.cancel_requested: bool` is the only new wire signal
|
||||||
|
|
||||||
|
Rejected alternatives: (a) a new dedicated Host-polled "check cancellation" endpoint —
|
||||||
|
adds a second polling loop and a second latency bound to reason about, when renewal
|
||||||
|
already runs on a well-understood cadence; (b) a push/webhook mechanism — breaks the
|
||||||
|
explicit "Host Agent operation requires no inbound cloud connection" requirement in
|
||||||
|
`host-agent-protocol`'s spec, which is a hard architectural constraint, not just current
|
||||||
|
practice.
|
||||||
|
|
||||||
|
`cancel_requested` defaults to `False`. When the Cloud repository's `renew_lease` finds
|
||||||
|
the active `ScheduledTask` has a pending cancellation record, it still renews the lease
|
||||||
|
normally (a task must keep a live lease while the Host works through shutdown) but flags
|
||||||
|
`cancel_requested=True` in the response. The Host Agent's `_renew_while_running` loop
|
||||||
|
treats this the same way it treats `StaleLeaseError`: mark the `LeaseGuard` (extended
|
||||||
|
with a distinct reason string, e.g. `"cancellation requested by control plane"`) and let
|
||||||
|
the existing `should_stop` composite (`guard.is_lost() or self._stop_requested.is_set()`)
|
||||||
|
do the rest — no new boolean plumbed through `ActiveAssignmentRunner`/`AssignmentExecutor`
|
||||||
|
signatures.
|
||||||
|
|
||||||
|
### D2: Cancellation request is durable state, not a fire-and-forget signal
|
||||||
|
|
||||||
|
A `POST .../cancel` on an `assigned`/`dispatched` task writes a `cancel_requested_at`
|
||||||
|
timestamp (see D5 schema) rather than only flipping an in-memory flag or directly setting
|
||||||
|
`status="cancelled"`. Rationale: the Host may not renew again for up to
|
||||||
|
`lease_duration_seconds / 3`; if the Cloud process restarts in that window, an in-memory
|
||||||
|
signal would be lost silently, defeating the cancellation with no user-visible error. A
|
||||||
|
durable row survives restarts and is what `renew_lease` reads.
|
||||||
|
|
||||||
|
The scheduled task's `status` only transitions to `cancelled` once the Host has actually
|
||||||
|
stopped and reported it (D3) — or, for the immediate `queued` case, synchronously in the
|
||||||
|
same request. This means `assigned`/`dispatched` tasks pass through an intermediate,
|
||||||
|
observable "cancellation pending" state (surfaced to callers as the existing `status`
|
||||||
|
value plus a non-null `cancel_requested_at`, not a new status literal — see D6) rather
|
||||||
|
than jumping straight to `cancelled` before the Host has confirmed.
|
||||||
|
|
||||||
|
### D3: Host reports `cancelled` as a new terminal outcome via the existing result-report endpoint
|
||||||
|
|
||||||
|
`TerminalResultRequest.status` (`internal_api/models.py`) is `Literal["done", "failed"]`.
|
||||||
|
This design adds `"cancelled"` to that literal rather than inventing a parallel
|
||||||
|
cancellation-report endpoint, because `report_result`'s idempotency/lease-validation
|
||||||
|
logic (`record_task_result` in `sql_repository.py`) already handles exactly the
|
||||||
|
concurrency shape needed (attempt/lease-id conflict checks, `already_recorded` replay
|
||||||
|
safety) — duplicating it for a cancel-specific endpoint would be pure risk with no
|
||||||
|
benefit. `AssignmentExecutionResult.status` (`host_agent/assignment.py`) similarly gains
|
||||||
|
a `"cancelled"` value alongside `"done"`/`"failed"`, set when `TaskRunner.run()` /
|
||||||
|
`WorkflowRunner.run()` returns because of a cancellation-flavored stop rather than a lost
|
||||||
|
lease or genuine step failure (see D4).
|
||||||
|
|
||||||
|
### D4: `should_stop` becomes a richer signal than a bare boolean at the `TaskRunner` boundary
|
||||||
|
|
||||||
|
Today `StopRequested = Callable[[], bool]`. Distinguishing "cancelled" from "lease
|
||||||
|
lost"/"shutdown requested" only matters for the *reason recorded on the terminal
|
||||||
|
status* — the control-flow behavior (stop at the next step boundary) is identical. Rather
|
||||||
|
than changing the `should_stop` callable's signature (which would ripple through
|
||||||
|
`workflow/runner.py`, tests, and every caller), `TaskRunner._interrupt_task()` and
|
||||||
|
`WorkflowRunner._drive()`'s stop branch gain an optional second callable,
|
||||||
|
`stop_reason: Callable[[], str] | None`, defaulting to the existing generic
|
||||||
|
`"execution interrupted"` string when absent. `ActiveAssignmentRunner` supplies a
|
||||||
|
`stop_reason` that reads `LeaseGuard.reason` (already a string field) so `"cancellation
|
||||||
|
requested by control plane"` vs `"lease rejected by control plane"` flows through
|
||||||
|
unchanged plumbing. `TaskRunner._interrupt_task()` sets `status="cancelled"` when the
|
||||||
|
reason string indicates cancellation, else keeps `status="failed"` (lease loss remains a
|
||||||
|
failure, not a cancellation, matching current behavior for that case). This is the
|
||||||
|
narrowest change that gets a real `cancelled` status without touching every
|
||||||
|
`should_stop`-typed parameter across the codebase.
|
||||||
|
|
||||||
|
Alternative considered and rejected: add a third `CancelRequested` callable parallel to
|
||||||
|
`should_stop`. Rejected because it doubles the number of callables threaded through
|
||||||
|
`ActiveAssignmentRunner → AssignmentExecutor → TaskRunner/WorkflowRunner` for
|
||||||
|
information that's only needed at the one moment execution actually stops.
|
||||||
|
|
||||||
|
### D5: Schema — one new nullable column pair on `scheduled_tasks`, no new table
|
||||||
|
|
||||||
|
`packages/cloud-platform/cloud/migrations/versions/0012_task_cancellation.py` adds to
|
||||||
|
`ScheduledTaskRow`/`scheduled_tasks`:
|
||||||
|
- `cancel_requested_at: str | None` (ISO datetime, nullable) — set when a cancel request
|
||||||
|
is recorded against an `assigned`/`dispatched` task; cleared (`NULL`) when the task
|
||||||
|
reaches any terminal status, so a subsequent, different attempt of the same task id (if
|
||||||
|
retry-on-lease-expiry logic in `reap_expired_leases` requeues it) doesn't inherit a
|
||||||
|
stale cancellation.
|
||||||
|
|
||||||
|
No new table: the volume and access pattern (one pending value per task, read on every
|
||||||
|
renewal, written once per cancel call) don't justify the join/joinless-read trade-off a
|
||||||
|
separate `task_cancellation_requests` table would add, and there is exactly one prior
|
||||||
|
migration precedent for this shape — `0008_task_progress_columns` added nullable
|
||||||
|
scalar columns directly to `scheduled_tasks` for the same reason (frequently-read,
|
||||||
|
single-value-per-task state). `TaskAttemptRow`/`task_attempts` needs no schema change:
|
||||||
|
its `status` column already accepts free-form strings and gains `"cancelled"` as a value
|
||||||
|
alongside `"assigned"/"dispatched"/"expired"/"done"/"failed"`.
|
||||||
|
|
||||||
|
`CloudRepository` Protocol gains:
|
||||||
|
- `request_task_cancellation(task_id, *, requested_at) -> CancellationRequestStatus`
|
||||||
|
where `CancellationRequestStatus = Literal["requested", "already_terminal",
|
||||||
|
"already_requested", "not_found"]` — synchronously transitions a `queued` task straight
|
||||||
|
to `cancelled` (there's no Host attempt in flight to notify) and otherwise sets
|
||||||
|
`cancel_requested_at` on an `assigned`/`dispatched` task.
|
||||||
|
- `renew_lease` gains a `cancel_requested` boolean in its return path (or the caller
|
||||||
|
re-reads the row — implementation detail left to tasks.md) so `internal_api/api.py`'s
|
||||||
|
`renew_assignment` handler can populate `LeaseRenewalResponse.cancel_requested`.
|
||||||
|
- `record_task_result` accepts `status: Literal["done", "failed", "cancelled"]`
|
||||||
|
(widened from today's `TerminalTaskStatus = Literal["done", "failed"]`) and clears
|
||||||
|
`cancel_requested_at` on write.
|
||||||
|
|
||||||
|
### D6: No new `ScheduledTaskStatus` value for "cancellation pending"
|
||||||
|
|
||||||
|
Considered adding `"cancelling"` as a distinct status between `assigned`/`dispatched` and
|
||||||
|
`cancelled`. Rejected: it would ripple into every place that already pattern-matches the
|
||||||
|
existing five-value `ScheduledTaskStatus` (scheduler assignment logic, device
|
||||||
|
reservation/`list_reserved_device_ids`, Cloud Console status filter, SDK response
|
||||||
|
`Literal`), each needing to decide whether "cancelling" behaves like "assigned" (device
|
||||||
|
still reserved, task still excluded from re-assignment) — which it always would, making
|
||||||
|
the new status a strict synonym with an extra bit of information. Instead,
|
||||||
|
"cancellation pending" is expressed as `status="assigned"` (or `"dispatched"`) plus
|
||||||
|
non-null `cancel_requested_at` — every existing status-based code path (assignment
|
||||||
|
matching, device reservation, retry-on-expiry) keeps working unmodified, and callers
|
||||||
|
that want to show "cancelling…" in a UI check the extra field.
|
||||||
|
|
||||||
|
### D7: Public cancel endpoint shape
|
||||||
|
|
||||||
|
`POST /v1/tasks/{task_id}/cancel`, `202 Accepted` for the pending (assigned/dispatched)
|
||||||
|
case and `200 OK` for the immediate (queued) case, both returning a small
|
||||||
|
`TaskCancellationResponse {task_id, status}` reflecting the resulting `ScheduledTask`
|
||||||
|
status. Repeated calls against an already-cancelled or already-`cancel_requested_at`-set
|
||||||
|
task return the same response idempotently (HTTP 200, not a conflict) — matching the
|
||||||
|
project's established idempotency style for `report_result`/`renew_assignment`, which
|
||||||
|
return `already_recorded`/success on replay rather than erroring. Calling cancel on a
|
||||||
|
task already `done`/`failed` returns `409 Conflict` with a clear "task is already
|
||||||
|
terminal" detail, mirroring `_stale_lease_conflict`'s existing shape.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Operators expect cancellation to be instant] → It isn't, by design (D1's latency
|
||||||
|
bound). The Cloud Console surfaces the `cancel_requested_at` pending state distinctly
|
||||||
|
(D6) so operators see "cancellation requested" rather than a UI that looks stuck; docs
|
||||||
|
(`docs/CLOUD_DEPLOYMENT.md`) get an explicit latency note per the proposal's Impact
|
||||||
|
section.
|
||||||
|
- [A Host Agent that never renews again (already offline, or wedged past its lease) never
|
||||||
|
learns about the cancellation] → Existing `reap_expired_leases` already requeues or
|
||||||
|
fails such tasks once the lease expires; this change doesn't need new machinery for
|
||||||
|
that case — an offline Host's task eventually reaches a terminal state via the existing
|
||||||
|
reaper, at which point `cancel_requested_at` being non-null is irrelevant (task is
|
||||||
|
already terminal). Worth noting in tasks.md that the reaper's requeue path should NOT
|
||||||
|
requeue (re-assign) a task with `cancel_requested_at` set — it should mark it
|
||||||
|
`cancelled` instead of `queued`, or the cancellation would be silently dropped on
|
||||||
|
retry.
|
||||||
|
- [`tasks:submit`-scoped cancel with no per-task ownership means any authorized submitter
|
||||||
|
can cancel any other submitter's task] → Accepted for this change (Non-Goals); this
|
||||||
|
matches the existing coarse-grained scope model everywhere else in the SDK (task
|
||||||
|
reading is likewise not ownership-scoped) and narrowing it is `cloud-console-governance`
|
||||||
|
territory, not this change's.
|
||||||
|
- [Widening `TaskStatus`/`ScheduledTaskStatus`/`TaskAttemptRow.status` literals is a
|
||||||
|
backward-compatible additive change in Python, but any exhaustive `switch`/discriminated
|
||||||
|
union in the TypeScript Console (`types.ts`'s `TaskStatus`) will fail to compile until
|
||||||
|
updated] → Caught at Console build time (Vite/`vue-tsc`), not runtime; tasks.md includes
|
||||||
|
updating `cloud-console/src/types.ts` and any exhaustiveness-checked switch in the same
|
||||||
|
commit as the backend change.
|
||||||
|
- [Race: cancel request arrives between the Host's `claim_assignment` (queued→dispatched
|
||||||
|
transition happens inside `claim_assignment`, not `assign_task`) and its first renewal]
|
||||||
|
→ Already handled by D2's durable-row design: the cancel call checks current `status`
|
||||||
|
regardless of exactly when the Host claims, and `renew_lease` always re-reads the
|
||||||
|
current row, so there's no window where the signal is lost — only a window (bounded by
|
||||||
|
D1's latency) where it hasn't been observed yet.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Ship the Alembic migration (0012) — additive nullable column, no backfill needed, safe
|
||||||
|
to apply with the application running (existing tasks get `cancel_requested_at = NULL`,
|
||||||
|
behaviorally identical to today).
|
||||||
|
2. Deploy Cloud API with the new repository methods, internal `renew_assignment` response
|
||||||
|
field (`cancel_requested`, defaults `False` — old Host Agents ignore unknown fields),
|
||||||
|
and the new public cancel endpoint. This step alone is a no-op for existing Hosts:
|
||||||
|
nothing calls the new endpoint yet, and `LeaseRenewalResponse` gaining an optional
|
||||||
|
field is backward-compatible with any Host Agent version already deployed (Pydantic
|
||||||
|
response models are additive-safe for JSON-decoding clients that only read known
|
||||||
|
fields).
|
||||||
|
3. Deploy Host Agents with the updated `lease.py`/`assignment.py`/`client.py` that read
|
||||||
|
and act on `cancel_requested`. Hosts not yet updated simply never observe cancellation
|
||||||
|
requests (task stays "pending cancellation" until its lease naturally expires and the
|
||||||
|
reaper marks it `cancelled` per the Risks section) — a soft-fail, not a hard error.
|
||||||
|
4. Ship the Cloud Console and Host Agent local console UI changes last, once the backend
|
||||||
|
contract is stable.
|
||||||
|
5. Rollback: the migration is purely additive and safe to leave in place even if the
|
||||||
|
feature is disabled; no rollback migration is required beyond the standard Alembic
|
||||||
|
downgrade (drop the column) if a full revert is ever needed.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Whether the public cancel endpoint should also accept an optional caller-supplied
|
||||||
|
reason string (for audit/UI display) — left to tasks.md to decide during
|
||||||
|
implementation; not a design-blocking question since it's purely additive to
|
||||||
|
`TaskCancellationRequest` if added later.
|
||||||
|
- Whether `reap_expired_leases`' "mark cancelled instead of requeue when
|
||||||
|
`cancel_requested_at` is set" behavior (noted in Risks) needs its own explicit
|
||||||
|
requirement in the `task-scheduler` delta spec or can be covered as an implementation
|
||||||
|
detail of the existing restart-recovery requirement — resolve when writing specs.md.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Task cancellation has been an explicit, repeatedly-acknowledged gap: `cloud-console`,
|
||||||
|
`cloud-control-plane-integration`, and `cloud-console-governance` each excluded it from
|
||||||
|
scope and deferred it to "a later lifecycle capability." Operators currently have no way
|
||||||
|
to stop a queued, assigned, or in-flight task short of letting it run to completion,
|
||||||
|
failure, or lease expiry — including tasks stuck against an offline device or a runaway
|
||||||
|
plan. `core/models.py`'s `TaskStatus` already reserves a `"cancelled"` value that no code
|
||||||
|
path ever sets, and `workflow/` already proves the collaborative-stop pattern this change
|
||||||
|
extends to goal-based cloud tasks.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a `cancelled` scheduler-side task status (`cloud/scheduler.py`'s
|
||||||
|
`ScheduledTaskStatus`) reachable from `queued`, `assigned`, and `dispatched`.
|
||||||
|
- Add a public SDK endpoint (`POST /v1/tasks/{task_id}/cancel`, gated by the existing
|
||||||
|
`tasks:submit` scope — the repository has no per-task submitter/ownership tracking to
|
||||||
|
authorize against more narrowly) that immediately cancels a `queued` task and otherwise
|
||||||
|
records a cancellation request against an `assigned`/`dispatched` task.
|
||||||
|
- Add an internal Host Agent protocol signal: `LeaseRenewalResponse` gains a
|
||||||
|
`cancel_requested: bool` field; the Cloud repository's `renew_lease` reports it when the
|
||||||
|
active attempt has a pending cancellation. This is the only new edge on the existing
|
||||||
|
outbound-only Host Agent protocol — no inbound push, no new endpoint on the Host side.
|
||||||
|
- Extend the Host Agent's existing `should_stop` collaborative-stop mechanism
|
||||||
|
(`ActiveAssignmentRunner` → `AssignmentExecutor` → `TaskRunner`/`WorkflowRunner`) so a
|
||||||
|
`cancel_requested` signal observed at lease-renewal time stops execution the same way a
|
||||||
|
lost lease does today, and reports a `cancelled`-flavored terminal result.
|
||||||
|
- Add `"cancelled"` as a real, reachable value of `core/models.py`'s `TaskStatus` (the
|
||||||
|
`_interrupt_task` path already flowing through `should_stop` gains a
|
||||||
|
cancellation-vs-lease-loss distinction) and confirm `TerminalResultRequest.status` can
|
||||||
|
express it end to end.
|
||||||
|
- Add an Alembic migration recording cancellation request/acknowledgement metadata on
|
||||||
|
`scheduled_tasks`/`task_attempts` (requestor, requested-at, and the terminal
|
||||||
|
`cancelled` outcome) — no schema change to unrelated tables.
|
||||||
|
- Add a "Cancel" action to the Cloud Console `TasksView.vue` task-detail panel (visible
|
||||||
|
for `queued`/`assigned`/`dispatched` tasks the operator is authorized to act on) and to
|
||||||
|
the status filter dropdown; add a matching `POST /tasks/{id}/cancel` route + button to
|
||||||
|
the Host Agent local console's task detail page for Host-local visibility/action on
|
||||||
|
tasks running on that Host.
|
||||||
|
- **BREAKING**: none of the existing status literals are renamed or removed; `cancelled`
|
||||||
|
is purely additive. Callers that exhaustively `match`/switch over `TaskStatus` (Python)
|
||||||
|
or `TaskStatus` (TypeScript) without a default arm will need to add a case — flagged in
|
||||||
|
design.md's migration plan.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `task-cancellation`: cancellation request lifecycle across `task-scheduler` (queued/
|
||||||
|
assigned/dispatched states), `host-agent-protocol` (collaborative cancel signal over
|
||||||
|
lease renewal), and `agent-runtime`/workflow execution (stopping mid-task on a
|
||||||
|
cancellation signal, distinct from lease loss).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `task-scheduler`: `ScheduledTaskStatus` gains `cancelled`; task submission/assignment
|
||||||
|
requirements are unchanged, but the status-transition requirements need a new terminal
|
||||||
|
transition path from `queued`/`assigned`/`dispatched`.
|
||||||
|
- `host-agent-protocol`: the lease-renewal requirement ("Active execution renews its
|
||||||
|
lease") gains a new SHALL for surfacing a cancellation request in the renewal response
|
||||||
|
and treating it as a stop condition alongside lease loss.
|
||||||
|
- `platform-sdk`: new cancel endpoint and scope-authorization requirement; task-status
|
||||||
|
responses gain the `cancelled` status value.
|
||||||
|
- `cloud-console-ui`: task list/detail view gains a Cancel action and the `cancelled`
|
||||||
|
status value in filtering/display.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Cloud API / persistence**: `packages/cloud-platform/cloud/scheduler.py`,
|
||||||
|
`repository.py` (Protocol), `sql_repository.py`, `db_models.py`, new Alembic migration,
|
||||||
|
`internal_api/models.py` + `internal_api/api.py` (renew/claim/cancel routes),
|
||||||
|
`sdk/api.py` + `sdk/models.py` (new public cancel endpoint), `auth.py` (scope reuse).
|
||||||
|
- **Host Agent**: `apps/device-host-agent/host_agent/lease.py` (`ActiveAssignmentRunner`
|
||||||
|
cancellation-aware stop), `client.py` (surface `cancel_requested` from renew response),
|
||||||
|
`processor.py`/`assignment.py` (terminal status reporting), local console
|
||||||
|
(`host_agent/web/app.py` + `templates/task_detail.html`) new cancel route.
|
||||||
|
- **Runtime**: `core/models.py` (`TaskStatus` reachability), `runtime/task.py`
|
||||||
|
(`_interrupt_task` cancellation-vs-interruption distinction), `workflow/runner.py`
|
||||||
|
(reuse of the already-existing `cancelled` terminal status — no change needed there).
|
||||||
|
- **Cloud Console frontend**: `cloud-console/src/views/TasksView.vue`, `src/types.ts`,
|
||||||
|
`src/api.ts` (new `cancelTask` client method).
|
||||||
|
- **Docs**: `docs/CLOUD_DEPLOYMENT.md` gets a short note on cancellation being
|
||||||
|
collaborative (not instantaneous) and its ~1/3-lease-period latency bound.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Task dashboard
|
||||||
|
The console SHALL render a task view listing tasks by status with pagination, and SHALL show a task's detail including its attempt history, using the platform SDK's task-listing and attempt-history endpoints. The task detail view SHALL offer a Cancel action for tasks in status `queued`, `assigned`, or `dispatched`, using the platform SDK's cancel endpoint, and the status filter SHALL include `cancelled`.
|
||||||
|
|
||||||
|
#### Scenario: Browse the task queue
|
||||||
|
- **WHEN** an operator with a `tasks:read`-scoped token opens the task view
|
||||||
|
- **THEN** the console displays tasks with their status, goal or workflow reference, and assigned device/host, and lets the operator filter by status, including `cancelled`
|
||||||
|
|
||||||
|
#### Scenario: Inspect a task's attempt history
|
||||||
|
- **WHEN** an operator selects a task from the list
|
||||||
|
- **THEN** the console displays that task's recorded attempts in order, including each attempt's outcome
|
||||||
|
|
||||||
|
#### Scenario: Cancel a task from the detail view
|
||||||
|
- **WHEN** an operator with a `tasks:submit`-scoped token views the detail of a task whose status is `queued`, `assigned`, or `dispatched`, and clicks Cancel
|
||||||
|
- **THEN** the console calls the cancel endpoint and updates the displayed status to reflect the immediate or pending cancellation result
|
||||||
|
|
||||||
|
#### Scenario: Cancel action is absent for terminal tasks
|
||||||
|
- **WHEN** an operator views the detail of a task whose status is `done`, `failed`, or `cancelled`
|
||||||
|
- **THEN** the console does not offer a Cancel action for that task
|
||||||
|
|
||||||
|
#### Scenario: Cancel attempted without submit scope
|
||||||
|
- **WHEN** an operator whose token lacks `tasks:submit` views a cancellable task's detail
|
||||||
|
- **THEN** the console does not offer a Cancel action, or surfaces the API's authorization error without implying the task was cancelled
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Active execution renews its lease
|
||||||
|
The Host Agent SHALL renew the active assignment lease before expiry while execution continues, and SHALL treat loss or rejection of the lease, or an observed cancellation request, as a stop condition for further planned actions where interruption is possible.
|
||||||
|
|
||||||
|
#### Scenario: Lease renewal succeeds
|
||||||
|
- **WHEN** the owning Host Agent renews an unexpired active lease
|
||||||
|
- **THEN** the control plane extends its expiry without changing the task attempt or device assignment
|
||||||
|
|
||||||
|
#### Scenario: Lease is stale or foreign
|
||||||
|
- **WHEN** a Host Agent attempts to renew an expired, replaced, or differently owned lease
|
||||||
|
- **THEN** the control plane returns a conflict and does not revive or alter the current attempt
|
||||||
|
|
||||||
|
#### Scenario: Renewal response signals a pending cancellation
|
||||||
|
- **WHEN** the control plane's renewal response for an active lease indicates a pending cancellation request
|
||||||
|
- **THEN** the Host Agent stops further planned actions at the next available step boundary, the same way it stops on lease loss
|
||||||
|
|
||||||
|
### Requirement: Terminal result reporting is idempotent
|
||||||
|
The Host Agent SHALL report a terminal result using the task, attempt, and lease identifiers, and repeating the same report SHALL return the already recorded outcome without duplicating state transitions. The terminal result SHALL be `done`, `failed`, or `cancelled`.
|
||||||
|
|
||||||
|
#### Scenario: Report a successful result
|
||||||
|
- **WHEN** the active lease owner reports successful completion
|
||||||
|
- **THEN** the control plane marks the scheduled task done, releases the device reservation, and records the result metadata
|
||||||
|
|
||||||
|
#### Scenario: Report a cancelled result
|
||||||
|
- **WHEN** the active lease owner reports that its execution stopped because of an observed cancellation request
|
||||||
|
- **THEN** the control plane marks the scheduled task cancelled, releases the device reservation, and records the result metadata
|
||||||
|
|
||||||
|
#### Scenario: Retry a result after response loss
|
||||||
|
- **WHEN** the Host Agent repeats the identical terminal report for an already completed active lease
|
||||||
|
- **THEN** the control plane returns the recorded terminal result without creating a new attempt or error
|
||||||
|
|
||||||
|
#### Scenario: Stale attempt reports after requeue
|
||||||
|
- **WHEN** an expired earlier attempt reports after a newer attempt has been created
|
||||||
|
- **THEN** the control plane rejects the stale report and preserves the newer attempt's state
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Host distinguishes cancellation stop from lease-loss stop when reporting outcome
|
||||||
|
The Host Agent SHALL track whether its active execution stopped because of an observed cancellation request or for another stop reason (lost or rejected lease), and SHALL report `cancelled` only in the cancellation case, reporting `failed` for other stop reasons.
|
||||||
|
|
||||||
|
#### Scenario: Stop triggered by cancellation
|
||||||
|
- **WHEN** the Host Agent's collaborative-stop mechanism is triggered by a renewal response signaling a pending cancellation
|
||||||
|
- **THEN** the terminal result it reports for that attempt is `cancelled`
|
||||||
|
|
||||||
|
#### Scenario: Stop triggered by lease loss
|
||||||
|
- **WHEN** the Host Agent's collaborative-stop mechanism is triggered by a rejected or lost lease unrelated to any cancellation signal
|
||||||
|
- **THEN** the terminal result it reports for that attempt is `failed`, not `cancelled`
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Task submission and status via the SDK
|
||||||
|
The system SHALL allow an external integrator to submit a task (goal or workflow reference plus constraints) through the platform SDK's API, to query that task's current status by id, and to request cancellation of that task by id, backed by the `task-scheduler` capability.
|
||||||
|
|
||||||
|
#### Scenario: Submit a task via the API
|
||||||
|
- **WHEN** an integrator calls the task-submission endpoint with a valid goal and optional constraints
|
||||||
|
- **THEN** the API returns a task id that can be used to poll status, and the underlying `task-scheduler` records a new `queued` `ScheduledTask`
|
||||||
|
|
||||||
|
#### Scenario: Query status of a known task
|
||||||
|
- **WHEN** an integrator requests status for a task id that exists
|
||||||
|
- **THEN** the API returns that task's current status (`queued`, `assigned`, `dispatched`, `done`, `failed`, or `cancelled`)
|
||||||
|
|
||||||
|
#### Scenario: Query status of an unknown task
|
||||||
|
- **WHEN** an integrator requests status for a task id that does not exist
|
||||||
|
- **THEN** the API returns a not-found response rather than an unhandled server error
|
||||||
|
|
||||||
|
#### Scenario: Cancel a known task via the API
|
||||||
|
- **WHEN** an integrator with the required scope calls the cancel endpoint for a task id that exists and is not already `done` or `failed`
|
||||||
|
- **THEN** the API accepts the request and the underlying `task-scheduler` records the cancellation per its immediate or collaborative rules for that task's current status
|
||||||
|
|
||||||
|
#### Scenario: Cancel an unknown task
|
||||||
|
- **WHEN** an integrator calls the cancel endpoint for a task id that does not exist
|
||||||
|
- **THEN** the API returns a not-found response rather than an unhandled server error
|
||||||
|
|
||||||
|
### Requirement: Public API operations enforce scopes
|
||||||
|
The public platform API SHALL require operation-specific scopes, including task submission, task cancellation, task reading, pool reading, plugin reading, and plugin administration.
|
||||||
|
|
||||||
|
#### Scenario: Submit token has task scope
|
||||||
|
- **WHEN** a principal with `tasks:submit` calls the task-submission endpoint
|
||||||
|
- **THEN** the request is authorized subject to normal task validation
|
||||||
|
|
||||||
|
#### Scenario: Submit-scoped token cancels a task
|
||||||
|
- **WHEN** a principal with `tasks:submit` calls the task-cancellation endpoint for any task id
|
||||||
|
- **THEN** the request is authorized; the platform SDK does not restrict cancellation to the task's original submitter, since no per-task submitter identity is tracked
|
||||||
|
|
||||||
|
#### Scenario: Read-only token attempts cancellation
|
||||||
|
- **WHEN** a principal that holds only `tasks:read` calls the task-cancellation endpoint
|
||||||
|
- **THEN** the API rejects the request before contacting the scheduler
|
||||||
|
|
||||||
|
#### Scenario: Non-admin token attempts plugin registration
|
||||||
|
- **WHEN** an authenticated principal without `plugins:admin` calls plugin registration
|
||||||
|
- **THEN** the API rejects the request before resolving or loading the plugin target
|
||||||
|
|
||||||
|
### Requirement: Python SDK client mirrors the REST API
|
||||||
|
The system SHALL provide a Python client (`CloudClient`) exposing methods corresponding to each `/v1/...` route (submit task, get task status, cancel task, list devices, list hosts, list plugins, register plugin), so integrators do not need to hand-construct HTTP requests.
|
||||||
|
|
||||||
|
#### Scenario: Client submits a task and retrieves status
|
||||||
|
- **WHEN** a caller uses `CloudClient` to submit a task and then fetch its status by the returned id
|
||||||
|
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
|
||||||
|
|
||||||
|
#### Scenario: Client cancels a task
|
||||||
|
- **WHEN** a caller uses `CloudClient` to cancel a task by id
|
||||||
|
- **THEN** the client's method produces the same result as calling the cancel endpoint directly over HTTP
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Queued task cancellation is immediate
|
||||||
|
The system SHALL, when a cancellation is requested against a task in status `queued`, transition that task directly to status `cancelled` synchronously within the same request, without contacting any Host.
|
||||||
|
|
||||||
|
#### Scenario: Cancel a task that has not been assigned
|
||||||
|
- **WHEN** an authorized caller requests cancellation of a task whose status is `queued`
|
||||||
|
- **THEN** the task's status becomes `cancelled` in the same request and no assignment or lease is ever created for it
|
||||||
|
|
||||||
|
### Requirement: In-flight task cancellation is a durable, collaborative request
|
||||||
|
The system SHALL, when a cancellation is requested against a task in status `assigned` or `dispatched`, durably record a cancellation request against that task rather than immediately marking it `cancelled`, and SHALL surface that pending request to the owning Host Agent no later than its next lease renewal.
|
||||||
|
|
||||||
|
#### Scenario: Cancel a task currently executing on a Host
|
||||||
|
- **WHEN** an authorized caller requests cancellation of a task whose status is `dispatched`
|
||||||
|
- **THEN** the system records the cancellation request against the task's current attempt, the task's status remains `dispatched` until the Host reports a terminal result, and the request survives a control-plane restart
|
||||||
|
|
||||||
|
#### Scenario: Owning Host observes the pending cancellation at lease renewal
|
||||||
|
- **WHEN** the Host Agent executing the task renews its lease after a cancellation request was recorded
|
||||||
|
- **THEN** the renewal response signals the pending cancellation and the Host Agent stops further planned actions at the next available step boundary
|
||||||
|
|
||||||
|
#### Scenario: Cancellation is not instantaneous
|
||||||
|
- **WHEN** a cancellation is requested against a `dispatched` task
|
||||||
|
- **THEN** the system does not guarantee the task reaches status `cancelled` before the owning Host's next lease-renewal cycle completes
|
||||||
|
|
||||||
|
### Requirement: Host reports a cancelled outcome distinct from a failed outcome
|
||||||
|
The Host Agent SHALL report a terminal status of `cancelled`, distinct from `failed`, when its active execution stopped because of an observed cancellation request rather than a lease loss or an execution error, and the control plane SHALL record that task as status `cancelled`.
|
||||||
|
|
||||||
|
#### Scenario: Execution stops due to a cancellation request
|
||||||
|
- **WHEN** the Host Agent's active `TaskRunner` or `WorkflowRunner` execution stops because a lease renewal signaled a pending cancellation
|
||||||
|
- **THEN** the Host Agent reports terminal status `cancelled`, and the control plane transitions the task to status `cancelled` and releases its device reservation
|
||||||
|
|
||||||
|
#### Scenario: Execution stops due to lease loss unrelated to cancellation
|
||||||
|
- **WHEN** the Host Agent's active execution stops because its lease was rejected or lost for a reason other than a pending cancellation
|
||||||
|
- **THEN** the Host Agent reports terminal status `failed`, not `cancelled`
|
||||||
|
|
||||||
|
### Requirement: Cancellation requests are idempotent
|
||||||
|
The system SHALL treat a repeated cancellation request against a task that already has a pending or completed cancellation as a no-op that returns the task's current status, rather than as an error.
|
||||||
|
|
||||||
|
#### Scenario: Cancel a task twice
|
||||||
|
- **WHEN** an authorized caller requests cancellation of a task that already has a pending cancellation request recorded
|
||||||
|
- **THEN** the system returns the same successful response as the first request without creating a duplicate cancellation record
|
||||||
|
|
||||||
|
#### Scenario: Cancel an already-cancelled task
|
||||||
|
- **WHEN** an authorized caller requests cancellation of a task whose status is already `cancelled`
|
||||||
|
- **THEN** the system returns success reflecting the `cancelled` status without error
|
||||||
|
|
||||||
|
### Requirement: Cancellation is rejected for tasks already in a terminal, non-cancelled state
|
||||||
|
The system SHALL reject a cancellation request against a task whose status is already `done` or `failed` with a clear conflict error, without altering that task's recorded outcome.
|
||||||
|
|
||||||
|
#### Scenario: Cancel a completed task
|
||||||
|
- **WHEN** an authorized caller requests cancellation of a task whose status is `done`
|
||||||
|
- **THEN** the system rejects the request with a conflict error and the task's status and result remain unchanged
|
||||||
|
|
||||||
|
### Requirement: An expiring lease on a task with a pending cancellation resolves to cancelled, not requeued
|
||||||
|
The system SHALL, when an active lease expires on a task that has a pending cancellation request, mark that task `cancelled` rather than returning it to `queued` for a further attempt.
|
||||||
|
|
||||||
|
#### Scenario: Lease expires while a cancellation is pending
|
||||||
|
- **WHEN** the active lease on a `dispatched` task with a pending cancellation request expires before a terminal result is reported
|
||||||
|
- **THEN** the task transitions to status `cancelled` and its device reservation is released, instead of being requeued for another attempt
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Terminal transitions validate the active lease
|
||||||
|
The system SHALL accept a `done`, `failed`, or `cancelled` result only from the current active task attempt and lease and SHALL make repeated identical terminal reports idempotent.
|
||||||
|
|
||||||
|
#### Scenario: Active lease reports completion
|
||||||
|
- **WHEN** the active lease owner reports a terminal result
|
||||||
|
- **THEN** the task transitions once to done, failed, or cancelled and releases its device reservation
|
||||||
|
|
||||||
|
#### Scenario: Superseded lease reports completion
|
||||||
|
- **WHEN** a result references a lease superseded by expiry and retry
|
||||||
|
- **THEN** the result is rejected and cannot overwrite the current task attempt
|
||||||
|
|
||||||
|
### Requirement: Expired attempts follow bounded retry policy
|
||||||
|
The system SHALL detect expired assigned or dispatched leases and SHALL either requeue the task with its reservation released, mark it failed when the configured attempt limit is reached, or mark it cancelled when a cancellation request is pending against it.
|
||||||
|
|
||||||
|
#### Scenario: Lease expires with attempts remaining
|
||||||
|
- **WHEN** an active lease expires before a terminal result and the task has remaining attempts and no pending cancellation request
|
||||||
|
- **THEN** the task returns to queued, the previous device reservation is released, and the expired attempt remains auditable
|
||||||
|
|
||||||
|
#### Scenario: Lease expires at attempt limit
|
||||||
|
- **WHEN** an active lease expires and the task has reached its maximum attempts
|
||||||
|
- **THEN** the task becomes failed with a lease-expiry reason and its device reservation is released
|
||||||
|
|
||||||
|
#### Scenario: Lease expires with a cancellation pending
|
||||||
|
- **WHEN** an active lease expires on a task that has a pending cancellation request, regardless of remaining attempts
|
||||||
|
- **THEN** the task becomes cancelled rather than being requeued or marked failed, and its device reservation is released
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Task status includes a reachable cancelled value
|
||||||
|
The `ScheduledTaskStatus` SHALL include `cancelled` as a terminal status reachable from `queued`, `assigned`, or `dispatched`, alongside the existing `done` and `failed` terminal statuses.
|
||||||
|
|
||||||
|
#### Scenario: Cancelled status is a valid terminal state
|
||||||
|
- **WHEN** a task's cancellation completes, whether immediately from `queued` or after collaborative stop from `assigned`/`dispatched`
|
||||||
|
- **THEN** the task's status is `cancelled`, and no further assignment, claim, or lease-renewal operation is accepted against it
|
||||||
|
|
||||||
|
### Requirement: Cancellation requests are recorded durably against in-flight tasks
|
||||||
|
The scheduler repository SHALL persist a cancellation request against an `assigned` or `dispatched` task's current attempt such that the request is observable across a control-plane process restart, before the task reaches a terminal status.
|
||||||
|
|
||||||
|
#### Scenario: Cancellation request survives a restart
|
||||||
|
- **WHEN** a cancellation request is recorded against a `dispatched` task and the control plane process restarts before the Host next renews its lease
|
||||||
|
- **THEN** the pending cancellation request is still present and is surfaced to the Host on its next renewal after restart
|
||||||
|
|
||||||
|
### Requirement: Lease renewal surfaces a pending cancellation request
|
||||||
|
The scheduler repository's lease-renewal operation SHALL report whether the renewing attempt has a pending cancellation request, without altering the normal lease-extension outcome.
|
||||||
|
|
||||||
|
#### Scenario: Renewal on a task with a pending cancellation
|
||||||
|
- **WHEN** the owning host renews the lease for an attempt that has a pending cancellation request
|
||||||
|
- **THEN** the lease is extended normally and the renewal result additionally indicates the pending cancellation
|
||||||
|
|
||||||
|
#### Scenario: Renewal on a task without a pending cancellation
|
||||||
|
- **WHEN** the owning host renews the lease for an attempt with no pending cancellation request
|
||||||
|
- **THEN** the lease is extended normally and the renewal result indicates no pending cancellation
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## 1. Core and Runtime status plumbing
|
||||||
|
|
||||||
|
- [x] 1.1 Confirm `core/models.py`'s `TaskStatus` already includes `"cancelled"` (it does); add `StepStatus` no change needed — verify no other literal needs widening.
|
||||||
|
- [x] 1.2 Add an optional `stop_reason: Callable[[], str] | None` parameter to `TaskRunner.run()` (`runtime/task.py`), defaulting to `None`.
|
||||||
|
- [x] 1.3 Update `TaskRunner._interrupt_task()` to accept the resolved reason string, set `status="cancelled"` when the reason indicates cancellation (e.g. contains `"cancel"`), else keep `status="failed"` as today, and record the reason as `failure_reason` in both cases.
|
||||||
|
- [x] 1.4 Update `WorkflowRunner`'s stop branch (`workflow/runner.py`) to accept and pass through the same optional `stop_reason`, reusing its existing `"cancelled"` checkpoint call — confirm no behavior change needed since it already lands on `"cancelled"` for any stop; only wire the reason through for consistency/logging. (Correction during implementation: WorkflowRunner previously collapsed *every* stop, including lease-loss, into `"cancelled"`, which contradicts the host-agent-protocol spec's requirement to distinguish cancellation from lease-loss stops. `_stop_status()` now branches on `stop_reason` the same way `TaskRunner` does, while preserving the exact pre-existing default (`"cancelled"`) when no `stop_reason` is supplied.)
|
||||||
|
- [x] 1.5 Add/extend unit tests in `tests/` for `TaskRunner` covering: cancellation-flavored stop → `status="cancelled"`; lease-loss-flavored stop → `status="failed"` (existing behavior preserved). Also added matching `WorkflowRunner` coverage for the same `_stop_status` branching.
|
||||||
|
|
||||||
|
## 2. Cloud persistence: schema and repository
|
||||||
|
|
||||||
|
- [x] 2.1 Add Alembic migration `0012_task_cancellation.py` under `packages/cloud-platform/cloud/migrations/versions/`: add nullable `cancel_requested_at` column to `scheduled_tasks`, matching the style of `0008_task_progress_columns`.
|
||||||
|
- [x] 2.2 Add `cancel_requested_at` to `ScheduledTaskRow` (`db_models.py`) and to the `ScheduledTask` dataclass (`cloud/scheduler.py`).
|
||||||
|
- [x] 2.3 Widen `ScheduledTaskStatus` (`cloud/scheduler.py`) to include `"cancelled"`.
|
||||||
|
- [x] 2.4 Widen `TerminalTaskStatus` and `record_task_result`'s accepted status literal (`repository.py` Protocol, `sql_repository.py`) to include `"cancelled"`; ensure the idempotency logic (`already_recorded`/`conflict`) treats a repeated `"cancelled"` report the same way it treats repeated `"done"`/`"failed"` reports today.
|
||||||
|
- [x] 2.5 Add `CancellationRequestStatus = Literal["requested", "already_terminal", "already_requested", "not_found"]` and a `request_task_cancellation(task_id, *, requested_at) -> CancellationRequestStatus` method to the `CloudRepository` Protocol.
|
||||||
|
- [x] 2.6 Implement `request_task_cancellation` in `SQLAlchemyCloudRepository`: for `queued` tasks, transition directly to `status="cancelled"`; for `assigned`/`dispatched` tasks, set `cancel_requested_at` if unset (return `"already_requested"` if already set); for `done`/`failed`/`cancelled` tasks, return `"already_terminal"`/success-idempotent as appropriate per design D7; for unknown task id, return `"not_found"`.
|
||||||
|
- [x] 2.7 Extend `renew_lease` (`sql_repository.py`) to read `cancel_requested_at` on the current row and report whether it is set, without changing its existing lease-extension/progress-update behavior.
|
||||||
|
- [x] 2.8 Update `reap_expired_leases` so a task whose `cancel_requested_at` is set resolves to `status="cancelled"` (clearing `cancel_requested_at`) instead of being requeued to `queued`, regardless of remaining attempts.
|
||||||
|
- [x] 2.9 Ensure `record_task_result` and the immediate `queued`-cancellation path both clear `cancel_requested_at` on reaching any terminal status.
|
||||||
|
- [x] 2.10 Add/extend repository-level tests (SQLite, matching existing test style) covering: immediate queued cancel; durable cancel request on assigned/dispatched surviving a simulated restart (re-fetch); renewal reporting the pending flag; expired lease with pending cancellation resolving to `cancelled`; idempotent repeat cancel calls; cancel rejected on `done`/`failed`.
|
||||||
|
|
||||||
|
## 3. Internal Host↔Cloud protocol
|
||||||
|
|
||||||
|
- [x] 3.1 Add `cancel_requested: bool = False` field to `LeaseRenewalResponse` (`internal_api/models.py`).
|
||||||
|
- [x] 3.2 Widen `TerminalResultRequest.status` (`internal_api/models.py`) to `Literal["done", "failed", "cancelled"]`.
|
||||||
|
- [x] 3.3 Update `renew_assignment` route (`internal_api/api.py`) to populate `LeaseRenewalResponse.cancel_requested` from the repository's `renew_lease` result.
|
||||||
|
- [x] 3.4 Update `report_result` route (`internal_api/api.py`) to accept and forward the `"cancelled"` status to `record_task_result`.
|
||||||
|
- [x] 3.5 Add/extend internal API tests covering a renewal response surfacing `cancel_requested=True` and a `"cancelled"` terminal report being accepted and idempotent on repeat.
|
||||||
|
|
||||||
|
## 4. Host Agent collaborative stop
|
||||||
|
|
||||||
|
- [x] 4.1 Extend `LeaseGuard` (`host_agent/lease.py`) with a `reason` attribute already implied by `mark_lost(reason)` — confirm it's readable, add a `is_cancellation` helper or convention (e.g. reason string prefix) to distinguish cancellation from other lost-lease reasons.
|
||||||
|
- [x] 4.2 Update `ActiveAssignmentRunner._renew_while_running()` (`host_agent/lease.py`): when `response.cancel_requested` is true, call `guard.mark_lost("cancellation requested by control plane")` instead of continuing the renewal loop.
|
||||||
|
- [x] 4.3 Update `client.py`'s `renew()` to ensure `LeaseRenewalResponse.cancel_requested` deserializes correctly (should be automatic via Pydantic model update, but add a test).
|
||||||
|
- [x] 4.4 Update `AssignmentExecutor._execute_goal()` and `_execute_workflow()` (`host_agent/assignment.py`) to map a cancellation-flavored stop to `AssignmentExecutionResult.status = "cancelled"` (new value alongside `"done"`/`"failed"`), reading the underlying `Task`/`WorkflowRun` status (`"cancelled"`) instead of collapsing it to `"failed"`.
|
||||||
|
- [x] 4.5 Update `AssignmentProcessor.process()` (`host_agent/processor.py`) so its `status = "done" if execution.status == "done" else "failed"` mapping becomes a three-way mapping that preserves `"cancelled"`, and `report_result` is called with `status="cancelled"` in that case.
|
||||||
|
- [x] 4.6 Add/extend Host Agent tests covering: a renewal response with `cancel_requested=True` stops the active `should_stop`-driven loop; the resulting `AssignmentExecutionResult.status` and reported terminal status are `"cancelled"`; a lease-loss stop unrelated to cancellation still reports `"failed"`.
|
||||||
|
|
||||||
|
## 5. Public SDK endpoint
|
||||||
|
|
||||||
|
- [x] 5.1 Add `POST /v1/tasks/{task_id}/cancel` route to `cloud/sdk/api.py`, scope-gated by `TASKS_SUBMIT_SCOPE`, calling the new `TaskScheduler`/repository cancellation operation.
|
||||||
|
- [x] 5.2 Add `TaskCancellationResponse {task_id, status}` model to `cloud/sdk/models.py` (or wherever SDK response models live); return `200 OK` for immediate/already-terminal-cancelled idempotent cases, `202 Accepted` for a newly recorded pending cancellation, `404` for unknown task id, `409 Conflict` for a `done`/`failed` task.
|
||||||
|
- [x] 5.3 Widen the `status_filter` `Literal` on `list_tasks` (`cloud/sdk/api.py`) to include `"cancelled"`.
|
||||||
|
- [x] 5.4 Add a `cancel_task(task_id)` method to `CloudClient` (`cloud/sdk/client.py`) mirroring the new route.
|
||||||
|
- [x] 5.5 Add/extend SDK-level tests: submit-scoped caller cancels a queued task (200, immediate); cancels a dispatched task (202, pending); read-only-scoped caller rejected before reaching the scheduler; cancel on unknown id (404); cancel on terminal id (409); repeat cancel calls idempotent (200).
|
||||||
|
|
||||||
|
## 6. Frontend: Cloud Console
|
||||||
|
|
||||||
|
- [x] 6.1 Add `"cancelled"` to `TaskStatus` in `cloud-console/src/types.ts` and to the `STATUSES` array in `TasksView.vue`.
|
||||||
|
- [x] 6.2 Add a `cancelTask(taskId)` method to `cloud-console/src/api.ts`.
|
||||||
|
- [x] 6.3 Add a "Cancel" button to `TasksView.vue`'s task detail panel, visible only when the selected task's status is `queued`/`assigned`/`dispatched` and the operator's token has `tasks:submit`; on click, call `cancelTask` and refresh the displayed task.
|
||||||
|
- [x] 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled. (Project has no Vue component-mounting test harness — `@vue/test-utils` isn't a dependency and no existing test exercises a `.vue` file directly. Followed the established pattern instead: extracted the visibility rule into a pure, unit-tested `taskCancellation.ts` module — mirroring `taskProgress.ts`/`plannerHistory.ts` — covering cancellable vs. terminal statuses and the `tasks:submit` scope gate. `cancelSelectedTask` in `TasksView.vue` only mutates `selectedTask.status` on a successful response and routes failures through the existing `handleError`/`errorMessage` path, so an error never flips the displayed status to cancelled.)
|
||||||
|
|
||||||
|
## 7. Frontend: Host Agent local console
|
||||||
|
|
||||||
|
- [x] 7.1 Add a `POST /tasks/{id}/cancel` route to the Host Agent local console (`host_agent/web/app.py`) that calls through to the same cancellation path used by the collaborative-stop mechanism for a locally-tracked task, consistent with existing local console read routes. (Correction during implementation: the Host Agent's own internal-API bearer credential — issued by `RepositoryHostAuthProvider` — carries an empty `scopes` frozenset and is authorized only via `Principal.require_host()` identity checks, not scopes. It therefore cannot call the public SDK's `tasks:submit`-scoped `POST /v1/tasks/{task_id}/cancel` endpoint from design.md/section 5. Added a new internal API route, `POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel` (`cloud/internal_api/api.py`), authenticated the same way as the existing host self-submission route (`authorize_host`), with an added ownership check rejecting tasks whose `constraints.target_host_id != host_id` with 404. This mirrors the pre-existing pattern where hosts already self-serve create/execute their own tasks over this credential, and does not conflict with design.md's Non-Goal — that constraint (no per-task ACL; anyone with `tasks:submit` can cancel any task) is scoped to the public SDK layer, not the internal Host↔Cloud API. `HostAgentClient.cancel_task()` calls this new route directly rather than proxying to the public SDK.)
|
||||||
|
- [x] 7.2 Add a Cancel button to `templates/task_detail.html` for tasks not yet in a terminal state.
|
||||||
|
- [x] 7.3 Add/extend local console tests covering the new route and template rendering.
|
||||||
|
|
||||||
|
## 8. Docs
|
||||||
|
|
||||||
|
- [x] 8.1 Add a short section to `docs/CLOUD_DEPLOYMENT.md` documenting that cancellation is collaborative (not instantaneous) for `assigned`/`dispatched` tasks, bounded by roughly one third of the configured lease duration, with immediate effect for `queued` tasks.
|
||||||
|
|
||||||
|
## 9. End-to-end verification
|
||||||
|
|
||||||
|
- [x] 9.1 Run the full test suite (`uv run pytest` at repo root, plus `cloud-console` frontend tests) and confirm no regressions in existing task-scheduler, host-agent-protocol, platform-sdk, or workflow-orchestration tests. (869 passed, 50 skipped, 4 pre-existing failures unrelated to this change — `test_verifier_against_real_llm`, `test_reflector_against_real_llm`, `test_real_anthropic_ai_planner_selects_a_tool`, `test_real_anthropic_semantic_enrichment_returns_schema_valid_scene` all require live Anthropic API network access and fail the same way on `master`. `cloud-console`: 27 tests passed, `vue-tsc --noEmit` typecheck clean.)
|
||||||
|
- [x] 9.2 Manually or via an integration test, exercise the full path: submit a task, cancel a `queued` task (immediate), submit and dispatch another task, cancel it mid-execution, and confirm it reaches `cancelled` within one lease-renewal cycle with `cancelled` visible in both the public API and the Cloud Console. (Added `test_cancellation_full_path_queued_immediate_and_dispatched_collaborative` in `tests/test_cloud_sdk_api.py`: submits and immediately cancels a queued task via `POST /v1/tasks/{id}/cancel` (200, `cancelled`); submits, dispatches, and cancels a second task mid-execution (202, pending); drives one lease renewal confirming `cancel_requested=True` is surfaced; reports a `cancelled` terminal result as the Host Agent would; and confirms the task shows `status="cancelled"` via both `GET /v1/tasks/{id}` and `GET /v1/tasks?status=cancelled`. The Cloud Console reads task status through this same public API and its `cancelled` rendering is covered by the Task 6.4 `taskCancellation.ts` unit tests, so this repository-to-API round trip is the full path exercised at the automated-test layer; no manual browser session was run.)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -86,6 +87,7 @@ class PooledDeviceRow(Base):
|
|||||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False)
|
capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
synced_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
synced_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
mcp_busy: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskRow(Base):
|
class ScheduledTaskRow(Base):
|
||||||
@@ -110,6 +112,7 @@ class ScheduledTaskRow(Base):
|
|||||||
progress_step_status: Mapped[str | None] = mapped_column(String, nullable=True)
|
progress_step_status: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
progress_summary: Mapped[str | None] = mapped_column(String, nullable=True)
|
progress_summary: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
progress_updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
progress_updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
cancel_requested_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
@@ -157,6 +160,8 @@ class PlannerDecisionLogRow(Base):
|
|||||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
thinking: Mapped[str | None] = mapped_column(Text, nullable=True)
|
thinking: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
expected_outcome: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class PluginRow(Base):
|
class PluginRow(Base):
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from inspect import Parameter, signature
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from cloud.auth import (
|
from cloud.auth import (
|
||||||
@@ -29,6 +30,7 @@ from cloud.internal_api.models import (
|
|||||||
HostGovernancePolicyModel,
|
HostGovernancePolicyModel,
|
||||||
HostEnrollmentRequest,
|
HostEnrollmentRequest,
|
||||||
HostEnrollmentResponse,
|
HostEnrollmentResponse,
|
||||||
|
HostTaskCancellationResponse,
|
||||||
HostTaskSubmissionRequest,
|
HostTaskSubmissionRequest,
|
||||||
HostTaskSubmissionResponse,
|
HostTaskSubmissionResponse,
|
||||||
LeaseRenewalRequest,
|
LeaseRenewalRequest,
|
||||||
@@ -184,6 +186,7 @@ def create_internal_router(
|
|||||||
address=payload.address,
|
address=payload.address,
|
||||||
allow_device_takeover=allow_device_takeover,
|
allow_device_takeover=allow_device_takeover,
|
||||||
planner_transport=payload.planner_transport,
|
planner_transport=payload.planner_transport,
|
||||||
|
mcp_busy_device_ids=payload.mcp_busy_device_ids,
|
||||||
)
|
)
|
||||||
policy = pool.store.get_host_governance_policy(host_id)
|
policy = pool.store.get_host_governance_policy(host_id)
|
||||||
policy_revision = policy.revision if policy is not None else 0
|
policy_revision = policy.revision if policy is not None else 0
|
||||||
@@ -250,6 +253,50 @@ def create_internal_router(
|
|||||||
)
|
)
|
||||||
return HostTaskSubmissionResponse(task_id=task_id)
|
return HostTaskSubmissionResponse(task_id=task_id)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/hosts/{host_id}/tasks/{task_id}/cancel",
|
||||||
|
response_model=HostTaskCancellationResponse,
|
||||||
|
responses={
|
||||||
|
status.HTTP_202_ACCEPTED: {"model": HostTaskCancellationResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def cancel_host_task(
|
||||||
|
host_id: str,
|
||||||
|
task_id: str,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
) -> HostTaskCancellationResponse:
|
||||||
|
authorize_host(request, host_id)
|
||||||
|
if scheduler is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="task cancellation is unavailable",
|
||||||
|
)
|
||||||
|
task = scheduler.store.get_task(task_id)
|
||||||
|
if task is None or task.constraints.target_host_id != host_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"task {task_id!r} not found",
|
||||||
|
)
|
||||||
|
result = scheduler.store.request_task_cancellation(
|
||||||
|
task_id, requested_at=utc_now()
|
||||||
|
)
|
||||||
|
if result == "not_found":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"task {task_id!r} not found",
|
||||||
|
)
|
||||||
|
if result == "already_terminal":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"task {task_id!r} has already reached a terminal state",
|
||||||
|
)
|
||||||
|
task = scheduler.store.get_task(task_id)
|
||||||
|
assert task is not None
|
||||||
|
if result == "requested" and task.status != "cancelled":
|
||||||
|
response.status_code = status.HTTP_202_ACCEPTED
|
||||||
|
return HostTaskCancellationResponse(task_id=task_id, status=task.status)
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/hosts/{host_id}/assignments/claim",
|
"/hosts/{host_id}/assignments/claim",
|
||||||
response_model=ClaimResponse,
|
response_model=ClaimResponse,
|
||||||
@@ -316,7 +363,7 @@ def create_internal_router(
|
|||||||
summary=payload.progress.summary[:500],
|
summary=payload.progress.summary[:500],
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
renewal_status = pool.store.renew_lease(
|
renewal = pool.store.renew_lease(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
attempt=payload.attempt,
|
attempt=payload.attempt,
|
||||||
lease_id=payload.lease_id,
|
lease_id=payload.lease_id,
|
||||||
@@ -325,16 +372,17 @@ def create_internal_router(
|
|||||||
now=now,
|
now=now,
|
||||||
progress=progress_snapshot,
|
progress=progress_snapshot,
|
||||||
)
|
)
|
||||||
if renewal_status == "not_found":
|
if renewal.status == "not_found":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="assignment not found",
|
detail="assignment not found",
|
||||||
)
|
)
|
||||||
if renewal_status != "renewed":
|
if renewal.status != "renewed":
|
||||||
return _stale_lease_conflict("assignment lease is stale or expired")
|
return _stale_lease_conflict("assignment lease is stale or expired")
|
||||||
return LeaseRenewalResponse(
|
return LeaseRenewalResponse(
|
||||||
status="renewed",
|
status="renewed",
|
||||||
lease_expires_at=lease_expires_at,
|
lease_expires_at=lease_expires_at,
|
||||||
|
cancel_requested=renewal.cancel_requested,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -458,13 +506,21 @@ def create_internal_router(
|
|||||||
|
|
||||||
started_at = monotonic()
|
started_at = monotonic()
|
||||||
try:
|
try:
|
||||||
decision = client.decide(
|
decision_kwargs = {
|
||||||
system_prompt=payload.system_prompt,
|
"system_prompt": payload.system_prompt,
|
||||||
user_prompt=payload.user_prompt,
|
"user_prompt": payload.user_prompt,
|
||||||
screenshot=screenshot,
|
"screenshot": screenshot,
|
||||||
tools=tools,
|
"tools": tools,
|
||||||
timeout=planner_timeout,
|
"timeout": planner_timeout,
|
||||||
)
|
}
|
||||||
|
parameters = signature(client.decide).parameters.values()
|
||||||
|
if any(
|
||||||
|
parameter.name == "history"
|
||||||
|
or parameter.kind == Parameter.VAR_KEYWORD
|
||||||
|
for parameter in parameters
|
||||||
|
):
|
||||||
|
decision_kwargs["history"] = payload.history
|
||||||
|
decision = client.decide(**decision_kwargs)
|
||||||
except ToolCallUnavailable as exc:
|
except ToolCallUnavailable as exc:
|
||||||
logger.info(
|
logger.info(
|
||||||
"planner-decision request failed",
|
"planner-decision request failed",
|
||||||
@@ -506,6 +562,8 @@ def create_internal_router(
|
|||||||
now=utc_now(),
|
now=utc_now(),
|
||||||
rationale=getattr(decision, "text_output", None),
|
rationale=getattr(decision, "text_output", None),
|
||||||
thinking=getattr(decision, "thinking", None),
|
thinking=getattr(decision, "thinking", None),
|
||||||
|
purpose=getattr(decision, "purpose", None),
|
||||||
|
expected_outcome=getattr(decision, "expected_outcome", None),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"planner-decision request resolved",
|
"planner-decision request resolved",
|
||||||
@@ -518,6 +576,10 @@ def create_internal_router(
|
|||||||
return PlannerDecisionResponse(
|
return PlannerDecisionResponse(
|
||||||
tool_name=decision.tool_name,
|
tool_name=decision.tool_name,
|
||||||
arguments=dict(decision.arguments),
|
arguments=dict(decision.arguments),
|
||||||
|
rationale=getattr(decision, "text_output", None),
|
||||||
|
thinking=getattr(decision, "thinking", None),
|
||||||
|
purpose=getattr(decision, "purpose", None),
|
||||||
|
expected_outcome=getattr(decision, "expected_outcome", None),
|
||||||
input_tokens=(decision.usage.input_tokens if decision.usage else None),
|
input_tokens=(decision.usage.input_tokens if decision.usage else None),
|
||||||
output_tokens=(decision.usage.output_tokens if decision.usage else None),
|
output_tokens=(decision.usage.output_tokens if decision.usage else None),
|
||||||
total_tokens=(decision.usage.total_tokens if decision.usage else None),
|
total_tokens=(decision.usage.total_tokens if decision.usage else None),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class HeartbeatRequest(BaseModel):
|
|||||||
devices: list[DeviceSnapshotModel] = Field(default_factory=list)
|
devices: list[DeviceSnapshotModel] = Field(default_factory=list)
|
||||||
policy_revision: int = Field(default=0, ge=0)
|
policy_revision: int = Field(default=0, ge=0)
|
||||||
planner_transport: Literal["direct", "cloud"] = "direct"
|
planner_transport: Literal["direct", "cloud"] = "direct"
|
||||||
|
mcp_busy_device_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class HostGovernancePolicyModel(BaseModel):
|
class HostGovernancePolicyModel(BaseModel):
|
||||||
@@ -95,6 +96,7 @@ class LeaseRenewalRequest(BaseModel):
|
|||||||
class LeaseRenewalResponse(BaseModel):
|
class LeaseRenewalResponse(BaseModel):
|
||||||
status: Literal["renewed"]
|
status: Literal["renewed"]
|
||||||
lease_expires_at: datetime
|
lease_expires_at: datetime
|
||||||
|
cancel_requested: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TerminalResultRequest(BaseModel):
|
class TerminalResultRequest(BaseModel):
|
||||||
@@ -102,7 +104,7 @@ class TerminalResultRequest(BaseModel):
|
|||||||
task_id: str = Field(min_length=1)
|
task_id: str = Field(min_length=1)
|
||||||
attempt: int = Field(ge=1)
|
attempt: int = Field(ge=1)
|
||||||
lease_id: str = Field(min_length=1)
|
lease_id: str = Field(min_length=1)
|
||||||
status: Literal["done", "failed"]
|
status: Literal["done", "failed", "cancelled"]
|
||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
result: dict[str, Any] | None = None
|
result: dict[str, Any] | None = None
|
||||||
|
|
||||||
@@ -121,6 +123,11 @@ class HostTaskSubmissionResponse(BaseModel):
|
|||||||
task_id: str
|
task_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class HostTaskCancellationResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
class StaleLeaseConflict(BaseModel):
|
class StaleLeaseConflict(BaseModel):
|
||||||
code: Literal["stale_lease"] = "stale_lease"
|
code: Literal["stale_lease"] = "stale_lease"
|
||||||
detail: str
|
detail: str
|
||||||
@@ -136,6 +143,7 @@ class PlannerDecisionRequest(BaseModel):
|
|||||||
host_id: str = Field(min_length=1)
|
host_id: str = Field(min_length=1)
|
||||||
system_prompt: str
|
system_prompt: str
|
||||||
user_prompt: str
|
user_prompt: str
|
||||||
|
history: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
screenshot_base64: str | None = None
|
screenshot_base64: str | None = None
|
||||||
tools: list[PlannerToolSpecModel] = Field(default_factory=list)
|
tools: list[PlannerToolSpecModel] = Field(default_factory=list)
|
||||||
timeout_seconds: float = Field(default=30.0, gt=0, le=120)
|
timeout_seconds: float = Field(default=30.0, gt=0, le=120)
|
||||||
@@ -147,6 +155,10 @@ class PlannerDecisionRequest(BaseModel):
|
|||||||
class PlannerDecisionResponse(BaseModel):
|
class PlannerDecisionResponse(BaseModel):
|
||||||
tool_name: str
|
tool_name: str
|
||||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
rationale: str | None = None
|
||||||
|
thinking: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
expected_outcome: str | None = None
|
||||||
input_tokens: int | None = Field(default=None, ge=0)
|
input_tokens: int | None = Field(default=None, ge=0)
|
||||||
output_tokens: int | None = Field(default=None, ge=0)
|
output_tokens: int | None = Field(default=None, ge=0)
|
||||||
total_tokens: int | None = Field(default=None, ge=0)
|
total_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add reusable action metadata to planner_decision_log."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0012_planner_decision_action_metadata"
|
||||||
|
down_revision = "0011_planner_decision_log_reflection"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"planner_decision_log",
|
||||||
|
sa.Column("purpose", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"planner_decision_log",
|
||||||
|
sa.Column("expected_outcome", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("planner_decision_log", "expected_outcome")
|
||||||
|
op.drop_column("planner_decision_log", "purpose")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Add nullable cancel_requested_at column to scheduled_tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0013_task_cancellation"
|
||||||
|
down_revision = "0012_planner_decision_action_metadata"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"scheduled_tasks",
|
||||||
|
sa.Column("cancel_requested_at", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("scheduled_tasks", "cancel_requested_at")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add mcp_busy flag column to pooled_devices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0014_pooled_device_mcp_busy"
|
||||||
|
down_revision = "0013_task_cancellation"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"pooled_devices",
|
||||||
|
sa.Column(
|
||||||
|
"mcp_busy",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("pooled_devices", "mcp_busy")
|
||||||
@@ -47,6 +47,7 @@ class PooledDevice:
|
|||||||
status: PooledDeviceStatus
|
status: PooledDeviceStatus
|
||||||
capability_tags: list[str] = field(default_factory=list)
|
capability_tags: list[str] = field(default_factory=list)
|
||||||
synced_at: datetime | None = None
|
synced_at: datetime | None = None
|
||||||
|
mcp_busy: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DevicePool:
|
class DevicePool:
|
||||||
@@ -64,6 +65,7 @@ class DevicePool:
|
|||||||
address: str | None = None,
|
address: str | None = None,
|
||||||
planner_transport: Literal["direct", "cloud"] = "direct",
|
planner_transport: Literal["direct", "cloud"] = "direct",
|
||||||
allow_device_takeover: bool = False,
|
allow_device_takeover: bool = False,
|
||||||
|
mcp_busy_device_ids: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Push a host's current device snapshot into the pool.
|
"""Push a host's current device snapshot into the pool.
|
||||||
|
|
||||||
@@ -78,7 +80,11 @@ class DevicePool:
|
|||||||
last_seen_at=now,
|
last_seen_at=now,
|
||||||
planner_transport=planner_transport,
|
planner_transport=planner_transport,
|
||||||
)
|
)
|
||||||
devices = [self._to_pooled(device, host_id, now) for device in snapshot]
|
busy_set = set(mcp_busy_device_ids or [])
|
||||||
|
devices = [
|
||||||
|
self._to_pooled(device, host_id, now, mcp_busy=device.id in busy_set)
|
||||||
|
for device in snapshot
|
||||||
|
]
|
||||||
if allow_device_takeover:
|
if allow_device_takeover:
|
||||||
self.store.replace_host_devices(
|
self.store.replace_host_devices(
|
||||||
host_id,
|
host_id,
|
||||||
@@ -119,6 +125,8 @@ class DevicePool:
|
|||||||
device: Device,
|
device: Device,
|
||||||
host_id: str,
|
host_id: str,
|
||||||
synced_at: datetime,
|
synced_at: datetime,
|
||||||
|
*,
|
||||||
|
mcp_busy: bool = False,
|
||||||
) -> PooledDevice:
|
) -> PooledDevice:
|
||||||
raw_status = (
|
raw_status = (
|
||||||
device.status if device.status in _HOST_REPORTED_STATUSES else "idle"
|
device.status if device.status in _HOST_REPORTED_STATUSES else "idle"
|
||||||
@@ -131,6 +139,7 @@ class DevicePool:
|
|||||||
status=raw_status, # type: ignore[arg-type]
|
status=raw_status, # type: ignore[arg-type]
|
||||||
capability_tags=tags,
|
capability_tags=tags,
|
||||||
synced_at=synced_at,
|
synced_at=synced_at,
|
||||||
|
mcp_busy=mcp_busy,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _is_stale(self, host: HostRegistration, now: datetime) -> bool:
|
def _is_stale(self, host: HostRegistration, now: datetime) -> bool:
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||||
TerminalTaskStatus = Literal["done", "failed"]
|
TerminalTaskStatus = Literal["done", "failed", "cancelled"]
|
||||||
ResultRecordStatus = Literal["recorded", "already_recorded", "conflict"]
|
ResultRecordStatus = Literal["recorded", "already_recorded", "conflict"]
|
||||||
LeaseRenewalStatus = Literal["renewed", "not_found", "conflict", "expired"]
|
LeaseRenewalStatus = Literal["renewed", "not_found", "conflict", "expired"]
|
||||||
|
CancellationRequestStatus = Literal[
|
||||||
|
"requested", "already_terminal", "already_requested", "not_found"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class HostEnrollmentConflictError(RuntimeError):
|
class HostEnrollmentConflictError(RuntimeError):
|
||||||
@@ -91,6 +94,19 @@ class TaskAttemptRecord:
|
|||||||
terminal_result: dict[str, Any] | None = None
|
terminal_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LeaseRenewalResult:
|
||||||
|
"""Outcome of a lease renewal, including whether cancellation is pending.
|
||||||
|
|
||||||
|
``cancel_requested`` reflects the task's durable ``cancel_requested_at``
|
||||||
|
column at renewal time regardless of ``status`` — callers only act on it
|
||||||
|
when ``status == "renewed"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: LeaseRenewalStatus
|
||||||
|
cancel_requested: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LeasedAssignment:
|
class LeasedAssignment:
|
||||||
task_id: str
|
task_id: str
|
||||||
@@ -136,6 +152,8 @@ class PlannerDecisionRecord:
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
rationale: str | None = None
|
rationale: str | None = None
|
||||||
thinking: str | None = None
|
thinking: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
expected_outcome: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CloudRepository(Protocol):
|
class CloudRepository(Protocol):
|
||||||
@@ -485,7 +503,7 @@ class CloudRepository(Protocol):
|
|||||||
lease_expires_at: datetime,
|
lease_expires_at: datetime,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
progress: AssignmentProgressSnapshot | None = None,
|
progress: AssignmentProgressSnapshot | None = None,
|
||||||
) -> LeaseRenewalStatus: ...
|
) -> LeaseRenewalResult: ...
|
||||||
|
|
||||||
def record_task_result(
|
def record_task_result(
|
||||||
self,
|
self,
|
||||||
@@ -500,6 +518,13 @@ class CloudRepository(Protocol):
|
|||||||
completed_at: datetime,
|
completed_at: datetime,
|
||||||
) -> ResultRecordStatus: ...
|
) -> ResultRecordStatus: ...
|
||||||
|
|
||||||
|
def request_task_cancellation(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
requested_at: datetime,
|
||||||
|
) -> CancellationRequestStatus: ...
|
||||||
|
|
||||||
def reap_expired_leases(
|
def reap_expired_leases(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -522,6 +547,8 @@ class CloudRepository(Protocol):
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
rationale: str | None = None,
|
rationale: str | None = None,
|
||||||
thinking: str | None = None,
|
thinking: str | None = None,
|
||||||
|
purpose: str | None = None,
|
||||||
|
expected_outcome: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Insert one planner-decision log row, returning the assigned step_index.
|
"""Insert one planner-decision log row, returning the assigned step_index.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ if TYPE_CHECKING:
|
|||||||
from cloud.store import CloudStore
|
from cloud.store import CloudStore
|
||||||
|
|
||||||
|
|
||||||
ScheduledTaskStatus = Literal["queued", "assigned", "dispatched", "done", "failed"]
|
ScheduledTaskStatus = Literal[
|
||||||
|
"queued", "assigned", "dispatched", "done", "failed", "cancelled"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -57,6 +59,7 @@ class ScheduledTask:
|
|||||||
progress_step_status: str | None = None
|
progress_step_status: str | None = None
|
||||||
progress_summary: str | None = None
|
progress_summary: str | None = None
|
||||||
progress_updated_at: datetime | None = None
|
progress_updated_at: datetime | None = None
|
||||||
|
cancel_requested_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -201,6 +204,8 @@ class TaskScheduler:
|
|||||||
|
|
||||||
|
|
||||||
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
||||||
|
if device.mcp_busy:
|
||||||
|
return False
|
||||||
if constraints.target_host_id and device.host_id != constraints.target_host_id:
|
if constraints.target_host_id and device.host_id != constraints.target_host_id:
|
||||||
return False
|
return False
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
|
|||||||
from cloud.database import create_database_engine, normalize_database_url
|
from cloud.database import create_database_engine, normalize_database_url
|
||||||
|
|
||||||
|
|
||||||
HEAD_REVISION = "0011_planner_decision_log_reflection"
|
HEAD_REVISION = "0014_pooled_device_mcp_busy"
|
||||||
|
|
||||||
|
|
||||||
class SchemaVersionError(RuntimeError):
|
class SchemaVersionError(RuntimeError):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ authentication can be added later without changing route signatures.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Callable, Literal
|
from typing import TYPE_CHECKING, Callable, Literal
|
||||||
|
|
||||||
from cloud.auth import (
|
from cloud.auth import (
|
||||||
@@ -31,6 +32,7 @@ from cloud.sdk.models import (
|
|||||||
PluginRegistrationRequest,
|
PluginRegistrationRequest,
|
||||||
PluginResponse,
|
PluginResponse,
|
||||||
TaskAttemptResponse,
|
TaskAttemptResponse,
|
||||||
|
TaskCancellationResponse,
|
||||||
TaskListItem,
|
TaskListItem,
|
||||||
TaskListResponse,
|
TaskListResponse,
|
||||||
TaskPlannerDecisionItem,
|
TaskPlannerDecisionItem,
|
||||||
@@ -39,7 +41,7 @@ from cloud.sdk.models import (
|
|||||||
TaskSubmissionRequest,
|
TaskSubmissionRequest,
|
||||||
TaskSubmissionResponse,
|
TaskSubmissionResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cloud.plugins import PluginRegistry
|
from cloud.plugins import PluginRegistry
|
||||||
@@ -156,7 +158,9 @@ def create_cloud_router(
|
|||||||
@router.get("/tasks", response_model=TaskListResponse)
|
@router.get("/tasks", response_model=TaskListResponse)
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
|
status_filter: Literal[
|
||||||
|
"queued", "assigned", "dispatched", "done", "failed", "cancelled"
|
||||||
|
]
|
||||||
| None = Query(default=None, alias="status"),
|
| None = Query(default=None, alias="status"),
|
||||||
limit: int = Query(default=50, ge=1, le=100),
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
@@ -263,11 +267,45 @@ def create_cloud_router(
|
|||||||
user_prompt=rec.user_prompt,
|
user_prompt=rec.user_prompt,
|
||||||
tool_name=rec.tool_name,
|
tool_name=rec.tool_name,
|
||||||
arguments=parsed_args,
|
arguments=parsed_args,
|
||||||
|
rationale=rec.rationale,
|
||||||
|
thinking=rec.thinking,
|
||||||
|
purpose=rec.purpose,
|
||||||
|
expected_outcome=rec.expected_outcome,
|
||||||
created_at=rec.created_at,
|
created_at=rec.created_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return TaskPlannerDecisionListResponse(items=items)
|
return TaskPlannerDecisionListResponse(items=items)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tasks/{task_id}/cancel",
|
||||||
|
response_model=TaskCancellationResponse,
|
||||||
|
responses={
|
||||||
|
status.HTTP_202_ACCEPTED: {"model": TaskCancellationResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def cancel_task(
|
||||||
|
task_id: str, request: Request, response: Response
|
||||||
|
) -> TaskCancellationResponse:
|
||||||
|
_authorize(request, TASKS_SUBMIT_SCOPE)
|
||||||
|
result = scheduler.store.request_task_cancellation(
|
||||||
|
task_id, requested_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
if result == "not_found":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"task {task_id!r} not found",
|
||||||
|
)
|
||||||
|
if result == "already_terminal":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"task {task_id!r} has already reached a terminal state",
|
||||||
|
)
|
||||||
|
task = scheduler.store.get_task(task_id)
|
||||||
|
assert task is not None
|
||||||
|
if result == "requested" and task.status != "cancelled":
|
||||||
|
response.status_code = status.HTTP_202_ACCEPTED
|
||||||
|
return TaskCancellationResponse(task_id=task_id, status=task.status)
|
||||||
|
|
||||||
@router.get("/devices", response_model=list[DeviceResponse])
|
@router.get("/devices", response_model=list[DeviceResponse])
|
||||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||||
_authorize(request, POOL_READ_SCOPE)
|
_authorize(request, POOL_READ_SCOPE)
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ class CloudClient:
|
|||||||
resp = self._request("GET", f"/tasks/{task_id}/attempts")
|
resp = self._request("GET", f"/tasks/{task_id}/attempts")
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
def cancel_task(self, task_id: str) -> dict[str, Any]:
|
||||||
|
resp = self._request("POST", f"/tasks/{task_id}/cancel")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
# ----------------------------------------------------------------- devices
|
# ----------------------------------------------------------------- devices
|
||||||
|
|
||||||
def list_devices(self) -> list[dict[str, Any]]:
|
def list_devices(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class TaskSubmissionResponse(BaseModel):
|
|||||||
task_id: str
|
task_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCancellationResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusResponse(BaseModel):
|
class TaskStatusResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
@@ -280,6 +285,10 @@ class TaskPlannerDecisionItem(BaseModel):
|
|||||||
user_prompt: str
|
user_prompt: str
|
||||||
tool_name: str
|
tool_name: str
|
||||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
rationale: str | None = None
|
||||||
|
thinking: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
expected_outcome: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from cloud.observability import current_correlation_id
|
|||||||
from core.models import utc_now
|
from core.models import utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cloud.repository import AssignmentProgressSnapshot
|
from cloud.repository import AssignmentProgressSnapshot, LeaseRenewalResult
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -342,6 +342,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
synced_at=_iso(device.synced_at) if device.synced_at else None,
|
synced_at=_iso(device.synced_at) if device.synced_at else None,
|
||||||
|
mcp_busy=getattr(device, "mcp_busy", False),
|
||||||
)
|
)
|
||||||
for device in devices
|
for device in devices
|
||||||
]
|
]
|
||||||
@@ -1497,7 +1498,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
lease_expires_at: datetime,
|
lease_expires_at: datetime,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
progress: AssignmentProgressSnapshot | None = None,
|
progress: AssignmentProgressSnapshot | None = None,
|
||||||
) -> str:
|
) -> "LeaseRenewalResult":
|
||||||
|
from cloud.repository import LeaseRenewalResult
|
||||||
|
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
task = session.get(
|
task = session.get(
|
||||||
ScheduledTaskRow,
|
ScheduledTaskRow,
|
||||||
@@ -1505,19 +1508,19 @@ class SQLAlchemyCloudRepository:
|
|||||||
with_for_update=self.engine.dialect.name == "postgresql",
|
with_for_update=self.engine.dialect.name == "postgresql",
|
||||||
)
|
)
|
||||||
if task is None:
|
if task is None:
|
||||||
return "not_found"
|
return LeaseRenewalResult(status="not_found")
|
||||||
if (
|
if (
|
||||||
task.status not in {"assigned", "dispatched"}
|
task.status not in {"assigned", "dispatched"}
|
||||||
or task.attempt_count != attempt
|
or task.attempt_count != attempt
|
||||||
or task.lease_id != lease_id
|
or task.lease_id != lease_id
|
||||||
or task.assigned_host_id != host_id
|
or task.assigned_host_id != host_id
|
||||||
):
|
):
|
||||||
return "conflict"
|
return LeaseRenewalResult(status="conflict")
|
||||||
current_expiry = _parse_dt(task.lease_expires_at)
|
current_expiry = _parse_dt(task.lease_expires_at)
|
||||||
if current_expiry is None or current_expiry <= now:
|
if current_expiry is None or current_expiry <= now:
|
||||||
return "expired"
|
return LeaseRenewalResult(status="expired")
|
||||||
if lease_expires_at <= now:
|
if lease_expires_at <= now:
|
||||||
return "conflict"
|
return LeaseRenewalResult(status="conflict")
|
||||||
|
|
||||||
attempt_row = session.get(
|
attempt_row = session.get(
|
||||||
TaskAttemptRow,
|
TaskAttemptRow,
|
||||||
@@ -1530,7 +1533,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
or attempt_row.lease_id != lease_id
|
or attempt_row.lease_id != lease_id
|
||||||
or attempt_row.host_id != host_id
|
or attempt_row.host_id != host_id
|
||||||
):
|
):
|
||||||
return "conflict"
|
return LeaseRenewalResult(status="conflict")
|
||||||
|
|
||||||
renewed_until = _iso(lease_expires_at)
|
renewed_until = _iso(lease_expires_at)
|
||||||
task.lease_expires_at = renewed_until
|
task.lease_expires_at = renewed_until
|
||||||
@@ -1542,7 +1545,10 @@ class SQLAlchemyCloudRepository:
|
|||||||
task.progress_summary = progress.summary
|
task.progress_summary = progress.summary
|
||||||
task.progress_updated_at = _iso(progress.updated_at)
|
task.progress_updated_at = _iso(progress.updated_at)
|
||||||
_log_task_lifecycle("renewed", task)
|
_log_task_lifecycle("renewed", task)
|
||||||
return "renewed"
|
return LeaseRenewalResult(
|
||||||
|
status="renewed",
|
||||||
|
cancel_requested=task.cancel_requested_at is not None,
|
||||||
|
)
|
||||||
|
|
||||||
def record_task_result(
|
def record_task_result(
|
||||||
self,
|
self,
|
||||||
@@ -1579,7 +1585,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
):
|
):
|
||||||
return "conflict"
|
return "conflict"
|
||||||
|
|
||||||
if task.status in {"done", "failed"}:
|
if task.status in {"done", "failed", "cancelled"}:
|
||||||
if (
|
if (
|
||||||
task.status == status
|
task.status == status
|
||||||
and task.failure_reason == failure_reason
|
and task.failure_reason == failure_reason
|
||||||
@@ -1593,7 +1599,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
current_expiry = _parse_dt(task.lease_expires_at)
|
current_expiry = _parse_dt(task.lease_expires_at)
|
||||||
if current_expiry is None or current_expiry <= completed_at:
|
if current_expiry is None or current_expiry <= completed_at:
|
||||||
return "conflict"
|
return "conflict"
|
||||||
if status not in {"done", "failed"}:
|
if status not in {"done", "failed", "cancelled"}:
|
||||||
return "conflict"
|
return "conflict"
|
||||||
|
|
||||||
result_json = (
|
result_json = (
|
||||||
@@ -1610,11 +1616,18 @@ class SQLAlchemyCloudRepository:
|
|||||||
task.progress_step_status = None
|
task.progress_step_status = None
|
||||||
task.progress_summary = None
|
task.progress_summary = None
|
||||||
task.progress_updated_at = None
|
task.progress_updated_at = None
|
||||||
|
task.cancel_requested_at = None
|
||||||
attempt_row.status = status
|
attempt_row.status = status
|
||||||
attempt_row.completed_at = completed_at_iso
|
attempt_row.completed_at = completed_at_iso
|
||||||
attempt_row.failure_reason = failure_reason
|
attempt_row.failure_reason = failure_reason
|
||||||
attempt_row.result_json = result_json
|
attempt_row.result_json = result_json
|
||||||
_log_task_lifecycle("completed" if status == "done" else "failed", task)
|
if status == "done":
|
||||||
|
lifecycle_event = "completed"
|
||||||
|
elif status == "cancelled":
|
||||||
|
lifecycle_event = "cancelled"
|
||||||
|
else:
|
||||||
|
lifecycle_event = "failed"
|
||||||
|
_log_task_lifecycle(lifecycle_event, task)
|
||||||
return "recorded"
|
return "recorded"
|
||||||
|
|
||||||
def reap_expired_leases(
|
def reap_expired_leases(
|
||||||
@@ -1657,7 +1670,12 @@ class SQLAlchemyCloudRepository:
|
|||||||
task.lease_id = None
|
task.lease_id = None
|
||||||
task.lease_expires_at = None
|
task.lease_expires_at = None
|
||||||
task.result_json = None
|
task.result_json = None
|
||||||
if task.attempt_count < max_attempts:
|
if task.cancel_requested_at is not None:
|
||||||
|
task.status = "cancelled"
|
||||||
|
task.assigned_host_id = None
|
||||||
|
task.assigned_device_id = None
|
||||||
|
task.cancel_requested_at = None
|
||||||
|
elif task.attempt_count < max_attempts:
|
||||||
task.status = "queued"
|
task.status = "queued"
|
||||||
task.assigned_host_id = None
|
task.assigned_host_id = None
|
||||||
task.assigned_device_id = None
|
task.assigned_device_id = None
|
||||||
@@ -1667,13 +1685,46 @@ class SQLAlchemyCloudRepository:
|
|||||||
task.failure_reason = (
|
task.failure_reason = (
|
||||||
f"lease expired after {task.attempt_count} attempts"
|
f"lease expired after {task.attempt_count} attempts"
|
||||||
)
|
)
|
||||||
_log_task_lifecycle(
|
if task.status == "queued":
|
||||||
"retried" if task.status == "queued" else "failed",
|
lifecycle_event = "retried"
|
||||||
task,
|
elif task.status == "cancelled":
|
||||||
)
|
lifecycle_event = "cancelled"
|
||||||
|
else:
|
||||||
|
lifecycle_event = "failed"
|
||||||
|
_log_task_lifecycle(lifecycle_event, task)
|
||||||
reaped_task_ids.append(task.id)
|
reaped_task_ids.append(task.id)
|
||||||
return reaped_task_ids
|
return reaped_task_ids
|
||||||
|
|
||||||
|
def request_task_cancellation(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
requested_at: datetime,
|
||||||
|
) -> str:
|
||||||
|
with self._sessions.begin() as session:
|
||||||
|
task = session.get(
|
||||||
|
ScheduledTaskRow,
|
||||||
|
task_id,
|
||||||
|
with_for_update=self.engine.dialect.name == "postgresql",
|
||||||
|
)
|
||||||
|
if task is None:
|
||||||
|
return "not_found"
|
||||||
|
if task.status == "queued":
|
||||||
|
task.status = "cancelled"
|
||||||
|
task.cancel_requested_at = None
|
||||||
|
task.updated_at = _iso(requested_at)
|
||||||
|
_log_task_lifecycle("cancelled", task)
|
||||||
|
return "requested"
|
||||||
|
if task.status in {"assigned", "dispatched"}:
|
||||||
|
if task.cancel_requested_at is not None:
|
||||||
|
return "already_requested"
|
||||||
|
task.cancel_requested_at = _iso(requested_at)
|
||||||
|
task.updated_at = _iso(requested_at)
|
||||||
|
return "requested"
|
||||||
|
if task.status == "cancelled":
|
||||||
|
return "requested"
|
||||||
|
return "already_terminal"
|
||||||
|
|
||||||
def list_task_attempts(self, task_id: str) -> list[Any]:
|
def list_task_attempts(self, task_id: str) -> list[Any]:
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
rows = session.scalars(
|
rows = session.scalars(
|
||||||
@@ -1696,6 +1747,8 @@ class SQLAlchemyCloudRepository:
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
rationale: str | None = None,
|
rationale: str | None = None,
|
||||||
thinking: str | None = None,
|
thinking: str | None = None,
|
||||||
|
purpose: str | None = None,
|
||||||
|
expected_outcome: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
current_max = session.scalars(
|
current_max = session.scalars(
|
||||||
@@ -1718,6 +1771,8 @@ class SQLAlchemyCloudRepository:
|
|||||||
created_at=_iso(now),
|
created_at=_iso(now),
|
||||||
rationale=rationale,
|
rationale=rationale,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
|
purpose=purpose,
|
||||||
|
expected_outcome=expected_outcome,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -1772,6 +1827,8 @@ class SQLAlchemyCloudRepository:
|
|||||||
created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
|
created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
|
||||||
rationale=row.rationale,
|
rationale=row.rationale,
|
||||||
thinking=row.thinking,
|
thinking=row.thinking,
|
||||||
|
purpose=row.purpose,
|
||||||
|
expected_outcome=row.expected_outcome,
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -1894,9 +1951,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
CloudSkillEntitlementRow.skill_id == skill_id
|
CloudSkillEntitlementRow.skill_id == skill_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.execute(
|
session.execute(delete(CloudSkillRow).where(CloudSkillRow.id == skill_id))
|
||||||
delete(CloudSkillRow).where(CloudSkillRow.id == skill_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_entitlements_for_skill(self, skill_id: str) -> list[str]:
|
def list_entitlements_for_skill(self, skill_id: str) -> list[str]:
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
@@ -1922,13 +1977,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
skills.sort(key=lambda s: s.name_normalized)
|
skills.sort(key=lambda s: s.name_normalized)
|
||||||
return skills
|
return skills
|
||||||
|
|
||||||
def grant_entitlement(
|
def grant_entitlement(self, skill_id: str, host_id: str, *, now: datetime) -> None:
|
||||||
self, skill_id: str, host_id: str, *, now: datetime
|
|
||||||
) -> None:
|
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
existing = session.get(
|
existing = session.get(CloudSkillEntitlementRow, (skill_id, host_id))
|
||||||
CloudSkillEntitlementRow, (skill_id, host_id)
|
|
||||||
)
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return # idempotent
|
return # idempotent
|
||||||
session.add(
|
session.add(
|
||||||
@@ -1940,21 +1991,15 @@ class SQLAlchemyCloudRepository:
|
|||||||
)
|
)
|
||||||
self._bump_host(session, host_id, skill_id, "upsert", now)
|
self._bump_host(session, host_id, skill_id, "upsert", now)
|
||||||
|
|
||||||
def revoke_entitlement(
|
def revoke_entitlement(self, skill_id: str, host_id: str, *, now: datetime) -> None:
|
||||||
self, skill_id: str, host_id: str, *, now: datetime
|
|
||||||
) -> None:
|
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
existing = session.get(
|
existing = session.get(CloudSkillEntitlementRow, (skill_id, host_id))
|
||||||
CloudSkillEntitlementRow, (skill_id, host_id)
|
|
||||||
)
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return
|
return
|
||||||
session.delete(existing)
|
session.delete(existing)
|
||||||
self._bump_host(session, host_id, skill_id, "remove", now)
|
self._bump_host(session, host_id, skill_id, "remove", now)
|
||||||
|
|
||||||
def fetch_host_delta(
|
def fetch_host_delta(self, host_id: str, since_version: int | None) -> Any:
|
||||||
self, host_id: str, since_version: int | None
|
|
||||||
) -> Any:
|
|
||||||
from cloud.skills import HostSkillDelta
|
from cloud.skills import HostSkillDelta
|
||||||
|
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
@@ -2188,6 +2233,7 @@ def _device_from_row(row: PooledDeviceRow) -> Any:
|
|||||||
status=row.status,
|
status=row.status,
|
||||||
capability_tags=tags,
|
capability_tags=tags,
|
||||||
synced_at=_parse_dt(row.synced_at),
|
synced_at=_parse_dt(row.synced_at),
|
||||||
|
mcp_busy=bool(getattr(row, "mcp_busy", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2223,6 +2269,7 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
|
|||||||
progress_step_status=row.progress_step_status,
|
progress_step_status=row.progress_step_status,
|
||||||
progress_summary=row.progress_summary,
|
progress_summary=row.progress_summary,
|
||||||
progress_updated_at=_parse_dt(row.progress_updated_at),
|
progress_updated_at=_parse_dt(row.progress_updated_at),
|
||||||
|
cancel_requested_at=_parse_dt(row.cancel_requested_at),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cloud.internal_api.models import HeartbeatRequest
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_request_defaults_mcp_busy_device_ids_to_empty() -> None:
|
||||||
|
req = HeartbeatRequest(host_id="h1")
|
||||||
|
assert req.mcp_busy_device_ids == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_request_accepts_mcp_busy_device_ids() -> None:
|
||||||
|
req = HeartbeatRequest(host_id="h1", mcp_busy_device_ids=["phone-1"])
|
||||||
|
assert req.mcp_busy_device_ids == ["phone-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_request_omitting_field_is_backward_compatible() -> None:
|
||||||
|
"""Old host-agents that don't send the field must still validate."""
|
||||||
|
raw = {"host_id": "h1", "devices": []}
|
||||||
|
req = HeartbeatRequest.model_validate(raw)
|
||||||
|
assert req.mcp_busy_device_ids == []
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Tests for the cloud DevicePool, focused on the mcp_busy flag plumbing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cloud.config import CloudConfig
|
||||||
|
from cloud.pool import DevicePool
|
||||||
|
from cloud.store import CloudStore
|
||||||
|
from core.models import Device
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool() -> DevicePool:
|
||||||
|
"""Build a fresh DevicePool backed by a temporary SQLite file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
store = CloudStore(Path(tmp) / "cloud.sqlite3")
|
||||||
|
try:
|
||||||
|
yield DevicePool(store=store, config=CloudConfig())
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _device(device_id: str, *, status: str = "idle") -> Device:
|
||||||
|
return Device(
|
||||||
|
id=device_id,
|
||||||
|
driver_type="wda",
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
capability_tags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_host_devices_marks_mcp_busy_devices(pool: DevicePool) -> None:
|
||||||
|
"""When a host reports device-1 as MCP-busy, the pool PooledDevice for
|
||||||
|
device-1 has mcp_busy=True."""
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-1",
|
||||||
|
[_device("device-1", status="idle")],
|
||||||
|
mcp_busy_device_ids=["device-1"],
|
||||||
|
)
|
||||||
|
devices = pool.list_devices()
|
||||||
|
busy = [d for d in devices if d.device_id == "device-1"]
|
||||||
|
assert len(busy) == 1
|
||||||
|
assert busy[0].mcp_busy is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_host_devices_default_mcp_busy_is_false(pool: DevicePool) -> None:
|
||||||
|
pool.sync_host_devices("host-1", [_device("device-1", status="idle")])
|
||||||
|
devices = pool.list_devices()
|
||||||
|
assert devices[0].mcp_busy is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_host_devices_clears_mcp_busy_on_next_sync(
|
||||||
|
pool: DevicePool,
|
||||||
|
) -> None:
|
||||||
|
"""MCP releases device -> next heartbeat without device in
|
||||||
|
mcp_busy_device_ids -> pool reflects mcp_busy=False."""
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-1",
|
||||||
|
[_device("device-1", status="idle")],
|
||||||
|
mcp_busy_device_ids=["device-1"],
|
||||||
|
)
|
||||||
|
pool.sync_host_devices("host-1", [_device("device-1", status="idle")])
|
||||||
|
devices = pool.list_devices()
|
||||||
|
assert devices[0].mcp_busy is False
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests for the cloud TaskScheduler, focused on skipping MCP-busy devices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cloud.config import CloudConfig
|
||||||
|
from cloud.pool import DevicePool
|
||||||
|
from cloud.scheduler import TaskConstraints, TaskScheduler
|
||||||
|
from cloud.store import CloudStore
|
||||||
|
from core.models import Device
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool() -> DevicePool:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
store = CloudStore(Path(tmp) / "cloud.sqlite3")
|
||||||
|
try:
|
||||||
|
yield DevicePool(store=store, config=CloudConfig())
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _device(device_id: str, *, status: str = "idle") -> Device:
|
||||||
|
return Device(
|
||||||
|
id=device_id,
|
||||||
|
driver_type="wda",
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
capability_tags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_busy_device_is_skipped_by_scheduler(pool: DevicePool) -> None:
|
||||||
|
"""A device with mcp_busy=True is not selected for assignment.
|
||||||
|
|
||||||
|
Two devices exist: dev-busy (idle status, mcp_busy=True) and dev-idle
|
||||||
|
(idle status, mcp_busy=False). One task is submitted with no
|
||||||
|
constraints, so both are candidates before the mcp_busy filter.
|
||||||
|
The scheduler must pick dev-idle.
|
||||||
|
"""
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-1",
|
||||||
|
[_device("dev-busy"), _device("dev-idle")],
|
||||||
|
mcp_busy_device_ids=["dev-busy"],
|
||||||
|
)
|
||||||
|
scheduler = TaskScheduler(pool=pool, store=pool.store, config=CloudConfig())
|
||||||
|
scheduler.submit(goal="test", constraints=TaskConstraints())
|
||||||
|
assignments = scheduler.assign()
|
||||||
|
assert len(assignments) == 1
|
||||||
|
assert assignments[0].device_id == "dev-idle"
|
||||||
+117
-2
@@ -4,7 +4,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from numbers import Real
|
from numbers import Real
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -19,6 +19,8 @@ class OCRBox:
|
|||||||
text: str
|
text: str
|
||||||
bounds: Bounds
|
bounds: Bounds
|
||||||
confidence: float | None = None
|
confidence: float | None = None
|
||||||
|
foreground_color: str | None = None
|
||||||
|
background_color: str | None = None
|
||||||
|
|
||||||
def to_scene_element(self, element_id: str) -> SceneElement:
|
def to_scene_element(self, element_id: str) -> SceneElement:
|
||||||
return SceneElement(
|
return SceneElement(
|
||||||
@@ -28,11 +30,23 @@ class OCRBox:
|
|||||||
bounds=self.bounds,
|
bounds=self.bounds,
|
||||||
confidence=self.confidence,
|
confidence=self.confidence,
|
||||||
source="ocr",
|
source="ocr",
|
||||||
|
foreground_color=self.foreground_color,
|
||||||
|
background_color=self.background_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaddleOCREngine:
|
class PaddleOCREngine:
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
# Device screenshots are already upright, flat digital captures, not
|
||||||
|
# photographed paper documents, so PaddleOCR's document-preprocessing
|
||||||
|
# models (orientation classification + UVDoc unwarping) have nothing
|
||||||
|
# real to correct. Left at their library defaults (True), they still
|
||||||
|
# run, geometrically warp the image, and detect/recognize text against
|
||||||
|
# that warped image, returning box coordinates that no longer line up
|
||||||
|
# with the original screenshot. Callers can still opt back in via an
|
||||||
|
# explicit kwarg.
|
||||||
|
kwargs.setdefault("use_doc_orientation_classify", False)
|
||||||
|
kwargs.setdefault("use_doc_unwarping", False)
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
self._engine: Any | None = None
|
self._engine: Any | None = None
|
||||||
|
|
||||||
@@ -44,7 +58,8 @@ class PaddleOCREngine:
|
|||||||
raw = engine.predict(input=image_input)
|
raw = engine.predict(input=image_input)
|
||||||
else:
|
else:
|
||||||
raw = engine.ocr(image_input, cls=True)
|
raw = engine.ocr(image_input, cls=True)
|
||||||
return parse_paddle_result(raw)
|
boxes = parse_paddle_result(raw)
|
||||||
|
return _with_sampled_colors(boxes, image_input)
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
temp_path.unlink(missing_ok=True)
|
temp_path.unlink(missing_ok=True)
|
||||||
@@ -73,6 +88,106 @@ def run_ocr(
|
|||||||
return [box.to_scene_element(f"ocr-{index:03d}") for index, box in enumerate(boxes)]
|
return [box.to_scene_element(f"ocr-{index:03d}") for index, box in enumerate(boxes)]
|
||||||
|
|
||||||
|
|
||||||
|
def _with_sampled_colors(boxes: list[OCRBox], image_path: str) -> list[OCRBox]:
|
||||||
|
if not boxes:
|
||||||
|
return boxes
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(image_path) as source:
|
||||||
|
picture = source.convert("RGB")
|
||||||
|
sampled = []
|
||||||
|
for box in boxes:
|
||||||
|
foreground, background = _estimate_colors(picture, box.bounds)
|
||||||
|
sampled.append(
|
||||||
|
replace(
|
||||||
|
box,
|
||||||
|
foreground_color=foreground,
|
||||||
|
background_color=background,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sampled
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"OCR color sampling failed; continuing without foreground/background colors",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_colors(picture: Any, bounds: Bounds) -> tuple[str | None, str | None]:
|
||||||
|
left = max(0, int(bounds.x))
|
||||||
|
top = max(0, int(bounds.y))
|
||||||
|
right = min(picture.width, int(round(bounds.right)))
|
||||||
|
bottom = min(picture.height, int(round(bounds.bottom)))
|
||||||
|
if right - left < 2 or bottom - top < 2:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
pixels = list(picture.crop((left, top, right, bottom)).get_flattened_data())
|
||||||
|
threshold = _otsu_threshold(pixels)
|
||||||
|
if threshold is None:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
dark = [pixel for pixel in pixels if _luminance(pixel) <= threshold]
|
||||||
|
light = [pixel for pixel in pixels if _luminance(pixel) > threshold]
|
||||||
|
if not dark or not light:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
# Text strokes normally cover a minority of the box's pixels regardless of
|
||||||
|
# whether the text is dark-on-light or light-on-dark, so the smaller of
|
||||||
|
# the two luminance clusters is treated as the foreground (text) color.
|
||||||
|
foreground, background = (dark, light) if len(dark) <= len(light) else (light, dark)
|
||||||
|
return _average_hex(foreground), _average_hex(background)
|
||||||
|
|
||||||
|
|
||||||
|
def _luminance(pixel: tuple[int, int, int]) -> float:
|
||||||
|
r, g, b = pixel
|
||||||
|
return 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
|
|
||||||
|
|
||||||
|
def _otsu_threshold(pixels: list[tuple[int, int, int]]) -> float | None:
|
||||||
|
total = len(pixels)
|
||||||
|
if total == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
histogram = [0] * 256
|
||||||
|
for pixel in pixels:
|
||||||
|
histogram[int(_luminance(pixel))] += 1
|
||||||
|
|
||||||
|
sum_total = sum(level * count for level, count in enumerate(histogram))
|
||||||
|
sum_background = 0.0
|
||||||
|
weight_background = 0
|
||||||
|
best_variance = -1.0
|
||||||
|
threshold = 0
|
||||||
|
for level, count in enumerate(histogram):
|
||||||
|
weight_background += count
|
||||||
|
if weight_background == 0:
|
||||||
|
continue
|
||||||
|
weight_foreground = total - weight_background
|
||||||
|
if weight_foreground == 0:
|
||||||
|
break
|
||||||
|
sum_background += level * count
|
||||||
|
mean_background = sum_background / weight_background
|
||||||
|
mean_foreground = (sum_total - sum_background) / weight_foreground
|
||||||
|
variance = (
|
||||||
|
weight_background
|
||||||
|
* weight_foreground
|
||||||
|
* (mean_background - mean_foreground) ** 2
|
||||||
|
)
|
||||||
|
if variance > best_variance:
|
||||||
|
best_variance = variance
|
||||||
|
threshold = level
|
||||||
|
return float(threshold)
|
||||||
|
|
||||||
|
|
||||||
|
def _average_hex(pixels: list[tuple[int, int, int]]) -> str:
|
||||||
|
count = len(pixels)
|
||||||
|
r = sum(pixel[0] for pixel in pixels) // count
|
||||||
|
g = sum(pixel[1] for pixel in pixels) // count
|
||||||
|
b = sum(pixel[2] for pixel in pixels) // count
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
def parse_paddle_result(raw: Any) -> list[OCRBox]:
|
def parse_paddle_result(raw: Any) -> list[OCRBox]:
|
||||||
boxes: list[OCRBox] = []
|
boxes: list[OCRBox] = []
|
||||||
for item in _flatten_pages(raw):
|
for item in _flatten_pages(raw):
|
||||||
|
|||||||
+27
-8
@@ -2,12 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.models import Bounds, SceneElement
|
from core.models import Bounds, SceneElement
|
||||||
|
|
||||||
_ANDROID_BOUNDS = re.compile(r"\[(?P<x1>-?\d+),(?P<y1>-?\d+)\]\[(?P<x2>-?\d+),(?P<y2>-?\d+)\]")
|
_ANDROID_BOUNDS = re.compile(r"\[(?P<x1>-?\d+),(?P<y1>-?\d+)\]\[(?P<x2>-?\d+),(?P<y2>-?\d+)\]")
|
||||||
|
|
||||||
|
# Accessibility interaction-state attributes, as literally emitted by
|
||||||
|
# WDA/XCUITest page_source (enabled) and Appium UiAutomator2 page_source
|
||||||
|
# (enabled, clickable, selected, checked, focused). iOS page_source does not
|
||||||
|
# emit selected/checked/focused/clickable, so those stay None for iOS trees.
|
||||||
|
_STATE_ATTR_KEYS = ("enabled", "clickable", "selected", "checked", "focused")
|
||||||
|
|
||||||
|
|
||||||
def parse_ui_tree(raw_tree: Any) -> list[SceneElement]:
|
def parse_ui_tree(raw_tree: Any) -> list[SceneElement]:
|
||||||
if raw_tree is None:
|
if raw_tree is None:
|
||||||
@@ -44,6 +51,7 @@ def _parse_xml(raw_tree: str) -> list[SceneElement]:
|
|||||||
bounds=bounds,
|
bounds=bounds,
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
source="ui",
|
source="ui",
|
||||||
|
**_state_from_attrs(node.attrib),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return _with_stable_ids(elements, "ui")
|
return _with_stable_ids(elements, "ui")
|
||||||
@@ -65,6 +73,7 @@ def _parse_dict_node(node: dict[str, Any], elements: list[SceneElement]) -> None
|
|||||||
bounds=bounds,
|
bounds=bounds,
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
source="ui",
|
source="ui",
|
||||||
|
**_state_from_attrs(attrs),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for child in children:
|
for child in children:
|
||||||
@@ -74,14 +83,7 @@ def _parse_dict_node(node: dict[str, Any], elements: list[SceneElement]) -> None
|
|||||||
|
|
||||||
def _with_stable_ids(elements: list[SceneElement], prefix: str) -> list[SceneElement]:
|
def _with_stable_ids(elements: list[SceneElement], prefix: str) -> list[SceneElement]:
|
||||||
return [
|
return [
|
||||||
SceneElement(
|
element if element.id else replace(element, id=f"{prefix}-{index:03d}")
|
||||||
id=element.id or f"{prefix}-{index:03d}",
|
|
||||||
type=element.type,
|
|
||||||
text=element.text,
|
|
||||||
bounds=element.bounds,
|
|
||||||
confidence=element.confidence,
|
|
||||||
source=element.source,
|
|
||||||
)
|
|
||||||
for index, element in enumerate(elements)
|
for index, element in enumerate(elements)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -122,6 +124,23 @@ def _text_from_attrs(attrs: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _state_from_attrs(attrs: dict[str, Any]) -> dict[str, bool | None]:
|
||||||
|
return {key: _parse_bool(attrs.get(key)) for key in _STATE_ATTR_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: Any) -> bool | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in ("true", "1", "yes"):
|
||||||
|
return True
|
||||||
|
if text in ("false", "0", "no"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_type(raw_type: str, attrs: dict[str, Any]) -> str:
|
def _normalize_type(raw_type: str, attrs: dict[str, Any]) -> str:
|
||||||
candidate = (
|
candidate = (
|
||||||
attrs.get("role")
|
attrs.get("role")
|
||||||
|
|||||||
+2
-1
@@ -9,8 +9,9 @@ dependencies = [
|
|||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"mcp>=1.27,<2",
|
"mcp>=1.27,<2",
|
||||||
"openai>=1.0.0",
|
"openai>=1.0.0",
|
||||||
"paddlepaddle>=3.0.0",
|
"paddlepaddle>=3.0.0,<3.3.0",
|
||||||
"paddleocr>=3.0.0",
|
"paddleocr>=3.0.0",
|
||||||
|
"pillow>=12.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user