Files
showtan001 9076f8ddb0
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
feat: discover and add connected iOS devices
2026-09-07 18:33:49 +08:00

1038 lines
39 KiB
Python

from __future__ import annotations
import asyncio
import base64
import json
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
import jinja2
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import (
HostAgentClient,
HostAgentEnrollmentClient,
HostAgentAPIError,
HostTaskSubmissionUnknownError,
)
from host_agent.config import HostAgentConfig
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.ios_discovery import IOSDiscoveryError, discover_connected_ios_devices
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.auth import (
SessionManager,
SessionState,
attempt_login,
change_password,
)
from host_agent.web.mcp_auth import BearerAuthMiddleware
from storage.device_config import DeviceConfigStore
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
SESSION_COOKIE_NAME = "host_console_session"
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_FORM_FIELD = "csrf_token"
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
TaskSubmissionCallable = Callable[..., Awaitable[str]]
TaskCancellationCallable = Callable[..., Awaitable[Any]]
AUTOMATIC_DEVICE_VALUE = "__automatic__"
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
_ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
autoescape=jinja2.select_autoescape(["html", "xml"]),
)
def _render(
template_name: str,
*,
status_code: int = 200,
**context: Any,
) -> HTMLResponse:
html = _ENV.get_template(template_name).render(**context)
return HTMLResponse(html, status_code=status_code)
def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
"""Connected-but-idle devices report "busy" at the DeviceManager layer
(an active Appium/WDA session is required to reuse it), which is not the
same as a task currently running on that device. Only the device actually
bound to the current assignment should read as "busy" here.
"""
if device.status == "busy" and device.id != busy_device_id:
return "connected"
return device.status
def _screenshot_data_uri(
record: dict[str, Any],
*,
path_key: str = "screenshot_path",
) -> str | None:
"""Return a ``data:`` URI for one step screenshot, or ``None``."""
screenshot_path = record.get(path_key)
if not screenshot_path:
return None
path = Path(str(screenshot_path))
if not path.exists():
return None
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_results = record.get("ocr_results")
if not isinstance(raw_results, list):
return []
return [result for result in raw_results if isinstance(result, dict)]
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_nodes = record.get("ui_tree_results")
if not isinstance(raw_nodes, list):
return []
return [node for node in raw_nodes if isinstance(node, dict)]
def _overlay_payload(record: dict[str, Any]) -> dict[str, Any]:
"""Combined perception elements + screen size for client-side bounding-box
overlay and action-effect rendering on the before-screenshot.
"""
scene = record.get("scene")
screen = scene.get("screen") if isinstance(scene, dict) else None
width = screen.get("width") if isinstance(screen, dict) else None
height = screen.get("height") if isinstance(screen, dict) else None
elements = scene.get("elements") if isinstance(scene, dict) else None
return {
"width": width if isinstance(width, (int, float)) else 0,
"height": height if isinstance(height, (int, float)) else 0,
"elements": [element for element in elements if isinstance(element, dict)]
if isinstance(elements, list)
else [],
}
def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
tool_call = record.get("tool_call")
result = record.get("result")
return {
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call": tool_call if isinstance(tool_call, dict) else {},
"result": result if isinstance(result, dict) else {},
"before_screenshot_src": _screenshot_data_uri(
record, path_key="before_screenshot_path"
),
"after_screenshot_src": _screenshot_data_uri(
record, path_key="after_screenshot_path"
)
or _screenshot_data_uri(record),
"ocr_results": _ocr_results(record),
"ui_tree_nodes": _ui_tree_nodes(record),
"overlay": _overlay_payload(record),
}
def _safe_submission_error(detail: str) -> str:
"""Return a safe, single-line error message for the operator.
The Cloud response ``detail`` is treated as a static control-plane message;
we strip surrounding whitespace and reject empty results so the operator
never sees a blank error or, through Jinja autoescape, anything that could
carry unrendered HTML.
"""
cleaned = " ".join(str(detail).split()).strip()
return cleaned or "Cloud rejected the task submission."
def _extract_task_id(response: Any) -> str | None:
"""Normalize the Host self-submission return value to a Cloud task ID.
Tests and narrow protocol overrides may return a bare string while the
production client returns a pydantic model. Accept either so the rest of
the handler can rely on a single string ID.
"""
candidate: Any = response
if hasattr(candidate, "task_id"):
candidate = getattr(candidate, "task_id")
if not isinstance(candidate, str):
return None
cleaned = candidate.strip()
return cleaned or None
def _dashboard_texts(*, snapshot: dict[str, Any]) -> dict[str, str]:
"""Pre-compute human-readable text strings for the dashboard template."""
heartbeat = snapshot.get("last_heartbeat")
assignment = snapshot.get("current_assignment")
policy = snapshot.get("host_policy")
progress = snapshot.get("progress")
return {
"heartbeat_text": (
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
f"({heartbeat['device_count']} devices)"
if heartbeat
else "never"
),
"assignment_text": (
f"{assignment['task_id']} on {assignment['device_id']} "
f"(started {assignment['started_at']})"
if assignment
else "none"
),
"progress_text": (
f"step {progress['step_index']} \u2014 {progress['step_status']}: "
f"{progress['summary']}"
if progress
else ""
),
"policy_text": (
f"revision {policy['revision']}; self-submission "
f"{'enabled' if policy['self_submission_enabled'] else 'disabled'}; "
f"max active tasks {policy['max_active_tasks'] or 'unlimited'}; "
f"daily token budget {policy['daily_token_budget'] or 'unmetered'}"
if policy
else "no Cloud policy cached"
),
}
def create_console_app(
*,
config: HostAgentConfig,
manager: DeviceManager,
config_store: DeviceConfigStore,
local_account_store: LocalAccountStore,
identity_store: HostIdentityStore,
history_store: ConsoleHistoryStore,
status_tracker: AgentStatusTracker,
session_manager: SessionManager,
enrollment_client: HostAgentEnrollmentClient | None,
host_client: HostAgentClient | None = None,
submit_self_task: TaskSubmissionCallable | None = None,
cancel_task: TaskCancellationCallable | None = None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None,
mcp_server: FastMCP | None = None,
mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
conversation_agent: ConversationAgent | None = None,
conversation_log: ConversationLogStore | None = None,
) -> FastAPI:
app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
if submit_self_task is None and host_client is not None:
submit_self_task = host_client.submit_self_task
if cancel_task is None and host_client is not None:
cancel_task = host_client.cancel_task
submission_available = submit_self_task is not None
mcp_mounted = mcp_server is not None and mcp_token_store is not None
if mcp_mounted:
from starlette.applications import Starlette
from starlette.middleware import Middleware
mcp_asgi = mcp_server.streamable_http_app()
authed = Starlette(
routes=[],
middleware=[Middleware(BearerAuthMiddleware, token_store=mcp_token_store)],
)
authed.router.mount("/", mcp_asgi)
app.mount("/mcp", authed)
def _running_devices() -> list[dict[str, str]]:
return [
{
"id": device.id,
"label": device.name or device.id,
}
for device in manager.list_devices()
]
def _session_token(request: Request) -> str | None:
return request.cookies.get(SESSION_COOKIE_NAME)
def require_session(request: Request) -> SessionState:
token = _session_token(request)
state = session_manager.validate(token) if token else None
if state is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
return state
async def require_csrf(
request: Request,
session: SessionState = Depends(require_session),
) -> SessionState:
provided = request.headers.get(CSRF_HEADER_NAME)
if not provided:
form = await request.form()
raw = form.get(CSRF_FORM_FIELD)
provided = str(raw) if raw is not None else None
token = _session_token(request)
if (
not token
or not provided
or not session_manager.validate_csrf(token, provided)
):
raise HTTPException(status_code=403, detail="invalid CSRF token")
return session
@app.get("/login", response_class=HTMLResponse)
async def login_page() -> HTMLResponse:
account = await asyncio.to_thread(local_account_store.load)
return _render("login.html", title="Login", session=None, account=account)
@app.post("/login")
async def login_submit(request: Request) -> Response:
account = await asyncio.to_thread(local_account_store.load)
if account is None:
return _render("login.html", title="Login", session=None, account=None)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
ok = await asyncio.to_thread(
attempt_login, local_account_store, username=username, password=password
)
if not ok:
return _render(
"login.html",
title="Login",
session=None,
account=account,
error="Invalid username or password.",
)
session_token, _ = session_manager.create_session(username)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_token,
httponly=True,
samesite="strict",
secure=cookie_secure,
path="/",
)
return response
@app.post("/logout")
async def logout(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
token = _session_token(request)
if token:
session_manager.invalidate(token)
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
return response
@app.get("/", response_class=HTMLResponse)
async def dashboard(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
identity = await asyncio.to_thread(identity_store.load)
snapshot = status_tracker.snapshot()
busy_device_id = (
snapshot["current_assignment"]["device_id"]
if snapshot.get("current_assignment")
else None
)
devices = [
{
"id": d.id,
"name": d.name,
"driver_type": d.driver_type,
"display_status": _device_display_status(
d, busy_device_id=busy_device_id
),
}
for d in manager.list_devices()
]
texts = _dashboard_texts(snapshot=snapshot)
mcp_endpoint = "/mcp" if mcp_mounted else None
mcp_busy_devices = (
mcp_busy_tracker.busy_device_ids() if mcp_busy_tracker is not None else []
)
return _render(
"dashboard.html",
title="Status",
session=session,
identity=identity,
devices=devices,
config=config,
mcp_endpoint=mcp_endpoint,
mcp_busy_devices=mcp_busy_devices,
**texts,
)
@app.get("/api/status")
async def api_status(
session: SessionState = Depends(require_session),
) -> JSONResponse:
snapshot = status_tracker.snapshot()
if executor is not None and snapshot.get("progress") is None:
live = executor.latest_progress()
if live is not None:
snapshot["progress"] = {
"step_index": live.step_index,
"step_status": live.step_status,
"summary": live.summary,
"updated_at": live.updated_at.isoformat(),
}
current_assignment = snapshot.get("current_assignment")
busy_device_id = current_assignment["device_id"] if current_assignment else None
devices = [
{
"id": device.id,
"name": device.name,
"driver_type": device.driver_type,
"status": _device_display_status(device, busy_device_id=busy_device_id),
}
for device in manager.list_devices()
]
return JSONResponse(
{
"status": snapshot,
"devices": devices,
"mcp_endpoint": "/mcp" if mcp_mounted else None,
"mcp_busy_devices": (
mcp_busy_tracker.busy_device_ids()
if mcp_busy_tracker is not None
else []
),
}
)
@app.post("/api/chat")
async def api_chat(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
if conversation_agent is None:
raise HTTPException(status_code=503, detail="chat agent is not configured")
payload = await request.json()
device_id = payload.get("device_id") if isinstance(payload, dict) else None
if not isinstance(device_id, str) or not device_id.strip():
raise HTTPException(status_code=400, detail="device_id is required")
if device_id not in {device.id for device in manager.list_devices()}:
raise HTTPException(status_code=404, detail="unknown device")
raw_messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(raw_messages, list) or not raw_messages:
raise HTTPException(status_code=400, detail="messages must be a non-empty list")
messages = [
{key: value for key, value in item.items() if key in {"role", "content", "image_base64", "mime_type", "text"}}
for item in raw_messages
if isinstance(item, dict)
and item.get("role") in {"user", "assistant"}
and (isinstance(item.get("content"), (str, list)) or isinstance(item.get("image_base64"), str))
]
if not messages:
raise HTTPException(status_code=400, detail="messages are invalid")
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "user_request", "device_id": device_id.strip(), "messages": messages},
)
try:
result = await asyncio.to_thread(
conversation_agent.chat_for_device, device_id.strip(), messages
)
except Exception as exc:
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "agent_error", "device_id": device_id.strip(), "error": str(exc)},
)
raise HTTPException(status_code=502, detail=str(exc)) from exc
return JSONResponse(
{"content": result.content, "tool_calls": result.tool_calls}
)
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
devices = await asyncio.to_thread(config_store.list)
edit_id = request.query_params.get("edit")
edit_record = (
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
)
connection_info_json = (
json.dumps(edit_record["connection_info"]) if edit_record else "{}"
)
return _render(
"devices.html",
title="Devices",
session=session,
devices=devices,
csrf_token=session.csrf_token,
edit_record=edit_record,
connection_info_json=connection_info_json,
error=None,
)
@app.post("/api/devices/{device_id}/screenshot")
async def api_device_screenshot(
device_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
"""Capture one on-demand screenshot for a connected local device."""
try:
screenshot = await asyncio.to_thread(
lambda: manager.active_driver(device_id).screenshot()
)
except DeviceNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except DeviceOfflineError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except DeviceRuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "failed to capture device screenshot",
) from exc
if not isinstance(screenshot, bytes) or not screenshot:
raise HTTPException(
status_code=502,
detail="device returned an empty screenshot",
)
return Response(
content=screenshot,
media_type="image/png",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"X-Content-Type-Options": "nosniff",
},
)
@app.get("/api/devices/discover-ios")
async def api_discover_ios_devices(
session: SessionState = Depends(require_session),
) -> JSONResponse:
try:
discovered = await asyncio.to_thread(discover_connected_ios_devices)
except IOSDiscoveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
configured = await asyncio.to_thread(config_store.list)
configured_udids = {
str(record["connection_info"].get("udid"))
for record in configured
if record["driver_type"] == "wda"
}
used_wda_ports = {
record["connection_info"].get("wda_local_port") for record in configured
}
used_mjpeg_ports = {
record["connection_info"].get("mjpegServerPort") for record in configured
}
next_wda_port = 8100
next_mjpeg_port = 9100
result = []
for device in discovered:
while next_wda_port in used_wda_ports:
next_wda_port += 1
while next_mjpeg_port in used_mjpeg_ports:
next_mjpeg_port += 1
result.append(
{
**device,
"configured": device["udid"] in configured_udids,
"suggested_wda_port": next_wda_port,
"suggested_mjpeg_port": next_mjpeg_port,
}
)
used_wda_ports.add(next_wda_port)
used_mjpeg_ports.add(next_mjpeg_port)
next_wda_port += 1
next_mjpeg_port += 1
return JSONResponse({"devices": result})
@app.post("/api/devices/test-connection")
async def api_device_test_connection(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Request must be an object.")
driver_type = str(payload.get("driver_type", "")).strip()
connection_info = payload.get("connection_info")
if not driver_type or not isinstance(connection_info, dict):
raise HTTPException(
status_code=400,
detail="Driver type and connection info are required.",
)
driver = None
connected = False
try:
driver = build_driver_factory(driver_type, connection_info)()
await asyncio.to_thread(driver.connect)
connected = True
await asyncio.to_thread(driver.health_check)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "device connection test failed",
) from exc
finally:
if connected and driver is not None:
try:
await asyncio.to_thread(driver.disconnect)
except Exception:
pass
return JSONResponse({"ok": True, "driver_type": driver_type})
@app.post("/devices/save")
async def devices_save(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
form = await request.form()
device_id = str(form.get("device_id", "")).strip()
driver_type = str(form.get("driver_type", "")).strip()
name = str(form.get("name", "")).strip() or None
connection_info_raw = str(form.get("connection_info", "") or "{}")
error: str | None = None
connection_info: dict[str, Any] = {}
if not device_id or not driver_type:
error = "Device ID and driver type are required."
else:
try:
parsed = json.loads(connection_info_raw)
except ValueError:
error = "Connection info must be valid JSON."
else:
if not isinstance(parsed, dict):
error = "Connection info must be a JSON object."
else:
connection_info = parsed
if error is None:
# The console exposes the common Appium settings as regular form
# fields. Advanced JSON remains available for uncommon capabilities.
field_map = {
"server_url": "server_url",
"udid": "udid",
"device_name": "device_name",
}
for form_key, config_key in field_map.items():
value = str(form.get(form_key, "")).strip()
if value:
connection_info[config_key] = value
port_key = "wda_local_port" if driver_type == "wda" else "system_port"
port_value = str(form.get(port_key, "")).strip()
if port_value:
try:
connection_info[port_key] = int(port_value)
except ValueError:
error = f"{port_key} must be an integer."
if error is None:
try:
await asyncio.to_thread(
register_local_device,
config_store,
manager,
device_id=device_id,
driver_type=driver_type,
connection_info=connection_info,
name=name,
config=config,
enrollment_client=enrollment_client,
)
except ValueError as exc:
error = str(exc)
if error is not None:
devices = await asyncio.to_thread(config_store.list)
return _render(
"devices.html",
title="Devices",
session=session,
devices=devices,
csrf_token=session.csrf_token,
edit_record=None,
connection_info_json="{}",
error=error,
status_code=400,
)
return RedirectResponse(url="/devices", status_code=303)
@app.post("/devices/remove")
async def devices_remove(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
form = await request.form()
device_id = str(form.get("device_id", "")).strip()
if device_id:
await asyncio.to_thread(
unregister_local_device, config_store, manager, device_id=device_id
)
return RedirectResponse(url="/devices", status_code=303)
@app.get("/account", response_class=HTMLResponse)
async def account_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error=None,
)
@app.post("/account", response_class=HTMLResponse)
async def account_submit(
request: Request,
session: SessionState = Depends(require_csrf),
) -> HTMLResponse:
form = await request.form()
current_password = str(form.get("current_password", ""))
new_password = str(form.get("new_password", ""))
confirm_password = str(form.get("confirm_password", ""))
if not new_password or new_password != confirm_password:
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error="New password and confirmation must match.",
status_code=400,
)
ok = await asyncio.to_thread(
change_password,
local_account_store,
current_password=current_password,
new_password=new_password,
)
if not ok:
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error="Current password is incorrect.",
status_code=400,
)
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message="Password updated.",
error=None,
)
@app.get("/history", response_class=HTMLResponse)
async def history_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
entries = await asyncio.to_thread(history_store.list_recent)
return _render(
"history.html",
title="History",
session=session,
entries=entries,
)
@app.get("/conversations", response_class=HTMLResponse)
async def conversations_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
events = await asyncio.to_thread(
conversation_log.list_recent if conversation_log is not None else (lambda: [])
)
return _render("conversations.html", title="Conversations", session=session, events=events)
def _tasks_list_context(
session: SessionState,
*,
goal_value: str = "",
selected_device: str = AUTOMATIC_DEVICE_VALUE,
submission_error: str | None = None,
submission_notice: str | None = None,
submission_unknown: bool = False,
status_code: int = 200,
) -> dict[str, Any]:
devices = _running_devices()
return {
"title": "Tasks",
"session": session,
"csrf_token": session.csrf_token,
"tasks": metadata_store.list_tasks() if metadata_store is not None else [],
"metadata_store_missing": metadata_store is None,
"devices": devices,
"automatic_device_value": AUTOMATIC_DEVICE_VALUE,
"selected_device": selected_device
if any(d["id"] == selected_device for d in devices)
or selected_device == AUTOMATIC_DEVICE_VALUE
else AUTOMATIC_DEVICE_VALUE,
"submission_available": submission_available,
"goal_value": goal_value,
"submission_error": submission_error,
"submission_notice": submission_notice,
"submission_unknown": submission_unknown,
"status_code": status_code,
}
@app.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
notice: str | None = None
unknown = False
error: str | None = None
if request.query_params.get("submitted") == "1":
task_id = request.query_params.get("task_id", "")
if task_id:
notice = (
f"Task submitted. Cloud task ID: {task_id}. "
"It will appear below when this Host begins executing it."
)
elif request.query_params.get("outcome") == "unknown":
unknown = True
context = _tasks_list_context(
session,
submission_notice=notice,
submission_unknown=unknown,
submission_error=error,
)
return _render(
"tasks_list.html",
status_code=context["status_code"],
**{k: v for k, v in context.items() if k != "status_code"},
)
@app.post("/tasks/submit", response_class=HTMLResponse)
async def tasks_submit(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
if submit_self_task is None:
context = _tasks_list_context(
session,
submission_error=(
"Host submission client is not available yet. "
"Wait for Host enrollment to complete, then retry."
),
)
return _render(
"tasks_list.html",
status_code=503,
**{k: v for k, v in context.items() if k != "status_code"},
)
form = await request.form()
raw_goal = str(form.get("goal", ""))
goal = raw_goal.strip()
device_selection = str(form.get("device_id", AUTOMATIC_DEVICE_VALUE)).strip()
explicit_device_id: str | None = None
if device_selection and device_selection != AUTOMATIC_DEVICE_VALUE:
snapshot_ids = {device.id for device in manager.list_devices()}
if device_selection not in snapshot_ids:
context = _tasks_list_context(
session,
goal_value=goal,
selected_device=device_selection,
submission_error=(
"Selected device is no longer registered. "
"Refresh and try again."
),
)
return _render(
"tasks_list.html",
status_code=400,
**{k: v for k, v in context.items() if k != "status_code"},
)
explicit_device_id = device_selection
if not goal:
context = _tasks_list_context(
session,
goal_value=goal,
selected_device=device_selection or AUTOMATIC_DEVICE_VALUE,
submission_error="Goal cannot be empty.",
)
return _render(
"tasks_list.html",
status_code=400,
**{k: v for k, v in context.items() if k != "status_code"},
)
try:
response = await submit_self_task(goal=goal, device_id=explicit_device_id)
except HostAgentAPIError as exc:
context = _tasks_list_context(
session,
goal_value=goal,
selected_device=device_selection,
submission_error=_safe_submission_error(str(exc.detail)),
)
return _render(
"tasks_list.html",
status_code=502,
**{k: v for k, v in context.items() if k != "status_code"},
)
except HostTaskSubmissionUnknownError:
return RedirectResponse(
url="/tasks?outcome=unknown",
status_code=303,
)
task_id = _extract_task_id(response)
if task_id is None:
context = _tasks_list_context(
session,
goal_value=goal,
selected_device=device_selection,
submission_error=(
"Host submission client returned an unexpected response."
),
)
return _render(
"tasks_list.html",
status_code=502,
**{k: v for k, v in context.items() if k != "status_code"},
)
try:
await asyncio.to_thread(
history_store.record_task_submission,
task_id=task_id,
device_id=explicit_device_id,
)
except Exception:
pass
params = f"submitted=1&task_id={task_id}"
return RedirectResponse(url=f"/tasks?{params}", status_code=303)
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
task_id: str,
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
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")
timeline_records: list[dict[str, Any]] = []
if timeline is not None:
timeline_records = await asyncio.to_thread(timeline.read, task_id)
task_rows = [
(label, task[key])
for key, label in (
("source_task_id", "Cloud task ID"),
("source_attempt", "Cloud attempt"),
("id", "Execution ID"),
("goal", "Goal"),
("device_id", "Device"),
("status", "Status"),
("created_at", "Created"),
("updated_at", "Updated"),
("completed_at", "Completed"),
("failure_reason", "Failure reason"),
)
if task.get(key) is not None
]
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
can_cancel = (
cancel_task is not None
and task.get("source_task_id") is not None
and task.get("status") not in _TERMINAL_LOCAL_TASK_STATUSES
)
cancel_notice = (
"Cancellation requested. It may take a moment to take effect."
if request.query_params.get("cancelled") == "1"
else None
)
cancel_error = (
"Failed to request cancellation. Try again."
if request.query_params.get("cancel_error") == "1"
else None
)
return _render(
"task_detail.html",
title=f"Task {task_id}",
session=session,
csrf_token=session.csrf_token,
task=task,
task_rows=task_rows,
timeline_steps=timeline_steps,
can_cancel=can_cancel,
cancel_notice=cancel_notice,
cancel_error=cancel_error,
)
@app.post("/tasks/{task_id}/cancel")
async def tasks_cancel(
task_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
task = await asyncio.to_thread(metadata_store.get_task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
source_task_id = task.get("source_task_id")
if cancel_task is None or source_task_id is None:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
try:
await cancel_task(source_task_id)
except HostAgentAPIError:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
return RedirectResponse(url=f"/tasks/{task_id}?cancelled=1", status_code=303)
return app