feat: surface task execution progress across Host Agent and Cloud

Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:47:49 +08:00
co-authored by Claude Opus 4.6
parent c049c3c1b1
commit ec261d57c2
59 changed files with 3801 additions and 122 deletions
+210
View File
@@ -0,0 +1,210 @@
"""Tests for Host Agent console /tasks routes (task 5.4)."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from device.manager import DeviceManager
from host_agent.config import HostAgentConfig
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.local_account import LocalAccountStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
def _build_client(
tmp_path,
*,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
create_account: bool = True,
) -> tuple[TestClient, dict]:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
enrollment_managed=False,
console_session_ttl_seconds=3600.0,
)
manager = DeviceManager()
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
local_account_store = LocalAccountStore(tmp_path / "host_local_account.json")
if create_account:
local_account_store.create("operator", "correct horse battery staple")
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
status_tracker = AgentStatusTracker()
session_manager = SessionManager(ttl_seconds=3600.0)
if metadata_store is None:
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
if timeline is None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
app = create_console_app(
config=config,
manager=manager,
config_store=config_store,
local_account_store=local_account_store,
identity_store=identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=session_manager,
enrollment_client=None,
metadata_store=metadata_store,
timeline=timeline,
)
client = TestClient(app)
context = {
"metadata_store": metadata_store,
"timeline": timeline,
"session_manager": session_manager,
}
return client, context
def _login(client: TestClient) -> str:
response = client.post(
"/login",
data={"username": "operator", "password": "correct horse battery staple"},
)
assert response.status_code == 200
match = CSRF_PATTERN.search(response.text)
assert match is not None
return match.group(1)
# --------------------------------------------------------------------------- #
# Unauthenticated requests rejected
# --------------------------------------------------------------------------- #
def test_unauthenticated_tasks_list_redirects_to_login(tmp_path) -> None:
client, _ = _build_client(tmp_path)
response = client.get("/tasks", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_unauthenticated_task_detail_redirects_to_login(tmp_path) -> None:
client, _ = _build_client(tmp_path)
response = client.get("/tasks/some-id", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_unauthenticated_devices_page_also_redirects_for_parity(tmp_path) -> None:
"""Confirm /tasks has the same auth behavior as the existing /devices route."""
client, _ = _build_client(tmp_path)
response = client.get("/devices", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
# --------------------------------------------------------------------------- #
# Authenticated list/detail render
# --------------------------------------------------------------------------- #
def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-visible",
goal="open settings",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
completed_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get("/tasks")
assert response.status_code == 200
assert "task-visible" in response.text
assert "completed" in response.text
def test_authenticated_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-detail",
goal="tap search",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
timeline.append(
task_id="task-detail",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="find search button",
tool_call={"action": "tap", "x": 5, "y": 10},
result={"ok": True},
screenshot=PNG_10X20,
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get("/tasks/task-detail")
assert response.status_code == 200
assert "task-detail" in response.text
# Timeline step visible.
assert "Step 1" in response.text
assert "find search button" in response.text
# Screenshot inlined as base64 data URI.
assert "data:image/png;base64," in response.text
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
client, _ = _build_client(tmp_path)
_login(client)
response = client.get("/tasks/nonexistent")
assert response.status_code == 404
def test_tasks_list_shows_empty_state(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
client, _ = _build_client(tmp_path, metadata_store=metadata_store)
_login(client)
response = client.get("/tasks")
assert response.status_code == 200
assert "No tasks recorded" in response.text