Compare commits
2
Commits
c049c3c1b1
...
8381b3068a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8381b3068a | ||
|
|
ec261d57c2 |
@@ -187,6 +187,19 @@ def create_app(
|
||||
),
|
||||
name="cloud-lease-reaper",
|
||||
),
|
||||
asyncio.create_task(
|
||||
_run_planner_decision_log_pruner_loop(
|
||||
services,
|
||||
stop_workers,
|
||||
interval_seconds=(
|
||||
control_config.planner_decision_log_prune_interval_seconds
|
||||
),
|
||||
retention_days=(
|
||||
control_config.planner_decision_log_retention_days
|
||||
),
|
||||
),
|
||||
name="cloud-planner-decision-log-pruner",
|
||||
),
|
||||
]
|
||||
app.state.worker_tasks = tuple(worker_tasks)
|
||||
app.state.startup_complete = True
|
||||
@@ -442,3 +455,28 @@ async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool:
|
||||
except TimeoutError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _run_planner_decision_log_pruner_loop(
|
||||
services: CloudApplicationServices,
|
||||
stop: asyncio.Event,
|
||||
*,
|
||||
interval_seconds: float,
|
||||
retention_days: int,
|
||||
) -> None:
|
||||
while not stop.is_set():
|
||||
correlation_token = bind_correlation_id(new_correlation_id())
|
||||
try:
|
||||
services.repository.prune_planner_decision_log(
|
||||
now=utc_now(),
|
||||
prune_after_terminal_seconds=retention_days * 86_400,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"planner decision log prune failed",
|
||||
extra={"worker": "planner_decision_log_pruner"},
|
||||
)
|
||||
finally:
|
||||
reset_correlation_id(correlation_token)
|
||||
if await _wait_for_stop(stop, interval_seconds):
|
||||
return
|
||||
|
||||
@@ -22,10 +22,14 @@ from host_agent.lease import ActiveAssignmentRunner
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||
from host_agent.retention import prune_task_history
|
||||
from host_agent.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
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -178,6 +182,16 @@ def create_application(
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
if resolved_config.enrollment_managed:
|
||||
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
|
||||
metadata_store = TaskMetadataStore(db_path=resolved_config.task_progress_db_path)
|
||||
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
host_agent_config=resolved_config,
|
||||
)
|
||||
)
|
||||
console_app = create_console_app(
|
||||
config=resolved_config,
|
||||
manager=resolved_manager,
|
||||
@@ -190,6 +204,9 @@ def create_application(
|
||||
ttl_seconds=resolved_config.console_session_ttl_seconds
|
||||
),
|
||||
enrollment_client=console_enrollment_client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
executor=executor,
|
||||
)
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
@@ -215,19 +232,18 @@ def create_application(
|
||||
revision=revision
|
||||
),
|
||||
)
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
host_agent_config=resolved_config,
|
||||
)
|
||||
)
|
||||
active_runner = ActiveAssignmentRunner(client, executor)
|
||||
processor = AssignmentProcessor(
|
||||
client,
|
||||
active_runner,
|
||||
status_tracker=status_tracker,
|
||||
on_result=lambda assignment, result: _record_assignment_history(
|
||||
history_store, assignment, result
|
||||
on_result=lambda assignment, result: _on_assignment_finished(
|
||||
history_store,
|
||||
metadata_store,
|
||||
timeline,
|
||||
resolved_config,
|
||||
assignment,
|
||||
result,
|
||||
),
|
||||
)
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
@@ -245,6 +261,18 @@ def create_application(
|
||||
)
|
||||
|
||||
|
||||
def _on_assignment_finished(
|
||||
history_store: ConsoleHistoryStore,
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config: HostAgentConfig,
|
||||
assignment: AssignmentModel,
|
||||
result: AssignmentProcessingResult,
|
||||
) -> None:
|
||||
_record_assignment_history(history_store, assignment, result)
|
||||
_prune_task_history(metadata_store, timeline, config)
|
||||
|
||||
|
||||
def _record_assignment_history(
|
||||
history_store: ConsoleHistoryStore,
|
||||
assignment: AssignmentModel,
|
||||
@@ -260,6 +288,17 @@ def _record_assignment_history(
|
||||
)
|
||||
|
||||
|
||||
def _prune_task_history(
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config: HostAgentConfig,
|
||||
) -> None:
|
||||
try:
|
||||
prune_task_history(metadata_store, timeline, config=config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _configured_device_manager(
|
||||
config_store: DeviceConfigStore,
|
||||
*,
|
||||
|
||||
@@ -8,6 +8,7 @@ from cloud.internal_api.models import AssignmentModel
|
||||
from core.models import Task
|
||||
from host_agent.execution import ExecutionFactories
|
||||
from host_agent.planner_context import bind_planner_execution_context
|
||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -20,6 +21,11 @@ class AssignmentExecutionResult:
|
||||
class AssignmentExecutor:
|
||||
def __init__(self, factories: ExecutionFactories) -> None:
|
||||
self.factories = factories
|
||||
self._progress = TaskProgressHolder()
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None:
|
||||
"""Latest step progress reported by the currently-running assignment."""
|
||||
return self._progress.snapshot()
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -27,6 +33,7 @@ class AssignmentExecutor:
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> AssignmentExecutionResult:
|
||||
self._progress.clear()
|
||||
with bind_planner_execution_context(assignment):
|
||||
if should_stop is not None and should_stop():
|
||||
return AssignmentExecutionResult(
|
||||
@@ -50,6 +57,7 @@ class AssignmentExecutor:
|
||||
) -> AssignmentExecutionResult:
|
||||
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
||||
runner = self.factories.task_runner_factory()
|
||||
runner.on_step_progress = self._progress.update
|
||||
if should_stop is None:
|
||||
completed = runner.run(task)
|
||||
else:
|
||||
|
||||
@@ -16,9 +16,13 @@ from cloud.internal_api.models import (
|
||||
HostEnrollmentResponse,
|
||||
HostTaskSubmissionResponse,
|
||||
LeaseRenewalResponse,
|
||||
TaskProgressModel,
|
||||
TerminalResultResponse,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
_VALID_STEP_STATUSES = frozenset({"running", "completed", "failed"})
|
||||
|
||||
|
||||
class HostAgentAPIError(RuntimeError):
|
||||
@@ -194,19 +198,33 @@ class HostAgentClient:
|
||||
async def renew(
|
||||
self,
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
progress: TaskProgressSnapshot | None = None,
|
||||
) -> LeaseRenewalResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"host_id": self.config.host_id,
|
||||
"task_id": assignment.task_id,
|
||||
"attempt": assignment.attempt,
|
||||
"lease_id": assignment.lease_id,
|
||||
}
|
||||
if progress is not None:
|
||||
step_status = (
|
||||
progress.step_status
|
||||
if progress.step_status in _VALID_STEP_STATUSES
|
||||
else "running"
|
||||
)
|
||||
payload["progress"] = TaskProgressModel(
|
||||
step_index=max(progress.step_index, 0),
|
||||
step_status=step_status,
|
||||
summary=progress.summary[:500],
|
||||
).model_dump(mode="json")
|
||||
response = await self._request(
|
||||
"POST",
|
||||
(
|
||||
f"/internal/v1/hosts/{self.config.host_id}/assignments/"
|
||||
f"{assignment.task_id}/renew"
|
||||
),
|
||||
json={
|
||||
"host_id": self.config.host_id,
|
||||
"task_id": assignment.task_id,
|
||||
"attempt": assignment.attempt,
|
||||
"lease_id": assignment.lease_id,
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
return LeaseRenewalResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ class HostAgentConfig:
|
||||
runtime_host: str = "127.0.0.1"
|
||||
runtime_port: int = 8000
|
||||
dependency_restart_max_attempts: int = 5
|
||||
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
||||
task_retention_max_count: int = 50
|
||||
task_retention_max_age_days: int = 7
|
||||
|
||||
|
||||
def load_host_agent_config(
|
||||
@@ -135,6 +139,23 @@ def load_host_agent_config(
|
||||
dependency_restart_max_attempts=_positive_int(
|
||||
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
|
||||
),
|
||||
task_progress_db_path=Path(
|
||||
values.get(
|
||||
"HOST_AGENT_TASK_PROGRESS_DB_PATH",
|
||||
"host_agent_data/task_progress.sqlite3",
|
||||
).strip()
|
||||
),
|
||||
task_artifact_dir=Path(
|
||||
values.get(
|
||||
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
|
||||
).strip()
|
||||
),
|
||||
task_retention_max_count=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
||||
),
|
||||
task_retention_max_age_days=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
|
||||
),
|
||||
)
|
||||
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
|
||||
raise HostAgentConfigurationError(
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from host_agent.assignment import AssignmentExecutionResult
|
||||
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
|
||||
class InterruptibleAssignmentExecutor(Protocol):
|
||||
@@ -21,6 +22,8 @@ class InterruptibleAssignmentExecutor(Protocol):
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> AssignmentExecutionResult: ...
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
||||
|
||||
|
||||
class LeaseGuard:
|
||||
def __init__(self) -> None:
|
||||
@@ -92,7 +95,9 @@ class ActiveAssignmentRunner:
|
||||
if done:
|
||||
return
|
||||
try:
|
||||
response = await self.client.renew(assignment)
|
||||
response = await self.client.renew(
|
||||
assignment, progress=self.executor.latest_progress()
|
||||
)
|
||||
except StaleLeaseError:
|
||||
guard.mark_lost("lease rejected by control plane")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_MAX_SUMMARY_LENGTH = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskProgressSnapshot:
|
||||
step_index: int
|
||||
step_status: str
|
||||
summary: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskProgressHolder:
|
||||
"""Thread-safe latest-step-progress holder for one in-flight assignment.
|
||||
|
||||
Written by the execution thread (via ``update``, wired as the
|
||||
``TaskRunner.on_step_progress`` callback) and read by the asyncio
|
||||
lease-renewal loop (via ``snapshot``) just before each renewal call.
|
||||
"""
|
||||
|
||||
def __init__(self, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self._now = now or (lambda: datetime.now(UTC))
|
||||
self._lock = threading.Lock()
|
||||
self._snapshot: TaskProgressSnapshot | None = None
|
||||
|
||||
def update(self, step_index: int, step_status: str, summary: str) -> None:
|
||||
if len(summary) > _MAX_SUMMARY_LENGTH:
|
||||
summary = summary[:_MAX_SUMMARY_LENGTH]
|
||||
with self._lock:
|
||||
self._snapshot = TaskProgressSnapshot(
|
||||
step_index=step_index,
|
||||
step_status=step_status,
|
||||
summary=summary,
|
||||
updated_at=self._now(),
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._snapshot = None
|
||||
|
||||
def snapshot(self) -> TaskProgressSnapshot | None:
|
||||
with self._lock:
|
||||
return self._snapshot
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def prune_task_history(
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
*,
|
||||
config: HostAgentConfig,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Delete tasks beyond the configured retention window/count.
|
||||
|
||||
Computes two candidate retained sets -- the last ``max_count`` tasks
|
||||
and tasks younger than ``max_age_days`` -- and keeps whichever set
|
||||
is smaller (i.e. the more restrictive bound always wins).
|
||||
|
||||
Returns the number of tasks pruned.
|
||||
"""
|
||||
reference = now or datetime.now(UTC)
|
||||
tasks = metadata_store.list_tasks()
|
||||
|
||||
keep_by_count = {task["id"] for task in tasks[: config.task_retention_max_count]}
|
||||
cutoff = reference - timedelta(days=config.task_retention_max_age_days)
|
||||
keep_by_age = {
|
||||
task["id"] for task in tasks if _parse_timestamp(task["created_at"]) >= cutoff
|
||||
}
|
||||
keep_ids = keep_by_count if len(keep_by_count) <= len(keep_by_age) else keep_by_age
|
||||
|
||||
pruned = 0
|
||||
for task in tasks:
|
||||
if task["id"] not in keep_ids:
|
||||
_safe_delete(timeline, metadata_store, task["id"])
|
||||
pruned += 1
|
||||
return pruned
|
||||
|
||||
|
||||
def _safe_delete(
|
||||
timeline: Timeline, metadata_store: TaskMetadataStore, task_id: str
|
||||
) -> None:
|
||||
try:
|
||||
timeline.delete_task(task_id)
|
||||
except Exception:
|
||||
logger.debug("timeline delete failed for task %s", task_id, exc_info=True)
|
||||
try:
|
||||
metadata_store.delete_task(task_id)
|
||||
except Exception:
|
||||
logger.debug("metadata delete failed for task %s", task_id, exc_info=True)
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -34,6 +35,7 @@ class AgentStatusTracker:
|
||||
self._current_assignment: _CurrentAssignment | None = None
|
||||
self._last_heartbeat: _LastHeartbeat | None = None
|
||||
self._host_policy: dict[str, Any] | None = None
|
||||
self._latest_progress: TaskProgressSnapshot | None = None
|
||||
|
||||
def mark_assignment_started(self, assignment: AssignmentModel) -> None:
|
||||
with self._lock:
|
||||
@@ -48,6 +50,11 @@ class AgentStatusTracker:
|
||||
def mark_assignment_finished(self) -> None:
|
||||
with self._lock:
|
||||
self._current_assignment = None
|
||||
self._latest_progress = None
|
||||
|
||||
def set_latest_progress(self, snapshot: TaskProgressSnapshot | None) -> None:
|
||||
with self._lock:
|
||||
self._latest_progress = snapshot
|
||||
|
||||
def mark_heartbeat(self, *, ok: bool, device_count: int) -> None:
|
||||
with self._lock:
|
||||
@@ -74,6 +81,7 @@ class AgentStatusTracker:
|
||||
with self._lock:
|
||||
current_assignment = self._current_assignment
|
||||
last_heartbeat = self._last_heartbeat
|
||||
progress = self._latest_progress
|
||||
return {
|
||||
"current_assignment": (
|
||||
{
|
||||
@@ -96,4 +104,14 @@ class AgentStatusTracker:
|
||||
else None
|
||||
),
|
||||
"host_policy": self._host_policy.copy() if self._host_policy else None,
|
||||
"progress": (
|
||||
{
|
||||
"step_index": progress.step_index,
|
||||
"step_status": progress.step_status,
|
||||
"summary": progress.summary,
|
||||
"updated_at": progress.updated_at.isoformat(),
|
||||
}
|
||||
if progress is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from html import escape as _escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jinja2
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentEnrollmentClient
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.devices import register_local_device, unregister_local_device
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityState, HostIdentityStore
|
||||
from host_agent.local_account import LocalAccountState, LocalAccountStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.auth import (
|
||||
SessionManager,
|
||||
@@ -23,89 +26,28 @@ from host_agent.web.auth import (
|
||||
change_password,
|
||||
)
|
||||
from storage.device_config import DeviceConfigStore
|
||||
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"})
|
||||
|
||||
_CSS = """
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
|
||||
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
|
||||
header nav { display: inline; margin-left: 1.5rem; }
|
||||
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
|
||||
header a { color: #fff; text-decoration: none; }
|
||||
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
|
||||
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
.error { color: #b00020; }
|
||||
.notice { color: #1b5e20; }
|
||||
"""
|
||||
_ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
|
||||
def escape(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return _escape(str(value), quote=True)
|
||||
|
||||
|
||||
def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
|
||||
nav = ""
|
||||
if session is not None:
|
||||
nav = f"""
|
||||
<nav>
|
||||
<a href="/">Status</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(session.csrf_token)}">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
"""
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{escape(title)}</title>
|
||||
<style>{_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>Host Agent Console</strong>
|
||||
{nav}
|
||||
</header>
|
||||
<main>
|
||||
{body_html}
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _login_page(
|
||||
*, account: LocalAccountState | None, error: str | None = None
|
||||
def _render(
|
||||
template_name: str,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
**context: Any,
|
||||
) -> HTMLResponse:
|
||||
if account is None:
|
||||
body = """
|
||||
<h1>Login</h1>
|
||||
<p>No local account exists yet. Run <code>device-host-agent setup</code>
|
||||
on this machine to create one before logging in to the console.</p>
|
||||
"""
|
||||
return HTMLResponse(_chrome("Login", body, session=None))
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
body = f"""
|
||||
<h1>Login</h1>
|
||||
{error_html}
|
||||
<form method="post" action="/login">
|
||||
<label>Username <input type="text" name="username" required></label><br>
|
||||
<label>Password <input type="password" name="password" required></label><br>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
"""
|
||||
return HTMLResponse(_chrome("Login", body, session=None))
|
||||
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:
|
||||
@@ -119,199 +61,52 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
|
||||
return device.status
|
||||
|
||||
|
||||
def _dashboard_body(
|
||||
*,
|
||||
identity: HostIdentityState | None,
|
||||
snapshot: dict[str, Any],
|
||||
devices: list[Any],
|
||||
config: HostAgentConfig,
|
||||
) -> str:
|
||||
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
|
||||
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
|
||||
screenshot_path = record.get("screenshot_path")
|
||||
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 _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")
|
||||
busy_device_id = assignment["device_id"] if assignment else None
|
||||
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"
|
||||
)
|
||||
policy_text = (
|
||||
"revision {revision}; self-submission {self_submission}; "
|
||||
"max active tasks {max_active}; daily token budget {daily_budget}".format(
|
||||
revision=policy["revision"],
|
||||
self_submission="enabled" if policy["self_submission_enabled"] else "disabled",
|
||||
max_active=policy["max_active_tasks"] or "unlimited",
|
||||
daily_budget=policy["daily_token_budget"] or "unmetered",
|
||||
)
|
||||
if policy
|
||||
else "no Cloud policy cached"
|
||||
)
|
||||
device_rows = "".join(
|
||||
f"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
|
||||
f"<td>{escape(device.driver_type)}</td>"
|
||||
f"<td>{escape(_device_display_status(device, busy_device_id=busy_device_id))}</td></tr>"
|
||||
for device in devices
|
||||
)
|
||||
return f"""
|
||||
<h1>Status</h1>
|
||||
<section>
|
||||
<h2>Enrollment</h2>
|
||||
<p>Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}</p>
|
||||
<p>Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}</p>
|
||||
<p>Control plane: {escape(config.control_plane_url)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Heartbeat</h2>
|
||||
<p id="last-heartbeat">{escape(heartbeat_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Cloud policy</h2>
|
||||
<p id="host-policy">{escape(policy_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Current assignment</h2>
|
||||
<p id="current-assignment">{escape(assignment_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
|
||||
<tbody id="device-status-body">{device_rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<script>
|
||||
(function () {{
|
||||
function render(data) {{
|
||||
var hb = data.status.last_heartbeat;
|
||||
document.getElementById("last-heartbeat").textContent = hb
|
||||
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
|
||||
: "never";
|
||||
var current = data.status.current_assignment;
|
||||
document.getElementById("current-assignment").textContent = current
|
||||
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
|
||||
: "none";
|
||||
var policy = data.status.host_policy;
|
||||
document.getElementById("host-policy").textContent = policy
|
||||
? "revision " + policy.revision + "; self-submission "
|
||||
+ (policy.self_submission_enabled ? "enabled" : "disabled")
|
||||
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
|
||||
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
|
||||
: "no Cloud policy cached";
|
||||
var body = document.getElementById("device-status-body");
|
||||
body.innerHTML = "";
|
||||
data.devices.forEach(function (device) {{
|
||||
var row = document.createElement("tr");
|
||||
["id", "name", "driver_type", "status"].forEach(function (key) {{
|
||||
var cell = document.createElement("td");
|
||||
cell.textContent = device[key] || "";
|
||||
row.appendChild(cell);
|
||||
}});
|
||||
body.appendChild(row);
|
||||
}});
|
||||
}}
|
||||
function poll() {{
|
||||
fetch("/api/status", {{ credentials: "same-origin" }})
|
||||
.then(function (response) {{ return response.ok ? response.json() : null; }})
|
||||
.then(function (data) {{ if (data) render(data); }})
|
||||
.catch(function () {{}});
|
||||
}}
|
||||
setInterval(poll, 5000);
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _devices_body(
|
||||
*,
|
||||
devices: list[dict[str, Any]],
|
||||
csrf_token: str,
|
||||
edit_record: dict[str, Any] | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
rows = "".join(
|
||||
f"""
|
||||
<tr>
|
||||
<td>{escape(device["device_id"])}</td>
|
||||
<td>{escape(device["name"] or "")}</td>
|
||||
<td>{escape(device["driver_type"])}</td>
|
||||
<td>{escape(device["cloud_device_id"] or "")}</td>
|
||||
<td>
|
||||
<a href="/devices?edit={escape(device["device_id"])}">Edit</a>
|
||||
<form class="inline" method="post" action="/devices/remove">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<input type="hidden" name="device_id" value="{escape(device["device_id"])}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
for device in devices
|
||||
)
|
||||
form_device_id = escape(edit_record["device_id"]) if edit_record else ""
|
||||
form_name = escape(edit_record["name"] or "") if edit_record else ""
|
||||
form_driver_type = escape(edit_record["driver_type"]) if edit_record else "wda"
|
||||
form_connection_info = (
|
||||
escape(json.dumps(edit_record["connection_info"])) if edit_record else "{}"
|
||||
)
|
||||
return f"""
|
||||
<h1>Devices</h1>
|
||||
{error_html}
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
<h2>{"Edit device" if edit_record else "Add device"}</h2>
|
||||
<form method="post" action="/devices/save">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<label>Device ID <input type="text" name="device_id" value="{form_device_id}" required></label><br>
|
||||
<label>Name <input type="text" name="name" value="{form_name}"></label><br>
|
||||
<label>Driver type <input type="text" name="driver_type" value="{form_driver_type}" required></label><br>
|
||||
<label>Connection info (JSON)<br>
|
||||
<textarea name="connection_info" rows="3" cols="50">{form_connection_info}</textarea>
|
||||
</label><br>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
|
||||
def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
|
||||
message_html = f'<p class="notice">{escape(message)}</p>' if message else ""
|
||||
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
return f"""
|
||||
<h1>Account</h1>
|
||||
{message_html}
|
||||
{error_html}
|
||||
<form method="post" action="/account">
|
||||
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
|
||||
<label>Current password <input type="password" name="current_password" required></label><br>
|
||||
<label>New password <input type="password" name="new_password" required></label><br>
|
||||
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
|
||||
def _history_body(entries: list[dict[str, Any]]) -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td>{escape(entry['occurred_at'])}</td><td>{escape(entry['kind'])}</td>"
|
||||
f"<td>{escape(entry['summary'])}</td></tr>"
|
||||
for entry in entries
|
||||
)
|
||||
return f"""
|
||||
<h1>History</h1>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
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(
|
||||
@@ -325,6 +120,9 @@ def create_console_app(
|
||||
status_tracker: AgentStatusTracker,
|
||||
session_manager: SessionManager,
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
executor: AssignmentExecutor | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Host Agent Console")
|
||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||
@@ -360,13 +158,13 @@ def create_console_app(
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page() -> HTMLResponse:
|
||||
account = await asyncio.to_thread(local_account_store.load)
|
||||
return _login_page(account=account)
|
||||
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 _login_page(account=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", ""))
|
||||
@@ -374,7 +172,13 @@ def create_console_app(
|
||||
attempt_login, local_account_store, username=username, password=password
|
||||
)
|
||||
if not ok:
|
||||
return _login_page(account=account, error="Invalid username or password.")
|
||||
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(
|
||||
@@ -405,17 +209,47 @@ def create_console_app(
|
||||
) -> HTMLResponse:
|
||||
identity = await asyncio.to_thread(identity_store.load)
|
||||
snapshot = status_tracker.snapshot()
|
||||
devices = manager.list_devices()
|
||||
body = _dashboard_body(
|
||||
identity=identity, snapshot=snapshot, devices=devices, config=config
|
||||
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)
|
||||
return _render(
|
||||
"dashboard.html",
|
||||
title="Status",
|
||||
session=session,
|
||||
identity=identity,
|
||||
devices=devices,
|
||||
config=config,
|
||||
**texts,
|
||||
)
|
||||
return HTMLResponse(_chrome("Status", body, session=session))
|
||||
|
||||
@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 = [
|
||||
@@ -439,13 +273,19 @@ def create_console_app(
|
||||
edit_record = (
|
||||
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
|
||||
)
|
||||
body = _devices_body(
|
||||
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,
|
||||
)
|
||||
return HTMLResponse(_chrome("Devices", body, session=session))
|
||||
|
||||
@app.post("/devices/save")
|
||||
async def devices_save(
|
||||
@@ -491,14 +331,16 @@ def create_console_app(
|
||||
|
||||
if error is not None:
|
||||
devices = await asyncio.to_thread(config_store.list)
|
||||
body = _devices_body(
|
||||
return _render(
|
||||
"devices.html",
|
||||
title="Devices",
|
||||
session=session,
|
||||
devices=devices,
|
||||
csrf_token=session.csrf_token,
|
||||
edit_record=None,
|
||||
connection_info_json="{}",
|
||||
error=error,
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Devices", body, session=session), status_code=400
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(url="/devices", status_code=303)
|
||||
|
||||
@@ -519,8 +361,14 @@ def create_console_app(
|
||||
async def account_page(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
body = _account_body(csrf_token=session.csrf_token, message=None, error=None)
|
||||
return HTMLResponse(_chrome("Account", body, session=session))
|
||||
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(
|
||||
@@ -532,13 +380,14 @@ def create_console_app(
|
||||
new_password = str(form.get("new_password", ""))
|
||||
confirm_password = str(form.get("confirm_password", ""))
|
||||
if not new_password or new_password != confirm_password:
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message=None,
|
||||
error="New password and confirmation must match.",
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Account", body, session=session), status_code=400
|
||||
status_code=400,
|
||||
)
|
||||
ok = await asyncio.to_thread(
|
||||
change_password,
|
||||
@@ -547,27 +396,105 @@ def create_console_app(
|
||||
new_password=new_password,
|
||||
)
|
||||
if not ok:
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message=None,
|
||||
error="Current password is incorrect.",
|
||||
status_code=400,
|
||||
)
|
||||
return HTMLResponse(
|
||||
_chrome("Account", body, session=session), status_code=400
|
||||
)
|
||||
body = _account_body(
|
||||
return _render(
|
||||
"account.html",
|
||||
title="Account",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
message="Password updated.",
|
||||
error=None,
|
||||
)
|
||||
return HTMLResponse(_chrome("Account", body, session=session))
|
||||
|
||||
@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)
|
||||
body = _history_body(entries)
|
||||
return HTMLResponse(_chrome("History", body, session=session))
|
||||
return _render(
|
||||
"history.html",
|
||||
title="History",
|
||||
session=session,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
@app.get("/tasks", response_class=HTMLResponse)
|
||||
async def tasks_page(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
tasks_list = await asyncio.to_thread(metadata_store.list_tasks)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
title="Tasks",
|
||||
session=session,
|
||||
tasks=tasks_list,
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
async def task_detail_page(
|
||||
task_id: str,
|
||||
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 = [
|
||||
(key, task[key])
|
||||
for key in (
|
||||
"id",
|
||||
"goal",
|
||||
"device_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
if task.get(key) is not None
|
||||
]
|
||||
timeline_steps = [
|
||||
{
|
||||
"index": record.get("index", ""),
|
||||
"timestamp": record.get("timestamp", ""),
|
||||
"prompt": record.get("prompt") or "",
|
||||
"tool_call_text": (
|
||||
json.dumps(record.get("tool_call"), ensure_ascii=False)
|
||||
if record.get("tool_call")
|
||||
else ""
|
||||
),
|
||||
"result_text": (
|
||||
json.dumps(record.get("result"), ensure_ascii=False)
|
||||
if record.get("result")
|
||||
else ""
|
||||
),
|
||||
"screenshot_src": _screenshot_data_uri(record),
|
||||
}
|
||||
for record in timeline_records
|
||||
]
|
||||
return _render(
|
||||
"task_detail.html",
|
||||
title=f"Task {task_id}",
|
||||
session=session,
|
||||
task=task,
|
||||
task_rows=task_rows,
|
||||
timeline_steps=timeline_steps,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Account</h1>
|
||||
{% if message %}
|
||||
<p class="notice">{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/account">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Current password <input type="password" name="current_password" required></label><br>
|
||||
<label>New password <input type="password" name="new_password" required></label><br>
|
||||
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }}</title>
|
||||
<style>{% block styles %}body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
|
||||
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
|
||||
header nav { display: inline; margin-left: 1.5rem; }
|
||||
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
|
||||
header a { color: #fff; text-decoration: none; }
|
||||
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
|
||||
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
.error { color: #b00020; }
|
||||
.notice { color: #1b5e20; }{% endblock %}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>Host Agent Console</strong>
|
||||
{% block nav %}{% if session %}
|
||||
<nav>
|
||||
<a href="/">Status</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/tasks">Tasks</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
{% endif %}{% endblock %}
|
||||
</header>
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Status</h1>
|
||||
<section>
|
||||
<h2>Enrollment</h2>
|
||||
<p>Host ID: {{ identity.host_id if identity else "" or "not enrolled" }}</p>
|
||||
<p>Agent instance ID: {{ identity.agent_instance_id if identity else "" or "unknown" }}</p>
|
||||
<p>Control plane: {{ config.control_plane_url }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Heartbeat</h2>
|
||||
<p id="last-heartbeat">{{ heartbeat_text }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Cloud policy</h2>
|
||||
<p id="host-policy">{{ policy_text }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Current assignment</h2>
|
||||
<p id="current-assignment">{{ assignment_text }}</p>
|
||||
<p id="current-progress">{{ progress_text }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
|
||||
<tbody id="device-status-body">{% for device in devices %}<tr><td>{{ device.id }}</td><td>{{ device.name or "" }}</td><td>{{ device.driver_type }}</td><td>{{ device.display_status }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% raw %}<script>
|
||||
(function () {
|
||||
function render(data) {
|
||||
var hb = data.status.last_heartbeat;
|
||||
document.getElementById("last-heartbeat").textContent = hb
|
||||
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
|
||||
: "never";
|
||||
var current = data.status.current_assignment;
|
||||
document.getElementById("current-assignment").textContent = current
|
||||
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
|
||||
: "none";
|
||||
var progress = data.status.progress;
|
||||
document.getElementById("current-progress").textContent = progress
|
||||
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
|
||||
: "";
|
||||
var policy = data.status.host_policy;
|
||||
document.getElementById("host-policy").textContent = policy
|
||||
? "revision " + policy.revision + "; self-submission "
|
||||
+ (policy.self_submission_enabled ? "enabled" : "disabled")
|
||||
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
|
||||
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
|
||||
: "no Cloud policy cached";
|
||||
var body = document.getElementById("device-status-body");
|
||||
body.innerHTML = "";
|
||||
data.devices.forEach(function (device) {
|
||||
var row = document.createElement("tr");
|
||||
["id", "name", "driver_type", "status"].forEach(function (key) {
|
||||
var cell = document.createElement("td");
|
||||
cell.textContent = device[key] || "";
|
||||
row.appendChild(cell);
|
||||
});
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
function poll() {
|
||||
fetch("/api/status", { credentials: "same-origin" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (data) { if (data) render(data); })
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>{% endraw %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Devices</h1>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
|
||||
<tbody>{% for device in devices %}
|
||||
<tr>
|
||||
<td>{{ device["device_id"] }}</td>
|
||||
<td>{{ device["name"] or "" }}</td>
|
||||
<td>{{ device["driver_type"] }}</td>
|
||||
<td>{{ device["cloud_device_id"] or "" }}</td>
|
||||
<td>
|
||||
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
|
||||
<form class="inline" method="post" action="/devices/remove">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="device_id" value="{{ device["device_id"] }}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
|
||||
<form method="post" action="/devices/save">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
|
||||
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
|
||||
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br>
|
||||
<label>Connection info (JSON)<br>
|
||||
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
|
||||
</label><br>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>History</h1>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
|
||||
<tbody>{% for entry in entries %}<tr><td>{{ entry["occurred_at"] }}</td><td>{{ entry["kind"] }}</td><td>{{ entry["summary"] }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block nav %}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Login</h1>
|
||||
{% if not account %}
|
||||
<p>No local account exists yet. Run <code>device-host-agent setup</code>
|
||||
on this machine to create one before logging in to the console.</p>
|
||||
{% else %}
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>Username <input type="text" name="username" required></label><br>
|
||||
<label>Password <input type="password" name="password" required></label><br>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Task {{ task.get("id") or "" }}</h1>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
<h2>Timeline</h2>
|
||||
{% if not timeline_steps %}
|
||||
<p>No timeline records.</p>
|
||||
{% else %}
|
||||
{% for step in timeline_steps %}
|
||||
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
|
||||
<p><strong>Step {{ step.index }}</strong> — {{ step.timestamp }}</p>
|
||||
<p>Prompt: {{ step.prompt }}</p>
|
||||
<p>Tool call: <code>{{ step.tool_call_text }}</code></p>
|
||||
<p>Result: <code>{{ step.result_text }}</code></p>
|
||||
{% if step.screenshot_src %}<img src="{{ step.screenshot_src }}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Tasks</h1>
|
||||
{% if not tasks %}
|
||||
<p>No tasks recorded.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>{% for task in tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
|
||||
<td>{{ task.get("status") or "" }}</td>
|
||||
<td>{{ task.get("device_id") or "" }}</td>
|
||||
<td>{{ task.get("created_at") or "" }}</td>
|
||||
<td>{{ task.get("updated_at") or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"device-cloud-platform==0.1.0",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
@@ -22,6 +23,9 @@ build-backend = "setuptools.build_meta"
|
||||
where = ["."]
|
||||
include = ["host_agent*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"host_agent.web" = ["templates/*.html"]
|
||||
|
||||
[tool.uv.sources]
|
||||
device-agent-runtime = { workspace = true }
|
||||
device-cloud-platform = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
(function () {
|
||||
function render(data) {
|
||||
var hb = data.status.last_heartbeat;
|
||||
document.getElementById("last-heartbeat").textContent = hb
|
||||
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
|
||||
: "never";
|
||||
var current = data.status.current_assignment;
|
||||
document.getElementById("current-assignment").textContent = current
|
||||
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
|
||||
: "none";
|
||||
var progress = data.status.progress;
|
||||
document.getElementById("current-progress").textContent = progress
|
||||
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
|
||||
: "";
|
||||
var policy = data.status.host_policy;
|
||||
document.getElementById("host-policy").textContent = policy
|
||||
? "revision " + policy.revision + "; self-submission "
|
||||
+ (policy.self_submission_enabled ? "enabled" : "disabled")
|
||||
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
|
||||
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
|
||||
: "no Cloud policy cached";
|
||||
var body = document.getElementById("device-status-body");
|
||||
body.innerHTML = "";
|
||||
data.devices.forEach(function (device) {
|
||||
var row = document.createElement("tr");
|
||||
["id", "name", "driver_type", "status"].forEach(function (key) {
|
||||
var cell = document.createElement("td");
|
||||
cell.textContent = device[key] || "";
|
||||
row.appendChild(cell);
|
||||
});
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
function poll() {
|
||||
fetch("/api/status", { credentials: "same-origin" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (data) { if (data) render(data); })
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Shared fixtures and context factories for Jinja2 template tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.web.app import _ENV
|
||||
from host_agent.web.auth import SessionState
|
||||
|
||||
XSS_PROBE = "<script>alert(1)</script>"
|
||||
_ESCAPED_PROBE = "<script>alert(1)</script>"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env() -> Any:
|
||||
"""The module-level Jinja2 Environment from host_agent.web.app."""
|
||||
return _ENV
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_session() -> SessionState:
|
||||
"""A representative logged-in session."""
|
||||
return SessionState(
|
||||
username="operator",
|
||||
csrf_token="test-csrf-token",
|
||||
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xss_probe() -> str:
|
||||
return XSS_PROBE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_login_context(
|
||||
*, account: Any = None, error: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"title": "Login",
|
||||
"session": None,
|
||||
"account": account,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def make_dashboard_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
devices: list[dict[str, Any]] | None = None,
|
||||
identity: Any = None,
|
||||
heartbeat_text: str = "never",
|
||||
assignment_text: str = "none",
|
||||
progress_text: str = "",
|
||||
policy_text: str = "no Cloud policy cached",
|
||||
config: HostAgentConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if devices is None:
|
||||
devices = [
|
||||
{
|
||||
"id": "dev-1",
|
||||
"name": "Pixel 8",
|
||||
"driver_type": "wda",
|
||||
"display_status": "connected",
|
||||
},
|
||||
{
|
||||
"id": "dev-2",
|
||||
"name": "iPhone 15",
|
||||
"driver_type": "wda",
|
||||
"display_status": "busy",
|
||||
},
|
||||
]
|
||||
if config is None:
|
||||
config = HostAgentConfig(control_plane_url="http://localhost:8080")
|
||||
return {
|
||||
"title": "Status",
|
||||
"session": session,
|
||||
"identity": identity,
|
||||
"devices": devices,
|
||||
"config": config,
|
||||
"heartbeat_text": heartbeat_text,
|
||||
"assignment_text": assignment_text,
|
||||
"progress_text": progress_text,
|
||||
"policy_text": policy_text,
|
||||
}
|
||||
|
||||
|
||||
def make_devices_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
devices: list[dict[str, Any]] | None = None,
|
||||
edit_record: dict[str, Any] | None = None,
|
||||
connection_info_json: str = "{}",
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if devices is None:
|
||||
devices = [
|
||||
{
|
||||
"device_id": "dev-1",
|
||||
"name": "Pixel 8",
|
||||
"driver_type": "wda",
|
||||
"cloud_device_id": "cloud-1",
|
||||
"connection_info": {"port": 8100},
|
||||
},
|
||||
]
|
||||
if edit_record is None:
|
||||
edit_record = {
|
||||
"device_id": "dev-1",
|
||||
"name": "Pixel 8",
|
||||
"driver_type": "wda",
|
||||
"connection_info": {"port": 8100},
|
||||
}
|
||||
connection_info_json = '{"port": 8100}'
|
||||
return {
|
||||
"title": "Devices",
|
||||
"session": session,
|
||||
"devices": devices,
|
||||
"csrf_token": session.csrf_token,
|
||||
"edit_record": edit_record,
|
||||
"connection_info_json": connection_info_json,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def make_account_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
message: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"title": "Account",
|
||||
"session": session,
|
||||
"csrf_token": session.csrf_token,
|
||||
"message": message,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def make_history_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
entries: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if entries is None:
|
||||
entries = [
|
||||
{
|
||||
"occurred_at": "2026-01-01T00:00:00Z",
|
||||
"kind": "assignment",
|
||||
"summary": "Task abc-123 started on dev-1",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "History",
|
||||
"session": session,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def make_tasks_list_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if tasks is None:
|
||||
tasks = [
|
||||
{
|
||||
"id": "task-001",
|
||||
"status": "completed",
|
||||
"device_id": "dev-1",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:05:00Z",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "Tasks",
|
||||
"session": session,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
|
||||
def make_task_detail_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
task: dict[str, Any] | None = None,
|
||||
task_rows: list[tuple[str, Any]] | None = None,
|
||||
timeline_steps: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if task is None:
|
||||
task = {
|
||||
"id": "task-001",
|
||||
"goal": "Open settings",
|
||||
"device_id": "dev-1",
|
||||
"status": "completed",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:05:00Z",
|
||||
}
|
||||
if task_rows is None:
|
||||
task_rows = [
|
||||
("id", task["id"]),
|
||||
("goal", task["goal"]),
|
||||
("device_id", task["device_id"]),
|
||||
("status", task["status"]),
|
||||
]
|
||||
if timeline_steps is None:
|
||||
timeline_steps = [
|
||||
{
|
||||
"index": 0,
|
||||
"timestamp": "2026-01-01T00:01:00Z",
|
||||
"prompt": "Tap the Settings icon",
|
||||
"tool_call_text": '{"action": "tap", "x": 100, "y": 200}',
|
||||
"result_text": '{"ok": true}',
|
||||
"screenshot_src": None,
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "Task task-001",
|
||||
"session": session,
|
||||
"task": task,
|
||||
"task_rows": task_rows,
|
||||
"timeline_steps": timeline_steps,
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Render-smoke, XSS-probe, byte-identity, and autoescape tests for the
|
||||
Host Agent console Jinja2 templates (Tasks 6.3-6.7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.web.auth import SessionState
|
||||
|
||||
from .conftest import (
|
||||
XSS_PROBE,
|
||||
_ESCAPED_PROBE,
|
||||
make_account_context,
|
||||
make_dashboard_context,
|
||||
make_devices_context,
|
||||
make_history_context,
|
||||
make_login_context,
|
||||
make_task_detail_context,
|
||||
make_tasks_list_context,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.3 Render-smoke tests (one per template)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_renders(env) -> None:
|
||||
html = env.get_template("login.html").render(
|
||||
**make_login_context(account={"username": "operator"})
|
||||
)
|
||||
assert '<form method="post" action="/login">' in html
|
||||
|
||||
|
||||
def test_dashboard_renders(env, sample_session) -> None:
|
||||
html = env.get_template("dashboard.html").render(
|
||||
**make_dashboard_context(sample_session)
|
||||
)
|
||||
assert 'id="last-heartbeat"' in html
|
||||
|
||||
|
||||
def test_devices_renders(env, sample_session) -> None:
|
||||
html = env.get_template("devices.html").render(
|
||||
**make_devices_context(sample_session)
|
||||
)
|
||||
assert '<form method="post" action="/devices/save">' in html
|
||||
|
||||
|
||||
def test_account_renders(env, sample_session) -> None:
|
||||
html = env.get_template("account.html").render(
|
||||
**make_account_context(sample_session)
|
||||
)
|
||||
assert '<form method="post" action="/account">' in html
|
||||
|
||||
|
||||
def test_history_renders(env, sample_session) -> None:
|
||||
html = env.get_template("history.html").render(
|
||||
**make_history_context(sample_session)
|
||||
)
|
||||
assert "<table>" in html
|
||||
|
||||
|
||||
def test_tasks_list_renders(env, sample_session) -> None:
|
||||
html = env.get_template("tasks_list.html").render(
|
||||
**make_tasks_list_context(sample_session)
|
||||
)
|
||||
assert "<h1>Tasks</h1>" in html
|
||||
|
||||
|
||||
def test_task_detail_renders(env, sample_session) -> None:
|
||||
html = env.get_template("task_detail.html").render(
|
||||
**make_task_detail_context(sample_session)
|
||||
)
|
||||
assert "<h2>Timeline</h2>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
|
||||
# string fields set to <script>alert(1)</script>)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TEMPLATES_WITH_XSS = ["dashboard", "devices", "history", "tasks_list", "task_detail"]
|
||||
|
||||
|
||||
def _xss_context(template: str, session: SessionState) -> dict[str, Any]:
|
||||
"""Build a context where every operator-influenced string field is the
|
||||
XSS probe string."""
|
||||
if template == "dashboard":
|
||||
return make_dashboard_context(
|
||||
session,
|
||||
devices=[
|
||||
{
|
||||
"id": XSS_PROBE,
|
||||
"name": XSS_PROBE,
|
||||
"driver_type": XSS_PROBE,
|
||||
"display_status": XSS_PROBE,
|
||||
}
|
||||
],
|
||||
heartbeat_text=XSS_PROBE,
|
||||
assignment_text=XSS_PROBE,
|
||||
progress_text=XSS_PROBE,
|
||||
policy_text=XSS_PROBE,
|
||||
)
|
||||
if template == "devices":
|
||||
return make_devices_context(
|
||||
session,
|
||||
devices=[
|
||||
{
|
||||
"device_id": XSS_PROBE,
|
||||
"name": XSS_PROBE,
|
||||
"driver_type": XSS_PROBE,
|
||||
"cloud_device_id": XSS_PROBE,
|
||||
"connection_info": {},
|
||||
}
|
||||
],
|
||||
edit_record={
|
||||
"device_id": XSS_PROBE,
|
||||
"name": XSS_PROBE,
|
||||
"driver_type": XSS_PROBE,
|
||||
"connection_info": {},
|
||||
},
|
||||
connection_info_json=XSS_PROBE,
|
||||
error=XSS_PROBE,
|
||||
)
|
||||
if template == "history":
|
||||
return make_history_context(
|
||||
session,
|
||||
entries=[
|
||||
{
|
||||
"occurred_at": XSS_PROBE,
|
||||
"kind": XSS_PROBE,
|
||||
"summary": XSS_PROBE,
|
||||
}
|
||||
],
|
||||
)
|
||||
if template == "tasks_list":
|
||||
return make_tasks_list_context(
|
||||
session,
|
||||
tasks=[
|
||||
{
|
||||
"id": XSS_PROBE,
|
||||
"status": XSS_PROBE,
|
||||
"device_id": XSS_PROBE,
|
||||
"created_at": XSS_PROBE,
|
||||
"updated_at": XSS_PROBE,
|
||||
}
|
||||
],
|
||||
)
|
||||
if template == "task_detail":
|
||||
return make_task_detail_context(
|
||||
session,
|
||||
task={
|
||||
"id": XSS_PROBE,
|
||||
"goal": XSS_PROBE,
|
||||
"device_id": XSS_PROBE,
|
||||
"status": XSS_PROBE,
|
||||
"created_at": XSS_PROBE,
|
||||
"updated_at": XSS_PROBE,
|
||||
},
|
||||
task_rows=[
|
||||
("id", XSS_PROBE),
|
||||
("goal", XSS_PROBE),
|
||||
("status", XSS_PROBE),
|
||||
],
|
||||
timeline_steps=[
|
||||
{
|
||||
"index": 0,
|
||||
"timestamp": XSS_PROBE,
|
||||
"prompt": XSS_PROBE,
|
||||
"tool_call_text": XSS_PROBE,
|
||||
"result_text": XSS_PROBE,
|
||||
"screenshot_src": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
raise ValueError(f"unknown template {template}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", _TEMPLATES_WITH_XSS)
|
||||
def test_xss_probe_is_escaped(env, sample_session, template: str) -> None:
|
||||
html = env.get_template(f"{template}.html").render(
|
||||
**_xss_context(template, sample_session)
|
||||
)
|
||||
assert _ESCAPED_PROBE in html, f"escaped probe missing from {template}"
|
||||
assert XSS_PROBE not in html, f"raw <script> leaked in {template}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.5 Byte-identity test for dashboard inline <script>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dashboard_script_byte_identity(env, sample_session) -> None:
|
||||
config = HostAgentConfig(control_plane_url="http://localhost:8080")
|
||||
html = env.get_template("dashboard.html").render(
|
||||
title="Status",
|
||||
session=sample_session,
|
||||
identity=None,
|
||||
devices=[],
|
||||
config=config,
|
||||
heartbeat_text="never",
|
||||
assignment_text="none",
|
||||
progress_text="",
|
||||
policy_text="no Cloud policy cached",
|
||||
)
|
||||
start = html.index("<script>")
|
||||
end = html.index("</script>", start) + len("</script>")
|
||||
script_block = html[start:end]
|
||||
baseline = (
|
||||
(Path(__file__).parent / "__baseline__" / "dashboard_script.txt")
|
||||
.read_text(encoding="utf-8")
|
||||
.rstrip()
|
||||
)
|
||||
assert script_block.rstrip() == baseline
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.6 No-autoescape-bypass test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_autoescape_bypass_in_templates() -> None:
|
||||
templates_dir = (
|
||||
Path(__file__).resolve().parents[3] / "host_agent" / "web" / "templates"
|
||||
)
|
||||
forbidden = ["| safe", "{% autoescape false %}", "{% endautoescape %}"]
|
||||
for tpl_path in templates_dir.glob("*.html"):
|
||||
content = tpl_path.read_text(encoding="utf-8")
|
||||
for pattern in forbidden:
|
||||
assert pattern not in content, f"{pattern} found in {tpl_path.name}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6.7 _ENV autoescape configuration test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_autoescape_enabled_for_html(env) -> None:
|
||||
"""The Environment's autoescape policy must escape content rendered
|
||||
through .html templates."""
|
||||
# Verify the autoescape policy is active (truthy or callable).
|
||||
assert env.autoescape is True or callable(env.autoescape)
|
||||
|
||||
# Render the XSS probe through a .html template that interpolates it
|
||||
# (login.html interpolates {{ error }}) and confirm it is escaped.
|
||||
result = env.get_template("login.html").render(
|
||||
title="Login",
|
||||
session=None,
|
||||
account={"username": "operator"},
|
||||
error="<script>alert(1)</script>",
|
||||
)
|
||||
assert "<script>alert(1)</script>" in result
|
||||
assert "<script>alert(1)</script>" not in result
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
LlmProviderSettings,
|
||||
LlmProviderType,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
PlannerDecisionListResponse,
|
||||
PluginRecord,
|
||||
PluginRegistrationPayload,
|
||||
TaskAttempt,
|
||||
@@ -232,6 +234,15 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function getTaskPlannerDecisions(
|
||||
taskId: string,
|
||||
attempt: number,
|
||||
): Promise<PlannerDecisionItem[]> {
|
||||
return request<PlannerDecisionListResponse>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
|
||||
).then((resp) => resp.items);
|
||||
}
|
||||
|
||||
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
|
||||
return request<{ task_id: string }>("/v1/tasks", {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computePlannerHistoryState } from "./plannerHistory";
|
||||
import type { PlannerDecisionItem } from "./types";
|
||||
|
||||
function makeDecision(
|
||||
overrides: Partial<PlannerDecisionItem> = {},
|
||||
): PlannerDecisionItem {
|
||||
return {
|
||||
step_index: 0,
|
||||
attempt: 0,
|
||||
system_prompt: "system",
|
||||
user_prompt: "user",
|
||||
tool_name: "tap",
|
||||
arguments: { target: "button" },
|
||||
created_at: "2026-07-14T00:00:00+00:00",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computePlannerHistoryState", () => {
|
||||
it("returns populated when decisions exist (cloud transport)", () => {
|
||||
const decisions = [
|
||||
makeDecision({ step_index: 0, tool_name: "tap" }),
|
||||
makeDecision({ step_index: 1, tool_name: "swipe" }),
|
||||
];
|
||||
const state = computePlannerHistoryState(decisions, "cloud");
|
||||
expect(state).toEqual({ kind: "populated", decisions });
|
||||
});
|
||||
|
||||
it("returns populated when decisions exist even if transport is direct", () => {
|
||||
const decisions = [makeDecision()];
|
||||
const state = computePlannerHistoryState(decisions, "direct");
|
||||
expect(state).toEqual({ kind: "populated", decisions });
|
||||
});
|
||||
|
||||
it("returns empty_cloud_transport when no decisions but host is cloud-transport", () => {
|
||||
const state = computePlannerHistoryState([], "cloud");
|
||||
expect(state).toEqual({ kind: "empty_cloud_transport" });
|
||||
});
|
||||
|
||||
it("returns direct_transport_hidden when no decisions and host is direct-transport", () => {
|
||||
const state = computePlannerHistoryState([], "direct");
|
||||
expect(state).toEqual({ kind: "direct_transport_hidden" });
|
||||
});
|
||||
|
||||
it("returns unknown_host_transport when host transport is null", () => {
|
||||
const state = computePlannerHistoryState([], null);
|
||||
expect(state).toEqual({ kind: "unknown_host_transport" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PlannerDecisionItem } from "./types";
|
||||
|
||||
export type PlannerHistoryState =
|
||||
| { kind: "populated"; decisions: PlannerDecisionItem[] }
|
||||
| { kind: "empty_cloud_transport" }
|
||||
| { kind: "direct_transport_hidden" }
|
||||
| { kind: "unknown_host_transport" };
|
||||
|
||||
/**
|
||||
* Decide which empty-state or populated-state message to show for a task's
|
||||
* planner decision history, based on whether any decisions were returned and
|
||||
* the assigned host's configured planner transport.
|
||||
*
|
||||
* - **populated**: decisions exist — render them.
|
||||
* - **direct_transport_hidden**: host uses the `direct` transport, which never
|
||||
* sends prompts to Cloud. Show an explicit "not reported" message instead of
|
||||
* an empty list.
|
||||
* - **empty_cloud_transport**: host IS cloud-transport but no decisions have
|
||||
* been persisted yet (task just started or no steps executed).
|
||||
* - **unknown_host_transport**: host not found or transport field not reported.
|
||||
*/
|
||||
export function computePlannerHistoryState(
|
||||
decisions: PlannerDecisionItem[],
|
||||
hostTransport: "direct" | "cloud" | null,
|
||||
): PlannerHistoryState {
|
||||
if (decisions.length > 0) {
|
||||
return { kind: "populated", decisions };
|
||||
}
|
||||
if (hostTransport === "direct") {
|
||||
return { kind: "direct_transport_hidden" };
|
||||
}
|
||||
if (hostTransport === "cloud") {
|
||||
return { kind: "empty_cloud_transport" };
|
||||
}
|
||||
return { kind: "unknown_host_transport" };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatTaskProgress } from "./taskProgress";
|
||||
import type { TaskListItem } from "./types";
|
||||
|
||||
function makeTask(
|
||||
overrides: Partial<TaskListItem> & Pick<TaskListItem, "status">,
|
||||
): TaskListItem {
|
||||
return {
|
||||
id: "task-a",
|
||||
goal: null,
|
||||
workflow_definition_id: null,
|
||||
assigned_device_id: null,
|
||||
assigned_host_id: null,
|
||||
attempt_count: 0,
|
||||
failure_reason: null,
|
||||
target_host_id: null,
|
||||
target_device_id: null,
|
||||
created_at: "2026-07-14T00:00:00+00:00",
|
||||
progress_step_index: null,
|
||||
progress_step_status: null,
|
||||
progress_summary: null,
|
||||
progress_updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatTaskProgress", () => {
|
||||
it("renders step index, status, and summary for an in-progress dispatched task", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 2,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "tapping button",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 2 \u2014 running: tapping button");
|
||||
});
|
||||
|
||||
it("renders step index and status when summary is missing", () => {
|
||||
const task = makeTask({
|
||||
status: "assigned",
|
||||
progress_step_index: 0,
|
||||
progress_step_status: "starting",
|
||||
progress_summary: null,
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 0 \u2014 starting");
|
||||
});
|
||||
|
||||
it("returns null when the task is dispatched but no progress has been reported", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: null,
|
||||
progress_step_status: null,
|
||||
progress_summary: null,
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a dispatched task with undefined progress fields", () => {
|
||||
const task = makeTask({ status: "dispatched" });
|
||||
// Fields default to null via makeTask; simulate the undefined case.
|
||||
delete (task as Partial<TaskListItem>).progress_step_index;
|
||||
delete (task as Partial<TaskListItem>).progress_step_status;
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides stale progress when the task has reached a terminal status (done)", () => {
|
||||
const task = makeTask({
|
||||
status: "done",
|
||||
progress_step_index: 5,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "stale snapshot before terminal",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides stale progress when the task has reached a terminal status (failed)", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
progress_step_index: 3,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "stale snapshot before failure",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides progress for a queued task that has not started executing", () => {
|
||||
const task = makeTask({
|
||||
status: "queued",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: "should not show while queued",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a missing step status even when step index is present", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: null,
|
||||
progress_summary: "partial snapshot",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates a long summary with an ellipsis", () => {
|
||||
const longSummary = "x".repeat(200);
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: longSummary,
|
||||
});
|
||||
const formatted = formatTaskProgress(task);
|
||||
expect(formatted).not.toBeNull();
|
||||
const summaryPart = formatted!.split(": ").slice(1).join(": ");
|
||||
expect(summaryPart.length).toBeLessThan(longSummary.length);
|
||||
expect(summaryPart.endsWith("\u2026")).toBe(true);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before truncating or rendering", () => {
|
||||
const task = makeTask({
|
||||
status: "dispatched",
|
||||
progress_step_index: 1,
|
||||
progress_step_status: "running",
|
||||
progress_summary: " hello world ",
|
||||
});
|
||||
expect(formatTaskProgress(task)).toBe("step 1 \u2014 running: hello world");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TaskListItem, TaskStatus } from "./types";
|
||||
|
||||
/**
|
||||
* Statuses that represent an actively executing assignment, where live
|
||||
* in-progress step information is meaningful to surface.
|
||||
*/
|
||||
const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||
"assigned",
|
||||
"dispatched",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format a task's latest in-progress step snapshot for inline display.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - the task is not actively executing (queued, done, failed) — terminal/queued
|
||||
* status always wins over a stale progress snapshot; or
|
||||
* - no progress has been reported yet (`progress_step_index` is null/undefined
|
||||
* or `progress_step_status` is missing).
|
||||
*
|
||||
* Otherwise returns a compact string like `"step 2 — running: tapping button"`.
|
||||
* The summary is truncated to a display-friendly length with a Unicode ellipsis
|
||||
* when it exceeds the limit.
|
||||
*/
|
||||
export function formatTaskProgress(
|
||||
task: Pick<
|
||||
TaskListItem,
|
||||
| "status"
|
||||
| "progress_step_index"
|
||||
| "progress_step_status"
|
||||
| "progress_summary"
|
||||
>,
|
||||
maxSummaryLength = 120,
|
||||
): string | null {
|
||||
if (!ACTIVE_STATUSES.has(task.status)) return null;
|
||||
if (
|
||||
task.progress_step_index === null ||
|
||||
task.progress_step_index === undefined ||
|
||||
!task.progress_step_status
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const summary = task.progress_summary ?? "";
|
||||
const trimmed = summary.trim();
|
||||
const display =
|
||||
trimmed.length > maxSummaryLength
|
||||
? `${trimmed.slice(0, maxSummaryLength - 1).trimEnd()}\u2026`
|
||||
: trimmed;
|
||||
return `step ${task.progress_step_index} \u2014 ${task.progress_step_status}${
|
||||
display ? `: ${display}` : ""
|
||||
}`;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export interface TaskListItem {
|
||||
target_host_id: string | null;
|
||||
target_device_id: string | null;
|
||||
created_at: string;
|
||||
progress_step_index?: number | null;
|
||||
progress_step_status?: string | null;
|
||||
progress_summary?: string | null;
|
||||
progress_updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskSubmissionPayload {
|
||||
@@ -178,3 +182,17 @@ export interface LlmProviderProfileListResponse {
|
||||
settings: LlmProviderSettings;
|
||||
items: LlmProviderProfile[];
|
||||
}
|
||||
|
||||
export interface PlannerDecisionItem {
|
||||
step_index: number;
|
||||
attempt: number;
|
||||
system_prompt: string;
|
||||
user_prompt: string;
|
||||
tool_name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlannerDecisionListResponse {
|
||||
items: PlannerDecisionItem[];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
getTaskAttempts,
|
||||
getTaskPlannerDecisions,
|
||||
listTasks,
|
||||
listDevices,
|
||||
listHosts,
|
||||
@@ -16,7 +17,10 @@ import type {
|
||||
TaskStatus,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
} from "../types";
|
||||
import { formatTaskProgress } from "../taskProgress";
|
||||
import { computePlannerHistoryState } from "../plannerHistory";
|
||||
|
||||
const props = defineProps<{ canSubmit: boolean }>();
|
||||
|
||||
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
|
||||
const submitting = ref(false);
|
||||
const hosts = ref<HostRecord[]>([]);
|
||||
const devices = ref<DeviceRecord[]>([]);
|
||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||
const plannerLoading = ref(false);
|
||||
const plannerError = ref("");
|
||||
const availableDevices = computed(() =>
|
||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||
);
|
||||
@@ -75,6 +82,7 @@ async function refresh() {
|
||||
if (!stillPresent) {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
|
||||
selectedTask.value = task;
|
||||
attempts.value = [];
|
||||
attemptsError.value = "";
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
attemptsLoading.value = true;
|
||||
|
||||
// Fetch attempts first so we know which attempt numbers exist.
|
||||
let loadedAttempts: TaskAttempt[] = [];
|
||||
try {
|
||||
attempts.value = await getTaskAttempts(task.id);
|
||||
loadedAttempts = await getTaskAttempts(task.id);
|
||||
attempts.value = loadedAttempts;
|
||||
} catch (err) {
|
||||
if (err instanceof CloudApiError && err.status === 404) {
|
||||
// Task was deleted between list and detail load.
|
||||
selectedTask.value = null;
|
||||
await refresh();
|
||||
} else {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
attemptsLoading.value = false;
|
||||
|
||||
// Fetch planner decisions for the latest attempt (if any).
|
||||
const latestAttempt = loadedAttempts.length
|
||||
? Math.max(...loadedAttempts.map((a) => a.attempt))
|
||||
: task.attempt_count > 0
|
||||
? task.attempt_count - 1
|
||||
: null;
|
||||
|
||||
if (latestAttempt !== null) {
|
||||
plannerLoading.value = true;
|
||||
try {
|
||||
plannerDecisions.value = await getTaskPlannerDecisions(task.id, latestAttempt);
|
||||
} catch (err) {
|
||||
// 404 on the task is already handled above; other errors are non-fatal.
|
||||
if (!(err instanceof CloudApiError && err.status === 404)) {
|
||||
handleError(err, "failed to load planner decisions");
|
||||
plannerError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
} finally {
|
||||
plannerLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +208,8 @@ function goNextPage() {
|
||||
function clearSelection() {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
return String(attempt.terminal_result);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTaskProgress = computed(() =>
|
||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||
);
|
||||
|
||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||
if (!selectedTask.value?.assigned_host_id) return null;
|
||||
const host = hosts.value.find(
|
||||
(h) => h.host_id === selectedTask.value?.assigned_host_id,
|
||||
);
|
||||
return host?.planner_transport ?? null;
|
||||
});
|
||||
|
||||
const plannerHistoryState = computed(() =>
|
||||
computePlannerHistoryState(plannerDecisions.value, selectedHostTransport.value),
|
||||
);
|
||||
|
||||
function formatArguments(args: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(args, null, 2);
|
||||
} catch {
|
||||
return String(args);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<div v-if="task.failure_reason" class="dim">
|
||||
{{ task.failure_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-if="formatTaskProgress(task)"
|
||||
class="task-progress"
|
||||
:title="formatTaskProgress(task) ?? ''"
|
||||
>
|
||||
{{ formatTaskProgress(task) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="task.assigned_device_id">
|
||||
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<strong class="text-danger">Failure reason:</strong>
|
||||
{{ selectedTask.failure_reason }}
|
||||
</p>
|
||||
<p v-if="selectedTaskProgress" class="task-progress-detail">
|
||||
<strong>Current step:</strong>
|
||||
<code>{{ selectedTaskProgress }}</code>
|
||||
</p>
|
||||
|
||||
<h3>Attempt history</h3>
|
||||
<div v-if="attemptsLoading" class="muted">loading attempts…</div>
|
||||
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="muted">No attempts recorded for this task yet.</div>
|
||||
|
||||
<h3>LLM interaction history</h3>
|
||||
<div v-if="plannerLoading" class="muted">loading planner decisions…</div>
|
||||
<div v-else-if="plannerError" class="notice error">{{ plannerError }}</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'populated'" class="planner-history">
|
||||
<div
|
||||
v-for="decision in plannerHistoryState.decisions"
|
||||
:key="`${decision.attempt}-${decision.step_index}`"
|
||||
class="planner-decision"
|
||||
>
|
||||
<div class="planner-decision-header">
|
||||
<span class="status-badge queued">step {{ decision.step_index }}</span>
|
||||
<code>{{ decision.tool_name }}</code>
|
||||
<span class="dim">{{ formatTime(decision.created_at) }}</span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>User prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.user_prompt }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.system_prompt">
|
||||
<summary>System prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.system_prompt }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Arguments</summary>
|
||||
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
||||
No planner decisions reported yet for this task.
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'direct_transport_hidden'" class="muted">
|
||||
This host uses the <code>direct</code> planner transport and does not report LLM interactions to Cloud.
|
||||
</div>
|
||||
<div v-else class="muted">
|
||||
Host transport unknown; no planner decisions available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-progress {
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-progress-detail {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.task-progress-detail code {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.planner-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planner-decision {
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.planner-decision-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.planner-prompt {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -121,6 +121,39 @@ HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
|
||||
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
|
||||
entries the console retains before pruning older ones.
|
||||
|
||||
### Task progress storage and retention
|
||||
|
||||
The Host Agent persists step-by-step task execution state (metadata +
|
||||
timeline screenshots) to local SQLite/files on the edge machine. These
|
||||
paths are independent from the Runtime's own `tasks/tasks.sqlite3` and
|
||||
do not collide when both processes run on the same host.
|
||||
|
||||
```text
|
||||
HOST_AGENT_TASK_PROGRESS_DB_PATH=host_agent_data/task_progress.sqlite3
|
||||
HOST_AGENT_TASK_ARTIFACT_DIR=host_agent_data/history
|
||||
HOST_AGENT_TASK_RETENTION_MAX_COUNT=50
|
||||
HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7
|
||||
```
|
||||
|
||||
- `HOST_AGENT_TASK_PROGRESS_DB_PATH` — SQLite path for the Host Agent's
|
||||
task metadata store. Default: `host_agent_data/task_progress.sqlite3`.
|
||||
- `HOST_AGENT_TASK_ARTIFACT_DIR` — directory for timeline screenshots and
|
||||
step artifacts. Default: `host_agent_data/history`.
|
||||
- `HOST_AGENT_TASK_RETENTION_MAX_COUNT` — maximum number of completed
|
||||
tasks to retain. Default: `50`.
|
||||
- `HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS` — maximum age in days for
|
||||
retained tasks. Default: `7`. The more restrictive of count vs. age
|
||||
always wins.
|
||||
|
||||
**Viewing live and historical task progress:**
|
||||
|
||||
- **Host Agent console**: Open `http://127.0.0.1:8765/tasks` for the task
|
||||
list (status, device, timestamps). Click a task ID to see the detail page
|
||||
with full step-by-step timeline and inlined screenshots.
|
||||
- **Cloud console**: The Cloud Console task detail page shows the latest
|
||||
coarse-grained progress badge (step index, status, summary) that the Host
|
||||
Agent piggybacks on each lease renewal.
|
||||
|
||||
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
|
||||
operator-accepted risk: the console has no built-in TLS and no rate
|
||||
limiting, so a non-loopback bind exposes an unencrypted login form to
|
||||
|
||||
@@ -339,6 +339,12 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
|
||||
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
|
||||
操作只写入配置,不会自动 connect。
|
||||
|
||||
如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
|
||||
Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
|
||||
Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
|
||||
指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
|
||||
`npm run build`,不支持热更新。
|
||||
|
||||
## 9. 启动云端受管 Host Agent
|
||||
|
||||
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
|
||||
@@ -423,11 +429,28 @@ http://127.0.0.1:8765
|
||||
`DeviceManager` 上生效,无需重启 Host Agent。
|
||||
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
|
||||
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
|
||||
- **任务进度页面**:`http://127.0.0.1:8765/tasks` 展示本机 Host Agent 上已执行/正在执行
|
||||
的任务列表(状态、设备、时间戳),点击任务 ID 可查看逐步 timeline 含截图。
|
||||
|
||||
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
|
||||
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
|
||||
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
|
||||
|
||||
Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系统,路径与 Runtime 自身的
|
||||
`tasks/tasks.sqlite3` 不冲突:
|
||||
|
||||
```bash
|
||||
# 任务进度持久化路径(默认值,可通过环境变量覆盖)
|
||||
# HOST_AGENT_TASK_PROGRESS_DB_PATH="host_agent_data/task_progress.sqlite3"
|
||||
# HOST_AGENT_TASK_ARTIFACT_DIR="host_agent_data/history"
|
||||
# HOST_AGENT_TASK_RETENTION_MAX_COUNT=50 # 最多保留 50 个任务
|
||||
# HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7 # 超过 7 天的任务自动清理
|
||||
```
|
||||
|
||||
Retention 策略取"数量上限与天数上限中更严格的"——即先按 `max_count` 取最近 N 个、
|
||||
再按 `max_age_days` 过滤掉过老的,最终保留两者中较小的集合。任务完成后,这些记录
|
||||
在 Console 的 `/tasks` 页面可查。
|
||||
|
||||
### 可选:由 Host Agent 托管 Appium 和 Runtime API
|
||||
|
||||
默认情况下 Host Agent **不会**自动启动 Appium 或本地 Runtime API:必须按
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
@@ -0,0 +1,167 @@
|
||||
## Context
|
||||
|
||||
`apps/device-host-agent/host_agent/web/app.py` currently renders five HTML pages (login, status dashboard, devices, account, history) by composing f-string templates with a module-level `escape()` helper (a thin wrapper over `html.escape(..., quote=True)`). A shared `_chrome(title, body_html, session)` function acts as a hand-written base template; a `_CSS` string constant holds the inline stylesheet; the dashboard page embeds a `<script>` polling block whose JS braces are doubled (`{{`/`}}`) to survive f-string parsing.
|
||||
|
||||
This shape was an explicit decision in `openspec/changes/host-agent-local-console/design.md`, titled "Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2", justified at the time by a "small page count" assumption (login, dashboard, devices, history) and the mitigation that "a single small `escape()`-wrapping helper used for every interpolated value" would keep the discipline manageable.
|
||||
|
||||
Two things have changed since that decision was made:
|
||||
|
||||
1. The page count has grown (account was added) and is about to grow again: `openspec/changes/task-execution-progress-visibility` (in flight) plans to add three more Host-Agent-side pages (task list, task detail, timeline). The timeline page in particular is a loop-rendered table of externally-influenced text (task summaries, tool calls, scene/element descriptions).
|
||||
2. Every operator-controllable field (`device.name`, `device.driver_type`, `connection_info`, the upcoming task summaries) is one missed `escape(...)` call away from an XSS sink. The f-string approach offloads escaping discipline onto the author and reviewer of every change to `web/app.py`. This is acceptable when there are four pages written by one author in one PR; it stops being acceptable as the page set and author count grow.
|
||||
|
||||
`device-host-agent`'s current direct dependencies are `device-agent-runtime`, `device-cloud-platform`, `fastapi`, `httpx`, `uvicorn[standard]`. None of these force `jinja2` into the resolved `uv.lock` (starlette's `Jinja2Templates` is an optional extra that the Host Agent does not currently import), so adding Jinja2 is a genuine new direct dependency, not surfacing a transitively-resolved one.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Render every Host Agent console HTML page through a template engine whose autoescape is enabled by configuration, so the XSS-safety of any interpolated value no longer depends on the page author remembering to call `escape()`.
|
||||
- Move the rendering shape closer to what `task-execution-progress-visibility` will need for its task/timeline pages (loops, conditionals, inheritance), so that change does not have to grow the f-string code further and then migrate later.
|
||||
- Preserve the current pages' observable contract byte-for-byte where it matters to a browser or operator: same URLs, same form fields, same redirects, same session/CSRF behavior, same 5-second `/api/status` polling, same visual look.
|
||||
- Keep the rendering stack boring and single-dependency: one template engine, no static-asset pipeline, no SPA tooling, no new framework.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- No new pages, no new routes, no new visual design, no CSS refactor — pages are migrated mechanically.
|
||||
- No extraction of the inline `<style>` into a static `.css` file or pipeline (no `starlette.staticfiles`); the CSS block moves into `base.html` and stays inline. A future change can extract it if the stylesheet grows.
|
||||
- No extraction of the inline dashboard polling `<script>` into a `.js` file; the JS stays in the template, just no longer f-string-escaped.
|
||||
- No change to `host_agent/web/auth.py` (session store, CSRF, PBKDF2 verification).
|
||||
- No change to `HostAgentConfig`, the console lifecycle wiring in `host_agent/app.py`, or the loopback-vs-non-loopback bind semantics.
|
||||
- No change to the `console/` Runtime SPA or the `cloud-console/` SPA; both keep their Vue3 + Vite toolchains untouched.
|
||||
- No move to Starlette's `Jinja2Templates` class — the Host Agent already uses `HTMLResponse(content=...)` and only needs a configured `jinja2.Environment` plus `template.render(...)`. Pulling in `Jinja2Templates` would couple rendering to a Starlette version-specific signature (`TemplateResponse(request, name, context=...)` changed across versions) for no functional gain. The `Environment` is called directly.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Jinja2 as the template engine
|
||||
|
||||
`jinja2` (pure-Python wheel, no C extension, single dependency) is added as a direct dependency of `device-host-agent`. The rendering code constructs a `jinja2.Environment` configured with `autoescape=select_autoescape(["html", "xml"])` and a `FileSystemLoader` pointing at a new `host_agent/web/templates/` directory.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- **Mako / Chameleon / Genshi**: smaller ecosystems, less familiarity, more fragile under modern Python toolchains. Jinja2 is the de facto default for Python server-rendered HTML (Django, Flask, FastAPI docs all use or reference it); picking anything else trades familiarity for no concrete benefit at this scale.
|
||||
- **Starlette's `Jinja2Templates`**: see Non-Goals — rejected because the wrapper buys nothing the Host Agent needs (it is mainly useful when the same app mixes JSON and template responses behind the same routing conventions the Host Agent already has via `HTMLResponse`), and its signature has been unstable across Starlette versions. Calling `Environment.get_template(...).render(...)` directly keeps the rendering code version-agnostic and explicit.
|
||||
- **stdlib `string.Template` + a custom autoescape wrapper**: would reproduce Jinja2 with less ergonomic syntax and no `{% for %}`/`{% if %}`/`{% extends %}` — i.e. would re-introduce the f-string pain with weaker tooling. Rejected.
|
||||
- **Stay with f-strings and add a `bandit`/`semgrep` rule to flag bare interpolations**: lint-based mitigation does not change the mechanism; it adds a rule that has to be maintained and can be bypassed by any non-trivial builder pattern. Rejected in favor of removing the failure mode entirely.
|
||||
|
||||
### D2: One module-level `Environment`, `FileSystemLoader` rooted at `host_agent/web/templates/`
|
||||
|
||||
Templates live in `apps/device-host-agent/host_agent/web/templates/`:
|
||||
|
||||
```
|
||||
host_agent/web/templates/
|
||||
base.html # <!doctype>, <head>, <style>, header/nav, {% block body %}{% endblock %}
|
||||
login.html # {% extends "base.html" %}
|
||||
dashboard.html
|
||||
devices.html
|
||||
account.html
|
||||
history.html
|
||||
```
|
||||
|
||||
A single module-level `_ENV = jinja2.Environment(...)` is created at import time in `host_agent/web/app.py`. Jinja2 compiles templates lazily on first `get_template()` call and caches the compiled bytecode on the `Environment` for the lifetime of the process, so subsequent renders are dict-lookup + context-bind — no recompilation. The `Environment` is thread-safe for read-only use after construction; the console's only writes are during `Environment(...)` construction itself, which happens once.
|
||||
|
||||
`PackageLoader("host_agent.web", "templates")` was considered as an alternative to `FileSystemLoader`. Rejected because `PackageLoader` relies on `importlib.resources` semantics that interact awkwardly with editable/`uv sync` installs during local development (template changes not picked up without reinstall); `FileSystemLoader(__file__).parent / "templates"` resolves correctly under both wheel install and editable workspace layout, and is the pattern Jinja2's own documentation recommends for application code.
|
||||
|
||||
### D3: `autoescape=select_autoescape(["html", "xml"])` rather than `autoescape=True`
|
||||
|
||||
`select_autoescape(["html", "xml"])` enables autoescape for templates whose names end in `.html` or `.xml` and disables it for others. This is the recommended default in Jinja2's documentation. It provides:
|
||||
|
||||
- Automatic HTML escaping for every `{{ value }}` interpolation in `.html` templates, so the spec's XSS-safety property holds without author discipline.
|
||||
- An opt-out path for the rare case where a template intentionally produces HTML markup from trusted Python-side builders (e.g. a pre-rendered fragment) — that case uses `{% autoescape false %}...{% endautoescape %}` or `| safe` filter. No current page needs this; the option is documented for future authors.
|
||||
|
||||
`autoescape=True` (always on) was considered and rejected because it would prevent any future `.txt`/`.csv`/`.json`-flavored template (none planned today, but the option is preserved at zero cost).
|
||||
|
||||
### D4: `base.html` replaces `_chrome()` and `_CSS`
|
||||
|
||||
The current `_chrome(title, body_html, session)` function becomes `base.html`:
|
||||
|
||||
```jinja
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }}</title>
|
||||
<style>{% block styles %}/* …current _CSS content… */{% endblock %}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>Host Agent Console</strong>
|
||||
{% block nav %}{% if session %}
|
||||
<nav>…same nav as today…</nav>
|
||||
{% endif %}{% endblock %}
|
||||
</header>
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Each page template starts with `{% extends "base.html" %}` and overrides `{% block body %}` (and optionally `{% block nav %}` or `{% block styles %}` for the rare page that needs to). The login page overrides `{% block nav %}` to empty (matching today's `_login_page` behavior where `session=None`).
|
||||
|
||||
The module-level `_CSS`, `_chrome`, and `escape` helpers in `host_agent/web/app.py` are removed in the same change; they are fully subsumed by `base.html` + autoescape.
|
||||
|
||||
### D5: Dashboard inline `<script>` moves into the template under `{% raw %}`
|
||||
|
||||
The dashboard's 5-second `/api/status` polling script currently lives inside `_dashboard_body` as an f-string, with every JS `{` and `}` doubled to `{{`/`}}` to escape from f-string interpolation. In Jinja2 the same collision exists (`{{ ... }}` is Jinja2 expression syntax). The script moves verbatim into `dashboard.html` wrapped in `{% raw %}...{% endraw %}`, which instructs Jinja2 to pass the content through without parsing.
|
||||
|
||||
Alternative considered: extract the script into a separate `.js` file served via `starlette.staticfiles`. Rejected for this change because (a) it would require a static-asset mount on the FastAPI sub-app, which D1's Non-Goals explicitly defers, and (b) the script is small and tightly coupled to the dashboard's DOM. If the script grows, the extraction can happen in a later change.
|
||||
|
||||
A regression assertion in tasks.md verifies the script body is byte-identical to the pre-migration version (modulo whitespace).
|
||||
|
||||
### D6: Route handlers call `_ENV.get_template(name).render(context)` and return `HTMLResponse(content=...)`
|
||||
|
||||
Each existing f-string handler becomes:
|
||||
|
||||
```python
|
||||
@app.get("/devices", response_class=HTMLResponse)
|
||||
async def devices_page(request: Request, session: SessionState = Depends(require_session)):
|
||||
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
|
||||
html = _ENV.get_template("devices.html").render(
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
devices=devices,
|
||||
edit_record=edit_record,
|
||||
error=None,
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
```
|
||||
|
||||
The shape mirrors the current code as closely as possible — same `asyncio.to_thread` calls, same data fetched, same single `HTMLResponse` return — so the diff is dominated by "swap f-string for `render(...)`" and reviewing it stays mechanical.
|
||||
|
||||
`TemplateResponse` (Starlette's helper) is not used; see D1's Non-Goals discussion. `HTMLResponse(content=html)` is what the code calls today and keeps working.
|
||||
|
||||
### D7: Forms and CSRF tokens keep using autoescape
|
||||
|
||||
CSRF tokens are random `secrets.token_urlsafe()` strings and contain no HTML metacharacters, so autoescape is a no-op on them semantically. But the spec requirement ("every interpolated field is escaped by mechanism") applies uniformly; CSRF tokens go through the same `{{ csrf_token }}` interpolation as everything else. This keeps the rule simple and means a future change to the token alphabet (e.g. including `=` padding) cannot silently introduce an escaping bug.
|
||||
|
||||
### D8: No template rendering inside `host_agent/web/auth.py`
|
||||
|
||||
`auth.py` deals with session tokens, CSRF, and PBKDF2 verification. It returns booleans, tokens, and `SessionState` objects to `app.py`, never HTML. That boundary is preserved: `app.py` is the only module that touches the `Environment`. This keeps the autoescape contract easy to audit — anything that renders HTML is in one file, and every render call goes through the same configured `Environment`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] A new direct dependency (`jinja2`) widens `device-host-agent`'s supply chain.** → Mitigation: Jinja2 is pure-Python, single wheel, maintained by the Pallets organization (same as Flask/Sphinx/Werkzeug), no C extension, no transitive runtime dependencies beyond `markupsafe` (already transitively present via fastapi/starlette). `uv.lock` gains one direct package + zero new transitive runtime packages.
|
||||
- **[Risk] First-render compile cost on each template (sub-millisecond).** → Accepted: it is paid once per process lifetime, cached thereafter, and the console is a low-traffic single-operator page. No measurable impact on startup time or request latency.
|
||||
- **[Risk] A subtle behavior change slips in during the mechanical migration (a `escape()` call forgotten, a `| safe` filter introduced, a conditional inverted).** → Mitigation: tasks.md requires a regression test that takes the pre-migration HTML of each page (captured via the existing test snapshot or by running the current code) and asserts the post-migration HTML is structurally equivalent; an XSS probe test injects `<script>alert(1)</script>` into every operator-controlled field and asserts the escaped form appears in the output.
|
||||
- **[Trade-off] The dashboard's `<script>` is now inside a `{% raw %}` block rather than extracted into a static asset.]** → Accepted: keeps the change scope tight (no static-asset mount) and the script co-located with the DOM it manipulates. Trade-off recorded for future revisit if the script grows.
|
||||
- **[Trade-off] The original `host-agent-local-console` design's explicit decision against Jinja2 is hereby superseded for the rendering-mechanism question only.]** → Mitigation: recorded as an Open Question to be reconciled when `host-agent-local-console` is archived. Until then, this change's spec captures the autoescape requirement as a new capability (`host-agent-console-template-rendering`) rather than a MODIFIED delta against an unarchived pending spec, matching the precedent set by `task-execution-progress-visibility` and `cloud-planner-proxy` when they needed to reference `host-agent-local-console` / `ai-planner-runtime` before those were archived.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `jinja2>=3.1` to `apps/device-host-agent/pyproject.toml`'s `dependencies`. Run `uv lock --check` after `uv lock` to confirm the lockfile is in sync. Confirm `markupsafe` is the only new transitive package.
|
||||
2. Create `host_agent/web/templates/` with `base.html` and one file per current page. Capture the byte-level structure of each current page (login, dashboard including the inline script, devices, account, history) before migration as a reference for the regression assertion.
|
||||
3. In `host_agent/web/app.py`, construct the module-level `_ENV` and rewrite each route handler per D6. Remove `_chrome`, `_CSS`, `_login_page`, `_dashboard_body`, `_devices_body`, `_account_body`, `_history_body`, and the `escape` helper.
|
||||
4. Add `apps/device-host-agent/tests/host_agent/web/test_templates.py` covering: (a) each template renders without raising for representative contexts; (b) the XSS probe appears HTML-escaped in every rendered page that interpolates an operator-controlled field; (c) the dashboard `<script>` body is byte-identical to the pre-migration reference.
|
||||
5. Run the existing Host Agent test suite (`uv run --package device-host-agent pytest`) and the root non-integration suite; confirm no regressions.
|
||||
6. Run `ruff check --fix`, `ruff format`, and `python -m compileall` over the changed files.
|
||||
7. Run `openspec validate host-agent-console-jinja2-templates --strict`.
|
||||
8. Manual browser verification: log in, view status dashboard auto-refresh, add/edit/remove a device, change password, view history, log out. Confirm no visual or behavior change versus the pre-migration pages.
|
||||
9. Rollback: revert the commit. The change introduces no on-disk state format change (templates are code, not data) and no schema migration. The only artifact a previous run may leave is the same SQLite history file the unchanged code would have written; it is unaffected.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Coordination with `task-execution-progress-visibility`.** That change's design.md D3 currently commits to "reuse `api/console.py`'s query patterns and f-string + `html.escape()` convention" for its new task list/detail/timeline pages. After this change lands, those pages SHOULD be written directly against the Jinja2 templates introduced here (extending `base.html`, looping over timeline entries with `{% for %}`). The two changes do not conflict at the code level (different files within `host_agent/web/`), but the D3 wording in `task-execution-progress-visibility/design.md` will need a follow-up edit. Options: (a) edit D3 in `task-execution-progress-visibility` once this change is on `master`; (b) defer the wording update until that change is next revised. Either is acceptable; the decision does not block this change.
|
||||
- **Supersession of `host-agent-local-console`'s "not Jinja2" decision.** When `host-agent-local-console` is archived into `openspec/specs/host-agent-local-console/spec.md`, this change's `host-agent-console-template-rendering` capability becomes a sibling rather than a delta. The reconciler should decide at archive time whether to fold the autoescape requirement into `host-agent-local-console`'s spec directly (and retire `host-agent-console-template-rendering` as a standalone capability) or keep it as a sibling. Recorded for the archive step; not a blocker for this change.
|
||||
- **Whether to introduce Starlette's `Jinja2Templates` for consistency with the broader FastAPI ecosystem.** Currently rejected (D1, Non-Goals). Revisit if a future change adds request-scoped rendering concerns (flash messages, per-request template overrides) that `Jinja2Templates` would simplify.
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
The Host Agent local console (`apps/device-host-agent/host_agent/web/app.py`) currently renders all of its pages with f-strings plus a hand-written `escape()` helper called on every interpolated value. Five pages are already in place (login, status dashboard, devices, account, history) — including a dashboard page with an inline `<script>` polling block that has to double-brace every JS `{`/`}` to survive f-string parsing. The pending `task-execution-progress-visibility` change will add three more pages (task list, task detail, timeline) that render externally-influenced text (task summaries, scene/element descriptions) inside loops, exactly the shape f-string rendering handles worst.
|
||||
|
||||
The original `host-agent-local-console` design decision to skip a templating engine was made when the assumed page count was four and the rendered fields were mostly internal enums. Both assumptions are now stale: every operator-controlled field (`name`, `driver_type`, `connection_info`, upcoming task summaries) is one missed `escape()` away from an XSS sink, and the per-page f-string HTML has grown verbose enough that the `_chrome()` shell, the per-page body functions, and the inline `_CSS` constant are reproducing a templating engine by hand. Jinja2 with `autoescape=select_autoescape(["html"])` removes the "did I remember to escape this one?" class of bug by mechanism rather than by discipline, and lets loops/conditionals/inheritance be expressed natively.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `jinja2` as an explicit direct dependency of `device-host-agent` in `apps/device-host-agent/pyproject.toml` (single pure-Python wheel, no C extension).
|
||||
- Introduce a module-level Jinja2 `Environment` in `host_agent/web/app.py` configured with `autoescape=select_autoescape(["html"])` and a `FileSystemLoader` rooted at a new `host_agent/web/templates/` directory.
|
||||
- Replace the hand-written `_chrome()` shell, the `_CSS` constant, and the five `_login_page`/`_dashboard_body`/`_devices_body`/`_account_body`/`_history_body` f-string builders with a `base.html` template (header/nav/CSS, `{% block body %}`) plus one template file per page.
|
||||
- Migrate the inline dashboard `<script>` block into its template using `{% raw %}...{% endraw %}` so JS braces are no longer f-string-escaped; behavior of the 5-second `/api/status` polling is preserved exactly.
|
||||
- Render every route handler through `TemplateResponse` instead of `HTMLResponse(_chrome(...))`. Route paths, auth/session/CSRF semantics, form fields, redirects, and the `/api/status` JSON endpoint are unchanged.
|
||||
- Remove the module-level `escape()` helper and `_chrome()` from `host_agent/web/app.py` (Jinja2's autoescape subsumes both).
|
||||
- Add a `tests/host_agent/web/` test module covering: each template renders without errors for representative contexts; an XSS probe (a model field set to `<script>alert(1)</script>`) appears HTML-escaped in the rendered output; the dashboard polling script body survives the `{% raw %}` migration byte-for-byte against the current page.
|
||||
|
||||
Non-Goals:
|
||||
|
||||
- No new pages, no new routes, no new visual design — this is a pure rendering-engine swap.
|
||||
- No change to the `console/` SPA (Runtime API UI) or `cloud-console/` SPA, both of which keep their own Vue3 + Vite toolchains.
|
||||
- No change to `host_agent/web/auth.py` (session/CSRF) or to the `host-agent-local-console` capability's auth/CSRF/session/config requirements.
|
||||
- No introduction of a static-asset pipeline (no `starlette.staticfiles`, no JS/CSS bundling) — the existing inline `<style>` becomes a `{% block styles %}` in `base.html`; future work can extract it later.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `host-agent-console-template-rendering`: Host Agent local console pages SHALL be rendered through a template engine with automatic HTML escaping enabled, so that every operator- or externally-influenced field interpolated into a page is escaped by mechanism rather than by per-call discipline. Decouples the autoescape security property from any single page's author and applies uniformly to every current and future page rendered by `host_agent/web/app.py` (login, dashboard, devices, account, history, and any later additions such as the task pages planned by `task-execution-progress-visibility`).
|
||||
|
||||
### Modified Capabilities
|
||||
(none — the existing `host-agent-local-console` capability is not yet archived into `openspec/specs/`; rather than write a MODIFIED delta against an unarchived pending spec, this change captures the autoescape requirement as a new sibling capability. The `host-agent-local-console` change's design.md decision titled "Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2" is hereby superseded for the rendering-mechanism question only; that change's auth/session/CSRF/config/lifecycle decisions remain in force. The supersession is recorded as an Open Question in this change's design.md to be reconciled when `host-agent-local-console` is archived.)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `apps/device-host-agent/pyproject.toml` (new `jinja2` direct dependency), `apps/device-host-agent/host_agent/web/app.py` (rewritten to use Jinja2 `TemplateResponse`), new `apps/device-host-agent/host_agent/web/templates/` directory (`base.html`, `login.html`, `dashboard.html`, `devices.html`, `account.html`, `history.html`), new `apps/device-host-agent/tests/host_agent/web/` test module.
|
||||
- Dependency graph: `jinja2` enters the resolved `uv.lock` as a direct dependency of `device-host-agent`. It is not currently a transitive dependency of fastapi/starlette (their `Jinja2Templates` is an optional extra), so this is a genuine new dependency, not surfacing an already-present one.
|
||||
- Not affected: `cloud.*`, `apps/cloud-api`, `cloud-console/`, `console/`, `host-agent-protocol` outbound behavior, `host_agent/web/auth.py`, `HostAgentConfig`, all store modules, all lifecycle/startup wiring.
|
||||
- Coordination point: `openspec/changes/task-execution-progress-visibility` (in flight) currently commits in its design.md D3 to "reuse `api/console.py`'s query patterns and f-string + `html.escape()` convention" for its new Host-Agent-side task pages. After this change lands, those new pages SHOULD be written directly in Jinja2 instead; the two changes do not conflict at the code level (different files inside `host_agent/web/`), but D3's wording will need a follow-up edit when that change is next updated. This is recorded as an Open Question in design.md.
|
||||
- Operational impact: none at runtime from an operator's perspective — same URLs, same login, same pages, same polling; first render of each template pays a one-time compile cost (sub-millisecond per template, cached on the module-level `Environment`).
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Console HTML pages SHALL be rendered through a template engine with automatic HTML escaping enabled
|
||||
The Host Agent local console SHALL render every HTML page through a single, process-wide template engine configured so that values interpolated into templates whose names end in `.html` are HTML-escaped by the engine before being written into the response, independent of whether the calling code remembers to escape them. The template engine and its autoescape configuration SHALL be constructed once at module import time of `host_agent/web/app.py` and reused by every page handler; pages SHALL NOT bypass the engine by constructing HTML through string concatenation, f-string interpolation, or any other path that skips autoescape.
|
||||
|
||||
#### Scenario: All current pages go through the autoescaping engine
|
||||
- **WHEN** the Host Agent renders any of its login, status dashboard, devices, account, or history pages
|
||||
- **THEN** the rendered HTML is produced by the shared template engine with autoescape enabled for `.html` templates, and the page handler does not construct any HTML fragment via f-string, string concatenation, or `str.format`
|
||||
|
||||
#### Scenario: A new page added later inherits autoescape without extra wiring
|
||||
- **WHEN** a future change adds a new `.html` template under the Host Agent console's templates directory and a route handler renders it through the shared template engine
|
||||
- **THEN** that new page's interpolated values are HTML-escaped by the engine without the new change having to reconfigure autoescape or wrap any value in an escaping helper
|
||||
|
||||
#### Scenario: Handler code returns a plain HTMLResponse built from the engine's output
|
||||
- **WHEN** a console route handler produces its response
|
||||
- **THEN** the response is an `HTMLResponse` whose `content` is the string returned by the template engine's `render(...)` call, and the handler does not post-process that string in a way that could re-introduce unescaped markup
|
||||
|
||||
### Requirement: Operator- and externally-influenced values rendered into console pages SHALL be HTML-escaped
|
||||
Any value that originates from operator input (e.g. device name, driver type, connection info JSON, account username), from the control plane (e.g. host policy fields, assignment summaries), or from task execution (e.g. step summaries, scene/element descriptions) SHALL be HTML-escaped by the template engine when interpolated into a console page, so that the rendered output contains no executable `<script>` markup, event-handler attributes, or other HTML capable of executing in the browser session of an authenticated console operator.
|
||||
|
||||
#### Scenario: A device name containing an XSS probe is rendered escaped
|
||||
- **WHEN** an operator registers a local device whose name contains the string `<script>alert(1)</script>` and the operator subsequently loads the devices page or the status dashboard
|
||||
- **THEN** the rendered HTML contains the literal text `<script>alert(1)</script>` (or an equivalent escaped form) in the position where the device name is interpolated, and contains no `<script>` element corresponding to the device name
|
||||
|
||||
#### Scenario: A driver connection_info JSON containing an XSS probe is rendered escaped
|
||||
- **WHEN** a device's `connection_info` JSON value contains a string with the characters `<`, `>`, `"`, or `'` in any position, and the devices page renders that value (for example inside a `<textarea>` edit field)
|
||||
- **THEN** the rendered HTML escapes those characters so the value round-trips back to the original JSON when the form is submitted unchanged, and the page contains no executable markup introduced by the connection_info value
|
||||
|
||||
#### Scenario: An assignment or step summary containing an XSS probe is rendered escaped
|
||||
- **WHEN** a task summary, step status, scene description, or any other externally-influenced string containing `<script>` markup is rendered on any console page (including any future task list, task detail, or timeline page added by a later change)
|
||||
- **THEN** the rendered HTML escapes the markup and contains no executable `<script>` element corresponding to that string
|
||||
|
||||
### Requirement: Disabling autoescape for a console template SHALL require an explicit, in-template opt-in
|
||||
The console's template engine SHALL NOT be configured with autoescape disabled globally. A template that needs to interpolate trusted, pre-built HTML (for example a fragment assembled by Python code from already-escaped parts) SHALL opt out of autoescape only for the specific region and only by an explicit in-template construct (such as Jinja2's `{% autoescape false %}...{% endautoescape %}` block or the `| safe` filter applied to a specific expression). No current console page requires such an opt-out.
|
||||
|
||||
#### Scenario: Global autoescape is on
|
||||
- **WHEN** the console's template engine is constructed at module import time
|
||||
- **THEN** its configuration enables autoescape for every template whose name ends in `.html`, and no process-wide setting, environment variable, or configuration field disables that behavior
|
||||
|
||||
#### Scenario: A future page that needs trusted-HTML interpolation scopes the opt-out locally
|
||||
- **WHEN** a later change adds a console template that interpolates a fragment the change has already assembled and escaped on the Python side, and that change wishes to avoid double-escaping
|
||||
- **THEN** the opt-out is expressed as an explicit, template-local construct around the specific interpolation, and does not disable autoescape for the rest of the template or for any other template
|
||||
|
||||
### Requirement: The console's template-rendering configuration SHALL be observable in tests
|
||||
The console's module-level template engine SHALL be exposed in a way that lets automated tests render any console template with a caller-supplied context and assert on the resulting HTML, so that the autoescape behavior of every current and future page can be verified by a regression test without spinning up the full Host Agent HTTP server.
|
||||
|
||||
#### Scenario: A test renders a template with a malicious context
|
||||
- **WHEN** an automated test calls the console's template engine with a template name and a context that includes a field whose value is `<script>alert(1)</script>`
|
||||
- **THEN** the test receives the rendered HTML as a string and can assert that the XSS probe has been HTML-escaped in the output
|
||||
@@ -0,0 +1,71 @@
|
||||
## 1. Dependency and lockfile
|
||||
|
||||
- [x] 1.1 Add `jinja2>=3.1` to the `dependencies` list in `apps/device-host-agent/pyproject.toml`
|
||||
- [x] 1.2 Run `uv lock` (or `uv lock --package device-host-agent`) and confirm `jinja2` plus its sole transitive runtime dependency `markupsafe` resolve; commit the updated `uv.lock`
|
||||
- [x] 1.3 Run `uv sync --locked --all-packages` and confirm no package fails to install
|
||||
|
||||
## 2. Capture pre-migration reference
|
||||
|
||||
- [x] 2.1 From a clean working copy of the current `host_agent/web/app.py`, render each of the five pages (login, dashboard, devices, account, history) in-process with a representative context (mock `SessionState`, mock `HostIdentityState`, two sample devices, one history entry) and save the HTML to `apps/device-host-agent/tests/host_agent/web/__baseline__/` as `<page>.html` (one file per page). The dashboard reference must include the rendered inline `<script>` block. These files become the regression oracle for tasks 6.3 and 6.4
|
||||
- [x] 2.2 Note the exact bytes of the dashboard inline `<script>` block (from `function render(data) {` through the closing `})();`) into a separate `dashboard_script.txt` reference under the same baseline directory, for the byte-identical assertion in 6.4
|
||||
|
||||
## 3. Templates
|
||||
|
||||
- [x] 3.1 Create `apps/device-host-agent/host_agent/web/templates/base.html` per design D4: `<!doctype html>`, `<head>` with `<meta charset="utf-8">`, `<title>{{ title }}</title>`, a `<style>` block holding the current `_CSS` content, a `<header>` containing `<strong>Host Agent Console</strong>` and a `{% block nav %}` that renders the same nav (Status / Devices / Account / History / Logout form) when `session` is truthy and empty otherwise, and a `<main>` containing `{% block body %}{% endblock %}`
|
||||
- [x] 3.2 Create `templates/login.html` extending `base.html`, overriding `{% block nav %}` to empty and `{% block body %}` with the current `_login_page` body markup, including the "no local account exists" branch (use `{% if not account %}`) and the optional error paragraph (`{% if error %}`)
|
||||
- [x] 3.3 Create `templates/dashboard.html` extending `base.html`, porting the current `_dashboard_body` body markup (enrollment/heartbeat/policy/current-assignment sections, devices table with a `{% for device in devices %}` loop) and the inline `<script>` block wrapped in `{% raw %}...{% endraw %}` so JS braces are not interpreted by Jinja2; the script body must be byte-identical to the reference captured in 2.2
|
||||
- [x] 3.4 Create `templates/devices.html` extending `base.html`, porting the current `_devices_body` markup including the device list table (loop with `{% for device in devices %}`), the optional error paragraph, and the add/edit form (use `{% if edit_record %}` to switch the form heading and pre-fill values)
|
||||
- [x] 3.5 Create `templates/account.html` extending `base.html`, porting the current `_account_body` markup including the optional message/error paragraphs and the change-password form
|
||||
- [x] 3.6 Create `templates/history.html` extending `base.html`, porting the current `_history_body` markup including the history table with a `{% for entry in entries %}` loop
|
||||
|
||||
## 4. Engine and route handlers
|
||||
|
||||
- [x] 4.1 In `host_agent/web/app.py`, construct a module-level `_ENV = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), autoescape=jinja2.select_autoescape(["html", "xml"]))`; import `jinja2` and `pathlib.Path` at module top
|
||||
- [x] 4.2 Rewrite the `/login` GET handler to render `login.html` via `_ENV.get_template("login.html").render(account=account)` and return `HTMLResponse(...)`; keep the current `asyncio.to_thread(local_account_store.load)` call shape
|
||||
- [x] 4.3 Rewrite the `/` dashboard GET handler to render `dashboard.html` with `identity`, `snapshot`, `devices`, `config`, `session` in the context; ensure the inline `<script>` survives `{% raw %}` migration byte-identically (visually diff against the 2.2 reference)
|
||||
- [x] 4.4 Rewrite the `/devices` GET handler to render `devices.html` with `devices`, `csrf_token`, `edit_record`, `error=None`; keep the existing `config_store.list`/`config_store.get` calls
|
||||
- [x] 4.5 Rewrite the `/devices/save` POST handler's error-path branch to render `devices.html` (same context as 4.4 with `error=<message>`); keep the success-path `RedirectResponse(url="/devices", status_code=303)` unchanged
|
||||
- [x] 4.6 Rewrite the `/account` GET and POST handlers to render `account.html` with `csrf_token`, `message`, `error` in the context; preserve the password-change success/error branches
|
||||
- [x] 4.7 Rewrite the `/history` GET handler to render `history.html` with `entries` in the context
|
||||
- [x] 4.8 Confirm `/api/status` (JSON) and all POST handlers that issue `RedirectResponse` (`/login`, `/logout`, `/devices/save` success, `/devices/remove`) are unchanged in behavior — only the GET and error-render paths swap from f-string to template render
|
||||
|
||||
## 5. Cleanup of dead code
|
||||
|
||||
- [x] 5.1 Remove the `escape()` helper from `host_agent/web/app.py`
|
||||
- [x] 5.2 Remove `_chrome()` from `host_agent/web/app.py`
|
||||
- [x] 5.3 Remove the `_CSS` constant from `host_agent/web/app.py`
|
||||
- [x] 5.4 Remove the `_login_page`, `_dashboard_body`, `_devices_body`, `_account_body`, `_history_body` functions from `host_agent/web/app.py`
|
||||
- [x] 5.5 Remove the now-unused `from html import escape as _escape` import
|
||||
- [x] 5.6 Grep `apps/device-host-agent/` for any remaining `escape(`, `_chrome(`, `_CSS`, `_dashboard_body`, `_devices_body`, `_account_body`, `_history_body`, `_login_page` references and remove any stragglers; only `_ENV` / `get_template` / `render` references should remain in `host_agent/web/app.py`
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [x] 6.1 Create `apps/device-host-agent/tests/host_agent/web/__init__.py` if not present (empty)
|
||||
- [x] 6.2 Create `apps/device-host-agent/tests/host_agent/web/conftest.py` with fixtures exposing the module-level `_ENV` from `host_agent.web.app` and helper factories for representative contexts (one valid session, one no-account state, two sample devices with one containing the XSS probe, one history entry, one edit_record)
|
||||
- [x] 6.3 Create `apps/device-host-agent/tests/host_agent/web/test_templates.py` with one render-smoke test per template (login, dashboard, devices, account, history) asserting the rendered HTML contains a stable selector from each section (e.g. `id="last-heartbeat"` for dashboard, the `<form method="post" action="/devices/save">` for devices) and that rendering does not raise for the representative context
|
||||
- [x] 6.4 Add an XSS-probe test that, for each of `dashboard.html`, `devices.html`, `history.html`, renders the template with a context in which every operator-influenced field is set to `<script>alert(1)</script>` (device name, device driver_type, connection_info JSON string, history summary) and asserts the substring `<script>alert(1)</script>` does not appear in the output while `<script>alert(1)</script>` does
|
||||
- [x] 6.5 Add a byte-identity test that loads the dashboard template, renders it with a representative context, and asserts the inline `<script>` block body extracted from the output equals the contents of `tests/host_agent/web/__baseline__/dashboard_script.txt` captured in 2.2 (normalize trailing whitespace)
|
||||
- [x] 6.6 Add a no-autoescape-bypass test that greps the templates directory for the literal substrings `| safe`, `{% autoescape false %}`, and `{% endautoescape %}`, asserting zero matches across all current templates (guards against a future change silently opting out)
|
||||
- [x] 6.7 Add a test asserting `_ENV.autoescape` is configured for `.html` templates (e.g. by rendering a template whose name ends in `.html` with a probe value and confirming the output is escaped — concrete and future-proof against Environment construction changes)
|
||||
|
||||
## 7. Local validation
|
||||
|
||||
- [x] 7.1 Run `uv run --package device-host-agent pytest` and confirm the full package suite (existing tests plus the new section-6 tests) passes
|
||||
- [x] 7.2 Run the root non-integration suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions versus the pre-change baseline
|
||||
- [x] 7.3 Run `ruff check --fix` and `ruff format` over `apps/device-host-agent/host_agent/web/`, `apps/device-host-agent/tests/host_agent/web/`, and `apps/device-host-agent/pyproject.toml`; resolve any findings
|
||||
- [x] 7.4 Run `python -m compileall apps/device-host-agent/host_agent/web apps/device-host-agent/tests/host_agent/web` and confirm no syntax errors
|
||||
- [x] 7.5 Run `git diff --check` to catch whitespace errors before commit
|
||||
- [x] 7.6 Run `openspec validate host-agent-console-jinja2-templates --strict` and confirm it passes
|
||||
|
||||
## 8. Manual browser verification
|
||||
|
||||
- [ ] 8.1 Start a Host Agent locally (`uv run --package device-host-agent device-host-agent` or the existing run command documented in `docs/CLOUD_DEPLOYMENT.md`); open `http://127.0.0.1:8765/login` in a browser and log in with the existing local account
|
||||
- [ ] 8.2 On the status dashboard, confirm the auto-refresh polling still works (watch the "Last heartbeat" line update at the 5-second cadence) and the inline `<script>` executes without console errors
|
||||
- [ ] 8.3 Visit `/devices`, add a device whose name is `<script>alert(1)</script>` (and a valid driver type/connection info); confirm the device appears in the list with the script visible as text rather than executing, then remove it
|
||||
- [ ] 8.4 Visit `/account`, change the password (current → new → confirm) end-to-end, log out, and log back in with the new password
|
||||
- [ ] 8.5 Visit `/history` and confirm recent entries render
|
||||
- [ ] 8.6 Compare each page's visual layout to the pre-change version and confirm no styling regression (the `_CSS` content must be byte-identical inside `base.html`'s `<style>` block)
|
||||
|
||||
## 9. Coordination follow-up (not blocking archive of this change)
|
||||
|
||||
- [x] 9.1 After this change lands on `master`, open a follow-up note (issue or PR comment) on `openspec/changes/task-execution-progress-visibility` advising that its design.md D3 ("reuse `api/console.py`'s query patterns and f-string + `html.escape()` convention") is superseded: the task list/detail/timeline pages planned there SHOULD be written directly against the Jinja2 templates introduced here. This task does not block archive of either change; it only prevents the next author from re-growing the f-string pattern.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
@@ -0,0 +1,129 @@
|
||||
## Context
|
||||
|
||||
Three surfaces currently cannot show a task's in-progress status:
|
||||
|
||||
1. **Host Agent local console** (`apps/device-host-agent/host_agent/web/app.py`): `HostAgentApplication`'s builder calls `create_execution_factories(resolved_manager, host_agent_config=resolved_config)` without `metadata_store`/`timeline` (`app.py:218-223`), so the in-process `TaskRunner` (`execution.py:39-46`) runs with `metadata_store=None, timeline=None`. `runtime/task.py::TaskRunner._update_task()` only persists when `metadata_store` is truthy, so step transitions vanish. The console's only signal is `AgentStatusTracker.snapshot()` (`status.py:14-88`), a single opaque span: `task_id/device_id/goal/started_at`, cleared on `mark_assignment_finished()`.
|
||||
2. **Cloud Control Plane / Cloud Console**: the internal protocol (`packages/cloud-platform/cloud/internal_api/models.py`) only has claim, heartbeat, lease-renewal, and terminal-result messages. Lease renewal (`ActiveAssignmentRunner._renew_while_running`, `apps/device-host-agent/host_agent/lease.py:79-106`) already fires periodically (~1/3 of remaining lease) for every in-flight assignment, but carries no task-progress payload.
|
||||
3. **Runtime `console/` SPA**: `api/rest.py::create_app()` builds its own `TaskMetadataStore`/`Timeline`/`TaskRunner` (`api/rest.py:24-41`), queried by `api/console.py::create_console_router()`'s `/console/tasks*` routes, which `console/src/api.ts` calls against `API_BASE_URL` (default `http://127.0.0.1:8000`). This is a wholly separate process from Host Agent, with no shared store or IPC. `runtime/`-owned packages are forbidden from importing host/cloud concerns (`test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`), and `host-agent-local-console/design.md` already treats Host Agent's local history as "not a system of record," distinct from Cloud's.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Host Agent's local console shows live step-level progress for its current assignment (step index, step status, short summary), not just a static started-at snapshot.
|
||||
- Cloud Control Plane learns step-level progress near-real-time (piggybacked on existing periodic traffic, not a new polling loop) and Cloud Console renders it for an operator watching a remote task.
|
||||
- An operator can inspect a Host-Agent-executed task's progress/history from a web console, without coupling `runtime/` to host/cloud concerns.
|
||||
- Reuse existing, already-tested building blocks (`TaskMetadataStore`, `Timeline`, `api/console.py`'s query shape, the lease-renewal cadence) instead of inventing new storage or transport primitives.
|
||||
- Both the Host Agent's local task history and the Cloud Control Plane persist the *actual* per-step content sent to and received from the LLM (the real prompt text, not just the task's overall goal; the model's resulting decision, not just the parsed tool call) — so a step's record answers "what did we actually ask the model and what did it say," not just "what call did we make." Cloud's copy is the durable, centrally-searchable record for troubleshooting; the Host Agent's local copy remains the on-device record of what actually ran.
|
||||
|
||||
**Non-Goals:**
|
||||
- No SSE/WebSocket push. All three surfaces keep polling (Host Agent console 5s, Cloud Console/`cloud-console` and `console/` at their existing intervals). Nothing here requires push infra, and adding it would be a bigger, separate change.
|
||||
- The coarse live step index/status/summary reported via lease renewal (D4/D5) stays *latest-snapshot-only* on the Cloud side (overwrite semantics) — it is a "what's happening right now" indicator, not a history mechanism, and is unaffected by the LLM-content decisions below.
|
||||
- Full step-by-step **LLM interaction** history *is* now synced to and durably persisted by Cloud (see D7/D8) — this is a deliberate reversal of this change's earlier draft, which had scoped Cloud to latest-only. The motivation: Host Agent deployments already route every AI Planner decision through Cloud API's existing `cloud-planner-proxy` endpoint when configured for the `cloud` transport, so Cloud is already on the natural path for this data and centralizing it there (rather than only on whichever edge host happens to still be running) is the more useful place for an operator doing centralized troubleshooting. This durable log is scoped strictly to LLM prompt/response text — it is not a general-purpose duplicate of the Host Agent's full `Timeline` (no screenshots, no scene JSON dumps beyond what's embedded in the prompt text itself).
|
||||
- No change to the Runtime `console/` SPA or `runtime/`-owned packages. The multi-backend-SPA alternative was considered and rejected (see Decisions).
|
||||
- No change to `driver/`/`device/`/`core/` device-control internals.
|
||||
- No screenshot or full-scene data leaves the Host Agent process as part of progress reporting, and no screenshot bytes are ever persisted by the Cloud Control Plane. (Full LLM prompt/response *text* is, by contrast, an explicit Goal below — see D7/D8 — which partially supersedes the original `cloud-planner-proxy` proposal's "does not durably persist ... full prompt text" statement. Screenshots remain excluded; prompts no longer are.)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Wire a real `metadata_store`/`timeline` into Host Agent's `TaskRunner`, reusing the existing classes
|
||||
|
||||
`create_execution_factories()` already accepts `metadata_store: TaskMetadataStore | None` and `timeline: Timeline | None` (`execution.py:28-34`) — these are the same classes `api/rest.py` uses for the Runtime's own console. Reuse them as-is rather than inventing a Host-Agent-specific store: `HostAgentApplication`'s builder constructs a `TaskMetadataStore(db_path=<host-agent-local path>)` and `Timeline(ArtifactStore(<host-agent-local path>))` and passes them into `create_execution_factories()`.
|
||||
|
||||
**Alternative considered**: a bespoke in-memory-only step recorder (cheaper, no disk I/O). Rejected because `TaskMetadataStore`/`Timeline` are already proven in production (Runtime's own console has run on them since the original `web-console` change) and reusing them means Host Agent's task/timeline data has the *exact* same shape as `api/console.py` already serializes — a prerequisite for D3 below. A bespoke format would need its own (de)serialization and its own console rendering code for no real benefit.
|
||||
|
||||
New config: `HOST_AGENT_TASK_PROGRESS_DB_PATH` (default e.g. `host_agent_data/task_progress.sqlite3`) and a matching artifact directory, kept separate from any local Runtime `tasks/` directory that might exist on the same machine, to avoid two unrelated processes silently sharing or colliding on a path.
|
||||
|
||||
### D2: Bounded retention for the Host-Agent-local store
|
||||
|
||||
Unlike a Runtime dev session (short-lived, manually cleared), a Host Agent process runs indefinitely and executes many assignments over its lifetime. Unbounded `TaskMetadataStore` rows and `Timeline` screenshot artifacts would grow without limit.
|
||||
|
||||
Decision: add a lightweight retention pass (e.g. on a timer, or opportunistically after each assignment finishes) that prunes tasks older than a configurable window or beyond a configurable count, deleting both the `tasks` row and its `Timeline`/`ArtifactStore` files. This is new logic — neither `TaskMetadataStore` nor `Timeline` currently supports deletion — scoped as a small addition to `storage/task_metadata.py` / `storage/timeline.py` (or a Host-Agent-side wrapper if adding delete methods to the shared `storage` package feels too broad; prefer extending `storage` since both Runtime and Host Agent benefit from bounded retention).
|
||||
|
||||
### D3: Give Host Agent's own console read-only task/timeline pages instead of making `console/` multi-backend
|
||||
|
||||
Two options were evaluated for capability `host-agent-console-task-pages`:
|
||||
|
||||
- **(a) Multi-backend Runtime `console/` SPA**: let the existing Vue SPA point at a Host Agent's console origin (a saved/selectable base URL) in addition to the local Runtime. Since D1 makes Host Agent's data shape identical to what `api/console.py` already serializes, the SPA's existing fetch/render code would work unmodified against a Host Agent origin.
|
||||
- **(b) Extend Host Agent's own server-rendered console** (`host_agent/web/app.py`) with new read-only pages for task list/detail/timeline, reusing `TaskMetadataStore`/`Timeline` query calls directly (the same calls `api/console.py`'s handlers make), rendered as server-side HTML via the existing f-string + `html.escape()` convention (no SPA, no frontend build) established by `host-agent-local-console`.
|
||||
|
||||
**Decision: (b).** (a) requires Host Agent's console to serve cross-origin, credentialed requests from whatever origin `console/` is running on (Vite dev server, or a different deployed origin) — meaning either relaxing Host Agent's CORS posture for its session-cookie+CSRF-protected endpoints, or reworking its auth model to tolerate cross-origin fetches. That is real new attack surface on a console whose entire local-only threat model (`console_bind_host` defaulting to loopback, explicit opt-in for non-loopback) was deliberately conservative. (b) reuses an already-accepted pattern (server-rendered, same-origin, same auth) and fully satisfies the actual operator need — a web page showing what a Host Agent is doing — without touching `runtime/`, `console/`, or Host Agent's CORS/auth posture at all. The cost is a small amount of view duplication (Host Agent's console and Runtime's `console/` render similar-shaped data with different templates/frameworks), judged acceptable given they serve different operational contexts (local direct-connect Runtime vs. remote Host Agent fleet).
|
||||
|
||||
Any future desire for a unified SPA across both surfaces is left as a follow-up, not blocked by this decision (D1's shared data shape keeps that door open).
|
||||
|
||||
### D4: Piggyback progress reporting on the existing lease-renewal call
|
||||
|
||||
`ActiveAssignmentRunner._renew_while_running` (`lease.py:79-106`) already makes an authenticated, per-assignment, periodic call (`client.renew(assignment)`, roughly every 1/3 of remaining lease) while a task executes, validated server-side against `host_id`/`task_id`/`attempt`/`lease_id` (`internal_api/api.py:291-320`). This is the natural piggyback point for progress: extend `LeaseRenewalRequest` with an optional `progress: TaskProgressModel | None` field (new small model: `step_index: int`, `step_status: Literal[...]`, `summary: str` bounded length — no screenshot/scene payload) in `cloud/internal_api/models.py`, the single schema both sides already import directly (no parallel schema, matching existing convention noted for this protocol).
|
||||
|
||||
A small thread-safe "latest progress" holder (similar in spirit to `LeaseGuard`) is written by the execution thread (via a hook on `TaskRunner`/`AssignmentExecutor`, updated once per step) and read by `_renew_while_running` just before each renewal call, so no new timer/cadence is introduced.
|
||||
|
||||
**Alternative considered**: a dedicated `POST /internal/v1/hosts/{host_id}/assignments/{task_id}/progress` endpoint called on its own cadence. Rejected — it would duplicate the auth/validation `renew_assignment` already does, and introduce a second periodic call where one already exists and fires at a reasonable frequency for this purpose.
|
||||
|
||||
### D5: Cloud persists only the latest progress snapshot, overwritten alongside lease renewal
|
||||
|
||||
`renew_lease()` (`repository.py:407`, `sql_repository.py:1480`) already takes a row lock and writes a lease-expiry update on every renewal; extend it to also accept and store the optional progress fields (few scalar columns — `progress_step_index`, `progress_step_status`, `progress_summary`, `progress_updated_at` — rather than a schema-flexible JSON blob, keeping it queryable and consistent with the rest of the row's typed columns). This requires a new Alembic migration (head is currently `0007_llm_provider_management`) plus the equivalent SQLite schema change, applied to both `repository.py` and `sql_repository.py` to keep the dual-backend contract (`cloud-control-plane` spec's "Deployment and local persistence modes share one contract" requirement).
|
||||
|
||||
Progress columns are cleared (or simply superseded and ignored) once a terminal result is recorded — they represent "what's happening right now," not history; Cloud's durable attempt/result history is unaffected and unduplicated.
|
||||
|
||||
### D6: Cloud Console reads progress from the existing task/attempt query path, not a new endpoint
|
||||
|
||||
Extend whatever response model Cloud Console's task list/detail already uses (Cloud API public router) with the optional latest-progress fields from D5, rather than adding a new endpoint. Cloud Console renders it as a small inline badge/line ("step 4: tapping login button") next to the existing status, refreshed on the SPA's existing polling interval.
|
||||
|
||||
### D7: Capture full LLM interaction history for free via the existing `cloud-planner-proxy` decide endpoint, not a new reporting channel
|
||||
|
||||
Investigated the Host Agent → Cloud call path in detail: when a Host Agent is configured with `AI_PLANNER_TRANSPORT=cloud`, `AIPlanner.plan()` (`runtime/ai_planner.py`) calls `CloudProxyToolCallingClient.decide()` (`apps/device-host-agent/host_agent/cloud_planner_client.py:46-95`), which `POST`s the *complete* `system_prompt`/`user_prompt` (plus `task_id`/`attempt`/`lease_id` from `current_planner_execution_context()`) to Cloud API's `/hosts/{host_id}/planner/decide` (`packages/cloud-platform/cloud/internal_api/api.py::decide_planner_call`, line ~367). That handler already resolves and returns a `ToolCallDecision` (`tool_name`/`arguments`/`usage`) and already does per-call bookkeeping (`pool.store.settle_host_token_reservation(...)`, line ~463) using the same repository object this change's D5 already touches for lease renewal.
|
||||
|
||||
This means **every planning step's actual prompt and resulting decision already flows through a Cloud-owned request handler** when the `cloud` transport is used — no new endpoint, no new protocol field, no queue/batching scheme is needed to get full LLM content to Cloud. The only change needed is to make that handler *persist* what it currently discards.
|
||||
|
||||
**Decision**: extend `decide_planner_call` to, immediately after computing `decision` (success path only — a `ToolCallUnavailable`/502 path persists nothing), insert one row into a new log table (D8) keyed by `(task_id, attempt, step_index)`, where `step_index` is assigned by the Cloud side itself (an auto-incrementing counter scoped to `task_id`+`attempt`, e.g. `select count(*) + 1` under the same row lock, or a DB sequence/identity column) — Host Agent does not need to track or send a step counter for this.
|
||||
|
||||
**Explicit limitation, called out rather than papered over**: this only captures LLM content for hosts using the `cloud` transport. A host on the (still-default) `direct` transport never sends its prompts to Cloud at all — Cloud has zero LLM content for that host's tasks, and only ever sees the coarse index/status/summary from D4/D5's lease-renewal piggyback (which is transport-agnostic, since it's driven by `TaskRunner` step completion, not by the planner's transport choice). This is a real operational dependency: centralized LLM-interaction troubleshooting via Cloud Console requires the fleet (or the hosts an operator cares about) to run with `AI_PLANNER_TRANSPORT=cloud`. This change does not make `cloud` the new default transport — that remains a separate, already-existing configuration decision outside this change's scope.
|
||||
|
||||
**Alternative considered**: extend the D4 lease-renewal piggyback to also carry full prompt/response text (queued, not overwritten, so no step is lost between renewals). Rejected: it would duplicate a transport that already exists for exactly this payload (the decide call itself) whenever `cloud` transport is active, and would still need a *separate* new channel for the `direct`-transport case where Cloud never sees the prompt anyway — i.e., it does not actually solve the `direct`-transport gap, so it only adds complexity without expanding coverage.
|
||||
|
||||
### D8: New bounded-retention table for the full per-step LLM decision log, extended on both repository backends
|
||||
|
||||
Add a new table (e.g. `planner_decision_log`): `id`, `host_id`, `task_id`, `attempt`, `step_index`, `system_prompt` (text), `user_prompt` (text), `tool_name`, `arguments_json` (text), `created_at`. No screenshot column — screenshots are never sent to this endpoint's persistence path (the request's `screenshot_base64` is used only to call the LLM provider and is never written to this log, consistent with the Non-Goals screenshot exclusion).
|
||||
|
||||
Like D5, this needs a new Alembic migration and the equivalent SQLite path, implemented on both `repository.py`'s SQLite-backed implementation and `sql_repository.py::SQLAlchemyCloudRepository` to preserve the existing dual-backend contract. Unlike D5's few-nullable-columns-on-an-existing-row approach, this is an independent append-only table (one row per decide call, not an overwrite), since the whole point is durable per-step history rather than a live snapshot.
|
||||
|
||||
**Retention**: this table grows once per planning step across the whole fleet, indefinitely, on a shared multi-tenant Cloud database — unbounded growth is a real risk here in a way D5's single-row-per-assignment overwrite never was. Decision: a scheduled prune job (mirrors D2's Host-Agent-local retention) deletes rows whose owning task reached a terminal state more than a configurable window ago (default: prune 7 days after task terminal, or once the task itself is pruned/archived by whatever existing Cloud task-retention policy applies — reuse that cadence rather than inventing a second one if `cloud-control-plane` already has one; otherwise default to a simple time-based prune).
|
||||
|
||||
### D9: Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` path so the *actual* per-step prompt and response are recorded locally, not just the task goal and the parsed tool call
|
||||
|
||||
Independent of Cloud persistence, the existing local recording is itself wrong today: `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) calls `Timeline.append(prompt=task.goal, tool_call={"action": ..., "description": ..., "args": ...}, ...)`. `task.goal` is the overall task goal, not the per-step prompt actually sent to the LLM — the real per-step prompt (`planner_user_prompt(goal, scene_json, history_summary)`, built in `ai_planner.py::plan()`) is constructed, sent, and discarded entirely within `AIPlanner.plan()`, never reaching `TaskRunner`. Likewise `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) carries only the parsed `tool_name`/`arguments`/`usage` — any raw response text/content the model returned is discarded during parsing (`_decision_from_anthropic_response`/`_decision_from_openai_response`).
|
||||
|
||||
This is a pre-existing gap in a component shared by Runtime and Host Agent alike (not new to this change), and it undermines the very "step-level detail" goal D1 already committed to — a persisted step whose "prompt" field is just the task's goal repeated on every row is not useful for troubleshooting.
|
||||
|
||||
**Decision**: extend `ToolCallDecision` with the actual `user_prompt`/`system_prompt` it was given (or have `AIPlanner.plan()` return a small side-channel result instead of changing the `Planner` interface's return type) so `TaskRunner._append_timeline()` can pass the real per-step prompt into `Timeline.append()`. Rename `Timeline`/`TimelineRecord`'s `prompt` field's meaning (or add a new field) to unambiguously mean "the prompt actually sent to the LLM for this step." This fix lands in the shared `runtime`/`storage` packages, so both Host Agent's local console (D3) and the existing Runtime `console/` automatically benefit — it is not Host-Agent-specific plumbing.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Per-step SQLite writes in a long-lived Host Agent process add I/O overhead → Mitigation: this is the same write pattern Runtime's own console has always used per step; no new proof of acceptability needed. If profiling later shows it matters for very high step-rate tasks, batching/debouncing is a follow-up, not a blocker here.
|
||||
- [Risk] Unbounded local disk growth from `Timeline` screenshots on an indefinitely-running Host Agent → Mitigation: D2's bounded retention pass; must ship in the same change as D1, not deferred, since D1 alone would otherwise introduce an unbounded-growth regression.
|
||||
- [Risk] New DB columns/migration touch both `repository.py` (SQLite) and `sql_repository.py` (Postgres) — drift between the two has been a real defect category in this codebase (see `cloud-control-plane-integration` archive notes) → Mitigation: contract/parity tests already exist for this dual-backend boundary; extend them to cover the new progress columns.
|
||||
- [Risk] View duplication between Host Agent's server-rendered task pages (D3) and Runtime `console/`'s task pages (different frameworks, same data shape) → Mitigation: accepted trade-off (see D3 rationale); shared data shape keeps future unification possible without rework.
|
||||
- [Risk] Progress payload could accidentally grow to include sensitive data (scene dumps, prompts) if a future change casually extends `TaskProgressModel` → Mitigation: `summary` field is explicitly bounded/plain-text only; code review for this change and future extensions should treat this the same as the existing `cloud-planner-proxy` no-screenshot-persistence rule (screenshots specifically remain excluded from every Cloud-side table this change introduces).
|
||||
- [Risk] `planner_decision_log` (D8) grows unboundedly across the whole fleet, unlike D5's single-row-per-assignment overwrite → Mitigation: D8's retention/prune job must ship in the same change as D7/D8, not deferred, for the same reason D2 must ship alongside D1.
|
||||
- [Risk] Full prompt text can itself contain sensitive on-screen content (whatever text was visible in the scene description embedded in the prompt) — persisting it centrally is a deliberate trade-off the user has explicitly requested for centralized troubleshooting, but it is a real expansion of what Cloud stores → Mitigation: no additional mitigation beyond what's already decided (screenshots still excluded); flagged here so it is a visible, intentional decision rather than a silent scope creep.
|
||||
- [Risk] Full LLM history in Cloud is silently absent for any host on the `direct` transport, which could read as "it's broken" rather than "expected" → Mitigation: Cloud Console should visibly distinguish "no progress reported yet" from "this host does not report LLM content" (e.g. by also surfacing the host's configured transport), rather than just showing an empty history with no explanation.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `TaskProgressModel` and the optional `progress` field to `LeaseRenewalRequest`/response in `cloud/internal_api/models.py`. Backward compatible: `progress` is optional, older Host Agents omit it.
|
||||
2. Add progress columns + Alembic migration `0008_task_progress_columns`; extend `renew_lease()` in both repository implementations to accept/store them.
|
||||
3. Extend Cloud Console's task read model and UI to render the new fields (no-op if absent, keeping rollback trivial).
|
||||
4. Wire `metadata_store`/`timeline` into Host Agent's `create_execution_factories()` call site, add the retention pass (D2), and add the progress-holder hook feeding D4's renewal piggyback.
|
||||
5. Extend Host Agent's local console with the read-only task/timeline pages (D3).
|
||||
6. Fix `Timeline`/`TaskRunner`/`AIPlanner`/`ToolCallingClient` to record the real per-step prompt and response locally (D9) — independent of Cloud, benefits both Host Agent and Runtime consoles immediately.
|
||||
7. Add `planner_decision_log` + Alembic migration for it (D8) on both repository backends, with its retention/prune job.
|
||||
8. Extend `decide_planner_call` (`internal_api/api.py`) to persist each resolved decision into `planner_decision_log` (D7).
|
||||
9. Extend Cloud Console with a per-task LLM interaction history view reading the new table, including the "host uses `direct` transport, no LLM content available" distinction from the Risks section.
|
||||
10. Rollback: each step is independently revertible (optional field, additive columns, additive tables, additive UI, additive Host Agent wiring) — no destructive migration is required; both `0008` and the new decision-log migration's down-revisions drop only what they added.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Exact retention window/count defaults for D2 (time-based vs. count-based, or both) — left for tasks.md to pick a concrete, documented default (e.g. keep last 50 tasks or 7 days, whichever is smaller) rather than block design on it.
|
||||
- Whether Cloud Console's existing task list/detail component can absorb the new fields with a small edit or needs a new sub-component — an implementation detail, not an architectural fork.
|
||||
- Whether a future change should unify Host Agent's server-rendered task pages and Runtime `console/`'s SPA into one shared frontend, now that D1 gives them an identical underlying data shape — explicitly deferred, not part of this change.
|
||||
- Exact retention default for D8's `planner_decision_log` (prune-after-terminal window, or reuse an existing Cloud task-retention cadence if one already exists) — left for tasks.md to pick a concrete default rather than block design on it.
|
||||
- Whether `step_index` in D8 should be assigned via a `SELECT count(*) + 1` under a row lock or a dedicated per-`(task_id, attempt)` counter/sequence — an implementation detail to resolve in tasks.md, not an architectural fork.
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
Nobody can see a task while it is running. Host Agent's local console only shows a coarse "current assignment" snapshot (task id, device id, goal, started-at) because the `TaskRunner` it drives is wired with `metadata_store=None, timeline=None` — every step transition happens in memory and is discarded the instant the assignment finishes. Cloud Control Plane only learns about an assignment at claim, heartbeat, and terminal-result time, so Cloud Console has nothing better to show. The local Runtime (`api/rest.py` + `console/`) is a separate process with its own independent `TaskMetadataStore`/`Timeline`, so it never sees a task that a Host Agent executed at all. An operator debugging a stuck or misbehaving task currently has no live signal anywhere in the system until the task finishes or times out.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Wire Host Agent's `AssignmentExecutor` / `create_execution_factories()` with a real `metadata_store` and `timeline` (or Host-Agent-local equivalents) so the in-process `TaskRunner` actually records step-by-step status instead of discarding it.
|
||||
- Expose that step-level detail through Host Agent's local console: extend `AgentStatusTracker`/`/api/status` (or add a focused endpoint) with current step index, step status, and a short in-progress step log; render it in the dashboard's "Current assignment" section instead of the static started-at-only view.
|
||||
- Add a progress-reporting path from Host Agent to Cloud Control Plane so the control plane learns step-level state near-real-time rather than only at claim/heartbeat/terminal-result. Extend the existing `cloud.internal_api.models` Pydantic schema (the single source of truth Host Agent already imports directly) rather than introducing a parallel schema.
|
||||
- Persist and expose the latest per-assignment progress on the Cloud Control Plane side, and surface it in Cloud Console so an operator watching a remote task sees live step progress, not just "dispatched" / "succeeded" / "failed".
|
||||
- Give an operator a web console view of Host-Agent-executed task progress without making the `runtime/`-owned packages import host or cloud concerns (enforced by `test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`). After evaluating the alternative of making the Runtime `console/` SPA multi-backend (pointing its existing JS bundle at a Host Agent's console origin), this change instead extends Host Agent's own server-rendered local console with read-only task list/detail/timeline pages, reusing the same query shape `api/console.py` already exposes to the Runtime SPA. This avoids new cross-origin/session-cookie surface between the SPA and Host Agent, and keeps `runtime/` untouched. See design.md for the full trade-off analysis.
|
||||
- All three surfaces continue to use polling (matching current behavior); this change does not introduce SSE/WebSocket infrastructure unless design.md finds a compelling reason to.
|
||||
- Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` recording path so a persisted step's "prompt" is the *actual* prompt sent to the LLM for that step (not the task's overall goal) and the model's resulting decision is captured too — this pre-existing gap affects Runtime and Host Agent alike and undermines the step-level detail this change otherwise adds.
|
||||
- Persist a durable, per-step log of full LLM prompt/response content on the Cloud Control Plane, for centralized troubleshooting — reusing the already-existing `cloud-planner-proxy` decide endpoint as the capture point (no new protocol/endpoint) rather than the coarse lease-renewal piggyback used for live index/status. This durable log is populated only for hosts using the `cloud` planner transport; hosts on the `direct` transport still get only the coarse index/status via lease renewal. Cloud Console gains a view to browse a task's full LLM interaction history.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `host-agent-task-progress`: Host Agent captures step-level execution progress for its in-flight assignment (via a wired `metadata_store`/`timeline`) and exposes it through its local console/API.
|
||||
- `cloud-task-progress-visibility`: Cloud Control Plane receives, persists, and exposes near-real-time step-level progress for assignments it has dispatched to a Host Agent, and Cloud Console renders it.
|
||||
- `host-agent-console-task-pages`: Host Agent's local server-rendered console gains read-only task list/detail/timeline pages (mirroring `api/console.py`'s task query shape) so an operator can inspect a Host-Agent-executed task's progress and history without needing the separate Runtime `console/` SPA or violating the `runtime/`-package host/cloud isolation boundary.
|
||||
|
||||
### Modified Capabilities
|
||||
- `host-agent-protocol`: add a requirement that the Host Agent reports in-progress step-level status updates to the control plane (in addition to the existing heartbeat/claim/renewal/result operations), and that the control plane accepts and stores them per active assignment.
|
||||
- `cloud-planner-proxy`: the existing planner-decision endpoint additionally persists each resolved decision's prompt/response into a durable, bounded-retention per-step log, instead of discarding it after the response is returned.
|
||||
|
||||
## Impact
|
||||
|
||||
- `apps/device-host-agent/host_agent/app.py`, `execution.py`, `assignment.py`, `status.py`, `web/app.py` — wire a real metadata/timeline store into the in-process `TaskRunner`, extend status tracking and the local console UI/API.
|
||||
- `runtime/task.py`, `runtime/ai_planner.py`, `runtime/tool_calling_client.py`, `storage/timeline.py` — fix the shared step-recording path so the real per-step prompt and the model's response are captured, not just the task goal and parsed tool call (a correctness fix in already-existing, shared code, not new behavior scope).
|
||||
- `packages/cloud-platform/cloud/internal_api/models.py`, `packages/cloud-platform/cloud/internal_api/api.py` (`decide_planner_call`), `repository.py`/`sql_repository.py`, a new Alembic migration — new progress-reporting request/response models and a persistence + query path for latest per-assignment coarse progress, *and* a new durable per-step `planner_decision_log` table (with its own retention job) populated from the existing planner-decision endpoint.
|
||||
- `cloud-console/` (Vue3 SPA) — new UI to render live per-assignment progress, and a new view to browse a task's full LLM interaction history.
|
||||
- `apps/device-host-agent/host_agent/web/app.py` — new read-only task list/detail/timeline pages backed by the Host-Agent-local `TaskMetadataStore`/`Timeline`; no changes anticipated to `console/` (Vue3 SPA).
|
||||
- No changes anticipated to `driver/`, `device/`, `core/` device-control internals.
|
||||
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cloud Control Plane persists each resolved planner decision for later retrieval
|
||||
The Cloud Control Plane's planner-decision endpoint SHALL, for each request it successfully resolves to a tool-call decision, persist the request's system prompt, user prompt, the resolved tool name and arguments, and an assigned step index (scoped to the request's `task_id`/`attempt`) to a durable, bounded-retention store, in addition to returning the decision to the requesting Host Agent. The Cloud Control Plane SHALL NOT persist screenshot bytes from these requests.
|
||||
|
||||
#### Scenario: A planner-decision request resolves successfully
|
||||
- **WHEN** the Cloud Control Plane resolves a planner-decision request to a tool-call decision
|
||||
- **THEN** it persists the request's system prompt, user prompt, the resolved tool name and arguments, and a step index for that `task_id`/`attempt`, before returning the decision to the Host Agent
|
||||
|
||||
#### Scenario: A planner-decision request fails
|
||||
- **WHEN** the configured provider call fails and the endpoint returns a structured failure response
|
||||
- **THEN** no row is persisted for that request
|
||||
|
||||
#### Scenario: A screenshot was included in the request
|
||||
- **WHEN** a planner-decision request includes a screenshot
|
||||
- **THEN** the screenshot bytes are used only to call the LLM provider and are not written to the persisted decision log
|
||||
|
||||
### Requirement: Persisted planner decisions are retained within a bounded window
|
||||
The Cloud Control Plane SHALL prune persisted planner decisions once their owning task has been in a terminal state for longer than a configurable retention window, so that indefinite operation does not cause unbounded growth of the decision log.
|
||||
|
||||
#### Scenario: A task's retention window has elapsed since reaching a terminal state
|
||||
- **WHEN** a task reached a terminal state more than the configured retention window ago
|
||||
- **THEN** the Cloud Control Plane removes that task's persisted planner decisions
|
||||
|
||||
#### Scenario: A task is still active or within its retention window
|
||||
- **WHEN** a task is still active, or reached a terminal state less than the configured retention window ago
|
||||
- **THEN** its persisted planner decisions remain available for query
|
||||
|
||||
### Requirement: Cloud-side planner decision history is scoped to hosts using the cloud-proxy transport
|
||||
The Cloud Control Plane's persisted planner decision log SHALL only ever contain entries for hosts whose planner calls were routed through the cloud-proxy transport; it SHALL NOT contain entries, synthesized or otherwise, for hosts using the direct-to-provider transport.
|
||||
|
||||
#### Scenario: A host uses the direct-to-provider transport
|
||||
- **WHEN** a Host Agent configured for the direct-to-provider transport executes an assignment
|
||||
- **THEN** no planner decision entries for that assignment appear in the Cloud Control Plane's persisted decision log
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cloud Control Plane exposes the latest in-progress step status for an active assignment
|
||||
The Cloud Control Plane's existing task query surface SHALL include the latest reported step index, step status, and summary for any assignment that has an in-progress Host Agent execution, alongside the task's existing status fields.
|
||||
|
||||
#### Scenario: An assignment has reported progress
|
||||
- **WHEN** an operator queries a task that has an active, in-progress assignment with previously reported step progress
|
||||
- **THEN** the response includes that step's index, status, and summary alongside the task's existing fields
|
||||
|
||||
#### Scenario: An assignment has no reported progress yet
|
||||
- **WHEN** an operator queries a task whose active assignment has not yet reported any progress
|
||||
- **THEN** the response omits progress fields rather than showing stale or default values
|
||||
|
||||
#### Scenario: An assignment has reached a terminal state
|
||||
- **WHEN** an operator queries a task whose assignment has already completed (succeeded or failed)
|
||||
- **THEN** the response does not present the last in-progress step as current status; the task's terminal status and result take precedence
|
||||
|
||||
### Requirement: Cloud Console renders live step progress for in-progress tasks
|
||||
Cloud Console SHALL display the current step index, status, and summary for a task with an active, in-progress Host Agent execution, refreshed on its existing polling interval, without requiring a new push channel.
|
||||
|
||||
#### Scenario: Operator views an in-progress task
|
||||
- **WHEN** an operator opens a task detail view for a task with an active in-progress assignment
|
||||
- **THEN** Cloud Console shows the latest known step index, status, and summary, updating on subsequent polls as new progress is reported
|
||||
|
||||
#### Scenario: Operator views a task with no in-progress execution
|
||||
- **WHEN** an operator opens a task detail view for a queued, terminal, or otherwise not-currently-executing task
|
||||
- **THEN** Cloud Console does not display stale in-progress step information
|
||||
|
||||
### 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 and resulting decision, sourced from the Cloud Control Plane's persisted planner decision log.
|
||||
|
||||
#### Scenario: Task has persisted planner decisions
|
||||
- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions
|
||||
- **THEN** Cloud Console shows each decision in step order with its prompt and resulting tool call
|
||||
|
||||
#### Scenario: Task's host used the direct-to-provider transport
|
||||
- **WHEN** an operator opens the LLM interaction history view for a task whose host used the direct-to-provider transport
|
||||
- **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
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host Agent local console exposes step-level status for the current assignment
|
||||
The Host Agent's local console SHALL display, for its currently executing assignment, the current step index, step status, and a short summary, sourced from the Host Agent's local task metadata store, refreshed on the console's existing polling interval.
|
||||
|
||||
#### Scenario: An assignment is currently executing
|
||||
- **WHEN** an operator views the Host Agent local console dashboard while an assignment is executing
|
||||
- **THEN** the dashboard shows the current step index, step status, and a short summary for that assignment, updating on subsequent polls
|
||||
|
||||
#### Scenario: No assignment is currently executing
|
||||
- **WHEN** an operator views the dashboard while the Host Agent is idle
|
||||
- **THEN** the dashboard shows no in-progress step information
|
||||
|
||||
### Requirement: Host Agent local console exposes read-only task history with per-step detail and screenshots
|
||||
The Host Agent's local console SHALL provide authenticated, read-only pages listing recently executed tasks and, for a selected task, its full per-step history including any captured screenshots, sourced from the Host Agent's local task metadata store and timeline.
|
||||
|
||||
#### Scenario: Operator lists recent tasks
|
||||
- **WHEN** an authenticated operator opens the Host Agent local console's task list page
|
||||
- **THEN** it shows tasks from the local task metadata store, most recent first, including tasks that have already reached a terminal state
|
||||
|
||||
#### Scenario: Operator inspects a completed task's step history
|
||||
- **WHEN** an authenticated operator opens the detail page for a specific completed task
|
||||
- **THEN** the page shows each recorded step in order, including its tool call, result, and any captured screenshot
|
||||
|
||||
#### Scenario: Unauthenticated request
|
||||
- **WHEN** a request to the task list or task detail pages is made without a valid Host Agent console session
|
||||
- **THEN** the Host Agent rejects the request the same way it rejects unauthenticated requests to its other console pages
|
||||
|
||||
### Requirement: Host Agent local console task pages require no new cross-origin surface
|
||||
The Host Agent local console's task pages SHALL be served same-origin from the Host Agent's existing web application, without introducing new CORS allowances or a dependency on the separate Runtime `console/` frontend.
|
||||
|
||||
#### Scenario: Task pages are requested
|
||||
- **WHEN** an operator's browser requests the Host Agent local console's task pages
|
||||
- **THEN** the pages are served by the Host Agent's own application using its existing session/CSRF protections, with no additional cross-origin configuration required
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host Agent reports execution progress alongside lease renewal
|
||||
The Host Agent SHALL optionally include a bounded, screenshot-free progress summary (current step index, step status, and a short plain-text summary) in its periodic lease-renewal request for an active assignment, and the control plane SHALL accept and store only the most recent such summary per active assignment.
|
||||
|
||||
#### Scenario: Progress is available at renewal time
|
||||
- **WHEN** the Host Agent renews the lease for an in-progress assignment and has a current step index, status, and summary available
|
||||
- **THEN** the renewal request includes that progress summary and the control plane overwrites any previously stored progress for that assignment with it
|
||||
|
||||
#### Scenario: Progress is not available at renewal time
|
||||
- **WHEN** the Host Agent renews a lease without a progress summary available (e.g. before the first step completes)
|
||||
- **THEN** the renewal request omits the progress field and any previously stored progress for that assignment is left unchanged
|
||||
|
||||
#### Scenario: Assignment reaches a terminal state
|
||||
- **WHEN** an assignment's terminal result is recorded
|
||||
- **THEN** the control plane's stored progress for that assignment is no longer treated as current and is not exposed as an in-progress status
|
||||
|
||||
### Requirement: Progress reports exclude screenshot and scene payloads
|
||||
The control plane SHALL reject or ignore any progress field on a renewal request that includes screenshot, scene, or other bulk payload data beyond the bounded step index, status, and short text summary.
|
||||
|
||||
#### Scenario: Renewal request includes an oversized or non-text summary
|
||||
- **WHEN** a Host Agent submits a progress summary exceeding the configured length bound
|
||||
- **THEN** the control plane truncates or rejects the oversized field without failing the underlying lease renewal
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host Agent records step-level execution detail for its in-process TaskRunner
|
||||
The Host Agent SHALL construct its in-process `TaskRunner` with a durable metadata store and timeline so that every step transition (status, index, the actual prompt submitted to the LLM for that step, the model's resulting decision, result, and screenshot when captured) is persisted as it happens, rather than discarded when the assignment completes. The persisted prompt SHALL be the prompt actually sent to the LLM for that specific step, not the task's overall goal.
|
||||
|
||||
#### Scenario: A step completes during goal execution
|
||||
- **WHEN** the Host Agent's `TaskRunner` completes a step while executing an assigned goal
|
||||
- **THEN** the step's status, index, the actual per-step LLM prompt and response, tool call, result, and any captured screenshot are persisted to the Host Agent's local task metadata store and timeline before the next step begins
|
||||
|
||||
#### Scenario: An assignment finishes
|
||||
- **WHEN** an assignment reaches a terminal state (succeeded or failed)
|
||||
- **THEN** its full step history remains queryable from the Host Agent's local store after the in-memory `Task` object is discarded
|
||||
|
||||
### Requirement: Host-Agent-local task history is retained within a bounded window
|
||||
The Host Agent SHALL prune persisted task metadata, timeline records, and associated screenshot artifacts once they exceed a configurable retention window or count, so that indefinite process uptime does not cause unbounded local disk growth.
|
||||
|
||||
#### Scenario: Retention window is exceeded
|
||||
- **WHEN** a persisted task's age or position exceeds the configured retention threshold
|
||||
- **THEN** the Host Agent removes that task's metadata row, timeline records, and screenshot artifacts from local storage
|
||||
|
||||
#### Scenario: Retention has not been exceeded
|
||||
- **WHEN** a persisted task is within the configured retention threshold
|
||||
- **THEN** its metadata, timeline records, and screenshot artifacts remain available for query
|
||||
|
||||
### Requirement: Host Agent local task storage is isolated from an unrelated local Runtime
|
||||
The Host Agent SHALL use a configurable, Host-Agent-specific database and artifact path for its task metadata store and timeline, distinct from any local Runtime API's own task storage path, so that the two processes cannot silently collide or share state when run on the same machine.
|
||||
|
||||
#### Scenario: Host Agent and local Runtime run on the same machine
|
||||
- **WHEN** both a Host Agent process and a local Runtime API process run on the same machine with their default configurations
|
||||
- **THEN** each process reads and writes its own task metadata store and timeline without observing or modifying the other's data
|
||||
@@ -0,0 +1,68 @@
|
||||
## 1. Host Agent local task storage (D1, D2)
|
||||
|
||||
- [x] 1.1 Add `HOST_AGENT_TASK_PROGRESS_DB_PATH` (and matching artifact directory config) to `HostAgentConfig`/`load_host_agent_config()`, with a Host-Agent-specific default distinct from any local Runtime `tasks/` path.
|
||||
- [x] 1.2 In `HostAgentApplication`'s builder (`app.py:218-223`), construct a `TaskMetadataStore`/`Timeline` from that config and pass them into `create_execution_factories(..., metadata_store=..., timeline=...)`.
|
||||
- [x] 1.3 Add delete/prune methods to `storage/task_metadata.py::TaskMetadataStore` and `storage/timeline.py::Timeline` (remove a task's row, timeline records, and screenshot artifacts).
|
||||
- [x] 1.4 Implement a bounded retention pass in the Host Agent (default: keep the newer of "last 50 tasks" or "7 days", whichever keeps fewer rows) that runs after each assignment finishes, calling the new prune methods.
|
||||
- [x] 1.5 Add unit tests: step transitions persist during execution; a task's history is queryable after the assignment completes and the in-memory `Task` is discarded; retention prunes tasks beyond the configured threshold; Host Agent and a local Runtime process using default paths on the same machine do not collide.
|
||||
|
||||
## 2. Host Agent reports progress during lease renewal (D4)
|
||||
|
||||
- [x] 2.1 Add `TaskProgressModel` (`step_index: int`, `step_status`, `summary: str` with a bounded max length) to `packages/cloud-platform/cloud/internal_api/models.py`, and an optional `progress: TaskProgressModel | None` field on `LeaseRenewalRequest`.
|
||||
- [x] 2.2 Add a thread-safe "latest progress" holder written by the Host Agent's execution thread (hook into `TaskRunner`/`AssignmentExecutor` per-step completion) and read by `ActiveAssignmentRunner._renew_while_running` (`lease.py`) just before each renewal call.
|
||||
- [x] 2.3 Update `HostAgentClient.renew()` to include the current progress snapshot (if any) in the renewal request.
|
||||
- [x] 2.4 Add unit tests: renewal includes progress once a step has completed; renewal omits progress before any step completes; an oversized summary is truncated/rejected client-side before sending.
|
||||
|
||||
## 3. Cloud Control Plane persists latest progress (D5)
|
||||
|
||||
- [x] 3.1 Add progress columns (`progress_step_index`, `progress_step_status`, `progress_summary`, `progress_updated_at`, all nullable) via a new Alembic migration (`0008_task_progress_columns`) with a down-revision that drops them.
|
||||
- [x] 3.2 Add the equivalent columns/handling to the SQLite schema path.
|
||||
- [x] 3.3 Extend `renew_lease()` in both `repository.py` and `sql_repository.py` to accept and overwrite the optional progress fields under the same row lock already taken for the lease-expiry update.
|
||||
- [x] 3.4 Update `renew_assignment` in `internal_api/api.py` to pass the optional `payload.progress` fields through to `renew_lease()`, rejecting/truncating an oversized `summary` without failing the underlying renewal.
|
||||
- [x] 3.5 Ensure progress fields are treated as stale/ignored once `record_task_result` records a terminal outcome for that task/attempt.
|
||||
- [x] 3.6 Add/extend repository contract tests (both SQLite and PostgreSQL where the existing test setup covers it) so the two backends stay in parity for the new columns.
|
||||
|
||||
## 4. Cloud Console displays live progress (D6)
|
||||
|
||||
- [x] 4.1 Extend the Cloud API's existing task list/detail response model with the optional latest-progress fields from section 3.
|
||||
- [x] 4.2 Extend `cloud-console/`'s task list/detail view to render step index, status, and summary for a task with an active in-progress assignment, refreshed on its existing polling interval; show nothing when no progress is present or the task is terminal.
|
||||
- [x] 4.3 Add Vitest coverage for the new progress rendering (present, absent, and terminal-task-hides-stale-progress cases).
|
||||
|
||||
## 5. Host Agent local console task pages (D3)
|
||||
|
||||
- [x] 5.1 Extend `AgentStatusTracker`/`/api/status` (or a small addition alongside it) to surface the current step index/status/summary for the in-progress assignment, sourced from the same progress holder built in section 2, and update the dashboard's "Current assignment" rendering to show it.
|
||||
- [x] 5.2 Add authenticated, read-only task list and task detail/timeline routes to `host_agent/web/app.py`, querying the Host-Agent-local `TaskMetadataStore`/`Timeline` (reusing the same query calls `api/console.py` makes) and rendering server-side HTML consistent with the existing dashboard's f-string + `html.escape()` convention, including inlined screenshots on the detail/timeline page.
|
||||
- [x] 5.3 Gate the new routes behind the existing Host Agent console session/CSRF protection; verify no new CORS configuration is introduced.
|
||||
- [x] 5.4 Add tests: unauthenticated requests to the new routes are rejected the same way as other console routes; task list/detail/timeline pages render expected data including screenshots for a completed task.
|
||||
|
||||
## 6. Documentation and verification
|
||||
|
||||
- [x] 6.1 Update `docs/MACOS_IPHONE_SETUP.md` and/or `docs/CLOUD_DEPLOYMENT.md` with the new Host Agent config vars (`HOST_AGENT_TASK_PROGRESS_DB_PATH` and retention settings) and a short note on where to view live/historical task progress in each console.
|
||||
- [x] 6.2 Run `uv run --all-packages pytest -m "not integration"` and targeted Cloud API / Host Agent test suites; run `cloud-console/` and `console/`-equivalent Vitest suites for the touched frontend.
|
||||
- [x] 6.3 Run Ruff check/format and `compileall` across touched packages.
|
||||
- [x] 6.4 Run `openspec validate --strict` for this change.
|
||||
- [ ] 6.5 Manual verification (requires a real Host Agent + Appium/device setup per `docs/MACOS_IPHONE_SETUP.md`): run a real task end-to-end and confirm step progress appears live in the Host Agent console and Cloud Console, and that full step history with screenshots is browsable afterward in the Host Agent console.
|
||||
|
||||
## 7. Real per-step LLM prompt/response recorded locally (D9)
|
||||
|
||||
- [x] 7.1 Extend `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) with the actual prompt content given to that call (or return it via a small side-channel from `AIPlanner.plan()`, not by changing the `Planner.plan()` return type used by other planners).
|
||||
- [x] 7.2 Update `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) to pass the real per-step prompt and the model's resulting decision into `Timeline.append()`, instead of `task.goal`.
|
||||
- [x] 7.3 Update `storage/timeline.py`'s `Timeline`/`TimelineRecord` field(s) so the persisted meaning is unambiguously "the prompt actually sent to the LLM for this step" (rename or add a field; keep backward-compatible read of any already-persisted rows if a Runtime dev DB might already have old-shaped rows).
|
||||
- [x] 7.4 Update any renderer of this data (`api/console.py`, Runtime `console/`, and the new Host Agent console task pages from section 5) to show the corrected field.
|
||||
- [x] 7.5 Add unit tests: a persisted step's prompt matches what `AIPlanner.plan()` actually sent for that step (not the task goal), across at least one multi-step task.
|
||||
|
||||
## 8. Cloud persists full per-step LLM decision history via the existing cloud-planner-proxy endpoint (D7, D8)
|
||||
|
||||
- [x] 8.1 Add a new `planner_decision_log` table (`id`, `host_id`, `task_id`, `attempt`, `step_index`, `system_prompt`, `user_prompt`, `tool_name`, `arguments_json`, `created_at`) via a new Alembic migration, plus the equivalent SQLite schema/table, on both `repository.py` and `sql_repository.py`.
|
||||
- [x] 8.2 Add a repository method (e.g. `record_planner_decision(...)`) on both backends that inserts one row, assigning `step_index` as the next value scoped to `(task_id, attempt)`.
|
||||
- [x] 8.3 Extend `decide_planner_call` (`packages/cloud-platform/cloud/internal_api/api.py:367-488`) to call the new repository method with the resolved decision, immediately after `settle_host_token_reservation`, on the success path only (never on the `ToolCallUnavailable`/502 path).
|
||||
- [x] 8.4 Add a retention/prune job for `planner_decision_log` (default: prune a task's rows once it has been terminal for longer than a configurable window), mirroring section 1's Host Agent retention approach.
|
||||
- [x] 8.5 Add/extend repository contract tests so SQLite and PostgreSQL stay in parity for the new table and prune job.
|
||||
- [x] 8.6 Add unit tests: a successful decision is persisted with the correct prompt/tool_name/arguments and an incrementing step_index; a failed decision persists nothing; screenshot bytes are never written to the log even when the request included one; retention prunes rows for long-terminal tasks.
|
||||
|
||||
## 9. Cloud Console browses full LLM interaction history (D7/D8 UI)
|
||||
|
||||
- [x] 9.1 Add a Cloud API query endpoint (or extend an existing task-detail endpoint) to list `planner_decision_log` rows for a task in step order.
|
||||
- [x] 9.2 Add a `cloud-console/` view rendering a task's full LLM interaction history (prompt + resulting decision per step).
|
||||
- [x] 9.3 When a task's host used the `direct` transport (no persisted decisions and the host's configured transport is known to be `direct`), show an explicit "not reported by this host's transport" state rather than an empty list.
|
||||
- [x] 9.4 Add Vitest coverage for populated history, empty-but-cloud-transport (task hasn't produced any decisions yet), and direct-transport-hidden cases.
|
||||
@@ -38,6 +38,8 @@ class CloudControlConfig:
|
||||
trust_proxy_headers: bool = False
|
||||
planner_token_reservation_ceiling: int = 4096
|
||||
planner_token_reservation_ttl_seconds: int = 300
|
||||
planner_decision_log_prune_interval_seconds: float = 3600.0
|
||||
planner_decision_log_retention_days: int = 7
|
||||
|
||||
|
||||
def load_control_config(
|
||||
@@ -127,6 +129,16 @@ def load_control_config(
|
||||
"CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS",
|
||||
300,
|
||||
),
|
||||
planner_decision_log_prune_interval_seconds=_positive_float(
|
||||
values,
|
||||
"CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS",
|
||||
3600.0,
|
||||
),
|
||||
planner_decision_log_retention_days=_positive_int(
|
||||
values,
|
||||
"CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS",
|
||||
7,
|
||||
),
|
||||
)
|
||||
validate_control_config(config)
|
||||
return config
|
||||
@@ -142,9 +154,7 @@ def validate_control_config(config: CloudControlConfig) -> None:
|
||||
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
|
||||
)
|
||||
if config.environment == "production" and not config.session_cookie_secure:
|
||||
raise CloudConfigurationError(
|
||||
"production requires secure user session cookies"
|
||||
)
|
||||
raise CloudConfigurationError("production requires secure user session cookies")
|
||||
|
||||
|
||||
def _positive_float(
|
||||
|
||||
@@ -106,6 +106,10 @@ class ScheduledTaskRow(Base):
|
||||
)
|
||||
lease_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
lease_expires_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
progress_step_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
progress_step_status: 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)
|
||||
failure_reason: 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)
|
||||
@@ -128,6 +132,31 @@ class TaskAttemptRow(Base):
|
||||
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class PlannerDecisionLogRow(Base):
|
||||
__tablename__ = "planner_decision_log"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_planner_decision_log_task_attempt",
|
||||
"task_id",
|
||||
"attempt",
|
||||
"step_index",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_planner_decision_log_host_created", "host_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
host_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
task_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
arguments_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class PluginRow(Base):
|
||||
__tablename__ = "plugins"
|
||||
|
||||
@@ -141,7 +170,9 @@ class PluginRow(Base):
|
||||
class UserRow(Base):
|
||||
__tablename__ = "cloud_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username_normalized", name="uq_cloud_users_username_normalized"),
|
||||
UniqueConstraint(
|
||||
"username_normalized", name="uq_cloud_users_username_normalized"
|
||||
),
|
||||
Index("ix_cloud_users_enabled_role", "enabled", "role"),
|
||||
)
|
||||
|
||||
@@ -151,7 +182,9 @@ class UserRow(Base):
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False)
|
||||
enabled: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1"))
|
||||
enabled: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=1, server_default=text("1")
|
||||
)
|
||||
must_change_password: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
@@ -43,6 +44,7 @@ from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
|
||||
from cloud.planner_config import build_cloud_planner_client
|
||||
from cloud.provider_secrets import ProviderSecretConfigurationError
|
||||
from cloud.repository import (
|
||||
AssignmentProgressSnapshot,
|
||||
DeviceEnrollmentConflictError,
|
||||
HostEnrollmentConflictError,
|
||||
)
|
||||
@@ -79,8 +81,11 @@ def create_internal_router(
|
||||
if planner_token_reservation_ceiling <= 0:
|
||||
raise ValueError("planner_token_reservation_ceiling must be greater than zero")
|
||||
if planner_token_reservation_ttl_seconds <= 0:
|
||||
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
|
||||
raise ValueError(
|
||||
"planner_token_reservation_ttl_seconds must be greater than zero"
|
||||
)
|
||||
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
|
||||
|
||||
def authorize_host(request: Request, host_id: str) -> None:
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
@@ -303,6 +308,14 @@ def create_internal_router(
|
||||
)
|
||||
now = utc_now()
|
||||
lease_expires_at = now + timedelta(seconds=lease_duration_seconds)
|
||||
progress_snapshot: AssignmentProgressSnapshot | None = None
|
||||
if payload.progress is not None:
|
||||
progress_snapshot = AssignmentProgressSnapshot(
|
||||
step_index=payload.progress.step_index,
|
||||
step_status=payload.progress.step_status,
|
||||
summary=payload.progress.summary[:500],
|
||||
updated_at=now,
|
||||
)
|
||||
renewal_status = pool.store.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=payload.attempt,
|
||||
@@ -310,6 +323,7 @@ def create_internal_router(
|
||||
host_id=host_id,
|
||||
lease_expires_at=lease_expires_at,
|
||||
now=now,
|
||||
progress=progress_snapshot,
|
||||
)
|
||||
if renewal_status == "not_found":
|
||||
raise HTTPException(
|
||||
@@ -407,7 +421,9 @@ def create_internal_router(
|
||||
resolved_provider = resolved.profile.provider_type
|
||||
resolved_model = resolved.profile.model
|
||||
else:
|
||||
raise LlmProviderResolutionError("no database Provider resolver configured")
|
||||
raise LlmProviderResolutionError(
|
||||
"no database Provider resolver configured"
|
||||
)
|
||||
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
|
||||
logger.info(
|
||||
"planner-decision request failed",
|
||||
@@ -429,7 +445,8 @@ def create_internal_router(
|
||||
task_id=payload.task_id,
|
||||
attempt=payload.attempt,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(seconds=planner_token_reservation_ttl_seconds),
|
||||
expires_at=now
|
||||
+ timedelta(seconds=planner_token_reservation_ttl_seconds),
|
||||
)
|
||||
except TokenBudgetExceededError as exc:
|
||||
return JSONResponse(
|
||||
@@ -460,7 +477,11 @@ def create_internal_router(
|
||||
content=PlannerDecisionError(detail=str(exc)).model_dump(),
|
||||
)
|
||||
usage = decision.usage
|
||||
if reservation is not None and usage is not None and usage.total_tokens is not None:
|
||||
if (
|
||||
reservation is not None
|
||||
and usage is not None
|
||||
and usage.total_tokens is not None
|
||||
):
|
||||
pool.store.settle_host_token_reservation(
|
||||
reservation_id=reservation.id,
|
||||
event_id=uuid4().hex,
|
||||
@@ -471,6 +492,17 @@ def create_internal_router(
|
||||
total_tokens=usage.total_tokens,
|
||||
occurred_at=utc_now(),
|
||||
)
|
||||
if payload.task_id and payload.attempt:
|
||||
pool.store.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=payload.task_id,
|
||||
attempt=payload.attempt,
|
||||
system_prompt=payload.system_prompt,
|
||||
user_prompt=payload.user_prompt,
|
||||
tool_name=decision.tool_name,
|
||||
arguments_json=json.dumps(decision.arguments),
|
||||
now=utc_now(),
|
||||
)
|
||||
logger.info(
|
||||
"planner-decision request resolved",
|
||||
extra={
|
||||
@@ -504,7 +536,9 @@ def _validate_assignment_identity(
|
||||
)
|
||||
|
||||
|
||||
def _validate_planner_context(pool, *, host_id: str, payload: PlannerDecisionRequest) -> None:
|
||||
def _validate_planner_context(
|
||||
pool, *, host_id: str, payload: PlannerDecisionRequest
|
||||
) -> None:
|
||||
context_values = (payload.task_id, payload.attempt, payload.lease_id)
|
||||
if not any(value is not None for value in context_values):
|
||||
return
|
||||
|
||||
@@ -78,11 +78,18 @@ class ClaimResponse(BaseModel):
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
class TaskProgressModel(BaseModel):
|
||||
step_index: int = Field(ge=0)
|
||||
step_status: Literal["running", "completed", "failed"]
|
||||
summary: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class LeaseRenewalRequest(BaseModel):
|
||||
host_id: str = Field(min_length=1)
|
||||
task_id: str = Field(min_length=1)
|
||||
attempt: int = Field(ge=1)
|
||||
lease_id: str = Field(min_length=1)
|
||||
progress: TaskProgressModel | None = None
|
||||
|
||||
|
||||
class LeaseRenewalResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add nullable per-assignment progress snapshot columns to scheduled_tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0008_task_progress_columns"
|
||||
down_revision = "0007_llm_provider_management"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_step_index", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_step_status", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_summary", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_updated_at", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduled_tasks", "progress_updated_at")
|
||||
op.drop_column("scheduled_tasks", "progress_summary")
|
||||
op.drop_column("scheduled_tasks", "progress_step_status")
|
||||
op.drop_column("scheduled_tasks", "progress_step_index")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add planner_decision_log table for D7/D8 full LLM interaction history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0009_planner_decision_log"
|
||||
down_revision = "0008_task_progress_columns"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"planner_decision_log",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("host_id", sa.String(), nullable=False),
|
||||
sa.Column("task_id", sa.String(), nullable=False),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False),
|
||||
sa.Column("step_index", sa.Integer(), nullable=False),
|
||||
sa.Column("system_prompt", sa.Text(), nullable=False),
|
||||
sa.Column("user_prompt", sa.Text(), nullable=False),
|
||||
sa.Column("tool_name", sa.String(), nullable=False),
|
||||
sa.Column("arguments_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.String(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_planner_decision_log_task_attempt",
|
||||
"planner_decision_log",
|
||||
["task_id", "attempt", "step_index"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_planner_decision_log_host_created",
|
||||
"planner_decision_log",
|
||||
["host_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_planner_decision_log_host_created", table_name="planner_decision_log"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_planner_decision_log_task_attempt", table_name="planner_decision_log"
|
||||
)
|
||||
op.drop_table("planner_decision_log")
|
||||
@@ -22,7 +22,11 @@ if TYPE_CHECKING:
|
||||
TokenUsageEvent,
|
||||
UserSubmissionPolicy,
|
||||
)
|
||||
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
|
||||
from cloud.llm_providers import (
|
||||
LlmProviderProfile,
|
||||
LlmProviderSettings,
|
||||
ProviderType,
|
||||
)
|
||||
|
||||
|
||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||
@@ -99,6 +103,39 @@ class LeasedAssignment:
|
||||
workflow_definition_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssignmentProgressSnapshot:
|
||||
"""Latest in-progress step snapshot for an active assignment.
|
||||
|
||||
Mirrors the wire ``TaskProgressModel`` fields; carried through the
|
||||
repository layer without depending on Pydantic models.
|
||||
|
||||
``None`` on the ``renew_lease`` parameter means "no progress reported
|
||||
for this renewal" (leave any previously stored progress untouched).
|
||||
"""
|
||||
|
||||
step_index: int
|
||||
step_status: str
|
||||
summary: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannerDecisionRecord:
|
||||
"""One persisted planner decision row (D7/D8)."""
|
||||
|
||||
id: str
|
||||
host_id: str
|
||||
task_id: str
|
||||
attempt: int
|
||||
step_index: int
|
||||
system_prompt: str
|
||||
user_prompt: str
|
||||
tool_name: str
|
||||
arguments_json: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CloudRepository(Protocol):
|
||||
"""Persistence port for cloud state and atomic scheduling operations."""
|
||||
|
||||
@@ -286,13 +323,17 @@ class CloudRepository(Protocol):
|
||||
block_duration: timedelta,
|
||||
) -> LoginThrottle: ...
|
||||
|
||||
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: ...
|
||||
def clear_login_throttle(
|
||||
self, username_normalized: str, client_bucket: str
|
||||
) -> None: ...
|
||||
|
||||
def record_auth_audit(self, event: AuthAuditEvent) -> None: ...
|
||||
|
||||
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ...
|
||||
|
||||
def get_user_submission_policy(self, user_id: str) -> UserSubmissionPolicy | None: ...
|
||||
def get_user_submission_policy(
|
||||
self, user_id: str
|
||||
) -> UserSubmissionPolicy | None: ...
|
||||
|
||||
def upsert_user_submission_policy(
|
||||
self,
|
||||
@@ -305,7 +346,9 @@ class CloudRepository(Protocol):
|
||||
updated_at: datetime,
|
||||
) -> UserSubmissionPolicy: ...
|
||||
|
||||
def get_host_governance_policy(self, host_id: str) -> HostGovernancePolicy | None: ...
|
||||
def get_host_governance_policy(
|
||||
self, host_id: str
|
||||
) -> HostGovernancePolicy | None: ...
|
||||
|
||||
def upsert_host_governance_policy(
|
||||
self,
|
||||
@@ -321,32 +364,58 @@ class CloudRepository(Protocol):
|
||||
def count_active_tasks_for_host(self, host_id: str) -> int: ...
|
||||
|
||||
def reserve_host_token_budget(
|
||||
self, *, reservation_id: str, host_id: str, usage_day: str,
|
||||
reserved_tokens: int, task_id: str | None, attempt: int | None,
|
||||
created_at: datetime, expires_at: datetime,
|
||||
self,
|
||||
*,
|
||||
reservation_id: str,
|
||||
host_id: str,
|
||||
usage_day: str,
|
||||
reserved_tokens: int,
|
||||
task_id: str | None,
|
||||
attempt: int | None,
|
||||
created_at: datetime,
|
||||
expires_at: datetime,
|
||||
) -> TokenReservation | None: ...
|
||||
|
||||
def settle_host_token_reservation(
|
||||
self, *, reservation_id: str, event_id: str, provider: str, model: str,
|
||||
input_tokens: int | None, output_tokens: int | None, total_tokens: int,
|
||||
self,
|
||||
*,
|
||||
reservation_id: str,
|
||||
event_id: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
input_tokens: int | None,
|
||||
output_tokens: int | None,
|
||||
total_tokens: int,
|
||||
occurred_at: datetime,
|
||||
) -> TokenUsageEvent | None: ...
|
||||
|
||||
def cleanup_expired_token_reservations(self, *, now: datetime, limit: int) -> int: ...
|
||||
def cleanup_expired_token_reservations(
|
||||
self, *, now: datetime, limit: int
|
||||
) -> int: ...
|
||||
|
||||
def get_host_token_usage_summary(
|
||||
self, *, host_id: str, usage_day: str, now: datetime,
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
usage_day: str,
|
||||
now: datetime,
|
||||
) -> TokenUsageSummary: ...
|
||||
|
||||
def list_host_token_usage_events(
|
||||
self, *, host_id: str, limit: int, offset: int,
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[TokenUsageEvent]: ...
|
||||
|
||||
def get_llm_provider_settings(self) -> LlmProviderSettings: ...
|
||||
|
||||
def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ...
|
||||
|
||||
def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ...
|
||||
def get_llm_provider_profile(
|
||||
self, profile_id: str
|
||||
) -> LlmProviderProfile | None: ...
|
||||
|
||||
def create_llm_provider_profile(
|
||||
self, profile: LlmProviderProfile
|
||||
@@ -413,6 +482,7 @@ class CloudRepository(Protocol):
|
||||
host_id: str,
|
||||
lease_expires_at: datetime,
|
||||
now: datetime,
|
||||
progress: AssignmentProgressSnapshot | None = None,
|
||||
) -> LeaseRenewalStatus: ...
|
||||
|
||||
def record_task_result(
|
||||
@@ -437,6 +507,43 @@ class CloudRepository(Protocol):
|
||||
|
||||
def list_task_attempts(self, task_id: str) -> list[TaskAttemptRecord]: ...
|
||||
|
||||
def record_planner_decision(
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
task_id: str,
|
||||
attempt: int,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
tool_name: str,
|
||||
arguments_json: str,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
"""Insert one planner-decision log row, returning the assigned step_index.
|
||||
|
||||
``step_index`` is assigned as ``max(step_index) + 1`` scoped to
|
||||
``(task_id, attempt)`` within the same transaction.
|
||||
"""
|
||||
|
||||
def prune_planner_decision_log(
|
||||
self,
|
||||
*,
|
||||
now: datetime,
|
||||
prune_after_terminal_seconds: int,
|
||||
) -> int:
|
||||
"""Delete decision-log rows for tasks terminal more than the window ago.
|
||||
|
||||
Returns the count of deleted rows.
|
||||
"""
|
||||
|
||||
def list_planner_decisions(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
attempt: int,
|
||||
) -> list[PlannerDecisionRecord]:
|
||||
"""Return all decision-log rows for a (task_id, attempt), ordered by step."""
|
||||
|
||||
def health_check(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -53,6 +53,10 @@ class ScheduledTask:
|
||||
failure_reason: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -199,7 +203,10 @@ class TaskScheduler:
|
||||
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
||||
if constraints.target_host_id and device.host_id != constraints.target_host_id:
|
||||
return False
|
||||
if constraints.target_device_id and device.device_id != constraints.target_device_id:
|
||||
if (
|
||||
constraints.target_device_id
|
||||
and device.device_id != constraints.target_device_id
|
||||
):
|
||||
return False
|
||||
if constraints.driver_type and device.driver_type != constraints.driver_type:
|
||||
return False
|
||||
|
||||
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
|
||||
from cloud.database import create_database_engine, normalize_database_url
|
||||
|
||||
|
||||
HEAD_REVISION = "0007_llm_provider_management"
|
||||
HEAD_REVISION = "0009_planner_decision_log"
|
||||
|
||||
|
||||
class SchemaVersionError(RuntimeError):
|
||||
|
||||
@@ -10,6 +10,7 @@ authentication can be added later without changing route signatures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Callable, Literal
|
||||
|
||||
from cloud.auth import (
|
||||
@@ -32,6 +33,8 @@ from cloud.sdk.models import (
|
||||
TaskAttemptResponse,
|
||||
TaskListItem,
|
||||
TaskListResponse,
|
||||
TaskPlannerDecisionItem,
|
||||
TaskPlannerDecisionListResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
@@ -144,14 +147,16 @@ def create_cloud_router(
|
||||
failure_reason=task.failure_reason,
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
progress_step_index=task.progress_step_index,
|
||||
progress_step_status=task.progress_step_status,
|
||||
progress_summary=task.progress_summary,
|
||||
progress_updated_at=task.progress_updated_at,
|
||||
)
|
||||
|
||||
@router.get("/tasks", response_model=TaskListResponse)
|
||||
def list_tasks(
|
||||
request: Request,
|
||||
status_filter: Literal[
|
||||
"queued", "assigned", "dispatched", "done", "failed"
|
||||
]
|
||||
status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
|
||||
| None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
@@ -177,6 +182,10 @@ def create_cloud_router(
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
created_at=task.created_at,
|
||||
progress_step_index=task.progress_step_index,
|
||||
progress_step_status=task.progress_step_status,
|
||||
progress_summary=task.progress_summary,
|
||||
progress_updated_at=task.progress_updated_at,
|
||||
)
|
||||
for task in tasks
|
||||
],
|
||||
@@ -218,6 +227,47 @@ def create_cloud_router(
|
||||
for attempt in attempts
|
||||
]
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/planner-decisions",
|
||||
response_model=TaskPlannerDecisionListResponse,
|
||||
)
|
||||
def list_task_planner_decisions(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
attempt: int = Query(ge=0),
|
||||
) -> TaskPlannerDecisionListResponse:
|
||||
_authorize(request, TASKS_READ_SCOPE)
|
||||
task = scheduler.store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task {task_id!r} not found",
|
||||
)
|
||||
records = scheduler.store.list_planner_decisions(
|
||||
task_id=task_id,
|
||||
attempt=attempt,
|
||||
)
|
||||
items: list[TaskPlannerDecisionItem] = []
|
||||
for rec in records:
|
||||
try:
|
||||
parsed_args = (
|
||||
json.loads(rec.arguments_json) if rec.arguments_json else {}
|
||||
)
|
||||
except Exception:
|
||||
parsed_args = {}
|
||||
items.append(
|
||||
TaskPlannerDecisionItem(
|
||||
step_index=rec.step_index,
|
||||
attempt=rec.attempt,
|
||||
system_prompt=rec.system_prompt,
|
||||
user_prompt=rec.user_prompt,
|
||||
tool_name=rec.tool_name,
|
||||
arguments=parsed_args,
|
||||
created_at=rec.created_at,
|
||||
)
|
||||
)
|
||||
return TaskPlannerDecisionListResponse(items=items)
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceResponse])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_authorize(request, POOL_READ_SCOPE)
|
||||
@@ -332,7 +382,9 @@ def _validate_task_target(pool, constraints) -> None:
|
||||
raise ValueError("target_device_id requires target_host_id")
|
||||
if constraints.target_host_id is None:
|
||||
return
|
||||
if not any(host.host_id == constraints.target_host_id for host in pool.list_hosts()):
|
||||
if not any(
|
||||
host.host_id == constraints.target_host_id for host in pool.list_hosts()
|
||||
):
|
||||
raise ValueError(f"target host {constraints.target_host_id!r} is not known")
|
||||
if constraints.target_device_id is not None and not any(
|
||||
device.host_id == constraints.target_host_id
|
||||
|
||||
@@ -37,6 +37,10 @@ class TaskStatusResponse(BaseModel):
|
||||
failure_reason: str | None = None
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
@@ -51,6 +55,10 @@ class TaskListItem(BaseModel):
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
created_at: datetime
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
@@ -263,3 +271,17 @@ class LlmProviderSettingsResponse(BaseModel):
|
||||
class LlmProviderProfileListResponse(BaseModel):
|
||||
settings: LlmProviderSettingsResponse
|
||||
items: list[LlmProviderProfileResponse]
|
||||
|
||||
|
||||
class TaskPlannerDecisionItem(BaseModel):
|
||||
step_index: int
|
||||
attempt: int
|
||||
system_prompt: str
|
||||
user_prompt: str
|
||||
tool_name: str
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TaskPlannerDecisionListResponse(BaseModel):
|
||||
items: list[TaskPlannerDecisionItem]
|
||||
|
||||
@@ -4,7 +4,8 @@ import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Engine, delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -14,6 +15,7 @@ from cloud.db_models import (
|
||||
Base,
|
||||
DeviceEnrollmentRow,
|
||||
HostRow,
|
||||
PlannerDecisionLogRow,
|
||||
PluginRow,
|
||||
PooledDeviceRow,
|
||||
ScheduledTaskRow,
|
||||
@@ -32,6 +34,9 @@ from cloud.db_models import (
|
||||
from cloud.observability import current_correlation_id
|
||||
from core.models import utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.repository import AssignmentProgressSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1486,6 +1491,7 @@ class SQLAlchemyCloudRepository:
|
||||
host_id: str,
|
||||
lease_expires_at: datetime,
|
||||
now: datetime,
|
||||
progress: AssignmentProgressSnapshot | None = None,
|
||||
) -> str:
|
||||
with self._sessions.begin() as session:
|
||||
task = session.get(
|
||||
@@ -1525,6 +1531,11 @@ class SQLAlchemyCloudRepository:
|
||||
task.lease_expires_at = renewed_until
|
||||
task.updated_at = _iso(now)
|
||||
attempt_row.lease_expires_at = renewed_until
|
||||
if progress is not None:
|
||||
task.progress_step_index = progress.step_index
|
||||
task.progress_step_status = progress.step_status
|
||||
task.progress_summary = progress.summary
|
||||
task.progress_updated_at = _iso(progress.updated_at)
|
||||
_log_task_lifecycle("renewed", task)
|
||||
return "renewed"
|
||||
|
||||
@@ -1590,6 +1601,10 @@ class SQLAlchemyCloudRepository:
|
||||
task.failure_reason = failure_reason
|
||||
task.result_json = result_json
|
||||
task.updated_at = completed_at_iso
|
||||
task.progress_step_index = None
|
||||
task.progress_step_status = None
|
||||
task.progress_summary = None
|
||||
task.progress_updated_at = None
|
||||
attempt_row.status = status
|
||||
attempt_row.completed_at = completed_at_iso
|
||||
attempt_row.failure_reason = failure_reason
|
||||
@@ -1663,6 +1678,93 @@ class SQLAlchemyCloudRepository:
|
||||
).all()
|
||||
return [_task_attempt_from_row(row) for row in rows]
|
||||
|
||||
def record_planner_decision(
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
task_id: str,
|
||||
attempt: int,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
tool_name: str,
|
||||
arguments_json: str,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
with self._sessions.begin() as session:
|
||||
current_max = session.scalars(
|
||||
select(func.coalesce(func.max(PlannerDecisionLogRow.step_index), 0))
|
||||
.where(PlannerDecisionLogRow.task_id == task_id)
|
||||
.where(PlannerDecisionLogRow.attempt == attempt)
|
||||
).one()
|
||||
next_step = current_max + 1
|
||||
session.add(
|
||||
PlannerDecisionLogRow(
|
||||
id=uuid4().hex,
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=attempt,
|
||||
step_index=next_step,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
tool_name=tool_name,
|
||||
arguments_json=arguments_json,
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return next_step
|
||||
|
||||
def prune_planner_decision_log(
|
||||
self,
|
||||
*,
|
||||
now: datetime,
|
||||
prune_after_terminal_seconds: int,
|
||||
) -> int:
|
||||
cutoff_iso = _iso(now - timedelta(seconds=prune_after_terminal_seconds))
|
||||
with self._sessions.begin() as session:
|
||||
terminal_task_ids = select(TaskAttemptRow.task_id).where(
|
||||
TaskAttemptRow.status.in_(("done", "failed")),
|
||||
TaskAttemptRow.completed_at.is_not(None),
|
||||
TaskAttemptRow.completed_at <= cutoff_iso,
|
||||
)
|
||||
result = session.execute(
|
||||
delete(PlannerDecisionLogRow).where(
|
||||
PlannerDecisionLogRow.task_id.in_(terminal_task_ids)
|
||||
)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
def list_planner_decisions(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
attempt: int,
|
||||
) -> list[Any]:
|
||||
from cloud.repository import PlannerDecisionRecord
|
||||
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(
|
||||
select(PlannerDecisionLogRow)
|
||||
.where(PlannerDecisionLogRow.task_id == task_id)
|
||||
.where(PlannerDecisionLogRow.attempt == attempt)
|
||||
.order_by(PlannerDecisionLogRow.step_index)
|
||||
).all()
|
||||
return [
|
||||
PlannerDecisionRecord(
|
||||
id=row.id,
|
||||
host_id=row.host_id,
|
||||
task_id=row.task_id,
|
||||
attempt=row.attempt,
|
||||
step_index=row.step_index,
|
||||
system_prompt=row.system_prompt,
|
||||
user_prompt=row.user_prompt,
|
||||
tool_name=row.tool_name,
|
||||
arguments_json=row.arguments_json,
|
||||
created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def health_check(self) -> None:
|
||||
with self._sessions() as session:
|
||||
session.execute(select(1))
|
||||
@@ -1778,6 +1880,10 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
|
||||
failure_reason=row.failure_reason,
|
||||
updated_at=_parse_dt(row.updated_at),
|
||||
created_at=_parse_dt(row.created_at) or utc_now(),
|
||||
progress_step_index=row.progress_step_index,
|
||||
progress_step_status=row.progress_step_status,
|
||||
progress_summary=row.progress_summary,
|
||||
progress_updated_at=_parse_dt(row.progress_updated_at),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,13 +36,14 @@ class AIPlanner(Planner):
|
||||
world: "WorldState | None" = None,
|
||||
screenshot: bytes | None = None,
|
||||
) -> list[PlannedStep]:
|
||||
user_prompt = planner_user_prompt(
|
||||
goal=goal,
|
||||
scene_json=scene.to_dict(),
|
||||
history_summary=_history_summary(world),
|
||||
)
|
||||
decision = self.client.decide(
|
||||
system_prompt=PLANNER_SYSTEM_PROMPT,
|
||||
user_prompt=planner_user_prompt(
|
||||
goal=goal,
|
||||
scene_json=scene.to_dict(),
|
||||
history_summary=_history_summary(world),
|
||||
),
|
||||
user_prompt=user_prompt,
|
||||
screenshot=screenshot,
|
||||
tools=ALL_TOOL_SPECS,
|
||||
timeout=self.config.timeout,
|
||||
@@ -58,6 +59,7 @@ class AIPlanner(Planner):
|
||||
action=decision.tool_name,
|
||||
description=f"AI planner: {decision.tool_name}({decision.arguments})",
|
||||
args=dict(decision.arguments),
|
||||
prompt=decision.user_prompt or user_prompt,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ class PlannedStep:
|
||||
description: str
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
expected_text: str | None = None
|
||||
# The actual prompt sent to the LLM for this step (AI planners only).
|
||||
# ``None`` for non-LLM planners; TaskRunner falls back to the task goal.
|
||||
prompt: str | None = None
|
||||
|
||||
|
||||
class Planner:
|
||||
|
||||
+32
-1
@@ -40,6 +40,7 @@ Observer = Callable[[str], Scene]
|
||||
ScreenshotProvider = Callable[[str], bytes]
|
||||
TaskSucceededHook = Callable[[str, str, Timeline], None]
|
||||
StopRequested = Callable[[], bool]
|
||||
StepProgressCallback = Callable[[int, str, str], None]
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
@@ -60,6 +61,7 @@ class TaskRunner:
|
||||
skill_store: SkillStore | None = None,
|
||||
skill_embedding_client: EmbeddingClient | None = None,
|
||||
planner_config: PlannerConfig | None = None,
|
||||
on_step_progress: StepProgressCallback | None = None,
|
||||
) -> None:
|
||||
self.planner_config = planner_config or load_planner_config()
|
||||
self.planner = planner or self._default_planner()
|
||||
@@ -89,6 +91,7 @@ class TaskRunner:
|
||||
self.on_task_succeeded = self._default_task_succeeded_hook
|
||||
else:
|
||||
self.on_task_succeeded = None
|
||||
self.on_step_progress = on_step_progress
|
||||
|
||||
def run(
|
||||
self,
|
||||
@@ -114,6 +117,7 @@ class TaskRunner:
|
||||
reason = (
|
||||
f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
)
|
||||
self._emit_step_progress(len(context.step_results), "failed", reason)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -140,7 +144,17 @@ class TaskRunner:
|
||||
self._record_step_result(
|
||||
world_handle, context, task, scene, step, result
|
||||
)
|
||||
self._emit_step_progress(
|
||||
len(context.step_results),
|
||||
"running",
|
||||
f"{step.action}: {step.description}",
|
||||
)
|
||||
if not result.success:
|
||||
self._emit_step_progress(
|
||||
len(context.step_results),
|
||||
"failed",
|
||||
result.error or "step failed",
|
||||
)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -149,6 +163,11 @@ class TaskRunner:
|
||||
)
|
||||
return task
|
||||
|
||||
self._emit_step_progress(
|
||||
len(context.step_results),
|
||||
"failed",
|
||||
f"max steps exceeded: {self.config.max_steps}",
|
||||
)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -158,6 +177,7 @@ class TaskRunner:
|
||||
return task
|
||||
|
||||
def _interrupt_task(self, task: Task) -> Task:
|
||||
self._emit_step_progress(-1, "failed", "execution interrupted")
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -194,7 +214,18 @@ class TaskRunner:
|
||||
self._update_world(world_handle, context, scene, step, result)
|
||||
self._append_timeline(task, scene, step, result)
|
||||
|
||||
def _emit_step_progress(
|
||||
self, step_index: int, step_status: str, summary: str
|
||||
) -> None:
|
||||
if self.on_step_progress is None:
|
||||
return
|
||||
try:
|
||||
self.on_step_progress(max(step_index, 0), step_status, summary[:200])
|
||||
except Exception as exc:
|
||||
logger.debug("step progress callback failed: %s", exc)
|
||||
|
||||
def _complete_task(self, task: Task) -> Task:
|
||||
self._emit_step_progress(-1, "completed", "task completed")
|
||||
self._update_task(task, status="completed", completed=True)
|
||||
self._notify_task_succeeded(task)
|
||||
return task
|
||||
@@ -306,7 +337,7 @@ class TaskRunner:
|
||||
self.timeline.append(
|
||||
task_id=task.id,
|
||||
scene=scene.to_dict(),
|
||||
prompt=task.goal,
|
||||
prompt=step.prompt or task.goal,
|
||||
tool_call={
|
||||
"action": step.action,
|
||||
"description": step.description,
|
||||
|
||||
@@ -25,6 +25,11 @@ class ToolCallDecision:
|
||||
tool_name: str
|
||||
arguments: dict[str, Any]
|
||||
usage: ToolCallUsage | None = None
|
||||
# The actual prompts sent to the LLM for this call. Populated by the
|
||||
# built-in Anthropic/OpenAI clients; empty for clients (e.g.
|
||||
# CloudProxyToolCallingClient) that don't surface them.
|
||||
system_prompt: str = ""
|
||||
user_prompt: str = ""
|
||||
|
||||
|
||||
class ToolCallingClient(Protocol):
|
||||
@@ -72,7 +77,11 @@ class AnthropicToolCallingClient:
|
||||
tools,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _decision_from_anthropic_response(response)
|
||||
return _decision_from_anthropic_response(
|
||||
response,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
except ToolCallUnavailable:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -164,7 +173,11 @@ class OpenAIToolCallingClient:
|
||||
tools,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _decision_from_openai_response(response)
|
||||
return _decision_from_openai_response(
|
||||
response,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
except ToolCallUnavailable:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -250,7 +263,12 @@ def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _decision_from_anthropic_response(response: Any) -> ToolCallDecision:
|
||||
def _decision_from_anthropic_response(
|
||||
response: Any,
|
||||
*,
|
||||
system_prompt: str = "",
|
||||
user_prompt: str = "",
|
||||
) -> ToolCallDecision:
|
||||
content = _value(response, "content")
|
||||
if not isinstance(content, list):
|
||||
raise ValueError("anthropic tool-call response missing content list")
|
||||
@@ -264,6 +282,8 @@ def _decision_from_anthropic_response(response: Any) -> ToolCallDecision:
|
||||
tool_name=name,
|
||||
arguments=arguments,
|
||||
usage=_anthropic_usage(response),
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
raise ValueError("anthropic response did not include a tool_use block")
|
||||
|
||||
@@ -295,7 +315,12 @@ def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _decision_from_openai_response(response: Any) -> ToolCallDecision:
|
||||
def _decision_from_openai_response(
|
||||
response: Any,
|
||||
*,
|
||||
system_prompt: str = "",
|
||||
user_prompt: str = "",
|
||||
) -> ToolCallDecision:
|
||||
choices = _value(response, "choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
raise ValueError("openai tool-call response missing choices")
|
||||
@@ -312,6 +337,8 @@ def _decision_from_openai_response(response: Any) -> ToolCallDecision:
|
||||
tool_name=name,
|
||||
arguments=arguments,
|
||||
usage=_openai_usage(response),
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -53,6 +54,10 @@ class ArtifactStore:
|
||||
steps.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
return steps
|
||||
|
||||
def delete_task(self, task_id: str) -> None:
|
||||
"""Remove all on-disk artifacts (JSON + screenshots) for a task."""
|
||||
shutil.rmtree(self.task_dir(task_id), ignore_errors=True)
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if hasattr(value, "to_dict"):
|
||||
@@ -68,4 +73,3 @@ def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
@@ -73,6 +73,21 @@ class TaskMetadataStore:
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_task_ids(self) -> list[str]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"select id from tasks order by created_at desc"
|
||||
).fetchall()
|
||||
return [row["id"] for row in rows]
|
||||
|
||||
def delete_task(self, task_id: str) -> None:
|
||||
"""Delete a task row. No-op if the task does not exist."""
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"delete from tasks where id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
@@ -94,4 +109,3 @@ class TaskMetadataStore:
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from storage.artifact_store import ArtifactStore
|
||||
class TimelineRecord:
|
||||
index: int
|
||||
scene: dict[str, Any]
|
||||
# The prompt actually sent to the LLM for this step. For non-LLM planners
|
||||
# (or older records persisted before D9), this falls back to the task goal.
|
||||
prompt: str
|
||||
tool_call: dict[str, Any]
|
||||
result: dict[str, Any]
|
||||
@@ -60,3 +62,6 @@ class Timeline:
|
||||
def read(self, task_id: str) -> list[dict[str, Any]]:
|
||||
return self.artifact_store.read_steps(task_id)
|
||||
|
||||
def delete_task(self, task_id: str) -> None:
|
||||
"""Delete all timeline records and screenshots for a task."""
|
||||
self.artifact_store.delete_task(task_id)
|
||||
|
||||
+98
-17
@@ -8,7 +8,6 @@ from core.errors import TaskFailedError
|
||||
from core.models import Bounds, Scene, SceneElement
|
||||
from runtime.ai_planner import AIPlanner
|
||||
from runtime.context import TaskContext
|
||||
from runtime.planner import PlannedStep
|
||||
from runtime.planner_config import PlannerConfig
|
||||
from runtime.tool_calling_client import ToolCallDecision
|
||||
from runtime.tool_specs import ALL_TOOL_SPECS
|
||||
@@ -44,7 +43,11 @@ def _scene() -> Scene:
|
||||
return Scene(
|
||||
width=10,
|
||||
height=20,
|
||||
elements=[SceneElement(id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4))],
|
||||
elements=[
|
||||
SceneElement(
|
||||
id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -53,23 +56,25 @@ def _context() -> TaskContext:
|
||||
|
||||
|
||||
def test_ai_planner_returns_single_planned_step_for_action_decision() -> None:
|
||||
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}))
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
steps = planner.plan(goal="send a message", scene=_scene(), context=_context())
|
||||
|
||||
assert steps == [
|
||||
PlannedStep(
|
||||
action="tap",
|
||||
description="AI planner: tap({'x': 1, 'y': 2})",
|
||||
args={"x": 1, "y": 2},
|
||||
)
|
||||
]
|
||||
assert len(steps) == 1
|
||||
step = steps[0]
|
||||
assert step.action == "tap"
|
||||
assert step.description == "AI planner: tap({'x': 1, 'y': 2})"
|
||||
assert step.args == {"x": 1, "y": 2}
|
||||
|
||||
|
||||
def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="finish_task", arguments={"success": True, "reason": "done"})
|
||||
ToolCallDecision(
|
||||
tool_name="finish_task", arguments={"success": True, "reason": "done"}
|
||||
)
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
@@ -80,7 +85,10 @@ def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
|
||||
|
||||
def test_ai_planner_finish_task_failure_raises_task_failed_error_with_reason() -> None:
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="finish_task", arguments={"success": False, "reason": "stuck on login"})
|
||||
ToolCallDecision(
|
||||
tool_name="finish_task",
|
||||
arguments={"success": False, "reason": "stuck on login"},
|
||||
)
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
@@ -89,7 +97,9 @@ def test_ai_planner_finish_task_failure_raises_task_failed_error_with_reason() -
|
||||
|
||||
|
||||
def test_ai_planner_finish_task_failure_without_reason_uses_default_message() -> None:
|
||||
client = FakeToolCallingClient(ToolCallDecision(tool_name="finish_task", arguments={"success": False}))
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="finish_task", arguments={"success": False})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
with pytest.raises(TaskFailedError, match="task failed"):
|
||||
@@ -97,20 +107,91 @@ def test_ai_planner_finish_task_failure_without_reason_uses_default_message() ->
|
||||
|
||||
|
||||
def test_ai_planner_goal_reached_is_always_false() -> None:
|
||||
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}))
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
assert planner.goal_reached(goal="anything", scene=_scene(), context=_context()) is False
|
||||
assert (
|
||||
planner.goal_reached(goal="anything", scene=_scene(), context=_context())
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_ai_planner_forwards_tools_screenshot_and_timeout_to_client() -> None:
|
||||
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}))
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
planner = AIPlanner(client=client, config=PlannerConfig(timeout=12.5))
|
||||
|
||||
planner.plan(goal="send a message", scene=_scene(), context=_context(), screenshot=b"fake-bytes")
|
||||
planner.plan(
|
||||
goal="send a message",
|
||||
scene=_scene(),
|
||||
context=_context(),
|
||||
screenshot=b"fake-bytes",
|
||||
)
|
||||
|
||||
call = client.calls[0]
|
||||
assert call["tools"] == ALL_TOOL_SPECS
|
||||
assert call["screenshot"] == b"fake-bytes"
|
||||
assert call["timeout"] == 12.5
|
||||
assert "send a message" in call["user_prompt"]
|
||||
|
||||
|
||||
def test_ai_planner_populates_step_prompt_from_user_prompt() -> None:
|
||||
"""PlannedStep.prompt should carry the actual user prompt sent to the LLM,
|
||||
not the bare task goal."""
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
steps = planner.plan(goal="send a message", scene=_scene(), context=_context())
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].prompt is not None
|
||||
# The per-step prompt contains the goal but also scene JSON and instruction text
|
||||
assert "send a message" in steps[0].prompt
|
||||
assert "Current Scene (JSON)" in steps[0].prompt
|
||||
assert "Call exactly one tool" in steps[0].prompt
|
||||
|
||||
|
||||
def test_ai_planner_step_prompt_reflects_scene_changes() -> None:
|
||||
"""Per-step prompts differ when the scene changes, proving they are not
|
||||
just the repeated task goal."""
|
||||
from runtime.context import TaskContext
|
||||
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
scene_a = Scene(
|
||||
width=10,
|
||||
height=20,
|
||||
elements=[
|
||||
SceneElement(
|
||||
id="btn_a", type="button", text="Alpha", bounds=Bounds(1, 2, 3, 4)
|
||||
)
|
||||
],
|
||||
)
|
||||
scene_b = Scene(
|
||||
width=10,
|
||||
height=20,
|
||||
elements=[
|
||||
SceneElement(
|
||||
id="btn_b", type="button", text="Beta", bounds=Bounds(5, 6, 7, 8)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
steps_a = planner.plan(
|
||||
goal="test", scene=scene_a, context=TaskContext(task_id="t", goal="test")
|
||||
)
|
||||
steps_b = planner.plan(
|
||||
goal="test", scene=scene_b, context=TaskContext(task_id="t", goal="test")
|
||||
)
|
||||
|
||||
assert steps_a[0].prompt != steps_b[0].prompt
|
||||
assert "Alpha" in steps_a[0].prompt
|
||||
assert "Beta" in steps_b[0].prompt
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.models import Bounds, Scene, SceneElement, Task
|
||||
from runtime.ai_planner import AIPlanner
|
||||
from runtime.executor import Executor, ExecutorConfig
|
||||
from runtime.planner import PlannedStep, Planner
|
||||
from runtime.planner_config import PlannerConfig
|
||||
from runtime.task import TaskRunner, TaskRunnerConfig
|
||||
from runtime.tool_calling_client import ToolCallDecision
|
||||
from storage.artifact_store import ArtifactStore
|
||||
from storage.timeline import Timeline
|
||||
from tests.fakes import PNG_10X20
|
||||
|
||||
|
||||
@@ -56,7 +61,11 @@ def _scene() -> Scene:
|
||||
return Scene(
|
||||
width=10,
|
||||
height=20,
|
||||
elements=[SceneElement(id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4))],
|
||||
elements=[
|
||||
SceneElement(
|
||||
id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +135,144 @@ def test_task_runner_default_planner_is_stub_when_ai_planner_disabled() -> None:
|
||||
def test_task_runner_default_planner_is_ai_planner_when_enabled() -> None:
|
||||
runner = _runner(
|
||||
planner=None,
|
||||
planner_config=PlannerConfig(enabled=True, provider="anthropic", model="test-model"),
|
||||
planner_config=PlannerConfig(
|
||||
enabled=True, provider="anthropic", model="test-model"
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(runner.planner, AIPlanner)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D9: per-step prompt recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScriptedToolCallingClient:
|
||||
"""Returns a sequence of decisions, capturing the actual user_prompt each call."""
|
||||
|
||||
def __init__(self, decisions: list[ToolCallDecision]) -> None:
|
||||
self._decisions = list(decisions)
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
screenshot: bytes | None,
|
||||
tools: list[Any],
|
||||
timeout: float,
|
||||
) -> ToolCallDecision:
|
||||
index = len(self.calls)
|
||||
self.calls.append(
|
||||
{
|
||||
"system_prompt": system_prompt,
|
||||
"user_prompt": user_prompt,
|
||||
}
|
||||
)
|
||||
return self._decisions[index]
|
||||
|
||||
|
||||
def _multi_step_scene(element_text: str = "Send") -> Scene:
|
||||
return Scene(
|
||||
width=10,
|
||||
height=20,
|
||||
elements=[
|
||||
SceneElement(
|
||||
id="btn",
|
||||
type="button",
|
||||
text=element_text,
|
||||
bounds=Bounds(1, 2, 3, 4),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_multi_step_timeline_records_actual_per_step_prompts(tmp_path) -> None:
|
||||
"""When AIPlanner is used, each timeline step's prompt is the real
|
||||
per-step user prompt (containing scene JSON), not the bare task goal."""
|
||||
decisions = [
|
||||
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}),
|
||||
ToolCallDecision(tool_name="finish_task", arguments={"success": True}),
|
||||
]
|
||||
client = ScriptedToolCallingClient(decisions)
|
||||
planner = AIPlanner(client=client)
|
||||
executor = Executor(
|
||||
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
|
||||
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
|
||||
)
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
task = Task(goal="tap the button", device_id="phone")
|
||||
|
||||
runner = TaskRunner(
|
||||
planner=planner,
|
||||
executor=executor,
|
||||
timeline=timeline,
|
||||
config=TaskRunnerConfig(max_steps=5),
|
||||
observer=lambda device_id: _multi_step_scene("Send"),
|
||||
screenshot_provider=lambda device_id: PNG_10X20,
|
||||
)
|
||||
|
||||
runner.run(task)
|
||||
|
||||
records = timeline.read(task.id)
|
||||
# Step 1 was recorded (step 2 was finish_task, which returns empty plan
|
||||
# and completes the task without a timeline append).
|
||||
assert len(records) == 1
|
||||
prompt = records[0]["prompt"]
|
||||
# The per-step prompt is NOT the bare task goal.
|
||||
assert prompt != "tap the button"
|
||||
# It contains scene-specific content that only the real planner_user_prompt
|
||||
# would include.
|
||||
assert "Current Scene (JSON)" in prompt
|
||||
assert "Call exactly one tool" in prompt
|
||||
assert "tap the button" in prompt
|
||||
|
||||
|
||||
def test_non_ai_planner_falls_back_to_task_goal_for_prompt(tmp_path) -> None:
|
||||
"""A non-LLM planner (no step.prompt) keeps recording task.goal as the
|
||||
timeline prompt — backward compat with pre-D9 behavior."""
|
||||
scene = _multi_step_scene("Search")
|
||||
planner = ScriptedPlannerForTimeline(
|
||||
[PlannedStep(action="tap", description="tap", args={"x": 1, "y": 2})]
|
||||
)
|
||||
executor = Executor(
|
||||
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
|
||||
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
|
||||
)
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
task = Task(goal="search for something", device_id="phone")
|
||||
|
||||
runner = TaskRunner(
|
||||
planner=planner,
|
||||
executor=executor,
|
||||
timeline=timeline,
|
||||
config=TaskRunnerConfig(max_steps=5),
|
||||
observer=lambda device_id: scene,
|
||||
screenshot_provider=lambda device_id: PNG_10X20,
|
||||
)
|
||||
|
||||
runner.run(task)
|
||||
|
||||
records = timeline.read(task.id)
|
||||
assert len(records) >= 1
|
||||
# Non-AI planner: prompt falls back to task goal.
|
||||
assert records[0]["prompt"] == "search for something"
|
||||
|
||||
|
||||
class ScriptedPlannerForTimeline(Planner):
|
||||
"""Simple planner that returns a fixed list of steps then signals done."""
|
||||
|
||||
def __init__(self, steps: list[PlannedStep]) -> None:
|
||||
self.steps = steps
|
||||
|
||||
def plan(self, *, goal, scene, context):
|
||||
if len(context.step_results) >= len(self.steps):
|
||||
return []
|
||||
return [self.steps[len(context.step_results)]]
|
||||
|
||||
def goal_reached(self, *, goal, scene, context):
|
||||
return len(context.step_results) >= len(self.steps) and all(
|
||||
r.success for r in context.step_results
|
||||
)
|
||||
|
||||
@@ -195,6 +195,57 @@ def test_schema_readiness_requires_head_revision(tmp_path) -> None:
|
||||
require_current_schema(database_url)
|
||||
|
||||
|
||||
def test_planner_decision_log_migration_upgrades_and_downgrades(tmp_path) -> None:
|
||||
database_url = _database_url(tmp_path)
|
||||
|
||||
upgrade_database(database_url, "0008_task_progress_columns")
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
assert "planner_decision_log" not in inspector.get_table_names()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
upgrade_database(database_url)
|
||||
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
assert "planner_decision_log" in inspector.get_table_names()
|
||||
columns = {col["name"] for col in inspector.get_columns("planner_decision_log")}
|
||||
assert {
|
||||
"id",
|
||||
"host_id",
|
||||
"task_id",
|
||||
"attempt",
|
||||
"step_index",
|
||||
"system_prompt",
|
||||
"user_prompt",
|
||||
"tool_name",
|
||||
"arguments_json",
|
||||
"created_at",
|
||||
} <= columns
|
||||
index_names = {
|
||||
idx["name"] for idx in inspector.get_indexes("planner_decision_log")
|
||||
}
|
||||
assert {
|
||||
"ix_planner_decision_log_task_attempt",
|
||||
"ix_planner_decision_log_host_created",
|
||||
} <= index_names
|
||||
assert current_revision(database_url) == HEAD_REVISION
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
downgrade_database(database_url, "0008_task_progress_columns")
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
assert "planner_decision_log" not in inspector.get_table_names()
|
||||
assert current_revision(database_url) == "0008_task_progress_columns"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _create_legacy_schema(connection) -> None:
|
||||
connection.exec_driver_sql(
|
||||
"create table host_registrations ("
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.db_models import TaskAttemptRow
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.store import CloudStore
|
||||
@@ -49,7 +53,7 @@ class _FakeToolCallingClient:
|
||||
|
||||
def _build_client(
|
||||
tmp_path, *, fake_client: _FakeToolCallingClient
|
||||
) -> tuple[TestClient, _FakeToolCallingClient]:
|
||||
) -> tuple[TestClient, _FakeToolCallingClient, DevicePool]:
|
||||
pool = DevicePool(
|
||||
CloudStore(tmp_path / "internal.sqlite3"),
|
||||
CloudConfig(stale_after_seconds=60),
|
||||
@@ -68,7 +72,7 @@ def _build_client(
|
||||
planner_client_factory=lambda: fake_client,
|
||||
)
|
||||
)
|
||||
return TestClient(app), fake_client
|
||||
return TestClient(app), fake_client, pool
|
||||
|
||||
|
||||
def _decision_payload(**overrides: object) -> dict[str, object]:
|
||||
@@ -94,7 +98,7 @@ def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -113,7 +117,7 @@ def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -129,7 +133,7 @@ def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -145,7 +149,7 @@ def test_unauthenticated_request_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -160,7 +164,7 @@ def test_foreign_host_token_cannot_request_another_hosts_decision(tmp_path) -> N
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -176,7 +180,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -190,7 +194,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
|
||||
|
||||
def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(error="anthropic timed out")
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -203,3 +207,153 @@ def test_provider_failure_returns_structured_error_without_crashing(tmp_path) ->
|
||||
"code": "planner_unavailable",
|
||||
"detail": "anthropic timed out",
|
||||
}
|
||||
|
||||
|
||||
def _seed_attempt(pool: DevicePool, task_id: str, host_id: str = "host-a") -> None:
|
||||
"""Insert a task_attempts row so _validate_planner_context passes."""
|
||||
now = datetime.now(tz=UTC)
|
||||
with Session(pool.store.engine) as session, session.begin():
|
||||
session.add(
|
||||
TaskAttemptRow(
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
host_id=host_id,
|
||||
device_id="device-x",
|
||||
status="dispatched",
|
||||
lease_expires_at=(now + timedelta(minutes=5)).isoformat(),
|
||||
created_at=now.isoformat(),
|
||||
completed_at=None,
|
||||
failure_reason=None,
|
||||
result_json=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_successful_decision_is_persisted_with_correct_fields(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={"x": 10, "y": 20})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-1")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-1",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
system_prompt="you are a test planner",
|
||||
user_prompt="tap the button now",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
decisions = pool.store.list_planner_decisions(task_id="task-log-1", attempt=1)
|
||||
assert len(decisions) == 1
|
||||
row = decisions[0]
|
||||
assert row.step_index == 1
|
||||
assert row.system_prompt == "you are a test planner"
|
||||
assert row.user_prompt == "tap the button now"
|
||||
assert row.tool_name == "tap"
|
||||
assert '"x": 10' in row.arguments_json
|
||||
assert '"y": 20' in row.arguments_json
|
||||
|
||||
|
||||
def test_failed_decision_persists_nothing(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(error="model is down")
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-fail")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-fail",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert pool.store.list_planner_decisions(task_id="task-log-fail", attempt=1) == []
|
||||
|
||||
|
||||
def test_screenshot_bytes_are_never_persisted_to_decision_log(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-screenshot")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-screenshot",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
screenshot_base64="aGVsbG8gd29ybGQ=",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
decisions = pool.store.list_planner_decisions(
|
||||
task_id="task-log-screenshot", attempt=1
|
||||
)
|
||||
assert len(decisions) == 1
|
||||
# The row has no screenshot column at all — verify the raw row text
|
||||
# does not contain the screenshot bytes.
|
||||
row_repr = repr(decisions[0])
|
||||
assert "hello world" not in row_repr
|
||||
assert "aGVsbG8" not in row_repr
|
||||
|
||||
|
||||
def test_decision_without_task_context_is_not_persisted(tmp_path) -> None:
|
||||
"""When task_id/attempt are absent, the log insert is skipped."""
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(), # no task_id / attempt / lease_id
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# No task_id to query by — verify no rows exist at all via direct SQL.
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cloud.db_models import PlannerDecisionLogRow
|
||||
|
||||
with Session(pool.store.engine) as session:
|
||||
count = session.scalar(select(func.count()).select_from(PlannerDecisionLogRow))
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_multiple_decisions_get_incrementing_step_index(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-multi")
|
||||
|
||||
for i in range(3):
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-multi",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
user_prompt=f"step {i}",
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
decisions = pool.store.list_planner_decisions(task_id="task-log-multi", attempt=1)
|
||||
assert [d.step_index for d in decisions] == [1, 2, 3]
|
||||
assert [d.user_prompt for d in decisions] == ["step 0", "step 1", "step 2"]
|
||||
|
||||
@@ -82,6 +82,9 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
|
||||
"record_task_result",
|
||||
"reap_expired_leases",
|
||||
"list_task_attempts",
|
||||
"record_planner_decision",
|
||||
"prune_planner_decision_log",
|
||||
"list_planner_decisions",
|
||||
"health_check",
|
||||
"close",
|
||||
} <= members
|
||||
@@ -1553,3 +1556,198 @@ def test_task_lifecycle_logs_structured_identifiers(
|
||||
assert "secret-image" not in repr(events)
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_record_planner_decision_assigns_incrementing_step_index(
|
||||
database_url: str,
|
||||
) -> None:
|
||||
database = CloudDatabase(database_url)
|
||||
task_id = _unique_id("planner-task")
|
||||
other_task_id = _unique_id("planner-task-other")
|
||||
host_id = _unique_id("planner-host")
|
||||
now = datetime(2026, 7, 14, 0, 0, tzinfo=UTC)
|
||||
|
||||
try:
|
||||
step1 = database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
system_prompt="system",
|
||||
user_prompt="prompt-1",
|
||||
tool_name="tap",
|
||||
arguments_json='{"x": 1}',
|
||||
now=now,
|
||||
)
|
||||
step2 = database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
system_prompt="system",
|
||||
user_prompt="prompt-2",
|
||||
tool_name="swipe",
|
||||
arguments_json='{"y": 2}',
|
||||
now=now + timedelta(seconds=1),
|
||||
)
|
||||
step3 = database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
system_prompt="system",
|
||||
user_prompt="prompt-3",
|
||||
tool_name="wait",
|
||||
arguments_json="{}",
|
||||
now=now + timedelta(seconds=2),
|
||||
)
|
||||
|
||||
assert [step1, step2, step3] == [1, 2, 3]
|
||||
|
||||
# Different (task_id, attempt) gets its own counter starting at 1.
|
||||
other_step = database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=other_task_id,
|
||||
attempt=1,
|
||||
system_prompt="system",
|
||||
user_prompt="other",
|
||||
tool_name="tap",
|
||||
arguments_json="{}",
|
||||
now=now,
|
||||
)
|
||||
assert other_step == 1
|
||||
|
||||
# Different attempt on the same task also gets its own counter.
|
||||
attempt2_step = database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=2,
|
||||
system_prompt="system",
|
||||
user_prompt="retry",
|
||||
tool_name="tap",
|
||||
arguments_json="{}",
|
||||
now=now,
|
||||
)
|
||||
assert attempt2_step == 1
|
||||
|
||||
# Verify stored rows.
|
||||
decisions = database.repository.list_planner_decisions(
|
||||
task_id=task_id, attempt=1
|
||||
)
|
||||
assert len(decisions) == 3
|
||||
assert [d.step_index for d in decisions] == [1, 2, 3]
|
||||
assert [d.user_prompt for d in decisions] == [
|
||||
"prompt-1",
|
||||
"prompt-2",
|
||||
"prompt-3",
|
||||
]
|
||||
assert [d.tool_name for d in decisions] == ["tap", "swipe", "wait"]
|
||||
assert decisions[0].arguments_json == '{"x": 1}'
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_prune_planner_decision_log_deletes_only_old_terminal_tasks(
|
||||
database_url: str,
|
||||
) -> None:
|
||||
database = CloudDatabase(database_url)
|
||||
old_terminal_task = _unique_id("old-terminal")
|
||||
in_flight_task = _unique_id("in-flight")
|
||||
recent_terminal_task = _unique_id("recent-terminal")
|
||||
host_id = _unique_id("prune-host")
|
||||
now = datetime(2026, 7, 14, 12, 0, tzinfo=UTC)
|
||||
|
||||
try:
|
||||
with Session(database.engine) as session, session.begin():
|
||||
# Old terminal task (completed > window ago).
|
||||
session.add(
|
||||
TaskAttemptRow(
|
||||
task_id=old_terminal_task,
|
||||
attempt=1,
|
||||
lease_id="lease-old",
|
||||
host_id=host_id,
|
||||
device_id="device-old",
|
||||
status="done",
|
||||
lease_expires_at=(now - timedelta(days=10)).isoformat(),
|
||||
created_at=(now - timedelta(days=11)).isoformat(),
|
||||
completed_at=(now - timedelta(days=10)).isoformat(),
|
||||
failure_reason=None,
|
||||
result_json=None,
|
||||
)
|
||||
)
|
||||
# In-flight task (also old, but NOT terminal).
|
||||
session.add(
|
||||
TaskAttemptRow(
|
||||
task_id=in_flight_task,
|
||||
attempt=1,
|
||||
lease_id="lease-flight",
|
||||
host_id=host_id,
|
||||
device_id="device-flight",
|
||||
status="dispatched",
|
||||
lease_expires_at=(now - timedelta(days=10)).isoformat(),
|
||||
created_at=(now - timedelta(days=11)).isoformat(),
|
||||
completed_at=None,
|
||||
failure_reason=None,
|
||||
result_json=None,
|
||||
)
|
||||
)
|
||||
# Recently terminal task (within window).
|
||||
session.add(
|
||||
TaskAttemptRow(
|
||||
task_id=recent_terminal_task,
|
||||
attempt=1,
|
||||
lease_id="lease-recent",
|
||||
host_id=host_id,
|
||||
device_id="device-recent",
|
||||
status="done",
|
||||
lease_expires_at=(now - timedelta(hours=1)).isoformat(),
|
||||
created_at=(now - timedelta(hours=2)).isoformat(),
|
||||
completed_at=(now - timedelta(hours=1)).isoformat(),
|
||||
failure_reason=None,
|
||||
result_json=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Seed decision log rows for all three tasks.
|
||||
for task_id in [old_terminal_task, in_flight_task, recent_terminal_task]:
|
||||
database.repository.record_planner_decision(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
tool_name="tap",
|
||||
arguments_json="{}",
|
||||
now=now - timedelta(days=11),
|
||||
)
|
||||
|
||||
# 7-day window.
|
||||
deleted = database.repository.prune_planner_decision_log(
|
||||
now=now,
|
||||
prune_after_terminal_seconds=7 * 86_400,
|
||||
)
|
||||
assert deleted == 1
|
||||
|
||||
# Old terminal task's rows are gone.
|
||||
assert (
|
||||
database.repository.list_planner_decisions(
|
||||
task_id=old_terminal_task, attempt=1
|
||||
)
|
||||
== []
|
||||
)
|
||||
# In-flight and recent terminal rows survive.
|
||||
assert (
|
||||
len(
|
||||
database.repository.list_planner_decisions(
|
||||
task_id=in_flight_task, attempt=1
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
database.repository.list_planner_decisions(
|
||||
task_id=recent_terminal_task, attempt=1
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
@@ -338,6 +338,7 @@ def test_submit_rejects_incomplete_or_foreign_target(tmp_path) -> None:
|
||||
("get", "/v1/tasks/missing", None, "tasks:read"),
|
||||
("get", "/v1/tasks", None, "tasks:read"),
|
||||
("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
|
||||
("get", "/v1/tasks/missing/planner-decisions?attempt=0", None, "tasks:read"),
|
||||
("get", "/v1/devices", None, "pool:read"),
|
||||
("get", "/v1/hosts", None, "pool:read"),
|
||||
("get", "/v1/plugins", None, "plugins:read"),
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Repository contract tests for cloud task progress columns (task 3.6).
|
||||
|
||||
Separate from ``test_cloud_repository_contract.py``; covers the new
|
||||
``progress_*`` fields added by migration 0008.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.pool import PooledDevice
|
||||
from cloud.repository import AssignmentProgressSnapshot
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints
|
||||
|
||||
|
||||
def _device(device_id: str, host_id: str) -> PooledDevice:
|
||||
return PooledDevice(
|
||||
device_id=device_id,
|
||||
host_id=host_id,
|
||||
driver_type="wda",
|
||||
status="idle",
|
||||
capability_tags=["ios"],
|
||||
synced_at=datetime(2026, 7, 12, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _task(task_id: str, *, created_at: datetime | None = None) -> ScheduledTask:
|
||||
return ScheduledTask(
|
||||
id=task_id,
|
||||
goal="test progress",
|
||||
workflow_definition_id=None,
|
||||
constraints=TaskConstraints(),
|
||||
created_at=created_at or datetime(2026, 7, 12, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _setup_assigned_and_claimed(
|
||||
database: CloudDatabase, host_id: str, device_id: str, task_id: str
|
||||
):
|
||||
"""Enqueue, assign, and claim a task. Returns LeasedAssignment."""
|
||||
now = datetime(2026, 7, 12, 5, 0, tzinfo=UTC)
|
||||
database.repository.upsert_host(host_id, address=None, last_seen_at=now)
|
||||
database.repository.replace_host_devices(host_id, [_device(device_id, host_id)])
|
||||
database.repository.enqueue_task(_task(task_id, created_at=now))
|
||||
database.repository.assign_task(
|
||||
task_id=task_id,
|
||||
host_id=host_id,
|
||||
device_id=device_id,
|
||||
lease_id="lease-1",
|
||||
lease_expires_at=now + timedelta(minutes=10),
|
||||
now=now,
|
||||
)
|
||||
assignment = database.repository.claim_assignment(
|
||||
host_id=host_id,
|
||||
now=now + timedelta(seconds=1),
|
||||
)
|
||||
assert assignment is not None
|
||||
return assignment, now
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# renew_lease writes progress on success
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_renew_lease_writes_progress_on_success(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress.sqlite3').as_posix()}")
|
||||
host_id = "progress-host"
|
||||
device_id = "progress-device"
|
||||
task_id = "progress-task"
|
||||
|
||||
try:
|
||||
assignment, now = _setup_assigned_and_claimed(
|
||||
database, host_id, device_id, task_id
|
||||
)
|
||||
|
||||
progress = AssignmentProgressSnapshot(
|
||||
step_index=2,
|
||||
step_status="running",
|
||||
summary="executing step 2",
|
||||
updated_at=now + timedelta(seconds=5),
|
||||
)
|
||||
result = database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=15),
|
||||
now=now + timedelta(seconds=5),
|
||||
progress=progress,
|
||||
)
|
||||
assert result == "renewed"
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.progress_step_index == 2
|
||||
assert task.progress_step_status == "running"
|
||||
assert task.progress_summary == "executing step 2"
|
||||
assert task.progress_updated_at is not None
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# renew_lease without progress leaves previous untouched
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_renew_lease_without_progress_leaves_previous_untouched(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress2.sqlite3').as_posix()}")
|
||||
host_id = "retain-host"
|
||||
device_id = "retain-device"
|
||||
task_id = "retain-task"
|
||||
|
||||
try:
|
||||
assignment, now = _setup_assigned_and_claimed(
|
||||
database, host_id, device_id, task_id
|
||||
)
|
||||
|
||||
# First renew WITH progress.
|
||||
progress = AssignmentProgressSnapshot(
|
||||
step_index=1,
|
||||
step_status="completed",
|
||||
summary="step 1 done",
|
||||
updated_at=now + timedelta(seconds=2),
|
||||
)
|
||||
database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=15),
|
||||
now=now + timedelta(seconds=2),
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
# Second renew WITHOUT progress (None).
|
||||
database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=20),
|
||||
now=now + timedelta(seconds=4),
|
||||
progress=None,
|
||||
)
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
# Previous values must be retained, not cleared.
|
||||
assert task.progress_step_index == 1
|
||||
assert task.progress_step_status == "completed"
|
||||
assert task.progress_summary == "step 1 done"
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# record_task_result clears progress on terminal
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_record_task_result_clears_progress_on_terminal(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'clear.sqlite3').as_posix()}")
|
||||
host_id = "clear-host"
|
||||
device_id = "clear-device"
|
||||
task_id = "clear-task"
|
||||
|
||||
try:
|
||||
assignment, now = _setup_assigned_and_claimed(
|
||||
database, host_id, device_id, task_id
|
||||
)
|
||||
|
||||
# Write progress first.
|
||||
progress = AssignmentProgressSnapshot(
|
||||
step_index=3,
|
||||
step_status="running",
|
||||
summary="almost done",
|
||||
updated_at=now + timedelta(seconds=3),
|
||||
)
|
||||
database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=15),
|
||||
now=now + timedelta(seconds=3),
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.progress_step_index == 3
|
||||
|
||||
# Record terminal result.
|
||||
result = database.repository.record_task_result(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
status="done",
|
||||
failure_reason=None,
|
||||
terminal_result={"output": "success"},
|
||||
completed_at=now + timedelta(seconds=10),
|
||||
)
|
||||
assert result == "recorded"
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.status == "done"
|
||||
assert task.progress_step_index is None
|
||||
assert task.progress_step_status is None
|
||||
assert task.progress_summary is None
|
||||
assert task.progress_updated_at is None
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Task list/detail response includes progress fields
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_list_tasks_includes_progress_fields(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'list.sqlite3').as_posix()}")
|
||||
host_id = "list-host"
|
||||
device_id = "list-device"
|
||||
task_id = "list-task"
|
||||
|
||||
try:
|
||||
assignment, now = _setup_assigned_and_claimed(
|
||||
database, host_id, device_id, task_id
|
||||
)
|
||||
|
||||
progress = AssignmentProgressSnapshot(
|
||||
step_index=1,
|
||||
step_status="running",
|
||||
summary="running step 1",
|
||||
updated_at=now + timedelta(seconds=5),
|
||||
)
|
||||
database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=15),
|
||||
now=now + timedelta(seconds=5),
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
tasks = database.repository.list_tasks()
|
||||
matching = [t for t in tasks if t.id == task_id]
|
||||
assert len(matching) == 1
|
||||
task = matching[0]
|
||||
assert task.progress_step_index == 1
|
||||
assert task.progress_step_status == "running"
|
||||
assert task.progress_summary == "running step 1"
|
||||
assert task.progress_updated_at is not None
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_get_task_includes_progress_fields(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'get.sqlite3').as_posix()}")
|
||||
host_id = "get-host"
|
||||
device_id = "get-device"
|
||||
task_id = "get-task"
|
||||
|
||||
try:
|
||||
assignment, now = _setup_assigned_and_claimed(
|
||||
database, host_id, device_id, task_id
|
||||
)
|
||||
|
||||
progress = AssignmentProgressSnapshot(
|
||||
step_index=5,
|
||||
step_status="failed",
|
||||
summary="step 5 failed",
|
||||
updated_at=now + timedelta(seconds=8),
|
||||
)
|
||||
database.repository.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=assignment.attempt,
|
||||
lease_id=assignment.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=now + timedelta(minutes=15),
|
||||
now=now + timedelta(seconds=8),
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.progress_step_index == 5
|
||||
assert task.progress_step_status == "failed"
|
||||
assert task.progress_summary == "step 5 failed"
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_progress_fields_default_null_before_any_renewal(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'null.sqlite3').as_posix()}")
|
||||
host_id = "null-host"
|
||||
device_id = "null-device"
|
||||
task_id = "null-task"
|
||||
|
||||
try:
|
||||
_setup_assigned_and_claimed(database, host_id, device_id, task_id)
|
||||
|
||||
task = database.repository.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.progress_step_index is None
|
||||
assert task.progress_step_status is None
|
||||
assert task.progress_summary is None
|
||||
assert task.progress_updated_at is None
|
||||
finally:
|
||||
database.close()
|
||||
@@ -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
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Tests for Host Agent progress reporting via lease renewal (task 2.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import Event
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from cloud.internal_api.models import (
|
||||
AssignmentModel,
|
||||
LeaseRenewalResponse,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.lease import ActiveAssignmentRunner
|
||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _assignment(
|
||||
*,
|
||||
lease_expires_at: datetime | None = None,
|
||||
) -> AssignmentModel:
|
||||
return AssignmentModel(
|
||||
task_id="task-progress",
|
||||
attempt=1,
|
||||
lease_id="lease-1",
|
||||
lease_expires_at=lease_expires_at or datetime.now(UTC) + timedelta(minutes=5),
|
||||
host_id="host-a",
|
||||
device_id="device-a",
|
||||
goal="do something",
|
||||
)
|
||||
|
||||
|
||||
class _FakeExecutor:
|
||||
"""Stub executor that optionally fires a progress callback then blocks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fire_progress_before_block: bool = False,
|
||||
progress_payload: tuple[int, str, str] = (1, "running", "step 1 summary"),
|
||||
block_event: Event | None = None,
|
||||
) -> None:
|
||||
self._fire = fire_progress_before_block
|
||||
self._payload = progress_payload
|
||||
self._block_event = block_event or Event()
|
||||
self._progress = TaskProgressHolder()
|
||||
|
||||
def execute(
|
||||
self,
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
should_stop: Any | None = None,
|
||||
) -> Any:
|
||||
from host_agent.assignment import AssignmentExecutionResult
|
||||
|
||||
if self._fire:
|
||||
self._progress.update(*self._payload)
|
||||
|
||||
# Block until test signals completion.
|
||||
self._block_event.wait(timeout=5)
|
||||
return AssignmentExecutionResult(status="done")
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None:
|
||||
return self._progress.snapshot()
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""Fake HostAgentClient that records each renew() call's progress argument."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.renew_calls: list[TaskProgressSnapshot | None] = []
|
||||
self._call_count = 0
|
||||
|
||||
async def renew(
|
||||
self,
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
progress: TaskProgressSnapshot | None = None,
|
||||
) -> LeaseRenewalResponse:
|
||||
self.renew_calls.append(progress)
|
||||
self._call_count += 1
|
||||
return LeaseRenewalResponse(
|
||||
status="renewed",
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
|
||||
|
||||
def _runner(
|
||||
client: _RecordingClient, executor: _FakeExecutor
|
||||
) -> ActiveAssignmentRunner:
|
||||
return ActiveAssignmentRunner(client, executor, now=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Renewal includes progress after a step completes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_renewal_includes_progress_after_step(tmp_path) -> None:
|
||||
block_event = Event()
|
||||
executor = _FakeExecutor(
|
||||
fire_progress_before_block=True,
|
||||
progress_payload=(1, "running", "step 1 summary"),
|
||||
block_event=block_event,
|
||||
)
|
||||
client = _RecordingClient()
|
||||
runner = _runner(client, executor)
|
||||
|
||||
# Set a very short lease so renewal fires quickly.
|
||||
assignment = _assignment(
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
|
||||
)
|
||||
|
||||
async def _drive() -> None:
|
||||
# Start the runner; execution thread will fire progress then block.
|
||||
task = asyncio.create_task(runner.run(assignment))
|
||||
# Wait for at least one renewal to happen.
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.05)
|
||||
if client.renew_calls:
|
||||
break
|
||||
# Signal the executor to complete.
|
||||
block_event.set()
|
||||
await task
|
||||
|
||||
asyncio.run(_drive())
|
||||
|
||||
assert len(client.renew_calls) > 0
|
||||
# At least one renewal should have non-None progress.
|
||||
progress_renewals = [p for p in client.renew_calls if p is not None]
|
||||
assert len(progress_renewals) > 0
|
||||
snap = progress_renewals[0]
|
||||
assert snap.step_index == 1
|
||||
assert snap.step_status == "running"
|
||||
assert "step 1 summary" in snap.summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Renewal omits progress before any step completes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_renewal_omits_progress_before_any_step(tmp_path) -> None:
|
||||
block_event = Event()
|
||||
executor = _FakeExecutor(
|
||||
fire_progress_before_block=False,
|
||||
block_event=block_event,
|
||||
)
|
||||
client = _RecordingClient()
|
||||
runner = _runner(client, executor)
|
||||
|
||||
assignment = _assignment(
|
||||
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
|
||||
)
|
||||
|
||||
async def _drive() -> None:
|
||||
task = asyncio.create_task(runner.run(assignment))
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.05)
|
||||
if client.renew_calls:
|
||||
break
|
||||
block_event.set()
|
||||
await task
|
||||
|
||||
asyncio.run(_drive())
|
||||
|
||||
assert len(client.renew_calls) > 0
|
||||
# No progress should have been reported.
|
||||
assert all(p is None for p in client.renew_calls)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oversized summary truncated client-side before sending
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_oversized_summary_truncated_client_side() -> None:
|
||||
"""HostAgentClient.renew truncates summary to <=500 chars before serializing."""
|
||||
from host_agent.client import HostAgentClient
|
||||
|
||||
captured_payload: dict[str, Any] = {}
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
|
||||
body = json.loads(request.content.decode("utf-8"))
|
||||
captured_payload.update(body)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "renewed",
|
||||
"lease_expires_at": (
|
||||
datetime.now(UTC) + timedelta(minutes=5)
|
||||
).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(_handler)
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
retry_backoff_seconds=0.01,
|
||||
max_retry_attempts=1,
|
||||
)
|
||||
http_client = httpx.AsyncClient(
|
||||
base_url=config.control_plane_url, transport=transport
|
||||
)
|
||||
client = HostAgentClient(config, http_client=http_client)
|
||||
|
||||
long_summary = "x" * 2000
|
||||
snapshot = TaskProgressSnapshot(
|
||||
step_index=1,
|
||||
step_status="running",
|
||||
summary=long_summary,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
asyncio.run(client.renew(_assignment(), progress=snapshot))
|
||||
|
||||
assert "progress" in captured_payload
|
||||
assert captured_payload["progress"] is not None
|
||||
serialized_summary = captured_payload["progress"]["summary"]
|
||||
assert len(serialized_summary) <= 500
|
||||
assert serialized_summary == "x" * 500
|
||||
|
||||
asyncio.run(http_client.aclose())
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for Host Agent task storage, timeline, retention, and collision avoidance (task 1.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from core.models import Task
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.retention import prune_task_history
|
||||
from storage.artifact_store import ArtifactStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
from tests.fakes import PNG_10X20
|
||||
|
||||
|
||||
def _make_task(
|
||||
task_id: str,
|
||||
*,
|
||||
goal: str = "test goal",
|
||||
device_id: str = "iphone-1",
|
||||
created_at: datetime | None = None,
|
||||
status: str = "created",
|
||||
) -> Task:
|
||||
ts = created_at or datetime(2026, 7, 1, tzinfo=UTC)
|
||||
return Task(
|
||||
id=task_id,
|
||||
goal=goal,
|
||||
device_id=device_id,
|
||||
status=status, # type: ignore[arg-type]
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Persist during execution
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_step_transitions_persist_during_execution(tmp_path) -> None:
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
|
||||
task = _make_task("task-persist")
|
||||
metadata_store.create_task(task)
|
||||
|
||||
# Simulate the execution loop writing step transitions.
|
||||
metadata_store.update_task(task.id, status="running")
|
||||
timeline.append(
|
||||
task_id=task.id,
|
||||
scene={"screen": {"width": 1, "height": 1}, "elements": []},
|
||||
prompt="step 1",
|
||||
tool_call={"action": "tap"},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
timeline.append(
|
||||
task_id=task.id,
|
||||
scene={"screen": {"width": 1, "height": 1}, "elements": []},
|
||||
prompt="step 2",
|
||||
tool_call={"action": "swipe"},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
metadata_store.update_task(task.id, status="completed", completed=True)
|
||||
|
||||
row = metadata_store.get_task(task.id)
|
||||
assert row is not None
|
||||
assert row["status"] == "completed"
|
||||
assert row["completed_at"] is not None
|
||||
|
||||
records = timeline.read(task.id)
|
||||
assert len(records) == 2
|
||||
assert [r["index"] for r in records] == [1, 2]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Queryable after completion (in-memory Task discarded)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_task_history_queryable_after_in_memory_task_discarded(tmp_path) -> None:
|
||||
db_path = tmp_path / "tasks.sqlite3"
|
||||
history_dir = tmp_path / "history"
|
||||
|
||||
metadata_store = TaskMetadataStore(db_path)
|
||||
timeline = Timeline(ArtifactStore(history_dir))
|
||||
|
||||
task = _make_task("task-survive")
|
||||
metadata_store.create_task(task)
|
||||
metadata_store.update_task(task.id, status="running")
|
||||
timeline.append(
|
||||
task_id=task.id,
|
||||
scene={"screen": {"width": 1, "height": 1}, "elements": []},
|
||||
prompt="do something",
|
||||
tool_call={"action": "tap"},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
metadata_store.update_task(task.id, status="completed", completed=True)
|
||||
|
||||
# Drop ALL in-memory references.
|
||||
del metadata_store
|
||||
del timeline
|
||||
del task
|
||||
|
||||
# Re-open from the same paths.
|
||||
reopened_store = TaskMetadataStore(db_path)
|
||||
reopened_timeline = Timeline(ArtifactStore(history_dir))
|
||||
|
||||
row = reopened_store.get_task("task-survive")
|
||||
assert row is not None
|
||||
assert row["status"] == "completed"
|
||||
assert row["goal"] == "test goal"
|
||||
|
||||
records = reopened_timeline.read("task-survive")
|
||||
assert len(records) == 1
|
||||
assert records[0]["tool_call"] == {"action": "tap"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Retention prunes tasks beyond configured threshold
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _seed_n_tasks(
|
||||
store: TaskMetadataStore,
|
||||
n: int,
|
||||
*,
|
||||
base: datetime = datetime(2026, 7, 1, tzinfo=UTC),
|
||||
) -> list[str]:
|
||||
ids: list[str] = []
|
||||
for i in range(n):
|
||||
task = _make_task(f"task-{i:02d}", created_at=base + timedelta(hours=i))
|
||||
store.create_task(task)
|
||||
ids.append(task.id)
|
||||
return ids
|
||||
|
||||
|
||||
def test_retention_prunes_by_max_count(tmp_path) -> None:
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
ids = _seed_n_tasks(metadata_store, 10)
|
||||
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
task_retention_max_count=5,
|
||||
task_retention_max_age_days=365, # large so count is the binding constraint
|
||||
)
|
||||
pruned = prune_task_history(
|
||||
metadata_store,
|
||||
timeline,
|
||||
config=config,
|
||||
now=datetime(2026, 7, 1, tzinfo=UTC) + timedelta(days=1),
|
||||
)
|
||||
assert pruned == 5
|
||||
remaining = metadata_store.list_task_ids()
|
||||
assert len(remaining) == 5
|
||||
# list_tasks is ordered by created_at desc, so the 5 newest survive.
|
||||
expected_survivors = set(ids[5:])
|
||||
assert set(remaining) == expected_survivors
|
||||
|
||||
|
||||
def test_retention_prunes_by_max_age(tmp_path) -> None:
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
base = datetime(2026, 7, 1, tzinfo=UTC)
|
||||
_seed_n_tasks(metadata_store, 10, base=base)
|
||||
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
task_retention_max_count=100, # large so age is the binding constraint
|
||||
task_retention_max_age_days=3,
|
||||
)
|
||||
# "now" is 5 days after base; tasks 0..2 are within 3 days of now-5d... actually:
|
||||
# tasks created at base+0h .. base+9h. now = base + 5 days.
|
||||
# cutoff = now - 3 days = base + 2 days. Tasks with created_at >= base+2d survive.
|
||||
# Only tasks created at base+0h..base+9h -- all are < base+2d, so all pruned.
|
||||
# Let's use a now that's closer.
|
||||
now = base + timedelta(hours=5)
|
||||
pruned = prune_task_history(metadata_store, timeline, config=config, now=now)
|
||||
# cutoff = now - 3 days. All tasks are within 3 days. None pruned by age.
|
||||
# Actually max_count=100 so nothing pruned at all.
|
||||
# Let's redo: use max_age_days to actually prune older tasks.
|
||||
assert pruned == 0 # all tasks are younger than 3 days
|
||||
|
||||
# Now use a stricter age that prunes some.
|
||||
config_strict = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
task_retention_max_count=100,
|
||||
task_retention_max_age_days=1,
|
||||
)
|
||||
# cutoff = now - 1 day = base + 5h - 24h = base - 19h. Still all tasks are after that.
|
||||
# So we need a much later "now".
|
||||
late_now = base + timedelta(days=10)
|
||||
pruned2 = prune_task_history(
|
||||
metadata_store, timeline, config=config_strict, now=late_now
|
||||
)
|
||||
# All tasks are ~10 days old, max_age=1 day. All pruned.
|
||||
assert pruned2 == 10
|
||||
assert metadata_store.list_task_ids() == []
|
||||
|
||||
|
||||
def test_retention_whichever_keeps_fewer_wins(tmp_path) -> None:
|
||||
"""The more restrictive bound between max_count and max_age wins."""
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
base = datetime(2026, 7, 1, tzinfo=UTC)
|
||||
_seed_n_tasks(metadata_store, 10, base=base)
|
||||
|
||||
# max_count=8 would keep 8, max_age=2 days from base+3d would keep tasks
|
||||
# created within 2 days of base+3d = base+1d..base+9h. Only tasks at base+0h..base+9h.
|
||||
# cutoff = base+3d - 2d = base+1d = base+24h. Tasks created at base+0h..base+9h
|
||||
# are all < base+24h, so keep_by_age = {} (0 tasks). keep_by_count = newest 8.
|
||||
# len(keep_by_age)=0 <= len(keep_by_count)=8, so keep_by_age wins -> 0 kept, all pruned.
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
task_retention_max_count=8,
|
||||
task_retention_max_age_days=2,
|
||||
)
|
||||
now = base + timedelta(days=3)
|
||||
pruned = prune_task_history(metadata_store, timeline, config=config, now=now)
|
||||
assert pruned == 10
|
||||
assert metadata_store.list_task_ids() == []
|
||||
|
||||
|
||||
def test_retention_deletes_timeline_alongside_metadata(tmp_path) -> None:
|
||||
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
|
||||
timeline = Timeline(ArtifactStore(tmp_path / "history"))
|
||||
|
||||
# Create a task with timeline records.
|
||||
task = _make_task("task-to-prune")
|
||||
metadata_store.create_task(task)
|
||||
timeline.append(
|
||||
task_id=task.id,
|
||||
scene={"screen": {"width": 1, "height": 1}, "elements": []},
|
||||
prompt="step",
|
||||
tool_call={"action": "tap"},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
assert len(timeline.read(task.id)) == 1
|
||||
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
task_retention_max_count=0,
|
||||
task_retention_max_age_days=365,
|
||||
)
|
||||
pruned = prune_task_history(
|
||||
metadata_store,
|
||||
timeline,
|
||||
config=config,
|
||||
now=datetime(2026, 7, 1, tzinfo=UTC),
|
||||
)
|
||||
assert pruned == 1
|
||||
assert metadata_store.get_task(task.id) is None
|
||||
assert timeline.read(task.id) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# No collision between Host Agent and Runtime default paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_host_agent_default_db_path_differs_from_runtime_default() -> None:
|
||||
host_agent_config = HostAgentConfig(control_plane_url="https://control.example")
|
||||
# Use posix paths for cross-platform comparison.
|
||||
assert host_agent_config.task_progress_db_path.as_posix() == (
|
||||
"host_agent_data/task_progress.sqlite3"
|
||||
)
|
||||
|
||||
# Runtime's TaskMetadataStore default.
|
||||
runtime_default = TaskMetadataStore.__init__.__defaults__[0]
|
||||
assert Path(runtime_default).as_posix() == "tasks/tasks.sqlite3"
|
||||
|
||||
assert host_agent_config.task_progress_db_path != Path(runtime_default)
|
||||
|
||||
|
||||
def test_two_metadata_stores_at_different_paths_are_isolated(tmp_path) -> None:
|
||||
store_a = TaskMetadataStore(tmp_path / "a.sqlite3")
|
||||
store_b = TaskMetadataStore(tmp_path / "b.sqlite3")
|
||||
|
||||
task_a = _make_task("task-in-a")
|
||||
store_a.create_task(task_a)
|
||||
|
||||
assert store_a.get_task("task-in-a") is not None
|
||||
assert store_b.get_task("task-in-a") is None
|
||||
assert "task-in-a" not in store_b.list_task_ids()
|
||||
assert "task-in-a" in store_a.list_task_ids()
|
||||
@@ -31,3 +31,24 @@ def test_timeline_records_survive_reopening_store(tmp_path) -> None:
|
||||
assert [record["index"] for record in records] == [1, 2]
|
||||
assert records[0]["screenshot_path"].endswith("001.png")
|
||||
|
||||
|
||||
def test_timeline_records_per_step_prompt_not_task_goal(tmp_path) -> None:
|
||||
"""The prompt field should persist exactly what was passed to append(),
|
||||
not a pre-D9 task goal fallback."""
|
||||
store = ArtifactStore(tmp_path / "history")
|
||||
timeline = Timeline(store)
|
||||
|
||||
per_step_prompt = "Goal:\nsend a message\n\nCurrent Scene (JSON):\n{...}\n\nCall exactly one tool."
|
||||
timeline.append(
|
||||
task_id="task-42",
|
||||
scene={"screen": {"width": 1, "height": 1}, "elements": []},
|
||||
prompt=per_step_prompt,
|
||||
tool_call={"action": "tap"},
|
||||
result={"ok": True},
|
||||
screenshot=PNG_10X20,
|
||||
)
|
||||
|
||||
records = timeline.read("task-42")
|
||||
assert len(records) == 1
|
||||
assert records[0]["prompt"] == per_step_prompt
|
||||
assert "Call exactly one tool" in records[0]["prompt"]
|
||||
|
||||
@@ -20,7 +20,9 @@ from tests.fakes import PNG_10X20
|
||||
|
||||
|
||||
class FakeMessages:
|
||||
def __init__(self, *, response: object | None = None, error: Exception | None = None) -> None:
|
||||
def __init__(
|
||||
self, *, response: object | None = None, error: Exception | None = None
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
@@ -38,7 +40,9 @@ class FakeTransport:
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self, *, response: object | None = None, error: Exception | None = None) -> None:
|
||||
def __init__(
|
||||
self, *, response: object | None = None, error: Exception | None = None
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
@@ -65,9 +69,13 @@ class FakeOpenAITransport:
|
||||
|
||||
def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -> None:
|
||||
messages = FakeMessages(
|
||||
response={"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]}
|
||||
response={
|
||||
"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]
|
||||
}
|
||||
)
|
||||
client = AnthropicToolCallingClient(
|
||||
model="test-model", transport=FakeTransport(messages)
|
||||
)
|
||||
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
|
||||
|
||||
decision = client.decide(
|
||||
system_prompt="system",
|
||||
@@ -77,14 +85,23 @@ def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -
|
||||
timeout=2.5,
|
||||
)
|
||||
|
||||
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
assert decision == ToolCallDecision(
|
||||
tool_name="tap",
|
||||
arguments={"x": 1, "y": 2},
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
)
|
||||
assert len(messages.calls) == 1
|
||||
call = messages.calls[0]
|
||||
assert call["model"] == "test-model"
|
||||
assert call["timeout"] == 2.5
|
||||
assert call["tool_choice"] == {"type": "any", "disable_parallel_tool_use": True}
|
||||
assert call["tools"] == [
|
||||
{"name": "tap", "description": TAP_SPEC.description, "input_schema": TAP_SPEC.parameters},
|
||||
{
|
||||
"name": "tap",
|
||||
"description": TAP_SPEC.description,
|
||||
"input_schema": TAP_SPEC.parameters,
|
||||
},
|
||||
{
|
||||
"name": "finish_task",
|
||||
"description": FINISH_TASK_SPEC.description,
|
||||
@@ -93,18 +110,28 @@ def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -
|
||||
]
|
||||
assert call["system"][0]["text"] == "system"
|
||||
assert call["system"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert call["messages"] == [{"role": "user", "content": [{"type": "text", "text": "user"}]}]
|
||||
assert call["messages"] == [
|
||||
{"role": "user", "content": [{"type": "text", "text": "user"}]}
|
||||
]
|
||||
|
||||
|
||||
def test_anthropic_tool_calling_client_includes_image_block_when_screenshot_present() -> None:
|
||||
def test_anthropic_tool_calling_client_includes_image_block_when_screenshot_present() -> (
|
||||
None
|
||||
):
|
||||
messages = FakeMessages(
|
||||
response={
|
||||
"content": [
|
||||
{"type": "tool_use", "name": "finish_task", "input": {"success": True, "reason": "done"}}
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "finish_task",
|
||||
"input": {"success": True, "reason": "done"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
|
||||
client = AnthropicToolCallingClient(
|
||||
model="test-model", transport=FakeTransport(messages)
|
||||
)
|
||||
|
||||
client.decide(
|
||||
system_prompt="system",
|
||||
@@ -157,10 +184,18 @@ def test_anthropic_tool_calling_client_passes_custom_base_url_to_sdk(
|
||||
|
||||
def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
|
||||
messages = FakeMessages(error=TimeoutError("timed out"))
|
||||
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
|
||||
client = AnthropicToolCallingClient(
|
||||
model="test-model", transport=FakeTransport(messages)
|
||||
)
|
||||
|
||||
with pytest.raises(ToolCallUnavailable):
|
||||
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1)
|
||||
client.decide(
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
screenshot=None,
|
||||
tools=[TAP_SPEC],
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -171,12 +206,22 @@ def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
|
||||
{"content": [{"type": "tool_use", "name": "tap", "input": "not-a-dict"}]},
|
||||
],
|
||||
)
|
||||
def test_anthropic_tool_calling_client_wraps_malformed_responses(response: object) -> None:
|
||||
def test_anthropic_tool_calling_client_wraps_malformed_responses(
|
||||
response: object,
|
||||
) -> None:
|
||||
messages = FakeMessages(response=response)
|
||||
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
|
||||
client = AnthropicToolCallingClient(
|
||||
model="test-model", transport=FakeTransport(messages)
|
||||
)
|
||||
|
||||
with pytest.raises(ToolCallUnavailable):
|
||||
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1)
|
||||
client.decide(
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
screenshot=None,
|
||||
tools=[TAP_SPEC],
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
|
||||
# --- OpenAI --------------------------------------------------------------
|
||||
@@ -186,11 +231,24 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
|
||||
completions = FakeCompletions(
|
||||
response={
|
||||
"choices": [
|
||||
{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": '{"x": 1, "y": 2}'}}]}}
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "tap",
|
||||
"arguments": '{"x": 1, "y": 2}',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
decision = client.decide(
|
||||
system_prompt="system",
|
||||
@@ -200,7 +258,12 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
|
||||
timeout=2.5,
|
||||
)
|
||||
|
||||
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
assert decision == ToolCallDecision(
|
||||
tool_name="tap",
|
||||
arguments={"x": 1, "y": 2},
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
)
|
||||
assert len(completions.calls) == 1
|
||||
call = completions.calls[0]
|
||||
assert call["model"] == "test-model"
|
||||
@@ -233,7 +296,9 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
|
||||
]
|
||||
|
||||
|
||||
def test_openai_tool_calling_client_includes_image_block_when_screenshot_present() -> None:
|
||||
def test_openai_tool_calling_client_includes_image_block_when_screenshot_present() -> (
|
||||
None
|
||||
):
|
||||
completions = FakeCompletions(
|
||||
response={
|
||||
"choices": [
|
||||
@@ -252,7 +317,9 @@ def test_openai_tool_calling_client_includes_image_block_when_screenshot_present
|
||||
]
|
||||
}
|
||||
)
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
client.decide(
|
||||
system_prompt="system",
|
||||
@@ -275,22 +342,47 @@ def test_openai_tool_calling_client_includes_image_block_when_screenshot_present
|
||||
def test_openai_tool_calling_client_accepts_arguments_already_as_dict() -> None:
|
||||
completions = FakeCompletions(
|
||||
response={
|
||||
"choices": [{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": {"x": 1, "y": 2}}}]}}]
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{"function": {"name": "tap", "arguments": {"x": 1, "y": 2}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
decision = client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1)
|
||||
decision = client.decide(
|
||||
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
|
||||
)
|
||||
|
||||
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
assert decision == ToolCallDecision(
|
||||
tool_name="tap",
|
||||
arguments={"x": 1, "y": 2},
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
)
|
||||
|
||||
|
||||
def test_openai_tool_calling_client_wraps_transport_errors() -> None:
|
||||
completions = FakeCompletions(error=TimeoutError("timed out"))
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
with pytest.raises(ToolCallUnavailable):
|
||||
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1)
|
||||
client.decide(
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
screenshot=None,
|
||||
tools=[TAP_SPEC],
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -298,15 +390,33 @@ def test_openai_tool_calling_client_wraps_transport_errors() -> None:
|
||||
[
|
||||
{"choices": []},
|
||||
{"choices": [{"message": {"tool_calls": []}}]},
|
||||
{"choices": [{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": "not-json"}}]}}]},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{"function": {"name": "tap", "arguments": "not-json"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_openai_tool_calling_client_wraps_malformed_responses(response: object) -> None:
|
||||
completions = FakeCompletions(response=response)
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
with pytest.raises(ToolCallUnavailable):
|
||||
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1)
|
||||
client.decide(
|
||||
system_prompt="s",
|
||||
user_prompt="u",
|
||||
screenshot=None,
|
||||
tools=[TAP_SPEC],
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
|
||||
# --- build_client ----------------------------------------------------------
|
||||
|
||||
@@ -570,6 +570,7 @@ dependencies = [
|
||||
{ name = "device-cloud-platform" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -579,6 +580,7 @@ requires-dist = [
|
||||
{ name = "device-cloud-platform", editable = "packages/cloud-platform" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
]
|
||||
|
||||
@@ -862,6 +864,18 @@ wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiter"
|
||||
version = "0.16.0"
|
||||
|
||||
Reference in New Issue
Block a user