From fb57b7fdbf7a4c268cd22610632403a3f92115be Mon Sep 17 00:00:00 2001 From: Ayu Date: Wed, 1 Apr 2026 21:34:57 +0800 Subject: [PATCH 1/3] Add agent registry console --- src/apps/api/app.py | 5 + src/apps/api/routers/agents.py | 123 +++++++- src/apps/web/agents.css | 317 ++++++++++++++++++++ src/apps/web/agents.html | 65 ++++ src/apps/web/agents.js | 187 ++++++++++++ src/packages/core/schemas.py | 28 ++ src/tests/test_agent_registry_page.py | 412 ++++++++++++++++++++++++++ 7 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 src/apps/web/agents.css create mode 100644 src/apps/web/agents.html create mode 100644 src/apps/web/agents.js create mode 100644 src/tests/test_agent_registry_page.py diff --git a/src/apps/api/app.py b/src/apps/api/app.py index 6216e97..abc134f 100644 --- a/src/apps/api/app.py +++ b/src/apps/api/app.py @@ -39,6 +39,11 @@ def console_batches() -> FileResponse: return FileResponse(WEB_DIR / "index.html") +@app.get("/console/agents") +def console_agents() -> FileResponse: + return FileResponse(WEB_DIR / "agents.html") + + @app.get("/console/batches/{batch_id}") def console_batch_detail(batch_id: str) -> FileResponse: return FileResponse(WEB_DIR / "batch-detail.html") diff --git a/src/apps/api/routers/agents.py b/src/apps/api/routers/agents.py index 2ae9db3..559bfa1 100644 --- a/src/apps/api/routers/agents.py +++ b/src/apps/api/routers/agents.py @@ -1,14 +1,19 @@ from __future__ import annotations +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from sqlalchemy import case, func, select from sqlalchemy.orm import Session from src.apps.api.deps import get_db -from src.packages.core.db.models import AgentRoleORM +from src.packages.core.db.models import AgentRoleORM, ExecutionRunORM from src.packages.core.schemas import ( AgentCapabilityDeclaration, AgentRoleDetailRead, + AgentRegistryDiagnosisRead, + AgentRegistryListItemRead, + AgentRegistryResponse, AgentRoleRegisterRequest, AgentRoleUpdateRequest, ) @@ -42,6 +47,32 @@ def _to_agent_detail(agent_role: AgentRoleORM) -> AgentRoleDetailRead: ) +def _to_agent_registry_item( + agent_role: AgentRoleORM, + *, + total_runs: int, + success_runs: int, +) -> AgentRegistryListItemRead: + success_rate = None + if total_runs > 0: + success_rate = round((success_runs / total_runs) * 100, 2) + + return AgentRegistryListItemRead( + id=agent_role.id, + role_name=agent_role.role_name, + description=agent_role.description, + capabilities=agent_role.capabilities, + capability_declaration=_build_capability_declaration(agent_role), + input_schema=agent_role.input_schema, + output_schema=agent_role.output_schema, + enabled=agent_role.enabled, + version=agent_role.version, + total_runs=total_runs, + success_runs=success_runs, + success_rate=success_rate, + ) + + def _merge_input_schema( base_schema: dict, capability_declaration: AgentCapabilityDeclaration, @@ -63,6 +94,50 @@ def _merge_output_schema( return merged +def _supported_task_types(agent_role: AgentRoleORM) -> list[str]: + supported = agent_role.input_schema.get("supported_task_types", []) + if isinstance(supported, list): + return [str(item) for item in supported] + return [] + + +def _build_registry_diagnosis( + agent_roles: list[AgentRoleORM], + task_type: str, +) -> AgentRegistryDiagnosisRead: + matching_enabled_roles: list[str] = [] + matching_disabled_roles: list[str] = [] + + for agent_role in agent_roles: + if task_type not in _supported_task_types(agent_role): + continue + if agent_role.enabled: + matching_enabled_roles.append(agent_role.role_name) + else: + matching_disabled_roles.append(agent_role.role_name) + + if matching_enabled_roles: + status_name = "matched_enabled" + message = f"Found {len(matching_enabled_roles)} enabled role(s) for task_type={task_type}." + elif matching_disabled_roles: + status_name = "matched_disabled_only" + message = ( + f"No enabled role can execute task_type={task_type}; " + "matching roles exist but are disabled." + ) + else: + status_name = "no_match" + message = f"No agent role declares support for task_type={task_type}." + + return AgentRegistryDiagnosisRead( + task_type=task_type, + status=status_name, + message=message, + matching_enabled_roles=matching_enabled_roles, + matching_disabled_roles=matching_disabled_roles, + ) + + @router.post("/register", response_model=AgentRoleDetailRead, status_code=status.HTTP_201_CREATED) def register_agent( payload: AgentRoleRegisterRequest, @@ -100,6 +175,50 @@ def list_agents(db: Session = Depends(get_db)) -> list[AgentRoleDetailRead]: return [_to_agent_detail(agent_role) for agent_role in agent_roles] +@router.get("/registry", response_model=AgentRegistryResponse) +def get_agent_registry( + task_type: str | None = None, + db: Session = Depends(get_db), +) -> AgentRegistryResponse: + agent_roles = db.scalars(select(AgentRoleORM).order_by(AgentRoleORM.role_name)).all() + + run_stats_rows = db.execute( + select( + ExecutionRunORM.agent_role_id, + func.count(ExecutionRunORM.id).label("total_runs"), + func.coalesce( + func.sum(case((ExecutionRunORM.run_status == "success", 1), else_=0)), + 0, + ).label("success_runs"), + ) + .group_by(ExecutionRunORM.agent_role_id) + ).all() + + run_stats_by_role_id: dict[str, dict[str, Any]] = { + row.agent_role_id: { + "total_runs": int(row.total_runs or 0), + "success_runs": int(row.success_runs or 0), + } + for row in run_stats_rows + } + + items = [ + _to_agent_registry_item( + agent_role, + total_runs=run_stats_by_role_id.get(agent_role.id, {}).get("total_runs", 0), + success_runs=run_stats_by_role_id.get(agent_role.id, {}).get("success_runs", 0), + ) + for agent_role in agent_roles + ] + + diagnosis = None + normalized_task_type = task_type.strip() if task_type else "" + if normalized_task_type: + diagnosis = _build_registry_diagnosis(agent_roles, normalized_task_type) + + return AgentRegistryResponse(items=items, diagnosis=diagnosis) + + @router.get("/{agent_id}", response_model=AgentRoleDetailRead) def get_agent(agent_id: str, db: Session = Depends(get_db)) -> AgentRoleDetailRead: agent_role = db.get(AgentRoleORM, agent_id) diff --git a/src/apps/web/agents.css b/src/apps/web/agents.css new file mode 100644 index 0000000..a70c13c --- /dev/null +++ b/src/apps/web/agents.css @@ -0,0 +1,317 @@ +:root { + --bg: #f4efe6; + --panel: #fffaf2; + --ink: #1f2520; + --muted: #667267; + --line: #dccfb8; + --accent: #194c3d; + --accent-soft: #d8ebe3; + --danger: #a43f2f; + --danger-soft: #f3d6cf; + --warning: #a16a1d; + --warning-soft: #f7e7bf; + --info: #255a8a; + --info-soft: #dbe8f4; + --neutral-soft: #e5dfd1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at top left, rgba(25, 76, 61, 0.08), transparent 30%), + linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%); + color: var(--ink); + font-family: Georgia, "Times New Roman", serif; +} + +.page-shell { + max-width: 1280px; + margin: 0 auto; + padding: 40px 20px 56px; +} + +.hero-row, +.card-top, +.section-heading { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.hero h1 { + margin: 8px 0; + font-size: clamp(2.5rem, 5vw, 4.4rem); + line-height: 0.95; +} + +.eyebrow, +.overline, +.section-label { + margin: 0; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.8rem; +} + +.subtitle, +.description, +.muted { + color: var(--muted); +} + +.subtitle { + max-width: 720px; + font-size: 1.04rem; +} + +.nav-link, +.action-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border-radius: 12px; + text-decoration: none; + font: inherit; +} + +.nav-link, +.action-button { + background: var(--accent); + color: #f4efe6; + border: none; +} + +.secondary-button { + background: #fffdf8; + color: var(--ink); + border: 1px solid var(--line); +} + +.toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + margin-top: 28px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 20px; + background: rgba(255, 250, 242, 0.9); + backdrop-filter: blur(10px); +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--muted); + font-size: 0.92rem; +} + +.field input { + height: 44px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fffdf8; + color: var(--ink); + font: inherit; + padding: 0 14px; +} + +.status-row { + margin: 18px 2px 0; + color: var(--muted); +} + +.overview-grid { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 16px; + margin-top: 18px; +} + +.panel, +.empty-state { + padding: 18px; + border: 1px solid var(--line); + border-radius: 22px; + background: var(--panel); + box-shadow: 0 12px 32px rgba(31, 37, 32, 0.06); +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 0; +} + +.metrics.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metrics div { + padding: 10px 12px; + border-radius: 14px; + background: #fff; +} + +.metrics dt { + color: var(--muted); + font-size: 0.78rem; +} + +.metrics dd { + margin: 6px 0 0; + font-size: 1.05rem; +} + +.registry-section { + margin-top: 28px; +} + +.agent-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.status-stack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.status-badge, +.version-pill, +.meta-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.8rem; +} + +.status-badge.enabled { + background: var(--accent-soft); + color: var(--accent); +} + +.status-badge.disabled, +.meta-pill.disabled { + background: var(--danger-soft); + color: var(--danger); +} + +.version-pill, +.meta-pill { + background: var(--neutral-soft); + color: #5d655f; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.detail-block + .detail-block { + margin-top: 16px; +} + +.schema-block { + margin: 10px 0 0; + padding: 12px; + border-radius: 14px; + background: #fbf7ef; + border: 1px solid var(--line); + overflow-x: auto; + font-size: 0.82rem; + line-height: 1.45; +} + +.diagnosis-card { + border-radius: 18px; + padding: 16px; + background: #fff; +} + +.diagnosis-matched_enabled { + border: 1px solid var(--accent-soft); +} + +.diagnosis-matched_disabled_only { + border: 1px solid var(--warning-soft); + background: #fff9eb; +} + +.diagnosis-no_match { + border: 1px solid var(--danger-soft); + background: #fff4f1; +} + +.diagnosis-status { + margin: 0; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.74rem; +} + +.diagnosis-message { + margin: 10px 0 0; +} + +.diagnosis-group + .diagnosis-group { + margin-top: 14px; +} + +.empty-panel { + color: var(--muted); +} + +@media (max-width: 960px) { + .overview-grid, + .agent-grid { + grid-template-columns: 1fr; + } + + .metrics, + .metrics.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .toolbar { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .hero-row, + .card-top, + .section-heading { + flex-direction: column; + } + + .status-stack { + align-items: flex-start; + } + + .metrics, + .metrics.compact { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/web/agents.html b/src/apps/web/agents.html new file mode 100644 index 0000000..33c7fce --- /dev/null +++ b/src/apps/web/agents.html @@ -0,0 +1,65 @@ + + + + + + Agent Registry + + + +
+
+

Operations Console

+
+
+

Registry visibility

+

Agent Registry

+

Inspect available executors, compare declared schemas, and diagnose why a task type has no eligible role.

+
+ +
+
+ +
+ + + +
+ +
+

Loading agent registry...

+
+ +
+
+ +

Current role inventory

+
+
+ +
+ +

Why no suitable role?

+
Enter a task type to inspect matching roles.
+
+
+ +
+
+
+ +

Execution registry

+
+
+
+
+
+ + + + diff --git a/src/apps/web/agents.js b/src/apps/web/agents.js new file mode 100644 index 0000000..e91144f --- /dev/null +++ b/src/apps/web/agents.js @@ -0,0 +1,187 @@ +const taskTypeInput = document.getElementById("task-type-input"); +const diagnoseButton = document.getElementById("diagnose-button"); +const refreshButton = document.getElementById("refresh-button"); +const statusText = document.getElementById("status-text"); +const summaryMetrics = document.getElementById("summary-metrics"); +const diagnosisContent = document.getElementById("diagnosis-content"); +const agentGrid = document.getElementById("agent-grid"); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function formatPercent(value) { + if (value === null || value === undefined) { + return "No run history"; + } + return `${value}%`; +} + +function formatJson(value) { + const entries = value && typeof value === "object" ? Object.keys(value) : []; + if (!entries.length) { + return "No schema declared"; + } + return escapeHtml(JSON.stringify(value, null, 2)); +} + +function renderSummary(items) { + const enabledCount = items.filter((item) => item.enabled).length; + const disabledCount = items.length - enabledCount; + const rolesWithHistory = items.filter((item) => item.total_runs > 0).length; + + const metrics = [ + ["Total roles", items.length], + ["Enabled", enabledCount], + ["Disabled", disabledCount], + ["With run history", rolesWithHistory], + ]; + + summaryMetrics.innerHTML = metrics + .map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`) + .join(""); +} + +function renderDiagnosis(diagnosis) { + if (!diagnosis) { + diagnosisContent.innerHTML = "Enter a task type to inspect matching roles."; + return; + } + + const enabled = diagnosis.matching_enabled_roles.length + ? diagnosis.matching_enabled_roles.map((role) => `${escapeHtml(role)}`).join("") + : 'None'; + const disabled = diagnosis.matching_disabled_roles.length + ? diagnosis.matching_disabled_roles.map((role) => `${escapeHtml(role)}`).join("") + : 'None'; + + diagnosisContent.innerHTML = ` +
+

${escapeHtml(diagnosis.status)}

+

${escapeHtml(diagnosis.message)}

+
+ Enabled matches +
${enabled}
+
+
+ Disabled matches +
${disabled}
+
+
+ `; +} + +function renderEmpty(message) { + agentGrid.innerHTML = `
${escapeHtml(message)}
`; +} + +function renderAgents(items) { + if (!items.length) { + renderEmpty("No agent roles registered."); + return; + } + + agentGrid.innerHTML = items + .map((item) => { + const taskTypes = item.capability_declaration.supported_task_types.length + ? item.capability_declaration.supported_task_types + .map((taskType) => `${escapeHtml(taskType)}`) + .join("") + : 'No explicit task types'; + const capabilities = item.capabilities.length + ? item.capabilities.map((capability) => `${escapeHtml(capability)}`).join("") + : 'No capability tags'; + + return ` +
+
+
+ +

${escapeHtml(item.role_name)}

+

${escapeHtml(item.description ?? "No description")}

+
+
+ ${item.enabled ? "enabled" : "disabled"} + v${escapeHtml(item.version)} +
+
+ +
+ Capability tags +
${capabilities}
+
+ +
+ Supported task types +
${taskTypes}
+
+ +
+
Total runs
${escapeHtml(item.total_runs)}
+
Success runs
${escapeHtml(item.success_runs)}
+
Success rate
${escapeHtml(formatPercent(item.success_rate))}
+
+ +
+ Input schema +
${formatJson(item.input_schema)}
+
+ +
+ Output schema +
${formatJson(item.output_schema)}
+
+
+ `; + }) + .join(""); +} + +async function loadRegistry() { + const params = new URLSearchParams(); + if (taskTypeInput.value.trim()) { + params.set("task_type", taskTypeInput.value.trim()); + } + + statusText.textContent = "Loading agent registry..."; + + try { + const query = params.toString(); + const response = await fetch(`/agents/registry${query ? `?${query}` : ""}`); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const payload = await response.json(); + statusText.textContent = `${payload.items.length} role${payload.items.length === 1 ? "" : "s"} loaded`; + renderSummary(payload.items); + renderDiagnosis(payload.diagnosis); + renderAgents(payload.items); + } catch (error) { + statusText.textContent = "Unable to load agent registry."; + summaryMetrics.innerHTML = ""; + diagnosisContent.innerHTML = `
${escapeHtml(error.message)}
`; + renderEmpty(error.message); + } +} + +diagnoseButton.addEventListener("click", () => { + loadRegistry(); +}); + +refreshButton.addEventListener("click", () => { + loadRegistry(); +}); + +taskTypeInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + loadRegistry(); + } +}); + +loadRegistry(); diff --git a/src/packages/core/schemas.py b/src/packages/core/schemas.py index 051e2e1..fee0b5c 100644 --- a/src/packages/core/schemas.py +++ b/src/packages/core/schemas.py @@ -238,6 +238,34 @@ class AgentRoleDetailRead(SchemaModel): version: str +class AgentRegistryListItemRead(SchemaModel): + id: str + role_name: str + description: str | None = None + capabilities: list[str] = Field(default_factory=list) + capability_declaration: AgentCapabilityDeclaration + input_schema: dict[str, Any] = Field(default_factory=dict) + output_schema: dict[str, Any] = Field(default_factory=dict) + enabled: bool + version: str + total_runs: int = 0 + success_runs: int = 0 + success_rate: float | None = None + + +class AgentRegistryDiagnosisRead(SchemaModel): + task_type: str + status: str + message: str + matching_enabled_roles: list[str] = Field(default_factory=list) + matching_disabled_roles: list[str] = Field(default_factory=list) + + +class AgentRegistryResponse(SchemaModel): + items: list[AgentRegistryListItemRead] = Field(default_factory=list) + diagnosis: AgentRegistryDiagnosisRead | None = None + + class AssignmentCreate(SchemaModel): task_id: str agent_role_id: str diff --git a/src/tests/test_agent_registry_page.py b/src/tests/test_agent_registry_page.py new file mode 100644 index 0000000..610c244 --- /dev/null +++ b/src/tests/test_agent_registry_page.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + + +ROOT = Path(__file__).resolve().parents[2] +TEST_ROLE_PREFIX = "registry-test-role-" +TEST_BATCH_PREFIX = "registry-test-batch-" + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _database_url() -> str: + database_url = os.getenv("DATABASE_URL") + if not database_url: + raise RuntimeError("DATABASE_URL is not set") + return database_url + + +def _cleanup_database() -> None: + engine = create_engine(_database_url()) + with engine.begin() as conn: + conn.execute( + text( + """ + DELETE FROM task_batches + WHERE title LIKE :batch_prefix + """ + ), + {"batch_prefix": f"{TEST_BATCH_PREFIX}%"}, + ) + conn.execute( + text( + """ + DELETE FROM agent_roles + WHERE role_name LIKE :role_prefix + """ + ), + {"role_prefix": f"{TEST_ROLE_PREFIX}%"}, + ) + + +def _register_agent( + client: TestClient, + *, + role_name: str, + supported_task_types: list[str], + enabled: bool = True, +) -> dict: + payload = { + "role_name": role_name, + "description": "registry test role", + "capabilities": [f"task:{role_name}", "task:registry"], + "capability_declaration": { + "supported_task_types": supported_task_types, + "input_requirements": {"type": "object", "properties": {"text": {"type": "string"}}}, + "output_contract": {"type": "object", "properties": {"summary": {"type": "string"}}}, + "supports_concurrency": True, + "allows_auto_retry": False, + }, + "input_schema": {"schema_version": "1"}, + "output_schema": {"schema_version": "1"}, + "timeout_seconds": 300, + "max_retries": 0, + "enabled": enabled, + "version": "1.0.0", + } + response = client.post("/agents/register", json=payload) + assert response.status_code == 201 + return response.json() + + +def _create_batch_with_task(client: TestClient, *, task_type: str, suffix: str) -> str: + response = client.post( + "/task-batches", + json={ + "title": f"{TEST_BATCH_PREFIX}{suffix}", + "description": "registry test batch", + "created_by": "pytest", + "metadata": {"suite": "agent-registry"}, + "tasks": [ + { + "client_task_id": "task_1", + "title": f"registry task {suffix} 1", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "alpha"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": [], + }, + { + "client_task_id": "task_2", + "title": f"registry task {suffix} 2", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "beta"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": [], + }, + { + "client_task_id": "task_3", + "title": f"registry task {suffix} 3", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "gamma"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": [], + }, + ], + }, + ) + assert response.status_code == 201 + return response.json()["tasks"][0]["task_id"] + + +def _insert_runs(task_id: str, agent_role_id: str, run_statuses: list[str]) -> None: + engine = create_engine(_database_url()) + with Session(engine) as session: + for run_status in run_statuses: + session.execute( + text( + """ + INSERT INTO execution_runs ( + id, + task_id, + agent_role_id, + run_status, + started_at, + finished_at, + logs, + input_snapshot, + output_snapshot, + error_message, + cancelled_at, + cancel_reason, + token_usage, + latency_ms + ) VALUES ( + :id, + :task_id, + :agent_role_id, + :run_status, + NOW(), + NOW(), + ARRAY[]::text[], + '{}'::jsonb, + '{}'::jsonb, + NULL, + NULL, + NULL, + '{}'::jsonb, + 100 + ) + """ + ), + { + "id": f"run_{uuid.uuid4().hex}", + "task_id": task_id, + "agent_role_id": agent_role_id, + "run_status": run_status, + }, + ) + session.commit() + + +def _seed_registry_history(*, suffix: str, run_statuses: list[str]) -> dict: + batch_id = f"batch_{uuid.uuid4().hex}" + task_id = f"task_{uuid.uuid4().hex}" + role = _register_agent( + client, + role_name=f"{TEST_ROLE_PREFIX}{suffix}", + supported_task_types=["registry_success_case"], + ) + role_id = role["id"] + role_name = role["role_name"] + + engine = create_engine(_database_url()) + with Session(engine) as session: + session.execute( + text( + """ + INSERT INTO task_batches ( + id, + title, + description, + created_by, + created_at, + status, + total_tasks, + metadata + ) VALUES ( + :id, + :title, + :description, + 'pytest', + NOW(), + 'submitted', + 1, + '{}'::jsonb + ) + """ + ), + { + "id": batch_id, + "title": f"{TEST_BATCH_PREFIX}{suffix}", + "description": "registry seeded batch", + }, + ) + session.execute( + text( + """ + INSERT INTO tasks ( + id, + batch_id, + title, + description, + task_type, + priority, + status, + input_payload, + expected_output_schema, + assigned_agent_role, + dependency_ids, + retry_count, + cancellation_requested, + cancellation_requested_at, + cancellation_reason, + created_at, + updated_at + ) VALUES ( + :id, + :batch_id, + :title, + :description, + 'registry_success_case', + 'medium', + 'success', + '{}'::jsonb, + '{}'::jsonb, + :assigned_agent_role, + ARRAY[]::varchar[], + 0, + FALSE, + NULL, + NULL, + NOW(), + NOW() + ) + """ + ), + { + "id": task_id, + "batch_id": batch_id, + "title": f"registry task {suffix}", + "description": "registry seeded task", + "assigned_agent_role": role_name, + }, + ) + for run_status in run_statuses: + session.execute( + text( + """ + INSERT INTO execution_runs ( + id, + task_id, + agent_role_id, + run_status, + started_at, + finished_at, + logs, + input_snapshot, + output_snapshot, + error_message, + cancelled_at, + cancel_reason, + token_usage, + latency_ms + ) VALUES ( + :id, + :task_id, + :agent_role_id, + :run_status, + NOW(), + NOW(), + ARRAY[]::text[], + '{}'::jsonb, + '{}'::jsonb, + NULL, + NULL, + NULL, + '{}'::jsonb, + 100 + ) + """ + ), + { + "id": f"run_{uuid.uuid4().hex}", + "task_id": task_id, + "agent_role_id": role_id, + "run_status": run_status, + }, + ) + session.commit() + + return role + + +_cleanup_database() + +from src.apps.api.app import app # noqa: E402 + + +client = TestClient(app) + + +def setup_function() -> None: + _cleanup_database() + + +def teardown_function() -> None: + _cleanup_database() + + +def test_agent_registry_aggregates_run_history_and_success_rate() -> None: + suffix = uuid.uuid4().hex[:8] + role = _seed_registry_history(suffix=suffix, run_statuses=["success", "failed", "cancelled"]) + + response = client.get("/agents/registry") + assert response.status_code == 200 + payload = response.json() + registry_item = next(item for item in payload["items"] if item["id"] == role["id"]) + + assert registry_item["role_name"] == role["role_name"] + assert registry_item["total_runs"] == 3 + assert registry_item["success_runs"] == 1 + assert registry_item["success_rate"] == 33.33 + + +def test_agent_registry_diagnosis_distinguishes_enabled_disabled_and_missing_matches() -> None: + suffix = uuid.uuid4().hex[:8] + enabled_role = _register_agent( + client, + role_name=f"{TEST_ROLE_PREFIX}enabled-{suffix}", + supported_task_types=["match_enabled_case"], + ) + disabled_role = _register_agent( + client, + role_name=f"{TEST_ROLE_PREFIX}disabled-{suffix}", + supported_task_types=["match_disabled_case"], + enabled=False, + ) + + enabled_response = client.get("/agents/registry", params={"task_type": "match_enabled_case"}) + assert enabled_response.status_code == 200 + enabled_diagnosis = enabled_response.json()["diagnosis"] + assert enabled_diagnosis["status"] == "matched_enabled" + assert enabled_diagnosis["matching_enabled_roles"] == [enabled_role["role_name"]] + assert enabled_diagnosis["matching_disabled_roles"] == [] + + disabled_response = client.get("/agents/registry", params={"task_type": "match_disabled_case"}) + assert disabled_response.status_code == 200 + disabled_diagnosis = disabled_response.json()["diagnosis"] + assert disabled_diagnosis["status"] == "matched_disabled_only" + assert disabled_diagnosis["matching_enabled_roles"] == [] + assert disabled_diagnosis["matching_disabled_roles"] == [disabled_role["role_name"]] + + missing_response = client.get("/agents/registry", params={"task_type": "totally_missing_case"}) + assert missing_response.status_code == 200 + missing_diagnosis = missing_response.json()["diagnosis"] + assert missing_diagnosis["status"] == "no_match" + assert missing_diagnosis["matching_enabled_roles"] == [] + assert missing_diagnosis["matching_disabled_roles"] == [] + + +def test_agent_registry_reports_no_run_history_when_role_has_no_runs() -> None: + suffix = uuid.uuid4().hex[:8] + role = _register_agent( + client, + role_name=f"{TEST_ROLE_PREFIX}no-runs-{suffix}", + supported_task_types=["no_runs_case"], + ) + + response = client.get("/agents/registry") + assert response.status_code == 200 + registry_item = next(item for item in response.json()["items"] if item["id"] == role["id"]) + + assert registry_item["total_runs"] == 0 + assert registry_item["success_runs"] == 0 + assert registry_item["success_rate"] is None + + +def test_console_agent_registry_page_is_accessible() -> None: + response = client.get("/console/agents") + assert response.status_code == 200 + assert "Agent Registry" in response.text + assert "/console/assets/agents.js" in response.text + + +def test_agent_registry_assets_include_success_rate_and_diagnosis_copy() -> None: + response = client.get("/console/assets/agents.js") + assert response.status_code == 200 + assert "Success rate" in response.text + assert "Why no suitable role?" in client.get("/console/agents").text + assert "No run history" in response.text From a2d23e5c5b9f1e7bea1acdf1a2aca2cf865546d0 Mon Sep 17 00:00:00 2001 From: Ayu Date: Wed, 1 Apr 2026 21:46:28 +0800 Subject: [PATCH 2/3] Add task status history API --- src/apps/api/routers/tasks.py | 34 ++++- src/packages/core/schemas.py | 9 ++ src/tests/test_task_status_history_api.py | 175 ++++++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_task_status_history_api.py diff --git a/src/apps/api/routers/tasks.py b/src/apps/api/routers/tasks.py index 055bfa9..e3c647c 100644 --- a/src/apps/api/routers/tasks.py +++ b/src/apps/api/routers/tasks.py @@ -8,7 +8,12 @@ from src.apps.api.deps import get_db from src.packages.core.db.models import EventLogORM, TaskORM -from src.packages.core.schemas import TaskCancelRequest, TaskEventRead, TaskRead +from src.packages.core.schemas import ( + TaskCancelRequest, + TaskEventRead, + TaskRead, + TaskStatusHistoryItemRead, +) from src.packages.core.task_state_machine import TaskStatusTransitionError, transition_task_status router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -40,6 +45,33 @@ def get_task_events(task_id: str, db: Session = Depends(get_db)) -> list[TaskEve return [TaskEventRead.model_validate(event) for event in events] +@router.get("/{task_id}/status-history", response_model=list[TaskStatusHistoryItemRead]) +def get_task_status_history(task_id: str, db: Session = Depends(get_db)) -> list[TaskStatusHistoryItemRead]: + task = db.get(TaskORM, task_id) + if task is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + + events = db.scalars( + select(EventLogORM) + .where( + EventLogORM.task_id == task_id, + EventLogORM.event_type == "task_status_changed", + ) + .order_by(EventLogORM.created_at.asc(), EventLogORM.id.asc()) + ).all() + return [ + TaskStatusHistoryItemRead( + task_id=event.task_id or task_id, + old_status=event.payload.get("from_status"), + new_status=event.payload.get("to_status") or event.event_status or "unknown", + timestamp=event.created_at, + reason=event.message, + actor=event.payload.get("source"), + ) + for event in events + ] + + @router.post("/{task_id}/cancel", response_model=TaskRead) def cancel_task( task_id: str, diff --git a/src/packages/core/schemas.py b/src/packages/core/schemas.py index fee0b5c..fbe27eb 100644 --- a/src/packages/core/schemas.py +++ b/src/packages/core/schemas.py @@ -175,6 +175,15 @@ class TaskEventRead(SchemaModel): created_at: datetime +class TaskStatusHistoryItemRead(SchemaModel): + task_id: str + old_status: str | None = None + new_status: str + timestamp: datetime + reason: str | None = None + actor: str | None = None + + class AgentRoleCreate(SchemaModel): role_name: str description: str | None = None diff --git a/src/tests/test_task_status_history_api.py b/src/tests/test_task_status_history_api.py new file mode 100644 index 0000000..72071a3 --- /dev/null +++ b/src/tests/test_task_status_history_api.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + + +ROOT = Path(__file__).resolve().parents[2] +TEST_PREFIX = "task-status-history-test-" + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _database_url() -> str: + database_url = os.getenv("DATABASE_URL") + if not database_url: + env_file = ROOT / ".env" + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("DATABASE_URL="): + database_url = line.split("=", 1)[1].strip() + break + if not database_url: + raise RuntimeError("DATABASE_URL is not set") + return database_url + + +def _cleanup_database() -> None: + engine = create_engine(_database_url()) + with engine.begin() as conn: + conn.execute( + text("DELETE FROM task_batches WHERE title LIKE :prefix"), + {"prefix": f"{TEST_PREFIX}%"}, + ) + + +_cleanup_database() + +from src.apps.api.app import app # noqa: E402 +from src.packages.core.db.models import EventLogORM, TaskBatchORM, TaskORM # noqa: E402 +from src.packages.core.task_state_machine import transition_task_status # noqa: E402 + + +client = TestClient(app) + + +def setup_function() -> None: + _cleanup_database() + + +def teardown_function() -> None: + _cleanup_database() + + +def _create_task(initial_status: str = "pending") -> str: + engine = create_engine(_database_url()) + suffix = uuid.uuid4().hex[:8] + with Session(engine) as session: + batch = TaskBatchORM( + title=f"{TEST_PREFIX}{suffix}", + description="task status history batch", + created_by="pytest", + status="draft", + total_tasks=1, + metadata_json={"suite": "task-status-history"}, + ) + session.add(batch) + session.flush() + task = TaskORM( + batch_id=batch.id, + title=f"{TEST_PREFIX}task-{suffix}", + description="task status history task", + task_type="generate", + priority="medium", + status=initial_status, + input_payload={}, + expected_output_schema={}, + assigned_agent_role=None, + dependency_ids=[], + retry_count=0, + ) + session.add(task) + session.commit() + return task.id + + +def test_status_history_returns_status_changes_in_order() -> None: + engine = create_engine(_database_url()) + task_id = _create_task("pending") + + with Session(engine) as session: + task = session.get(TaskORM, task_id) + assert task is not None + transition_task_status(session, task, "queued", "routed to worker", "router") + transition_task_status(session, task, "running", "worker started", "worker") + transition_task_status(session, task, "success", "worker finished", "worker") + session.commit() + + response = client.get(f"/tasks/{task_id}/status-history") + assert response.status_code == 200 + payload = response.json() + + assert [item["new_status"] for item in payload] == ["queued", "running", "success"] + assert payload[0]["task_id"] == task_id + assert payload[0]["old_status"] == "pending" + assert payload[0]["new_status"] == "queued" + assert payload[0]["reason"] == "routed to worker" + assert payload[0]["actor"] == "router" + assert payload[0]["timestamp"] + + +def test_status_history_filters_out_non_status_events() -> None: + engine = create_engine(_database_url()) + task_id = _create_task("pending") + + with Session(engine) as session: + task = session.get(TaskORM, task_id) + assert task is not None + transition_task_status(session, task, "queued", "routed to worker", "router") + session.add( + EventLogORM( + batch_id=task.batch_id, + task_id=task.id, + event_type="execution_run_started", + event_status="running", + message="worker started execution run", + payload={"task_id": task.id, "source": "worker"}, + ) + ) + session.commit() + + response = client.get(f"/tasks/{task_id}/status-history") + assert response.status_code == 200 + payload = response.json() + + assert len(payload) == 1 + assert payload[0]["new_status"] == "queued" + + +def test_status_history_returns_empty_list_when_no_transition_exists() -> None: + task_id = _create_task("pending") + + response = client.get(f"/tasks/{task_id}/status-history") + assert response.status_code == 200 + assert response.json() == [] + + +def test_status_history_returns_404_for_unknown_task() -> None: + response = client.get("/tasks/not_found/status-history") + assert response.status_code == 404 + assert response.json()["detail"] == "Task not found" + + +def test_status_history_includes_cancel_transition_from_api() -> None: + task_id = _create_task("pending") + + cancel_response = client.post(f"/tasks/{task_id}/cancel", json={"reason": "manual stop"}) + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "cancelled" + + response = client.get(f"/tasks/{task_id}/status-history") + assert response.status_code == 200 + payload = response.json() + + assert len(payload) == 1 + assert payload[0]["old_status"] == "pending" + assert payload[0]["new_status"] == "cancelled" + assert payload[0]["reason"] == "manual stop" + assert payload[0]["actor"] == "api" From f39f0b936c601309d480ed4c01ee31ce8724681a Mon Sep 17 00:00:00 2001 From: Ayu Date: Wed, 1 Apr 2026 22:09:38 +0800 Subject: [PATCH 3/3] Add execution timeline views --- src/apps/api/routers/task_batches.py | 10 + src/apps/api/routers/tasks.py | 10 + src/apps/web/batch-detail.css | 39 +++- src/apps/web/batch-detail.html | 10 + src/apps/web/batch-detail.js | 49 ++++- src/apps/web/run-detail.html | 10 + src/apps/web/run-detail.js | 41 +++- src/packages/core/schemas.py | 23 +++ src/packages/core/timeline.py | 247 +++++++++++++++++++++++ src/tests/test_batch_detail_page.py | 10 + src/tests/test_execution_timeline_api.py | 215 ++++++++++++++++++++ src/tests/test_run_detail_page.py | 10 + 12 files changed, 660 insertions(+), 14 deletions(-) create mode 100644 src/packages/core/timeline.py create mode 100644 src/tests/test_execution_timeline_api.py diff --git a/src/apps/api/routers/task_batches.py b/src/apps/api/routers/task_batches.py index b34096e..c1a5e87 100644 --- a/src/apps/api/routers/task_batches.py +++ b/src/apps/api/routers/task_batches.py @@ -20,6 +20,7 @@ TaskORM, ) from src.packages.core.schemas import ( + BatchTimelineRead, BatchArtifactRead, BatchCountsRead, BatchProgressRead, @@ -32,6 +33,7 @@ TaskBatchSubmitResponse, TaskBatchSubmitTaskRead, ) +from src.packages.core.timeline import load_batch_timeline from src.packages.core.task_state_machine import transition_task_status from src.packages.router import route_task @@ -430,6 +432,14 @@ def get_task_batch(batch_id: str, db: Session = Depends(get_db)) -> TaskBatchRea return TaskBatchRead.model_validate(task_batch) +@router.get("/{batch_id}/timeline", response_model=BatchTimelineRead) +def get_task_batch_timeline(batch_id: str, db: Session = Depends(get_db)) -> BatchTimelineRead: + timeline = load_batch_timeline(db, batch_id) + if timeline is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task batch not found") + return timeline + + @router.get("/{batch_id}/summary", response_model=TaskBatchSummaryRead) def get_task_batch_summary(batch_id: str, db: Session = Depends(get_db)) -> TaskBatchSummaryRead: task_batch = db.get(TaskBatchORM, batch_id) diff --git a/src/apps/api/routers/tasks.py b/src/apps/api/routers/tasks.py index e3c647c..ddab506 100644 --- a/src/apps/api/routers/tasks.py +++ b/src/apps/api/routers/tasks.py @@ -12,8 +12,10 @@ TaskCancelRequest, TaskEventRead, TaskRead, + TaskTimelineRead, TaskStatusHistoryItemRead, ) +from src.packages.core.timeline import load_task_timeline from src.packages.core.task_state_machine import TaskStatusTransitionError, transition_task_status router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -72,6 +74,14 @@ def get_task_status_history(task_id: str, db: Session = Depends(get_db)) -> list ] +@router.get("/{task_id}/timeline", response_model=TaskTimelineRead) +def get_task_timeline(task_id: str, db: Session = Depends(get_db)) -> TaskTimelineRead: + timeline = load_task_timeline(db, task_id) + if timeline is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + return timeline + + @router.post("/{task_id}/cancel", response_model=TaskRead) def cancel_task( task_id: str, diff --git a/src/apps/web/batch-detail.css b/src/apps/web/batch-detail.css index aec8a0d..c5e326f 100644 --- a/src/apps/web/batch-detail.css +++ b/src/apps/web/batch-detail.css @@ -84,7 +84,8 @@ .risk-groups, .artifact-list, -.dependency-map { +.dependency-map, +.timeline-list { display: grid; gap: 12px; } @@ -92,6 +93,7 @@ .risk-group, .artifact-row, .dependency-row, +.timeline-entry, .empty-panel { padding: 14px; border-radius: 16px; @@ -127,12 +129,14 @@ } .artifact-list, -.dependency-map { +.dependency-map, +.timeline-list { min-height: 120px; } .artifact-row, -.dependency-row { +.dependency-row, +.timeline-entry { border: 1px solid var(--line); } @@ -142,11 +146,38 @@ } .artifact-meta, -.dependency-meta { +.dependency-meta, +.timeline-meta { color: var(--muted); font-size: 0.86rem; } +.timeline-entry { + display: grid; + gap: 8px; +} + +.timeline-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.timeline-head strong, +.timeline-entry p { + margin: 0; +} + +.timeline-stage { + padding: 6px 10px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.8rem; + text-transform: uppercase; +} + .task-section { margin-top: 22px; } diff --git a/src/apps/web/batch-detail.html b/src/apps/web/batch-detail.html index 1f292b2..d065c96 100644 --- a/src/apps/web/batch-detail.html +++ b/src/apps/web/batch-detail.html @@ -72,6 +72,16 @@

Batch outputs

+
+
+
+ +

Batch trajectory

+
+
+
+
+
diff --git a/src/apps/web/batch-detail.js b/src/apps/web/batch-detail.js index cb503e4..be34bd5 100644 --- a/src/apps/web/batch-detail.js +++ b/src/apps/web/batch-detail.js @@ -7,6 +7,7 @@ const overviewMetrics = document.getElementById("overview-metrics"); const riskGroups = document.getElementById("risk-groups"); const dependencyMap = document.getElementById("dependency-map"); const artifactList = document.getElementById("artifact-list"); +const timelineList = document.getElementById("timeline-list"); const taskGrid = document.getElementById("task-grid"); function formatDate(value) { @@ -162,6 +163,33 @@ function renderArtifacts(artifacts) { .join(""); } +function renderTimeline(items) { + if (!items.length) { + timelineList.innerHTML = `
No execution timeline available for this batch.
`; + return; + } + + timelineList.innerHTML = items + .map( + (item) => ` +
+
+ ${escapeHtml(item.title)} + ${escapeHtml(item.stage)} +
+

${escapeHtml(item.detail ?? "No additional detail.")}

+

+ ${escapeHtml(formatDate(item.timestamp))} + · task ${escapeHtml(item.task_id ?? "batch")} + · run ${escapeHtml(item.run_id ?? "n/a")} + · actor ${escapeHtml(item.actor ?? "system")} +

+
+ `, + ) + .join(""); +} + function taskFlags(task) { const flags = []; if (task.status === "needs_review") { @@ -255,6 +283,7 @@ function renderError(message) { riskGroups.innerHTML = `
${escapeHtml(message)}
`; dependencyMap.innerHTML = `
${escapeHtml(message)}
`; artifactList.innerHTML = `
${escapeHtml(message)}
`; + timelineList.innerHTML = `
${escapeHtml(message)}
`; taskGrid.innerHTML = `
${escapeHtml(message)}
`; } @@ -268,20 +297,30 @@ async function loadBatchDetail() { statusText.textContent = "Loading batch summary..."; try { - const response = await fetch(`/task-batches/${batchId}/summary`); - if (response.status === 404) { + const [summaryResponse, timelineResponse] = await Promise.all([ + fetch(`/task-batches/${batchId}/summary`), + fetch(`/task-batches/${batchId}/timeline`), + ]); + if (summaryResponse.status === 404 || timelineResponse.status === 404) { throw new Error("Batch not found."); } - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); + if (!summaryResponse.ok) { + throw new Error(`Summary request failed with status ${summaryResponse.status}`); + } + if (!timelineResponse.ok) { + throw new Error(`Timeline request failed with status ${timelineResponse.status}`); } - const summary = await response.json(); + const [summary, timeline] = await Promise.all([ + summaryResponse.json(), + timelineResponse.json(), + ]); statusText.textContent = `Batch ${summary.batch.id} is currently ${summary.derived_status}.`; renderOverview(summary); renderRiskGroups(summary.tasks); renderDependencyMap(summary.tasks); renderArtifacts(summary.artifacts); + renderTimeline(timeline.items); renderTasks(summary.tasks); } catch (error) { renderError(error.message); diff --git a/src/apps/web/run-detail.html b/src/apps/web/run-detail.html index 9e4366e..a59366c 100644 --- a/src/apps/web/run-detail.html +++ b/src/apps/web/run-detail.html @@ -94,6 +94,16 @@

Previous attempts

+
+
+
+ +

Task trajectory

+
+
+
+
+
diff --git a/src/apps/web/run-detail.js b/src/apps/web/run-detail.js index 784373f..86896df 100644 --- a/src/apps/web/run-detail.js +++ b/src/apps/web/run-detail.js @@ -10,6 +10,7 @@ const outputSnapshot = document.getElementById("output-snapshot"); const errorPanel = document.getElementById("error-panel"); const logList = document.getElementById("log-list"); const retryHistory = document.getElementById("retry-history"); +const taskTimeline = document.getElementById("task-timeline"); const eventList = document.getElementById("event-list"); const backToBatch = document.getElementById("back-to-batch"); @@ -144,6 +145,29 @@ function renderRetryHistory(detail) { .join(""); } +function renderTaskTimeline(timeline) { + if (!timeline.items.length) { + taskTimeline.innerHTML = `
No lifecycle timeline available.
`; + return; + } + + taskTimeline.innerHTML = timeline.items + .map( + (item) => ` +
+ ${escapeHtml(item.title)} +

${escapeHtml(item.detail ?? "No additional detail.")}

+

+ ${escapeHtml(item.stage)} · ${escapeHtml(formatDate(item.timestamp))} + · run ${escapeHtml(item.run_id ?? "n/a")} + · actor ${escapeHtml(item.actor ?? "system")} +

+
+ `, + ) + .join(""); +} + function renderEvents(detail) { if (!detail.events.length) { eventList.innerHTML = `
No task events available.
`; @@ -171,6 +195,7 @@ function renderError(message) { errorPanel.innerHTML = `
${escapeHtml(message)}
`; logList.innerHTML = `
${escapeHtml(message)}
`; retryHistory.innerHTML = `
${escapeHtml(message)}
`; + taskTimeline.innerHTML = `
${escapeHtml(message)}
`; eventList.innerHTML = `
${escapeHtml(message)}
`; } @@ -182,20 +207,26 @@ async function loadRunDetail() { } try { - const response = await fetch(`/runs/${runId}/detail`); - if (response.status === 404) { + const detailResponse = await fetch(`/runs/${runId}/detail`); + if (detailResponse.status === 404) { throw new Error("Run not found."); } - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); + if (!detailResponse.ok) { + throw new Error(`Request failed with status ${detailResponse.status}`); } - const detail = await response.json(); + const detail = await detailResponse.json(); + const timelineResponse = await fetch(`/tasks/${detail.task.task_id}/timeline`); + if (!timelineResponse.ok) { + throw new Error(`Timeline request failed with status ${timelineResponse.status}`); + } + const timeline = await timelineResponse.json(); renderOverview(detail); renderRouting(detail); renderSnapshots(detail); renderErrorAndLogs(detail); renderRetryHistory(detail); + renderTaskTimeline(timeline); renderEvents(detail); } catch (error) { renderError(error.message); diff --git a/src/packages/core/schemas.py b/src/packages/core/schemas.py index fbe27eb..9fd0cbc 100644 --- a/src/packages/core/schemas.py +++ b/src/packages/core/schemas.py @@ -184,6 +184,29 @@ class TaskStatusHistoryItemRead(SchemaModel): actor: str | None = None +class TimelineItemRead(SchemaModel): + timestamp: datetime + stage: str + title: str + detail: str | None = None + task_id: str | None = None + run_id: str | None = None + status: str | None = None + actor: str | None = None + + +class TaskTimelineRead(SchemaModel): + task_id: str + batch_id: str + items: list[TimelineItemRead] = Field(default_factory=list) + + +class BatchTimelineRead(SchemaModel): + batch_id: str + title: str + items: list[TimelineItemRead] = Field(default_factory=list) + + class AgentRoleCreate(SchemaModel): role_name: str description: str | None = None diff --git a/src/packages/core/timeline.py b/src/packages/core/timeline.py new file mode 100644 index 0000000..860e58d --- /dev/null +++ b/src/packages/core/timeline.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.packages.core.db.models import EventLogORM, TaskBatchORM, TaskORM +from src.packages.core.schemas import BatchTimelineRead, TaskTimelineRead, TimelineItemRead + + +@dataclass(frozen=True) +class _SortableTimelineItem: + item: TimelineItemRead + event_id: str + sequence: int + + +def _timeline_item( + *, + timestamp, + stage: str, + title: str, + detail: str | None, + task_id: str | None, + run_id: str | None, + status: str | None, + actor: str | None, +) -> TimelineItemRead: + return TimelineItemRead( + timestamp=timestamp, + stage=stage, + title=title, + detail=detail, + task_id=task_id, + run_id=run_id, + status=status, + actor=actor, + ) + + +def _status_stage(to_status: str) -> tuple[str, str]: + mapping = { + "queued": ("queued", "Task queued"), + "blocked": ("blocked", "Task blocked"), + "running": ("running", "Task started"), + "success": ("completed", "Task completed"), + "failed": ("failed", "Task failed"), + "cancelled": ("cancelled", "Task cancelled"), + "needs_review": ("review", "Task sent to review"), + } + return mapping.get(to_status, ("status", f"Task moved to {to_status}")) + + +def build_task_timeline(task: TaskORM, events: list[EventLogORM]) -> TaskTimelineRead: + sortable_items: list[_SortableTimelineItem] = [ + _SortableTimelineItem( + item=_timeline_item( + timestamp=task.created_at, + stage="created", + title="Task created", + detail=task.description, + task_id=task.id, + run_id=None, + status="pending", + actor="system", + ), + event_id=f"created-{task.id}", + sequence=0, + ) + ] + + for event in events: + payload = event.payload or {} + task_id = event.task_id or task.id + actor = payload.get("source") + detail = event.message + sequence = 0 + + if event.event_type == "task_status_changed": + from_status = payload.get("from_status") + to_status = payload.get("to_status") or event.event_status or "unknown" + + if actor == "router": + sortable_items.append( + _SortableTimelineItem( + item=_timeline_item( + timestamp=event.created_at, + stage="routed", + title="Task routed", + detail=detail, + task_id=task_id, + run_id=event.run_id, + status=to_status, + actor=actor, + ), + event_id=event.id, + sequence=sequence, + ) + ) + sequence += 1 + + if from_status == "failed" and to_status == "queued": + sortable_items.append( + _SortableTimelineItem( + item=_timeline_item( + timestamp=event.created_at, + stage="retry", + title="Retry requested", + detail=detail, + task_id=task_id, + run_id=event.run_id, + status=to_status, + actor=actor, + ), + event_id=event.id, + sequence=sequence, + ) + ) + sequence += 1 + + stage, title = _status_stage(to_status) + sortable_items.append( + _SortableTimelineItem( + item=_timeline_item( + timestamp=event.created_at, + stage=stage, + title=title, + detail=detail, + task_id=task_id, + run_id=event.run_id, + status=to_status, + actor=actor, + ), + event_id=event.id, + sequence=sequence, + ) + ) + continue + + stage_title_mapping = { + "review_checkpoint_created": ("review", "Approval requested"), + "review_approved": ("review", "Review approved"), + "review_rejected": ("review", "Review rejected"), + "task_review_resolved": ("review", "Review resolved"), + "execution_run_started": ("running", "Execution started"), + "execution_run_finished": ( + "completed" if event.event_status == "success" else "failed", + "Execution finished" if event.event_status == "success" else "Execution failed", + ), + "execution_run_cancelled": ("cancelled", "Execution cancelled"), + "task_cancellation_requested": ("cancelled", "Cancellation requested"), + "task_cancellation_completed": ("cancelled", "Cancellation completed"), + "task_unblocked": ("queued", "Dependencies resolved"), + } + if event.event_type not in stage_title_mapping: + continue + stage, title = stage_title_mapping[event.event_type] + sortable_items.append( + _SortableTimelineItem( + item=_timeline_item( + timestamp=event.created_at, + stage=stage, + title=title, + detail=detail, + task_id=task_id, + run_id=event.run_id, + status=event.event_status, + actor=actor, + ), + event_id=event.id, + sequence=0, + ) + ) + + items = [ + sortable.item + for sortable in sorted( + sortable_items, + key=lambda entry: (entry.item.timestamp, entry.event_id, entry.sequence), + ) + ] + return TaskTimelineRead(task_id=task.id, batch_id=task.batch_id, items=items) + + +def load_task_timeline(db: Session, task_id: str) -> TaskTimelineRead | None: + task = db.get(TaskORM, task_id) + if task is None: + return None + + events = db.scalars( + select(EventLogORM) + .where(EventLogORM.task_id == task.id) + .order_by(EventLogORM.created_at.asc(), EventLogORM.id.asc()) + ).all() + return build_task_timeline(task, events) + + +def load_batch_timeline(db: Session, batch_id: str) -> BatchTimelineRead | None: + batch = db.get(TaskBatchORM, batch_id) + if batch is None: + return None + + tasks = db.scalars( + select(TaskORM) + .where(TaskORM.batch_id == batch.id) + .order_by(TaskORM.created_at.asc(), TaskORM.id.asc()) + ).all() + + sortable_items: list[_SortableTimelineItem] = [ + _SortableTimelineItem( + item=_timeline_item( + timestamp=batch.created_at, + stage="created", + title="Batch created", + detail=batch.description, + task_id=None, + run_id=None, + status=batch.status, + actor=batch.created_by, + ), + event_id=f"created-{batch.id}", + sequence=0, + ) + ] + + for task in tasks: + task_timeline = load_task_timeline(db, task.id) + if task_timeline is None: + continue + for index, item in enumerate(task_timeline.items): + sortable_items.append( + _SortableTimelineItem( + item=item, + event_id=f"{task.id}-{index}", + sequence=index, + ) + ) + + items = [ + sortable.item + for sortable in sorted( + sortable_items, + key=lambda entry: (entry.item.timestamp, entry.event_id, entry.sequence), + ) + ] + return BatchTimelineRead(batch_id=batch.id, title=batch.title, items=items) diff --git a/src/tests/test_batch_detail_page.py b/src/tests/test_batch_detail_page.py index 91141ec..253a1b1 100644 --- a/src/tests/test_batch_detail_page.py +++ b/src/tests/test_batch_detail_page.py @@ -144,6 +144,16 @@ def test_batch_detail_page_can_link_to_run_detail_when_latest_run_exists() -> No assert "View run detail" in response.text +def test_batch_detail_page_assets_include_batch_timeline() -> None: + page_response = client.get("/console/batches/sample-batch-id") + assert page_response.status_code == 200 + assert "Execution timeline" in page_response.text + + asset_response = client.get("/console/assets/batch-detail.js") + assert asset_response.status_code == 200 + assert "/task-batches/${batchId}/timeline" in asset_response.text + + def test_batch_detail_summary_supports_mixed_risk_sections() -> None: suffix = uuid.uuid4().hex[:8] _register_agent(client, role_name="default_worker", supported_task_types=[]) diff --git a/src/tests/test_execution_timeline_api.py b/src/tests/test_execution_timeline_api.py new file mode 100644 index 0000000..b7618e9 --- /dev/null +++ b/src/tests/test_execution_timeline_api.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import os +import sys +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + + +ROOT = Path(__file__).resolve().parents[2] +TEST_PREFIX = "execution-timeline-test-" + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _database_url() -> str: + database_url = os.getenv("DATABASE_URL") + if not database_url: + raise RuntimeError("DATABASE_URL is not set") + return database_url + + +def _cleanup_database() -> None: + engine = create_engine(_database_url()) + with engine.begin() as conn: + conn.execute(text("DELETE FROM task_batches WHERE title LIKE :prefix"), {"prefix": f"{TEST_PREFIX}%"}) + conn.execute(text("DELETE FROM agent_roles WHERE role_name LIKE :prefix"), {"prefix": f"{TEST_PREFIX}%"}) + + +def _register_agent(client: TestClient, *, role_name: str, supported_task_types: list[str]) -> dict: + payload = { + "role_name": role_name, + "description": "timeline role", + "capabilities": [f"task:{role_name}"], + "capability_declaration": { + "supported_task_types": supported_task_types, + "input_requirements": {"properties": {"text": {"type": "string"}}}, + "output_contract": {"type": "object"}, + "supports_concurrency": True, + "allows_auto_retry": False, + }, + "input_schema": {}, + "output_schema": {}, + "timeout_seconds": 300, + "max_retries": 0, + "enabled": True, + "version": "1.0.0", + } + response = client.post("/agents/register", json=payload) + assert response.status_code == 201 + return response.json() + + +def _batch_payload(task_type: str, suffix: str) -> dict: + return { + "title": f"{TEST_PREFIX}batch-{suffix}", + "description": "timeline batch", + "created_by": "pytest", + "metadata": {"suite": "execution-timeline"}, + "tasks": [ + { + "client_task_id": "task_1", + "title": f"{TEST_PREFIX}task-{suffix}-1", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "hello"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": [], + }, + { + "client_task_id": "task_2", + "title": f"{TEST_PREFIX}task-{suffix}-2", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "world"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": ["task_1"], + }, + { + "client_task_id": "task_3", + "title": f"{TEST_PREFIX}task-{suffix}-3", + "task_type": task_type, + "priority": "medium", + "input_payload": {"text": "!"}, + "expected_output_schema": {"type": "object"}, + "dependency_client_task_ids": [], + }, + ], + } + + +_cleanup_database() + +from src.apps.api.app import app # noqa: E402 +from src.packages.core.db.models import AssignmentORM, EventLogORM, ExecutionRunORM, TaskORM # noqa: E402 +from src.packages.core.task_state_machine import transition_task_status # noqa: E402 + + +client = TestClient(app) + + +def setup_function() -> None: + _cleanup_database() + + +def teardown_function() -> None: + _cleanup_database() + + +def test_task_timeline_rebuilds_full_lifecycle_with_retry() -> None: + suffix = uuid.uuid4().hex[:8] + role = _register_agent(client, role_name=f"{TEST_PREFIX}worker-{suffix}", supported_task_types=["generate"]) + response = client.post("/task-batches", json=_batch_payload("generate", suffix)) + assert response.status_code == 201 + task_id = response.json()["tasks"][0]["task_id"] + + engine = create_engine(_database_url()) + with Session(engine) as session: + task = session.get(TaskORM, task_id) + assert task is not None + assignment = session.query(AssignmentORM).filter(AssignmentORM.task_id == task.id).first() + assert assignment is not None + first_run = ExecutionRunORM( + task_id=task.id, + agent_role_id=assignment.agent_role_id, + run_status="failed", + started_at=datetime.now(timezone.utc), + finished_at=datetime.now(timezone.utc) + timedelta(seconds=1), + error_message="first attempt failed", + input_snapshot={"attempt": 1}, + output_snapshot={}, + ) + session.add(first_run) + session.flush() + session.add( + EventLogORM( + batch_id=task.batch_id, + task_id=task.id, + run_id=first_run.id, + event_type="execution_run_started", + event_status="running", + message="worker started execution run", + payload={"task_id": task.id, "run_id": first_run.id, "source": "worker"}, + ) + ) + transition_task_status(session, task, "running", "worker claimed queued task", "worker", run_id=first_run.id) + transition_task_status(session, task, "failed", "worker execution failed", "worker", run_id=first_run.id) + transition_task_status(session, task, "queued", "retry requested", "review") + second_run = ExecutionRunORM( + task_id=task.id, + agent_role_id=assignment.agent_role_id, + run_status="success", + started_at=datetime.now(timezone.utc) + timedelta(seconds=2), + finished_at=datetime.now(timezone.utc) + timedelta(seconds=3), + input_snapshot={"attempt": 2}, + output_snapshot={"artifact": "report"}, + ) + session.add(second_run) + session.flush() + session.add( + EventLogORM( + batch_id=task.batch_id, + task_id=task.id, + run_id=second_run.id, + event_type="execution_run_finished", + event_status="success", + message="worker completed execution run", + payload={"task_id": task.id, "run_id": second_run.id, "source": "worker"}, + ) + ) + transition_task_status(session, task, "running", "worker claimed queued task", "worker", run_id=second_run.id) + transition_task_status(session, task, "success", "worker finished task successfully", "worker", run_id=second_run.id) + session.commit() + + timeline_response = client.get(f"/tasks/{task_id}/timeline") + assert timeline_response.status_code == 200 + payload = timeline_response.json() + stages = [item["stage"] for item in payload["items"]] + assert stages[0] == "created" + assert "routed" in stages + assert "queued" in stages + assert "running" in stages + assert "failed" in stages + assert "retry" in stages + assert "completed" in stages + + +def test_task_timeline_includes_review_stage_and_batch_timeline_merges_task_events() -> None: + suffix = uuid.uuid4().hex[:8] + response = client.post("/task-batches", json=_batch_payload("unmatched_type", suffix)) + assert response.status_code == 201 + batch_id = response.json()["batch_id"] + task_id = response.json()["tasks"][0]["task_id"] + + task_timeline = client.get(f"/tasks/{task_id}/timeline") + assert task_timeline.status_code == 200 + task_stages = [item["stage"] for item in task_timeline.json()["items"]] + assert "review" in task_stages + + batch_timeline = client.get(f"/task-batches/{batch_id}/timeline") + assert batch_timeline.status_code == 200 + items = batch_timeline.json()["items"] + assert items[0]["title"] == "Batch created" + assert any(item["task_id"] == task_id for item in items) + assert any(item["stage"] == "review" for item in items) + + +def test_timeline_endpoints_return_404_for_unknown_resources() -> None: + assert client.get("/tasks/not_found/timeline").status_code == 404 + assert client.get("/task-batches/not_found/timeline").status_code == 404 diff --git a/src/tests/test_run_detail_page.py b/src/tests/test_run_detail_page.py index dbab614..fdad5e5 100644 --- a/src/tests/test_run_detail_page.py +++ b/src/tests/test_run_detail_page.py @@ -228,6 +228,16 @@ def test_console_run_detail_page_is_accessible() -> None: assert "/console/assets/run-detail.js" in response.text +def test_run_detail_page_assets_include_task_lifecycle_timeline() -> None: + page_response = client.get("/console/runs/sample-run-id") + assert page_response.status_code == 200 + assert "Lifecycle timeline" in page_response.text + + asset_response = client.get("/console/assets/run-detail.js") + assert asset_response.status_code == 200 + assert "/tasks/${detail.task.task_id}/timeline" in asset_response.text + + def test_batch_detail_assets_link_to_run_detail_page() -> None: response = client.get("/console/assets/batch-detail.js") assert response.status_code == 200