feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded in-progress snapshot piggybacked on lease renewal. Cloud persists that snapshot per active assignment and exposes it through the existing task list/detail query path; Cloud Console renders it as a live badge. Host Agent's local console gains authenticated, read-only task list and detail/timeline pages (same-origin, server-rendered) with inlined screenshots. Also fixes a pre-existing gap in the shared Timeline: the actual per-step LLM prompt is now recorded instead of the task goal, benefiting both Runtime and Host Agent consoles. When a host uses the cloud planner transport, each decide call's prompt and resulting tool decision are durably logged in a new planner_decision_log table (with bounded retention) and browsable from Cloud Console; direct-transport hosts explicitly surface a "not reported" state. Includes Alembic migrations 0008 (progress columns on scheduled_tasks) and 0009 (planner_decision_log), bounded Host-Agent-local retention, dual-backend repository parity, and Vitest + pytest coverage. Task 6.5 (manual end-to-end device verification) remains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from html import escape as _escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
@@ -23,6 +26,8 @@ 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"
|
||||
@@ -58,6 +63,7 @@ def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
|
||||
<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">
|
||||
@@ -142,11 +148,19 @@ def _dashboard_body(
|
||||
if assignment
|
||||
else "none"
|
||||
)
|
||||
progress = snapshot.get("progress")
|
||||
progress_text = (
|
||||
f"step {progress['step_index']} — {progress['step_status']}: {progress['summary']}"
|
||||
if progress
|
||||
else ""
|
||||
)
|
||||
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",
|
||||
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",
|
||||
)
|
||||
@@ -178,6 +192,7 @@ def _dashboard_body(
|
||||
<section>
|
||||
<h2>Current assignment</h2>
|
||||
<p id="current-assignment">{escape(assignment_text)}</p>
|
||||
<p id="current-progress">{escape(progress_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
@@ -197,6 +212,10 @@ def _dashboard_body(
|
||||
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 "
|
||||
@@ -314,6 +333,88 @@ def _history_body(entries: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _inline_screenshot(record: dict[str, Any]) -> str:
|
||||
"""Return a ``<img>`` tag with base64-encoded screenshot, or empty string."""
|
||||
screenshot_path = record.get("screenshot_path")
|
||||
if not screenshot_path:
|
||||
return ""
|
||||
path = Path(str(screenshot_path))
|
||||
if not path.exists():
|
||||
return ""
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f'<img src="data:image/png;base64,{encoded}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">'
|
||||
|
||||
|
||||
def _tasks_body(tasks: list[dict[str, Any]]) -> str:
|
||||
if not tasks:
|
||||
return """
|
||||
<h1>Tasks</h1>
|
||||
<p>No tasks recorded.</p>
|
||||
"""
|
||||
rows = "".join(
|
||||
f"<tr>"
|
||||
f'<td><a href="/tasks/{escape(task["id"])}">{escape(task["id"])}</a></td>'
|
||||
f"<td>{escape(task.get('status') or '')}</td>"
|
||||
f"<td>{escape(task.get('device_id') or '')}</td>"
|
||||
f"<td>{escape(task.get('created_at') or '')}</td>"
|
||||
f"<td>{escape(task.get('updated_at') or '')}</td>"
|
||||
f"</tr>"
|
||||
for task in tasks
|
||||
)
|
||||
return f"""
|
||||
<h1>Tasks</h1>
|
||||
<table>
|
||||
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _task_detail_body(
|
||||
task: dict[str, Any], timeline_records: list[dict[str, Any]]
|
||||
) -> str:
|
||||
task_rows = "".join(
|
||||
f"<tr><td>{escape(key)}</td><td>{escape(task[key])}</td></tr>"
|
||||
for key in ("id", "goal", "device_id", "status", "created_at", "updated_at")
|
||||
if task.get(key) is not None
|
||||
)
|
||||
metadata_html = f"""
|
||||
<h1>Task {escape(task.get("id") or "")}</h1>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>{task_rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
if not timeline_records:
|
||||
timeline_html = "<h2>Timeline</h2><p>No timeline records.</p>"
|
||||
else:
|
||||
step_blocks = "".join(
|
||||
_timeline_step_html(record) for record in timeline_records
|
||||
)
|
||||
timeline_html = f"<h2>Timeline</h2>{step_blocks}"
|
||||
return metadata_html + timeline_html
|
||||
|
||||
|
||||
def _timeline_step_html(record: dict[str, Any]) -> str:
|
||||
index = record.get("index", "")
|
||||
timestamp = record.get("timestamp", "")
|
||||
tool_call = record.get("tool_call")
|
||||
result = record.get("result")
|
||||
prompt = record.get("prompt") or ""
|
||||
tool_call_text = json.dumps(tool_call, ensure_ascii=False) if tool_call else ""
|
||||
result_text = json.dumps(result, ensure_ascii=False) if result else ""
|
||||
screenshot_html = _inline_screenshot(record)
|
||||
return f"""
|
||||
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
|
||||
<p><strong>Step {escape(index)}</strong> — {escape(timestamp)}</p>
|
||||
<p>Prompt: {escape(prompt)}</p>
|
||||
<p>Tool call: <code>{escape(tool_call_text)}</code></p>
|
||||
<p>Result: <code>{escape(result_text)}</code></p>
|
||||
{screenshot_html}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def create_console_app(
|
||||
*,
|
||||
config: HostAgentConfig,
|
||||
@@ -325,6 +426,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
|
||||
@@ -416,6 +520,16 @@ def create_console_app(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> JSONResponse:
|
||||
snapshot = status_tracker.snapshot()
|
||||
# Pull live progress from the executor if the tracker has no data yet.
|
||||
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 = [
|
||||
@@ -570,4 +684,34 @@ def create_console_app(
|
||||
body = _history_body(entries)
|
||||
return HTMLResponse(_chrome("History", body, session=session))
|
||||
|
||||
@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)
|
||||
body = _tasks_body(tasks_list)
|
||||
return HTMLResponse(_chrome("Tasks", body, session=session))
|
||||
|
||||
@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)
|
||||
body = _task_detail_body(task, timeline_records)
|
||||
return HTMLResponse(_chrome(f"Task {task_id}", body, session=session))
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user