From 0b62976e584cf9a17efaf459cb93c7b2e0aadcac Mon Sep 17 00:00:00 2001 From: Ayu Date: Wed, 1 Apr 2026 15:08:38 +0800 Subject: [PATCH 1/2] Add batch detail console page --- src/apps/api/app.py | 5 + src/apps/api/routers/task_batches.py | 1 + src/apps/web/app.js | 3 + src/apps/web/batch-detail.css | 283 ++++++++++++++++++++++++++ src/apps/web/batch-detail.html | 88 +++++++++ src/apps/web/batch-detail.js | 286 +++++++++++++++++++++++++++ src/apps/web/styles.css | 14 ++ src/packages/core/schemas.py | 1 + src/tests/test_batch_detail_page.py | 191 ++++++++++++++++++ src/tests/test_task_batch_list.py | 6 + 10 files changed, 878 insertions(+) create mode 100644 src/apps/web/batch-detail.css create mode 100644 src/apps/web/batch-detail.html create mode 100644 src/apps/web/batch-detail.js create mode 100644 src/tests/test_batch_detail_page.py diff --git a/src/apps/api/app.py b/src/apps/api/app.py index c6ae08b..5eba0c9 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/batches/{batch_id}") +def console_batch_detail(batch_id: str) -> FileResponse: + return FileResponse(WEB_DIR / "batch-detail.html") + + @app.on_event("startup") def bootstrap_defaults() -> None: ensure_builtin_agent_roles() diff --git a/src/apps/api/routers/task_batches.py b/src/apps/api/routers/task_batches.py index c2445a1..b34096e 100644 --- a/src/apps/api/routers/task_batches.py +++ b/src/apps/api/routers/task_batches.py @@ -454,6 +454,7 @@ def get_task_batch_summary(batch_id: str, db: Session = Depends(get_db)) -> Task title=task.title, task_type=task.task_type, status=task.status, + dependency_ids=task.dependency_ids, assigned_agent_role=task.assigned_agent_role, latest_run_id=latest_runs.get(task.id).id if latest_runs.get(task.id) is not None else None, latest_run_status=latest_runs.get(task.id).run_status if latest_runs.get(task.id) is not None else None, diff --git a/src/apps/web/app.js b/src/apps/web/app.js index 01aa973..661a1d5 100644 --- a/src/apps/web/app.js +++ b/src/apps/web/app.js @@ -46,6 +46,9 @@ function renderBatches(items) {

Created: ${formatDate(item.created_at)}

Updated: ${formatDate(item.updated_at)}

+
+ View detail +
`, ) diff --git a/src/apps/web/batch-detail.css b/src/apps/web/batch-detail.css new file mode 100644 index 0000000..aec8a0d --- /dev/null +++ b/src/apps/web/batch-detail.css @@ -0,0 +1,283 @@ +@import url("/console/assets/styles.css"); + +.detail-shell { + max-width: 1320px; +} + +.hero-row, +.section-heading, +.task-header, +.detail-row, +.task-footer, +.dependency-row, +.artifact-row { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.hero-row, +.section-heading, +.task-header, +.task-footer, +.artifact-row { + align-items: center; +} + +.back-link { + color: var(--accent); + font-size: 0.92rem; + text-decoration: none; +} + +.back-link:hover { + text-decoration: underline; +} + +.hero-actions { + display: flex; + gap: 10px; +} + +.placeholder-button { + height: 42px; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.75); + color: var(--muted); + font: inherit; +} + +.overview-grid, +.content-grid { + display: grid; + gap: 18px; + margin-top: 18px; +} + +.overview-grid { + grid-template-columns: 1.4fr 1fr; +} + +.content-grid { + grid-template-columns: 1.35fr 1fr; +} + +.overview-card .metrics { + margin-bottom: 0; +} + +.section-label { + margin: 0 0 6px; + color: var(--muted); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.section-heading h2, +.risk-card h2, +.overview-card h2 { + margin: 0; +} + +.risk-groups, +.artifact-list, +.dependency-map { + display: grid; + gap: 12px; +} + +.risk-group, +.artifact-row, +.dependency-row, +.empty-panel { + padding: 14px; + border-radius: 16px; + background: #fff; +} + +.risk-group strong, +.artifact-row strong { + display: block; + margin-bottom: 4px; +} + +.risk-group.failed { + border: 1px solid rgba(164, 63, 47, 0.24); + background: var(--danger-soft); +} + +.risk-group.needs_review { + border: 1px solid rgba(161, 106, 29, 0.24); + background: var(--warning-soft); +} + +.risk-group.blocked { + border: 1px solid rgba(37, 90, 138, 0.18); + background: rgba(219, 232, 244, 0.72); +} + +.risk-group ul, +.dependency-list, +.task-flags { + margin: 8px 0 0; + padding-left: 18px; +} + +.artifact-list, +.dependency-map { + min-height: 120px; +} + +.artifact-row, +.dependency-row { + border: 1px solid var(--line); +} + +.artifact-row p, +.dependency-row p { + margin: 0; +} + +.artifact-meta, +.dependency-meta { + color: var(--muted); + font-size: 0.86rem; +} + +.task-section { + margin-top: 22px; +} + +.task-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 18px; +} + +.task-card { + padding: 18px; + border: 1px solid var(--line); + border-radius: 22px; + background: var(--panel); + box-shadow: 0 12px 32px rgba(31, 37, 32, 0.06); +} + +.task-card.failed { + border-color: rgba(164, 63, 47, 0.45); + box-shadow: 0 14px 30px rgba(164, 63, 47, 0.12); +} + +.task-card.needs_review { + border-color: rgba(161, 106, 29, 0.45); +} + +.task-card.blocked { + border-color: rgba(37, 90, 138, 0.35); +} + +.task-header { + align-items: flex-start; +} + +.task-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.task-meta, +.task-footer, +.task-empty, +.task-error, +.task-output, +.task-dependencies { + color: var(--muted); + font-size: 0.9rem; +} + +.task-meta { + margin: 6px 0 0; +} + +.task-body { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.detail-block { + padding: 14px; + border-radius: 16px; + background: #fff; +} + +.detail-block strong { + display: block; + margin-bottom: 6px; + color: var(--ink); +} + +.task-output { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.task-error { + margin: 0; + color: var(--danger); +} + +.task-flags { + color: var(--ink); +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meta-pill { + padding: 6px 10px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--line); + font-size: 0.82rem; + color: var(--muted); +} + +.empty-panel { + color: var(--muted); + border: 1px dashed var(--line); +} + +@media (max-width: 960px) { + .overview-grid, + .content-grid, + .task-grid { + grid-template-columns: 1fr; + } + + .hero-row, + .section-heading, + .artifact-row, + .dependency-row { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 640px) { + .hero-actions { + width: 100%; + flex-direction: column; + } + + .placeholder-button { + width: 100%; + } +} diff --git a/src/apps/web/batch-detail.html b/src/apps/web/batch-detail.html new file mode 100644 index 0000000..1f292b2 --- /dev/null +++ b/src/apps/web/batch-detail.html @@ -0,0 +1,88 @@ + + + + + + Batch Detail + + + +
+
+

Operations Console

+
+
+ Back to batches +

Batch Detail

+

Loading batch summary...

+
+
+ + +
+
+
+ +
+

Loading batch summary...

+
+ +
+
+
+
+ +

Current state

+
+ pending +
+
+
+ +
+
+
+ +

Immediate attention

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

Task graph

+
+
+
+
+ +
+
+
+ +

Batch outputs

+
+
+
+
+
+ +
+
+
+ +

Execution detail

+
+
+
+
+
+ + + + diff --git a/src/apps/web/batch-detail.js b/src/apps/web/batch-detail.js new file mode 100644 index 0000000..d6f68bb --- /dev/null +++ b/src/apps/web/batch-detail.js @@ -0,0 +1,286 @@ +const batchTitle = document.getElementById("batch-title"); +const batchSubtitle = document.getElementById("batch-subtitle"); +const statusText = document.getElementById("status-text"); +const overviewHeading = document.getElementById("overview-heading"); +const overviewStatus = document.getElementById("overview-status"); +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 taskGrid = document.getElementById("task-grid"); + +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function batchIdFromPath() { + const parts = window.location.pathname.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; +} + +function renderOverview(summary) { + batchTitle.textContent = summary.batch.title; + batchSubtitle.textContent = `Batch ${summary.batch.id} created ${formatDate(summary.batch.created_at)}`; + overviewHeading.textContent = `${summary.tasks.length} tasks in this batch`; + overviewStatus.textContent = summary.derived_status; + overviewStatus.className = `status-badge status-${summary.derived_status}`; + + const metrics = [ + ["Derived status", summary.derived_status], + ["Total tasks", summary.progress.total_tasks], + ["Completed", summary.progress.completed_count], + ["Progress", `${summary.progress.progress_percent}%`], + ["Success", summary.counts.success_count], + ["Failed", summary.counts.failed_count], + ["Needs review", summary.counts.needs_review_count], + ["Blocked", summary.counts.blocked_count], + ["Cancelled", summary.counts.cancelled_count], + ]; + overviewMetrics.innerHTML = metrics + .map(([label, value]) => `
${label}
${escapeHtml(value)}
`) + .join(""); +} + +function renderRiskGroups(tasks) { + const groups = [ + { + key: "failed", + label: "Failed tasks", + items: tasks.filter((task) => task.status === "failed"), + description: "Execution failed and needs intervention.", + }, + { + key: "needs_review", + label: "Needs review", + items: tasks.filter((task) => task.status === "needs_review"), + description: "Routing or approval is waiting for a human decision.", + }, + { + key: "blocked", + label: "Blocked tasks", + items: tasks.filter((task) => task.status === "blocked"), + description: "Dependencies are not complete yet.", + }, + ]; + + const activeGroups = groups.filter((group) => group.items.length > 0); + if (!activeGroups.length) { + riskGroups.innerHTML = `
No failed, blocked, or review-pending tasks in this batch.
`; + return; + } + + riskGroups.innerHTML = activeGroups + .map( + (group) => ` +
+ ${group.label} +

${group.description}

+ +
+ `, + ) + .join(""); +} + +function renderDependencyMap(tasks) { + const taskTitleById = new Map(tasks.map((task) => [task.task_id, task.title])); + if (!tasks.length) { + dependencyMap.innerHTML = `
No tasks found.
`; + return; + } + + dependencyMap.innerHTML = tasks + .map((task) => { + if (!task.dependency_ids.length) { + return ` +
+
+ ${escapeHtml(task.title)} +

${escapeHtml(task.task_id)}

+
+

No upstream dependency.

+
+ `; + } + const dependencies = task.dependency_ids + .map((dependencyId) => `${taskTitleById.get(dependencyId) ?? dependencyId} (${dependencyId})`) + .map((text) => `
  • ${escapeHtml(text)}
  • `) + .join(""); + return ` +
    +
    + ${escapeHtml(task.title)} +

    ${escapeHtml(task.task_id)}

    +
    +
    +

    Depends on ${task.dependency_ids.length} task${task.dependency_ids.length === 1 ? "" : "s"}.

    +
      ${dependencies}
    +
    +
    + `; + }) + .join(""); +} + +function renderArtifacts(artifacts) { + if (!artifacts.length) { + artifactList.innerHTML = `
    This batch has not produced artifacts yet.
    `; + return; + } + + artifactList.innerHTML = artifacts + .map( + (artifact) => ` +
    +
    + ${escapeHtml(artifact.artifact_type)} +

    ${escapeHtml(artifact.uri)}

    +
    +
    + task ${escapeHtml(artifact.task_id ?? "n/a")} + ${escapeHtml(artifact.content_type ?? "unknown")} + ${escapeHtml(formatDate(artifact.created_at))} +
    +
    + `, + ) + .join(""); +} + +function taskFlags(task) { + const flags = []; + if (task.status === "needs_review") { + flags.push("Manual review required"); + } + if (task.status === "blocked" && task.dependency_ids.length) { + flags.push(`Blocked by ${task.dependency_ids.length} dependency`); + } + if (task.status === "failed" && task.error_message) { + flags.push("Latest run returned an error"); + } + return flags; +} + +function renderTasks(tasks) { + if (!tasks.length) { + taskGrid.innerHTML = `
    No tasks available for this batch.
    `; + return; + } + + taskGrid.innerHTML = tasks + .map((task) => { + const flags = taskFlags(task); + const dependencyText = task.dependency_ids.length + ? task.dependency_ids.join(", ") + : "No dependencies"; + const outputText = Object.keys(task.output_snapshot ?? {}).length + ? escapeHtml(JSON.stringify(task.output_snapshot, null, 2)) + : "No output snapshot"; + return ` +
    +
    +
    +

    ${escapeHtml(task.title)}

    +

    ${escapeHtml(task.task_type)} · ${escapeHtml(task.task_id)}

    +
    + ${escapeHtml(task.status)} +
    +
    +
    + Routing +
    + agent ${escapeHtml(task.assigned_agent_role ?? "unassigned")} + latest run ${escapeHtml(task.latest_run_status ?? "not started")} + ${escapeHtml(task.artifact_count)} artifacts +
    +
    +
    + Dependencies +

    ${escapeHtml(dependencyText)}

    +
    +
    + Latest output +
    ${outputText}
    +
    +
    + Error / cancel context + ${ + task.error_message + ? `

    ${escapeHtml(task.error_message)}

    ` + : task.cancel_reason + ? `

    ${escapeHtml(task.cancel_reason)}

    ` + : `

    No error or cancel signal.

    ` + } +
    + ${ + flags.length + ? ` +
    + Attention flags +
      ${flags.map((flag) => `
    • ${escapeHtml(flag)}
    • `).join("")}
    +
    + ` + : "" + } +
    +
    + `; + }) + .join(""); +} + +function renderError(message) { + statusText.textContent = message; + overviewMetrics.innerHTML = ""; + riskGroups.innerHTML = `
    ${escapeHtml(message)}
    `; + dependencyMap.innerHTML = `
    ${escapeHtml(message)}
    `; + artifactList.innerHTML = `
    ${escapeHtml(message)}
    `; + taskGrid.innerHTML = `
    ${escapeHtml(message)}
    `; +} + +async function loadBatchDetail() { + const batchId = batchIdFromPath(); + if (!batchId) { + renderError("Batch id is missing from the URL."); + return; + } + + statusText.textContent = "Loading batch summary..."; + + try { + const response = await fetch(`/task-batches/${batchId}/summary`); + if (response.status === 404) { + throw new Error("Batch not found."); + } + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const summary = await response.json(); + statusText.textContent = `Batch ${summary.batch.id} is currently ${summary.derived_status}.`; + renderOverview(summary); + renderRiskGroups(summary.tasks); + renderDependencyMap(summary.tasks); + renderArtifacts(summary.artifacts); + renderTasks(summary.tasks); + } catch (error) { + renderError(error.message); + } +} + +loadBatchDetail(); diff --git a/src/apps/web/styles.css b/src/apps/web/styles.css index 563490a..d04dc5f 100644 --- a/src/apps/web/styles.css +++ b/src/apps/web/styles.css @@ -181,6 +181,20 @@ body { font-size: 0.88rem; } +.card-actions { + margin-top: 16px; +} + +.detail-link { + color: var(--accent); + text-decoration: none; + font-size: 0.92rem; +} + +.detail-link:hover { + text-decoration: underline; +} + @media (max-width: 960px) { .toolbar { grid-template-columns: 1fr 1fr; diff --git a/src/packages/core/schemas.py b/src/packages/core/schemas.py index 89efe7b..47e1954 100644 --- a/src/packages/core/schemas.py +++ b/src/packages/core/schemas.py @@ -95,6 +95,7 @@ class BatchTaskResultRead(SchemaModel): title: str task_type: str status: TaskStatus + dependency_ids: list[str] = Field(default_factory=list) assigned_agent_role: str | None = None latest_run_id: str | None = None latest_run_status: ExecutionRunStatus | None = None diff --git a/src/tests/test_batch_detail_page.py b/src/tests/test_batch_detail_page.py new file mode 100644 index 0000000..64ed8bf --- /dev/null +++ b/src/tests/test_batch_detail_page.py @@ -0,0 +1,191 @@ +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 = "batch-detail-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")) + conn.execute(text("DELETE FROM agent_roles")) + + +def _register_agent(client: TestClient, *, role_name: str, supported_task_types: list[str]) -> dict: + payload = { + "role_name": role_name, + "description": "batch detail 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 in {201, 400} + if response.status_code == 400: + roles_response = client.get("/agents") + assert roles_response.status_code == 200 + return next(role for role in roles_response.json() if role["role_name"] == role_name) + return response.json() + + +def _batch_payload(task_type: str, suffix: str) -> dict: + return { + "title": f"{TEST_PREFIX}batch-{suffix}", + "description": "batch detail batch", + "created_by": "pytest", + "metadata": {"suite": "batch-detail"}, + "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, ExecutionRunORM, TaskORM # noqa: E402 + + +client = TestClient(app) + + +def setup_function() -> None: + _cleanup_database() + + +def teardown_function() -> None: + _cleanup_database() + + +def test_batch_summary_includes_dependency_ids_for_detail_view() -> None: + suffix = uuid.uuid4().hex[:8] + _register_agent(client, role_name="default_worker", supported_task_types=[]) + response = client.post("/task-batches", json=_batch_payload("unmatched_type", suffix)) + assert response.status_code == 201 + batch_id = response.json()["batch_id"] + first_task_id = response.json()["tasks"][0]["task_id"] + second_task_id = response.json()["tasks"][1]["task_id"] + + summary_response = client.get(f"/task-batches/{batch_id}/summary") + assert summary_response.status_code == 200 + tasks = summary_response.json()["tasks"] + second_task = next(item for item in tasks if item["task_id"] == second_task_id) + assert second_task["dependency_ids"] == [first_task_id] + + +def test_console_batch_detail_page_is_accessible() -> None: + response = client.get("/console/batches/sample-batch-id") + assert response.status_code == 200 + assert "Batch Detail" in response.text + assert "/console/assets/batch-detail.js" in 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=[]) + response = client.post("/task-batches", json=_batch_payload("unmatched_type", suffix)) + assert response.status_code == 201 + batch_id = response.json()["batch_id"] + task_ids = [task["task_id"] for task in response.json()["tasks"]] + + engine = create_engine(_database_url()) + with Session(engine) as session: + first_task = session.get(TaskORM, task_ids[0]) + second_task = session.get(TaskORM, task_ids[1]) + third_task = session.get(TaskORM, task_ids[2]) + assert first_task is not None and second_task is not None and third_task is not None + + first_assignment = session.query(AssignmentORM).filter(AssignmentORM.task_id == first_task.id).first() + assert first_assignment is not None + + first_task.status = "failed" + second_task.status = "blocked" + third_task.status = "needs_review" + + started_at = datetime.now(timezone.utc) + session.add( + ExecutionRunORM( + task_id=first_task.id, + agent_role_id=first_assignment.agent_role_id, + run_status="failed", + started_at=started_at, + finished_at=started_at + timedelta(seconds=1), + error_message="detail page should highlight this failure", + output_snapshot={"step": "compile"}, + ) + ) + session.commit() + + summary_response = client.get(f"/task-batches/{batch_id}/summary") + assert summary_response.status_code == 200 + payload = summary_response.json() + assert payload["derived_status"] == "needs_review" + assert payload["counts"]["failed_count"] == 1 + assert payload["counts"]["blocked_count"] == 1 + assert payload["counts"]["needs_review_count"] == 1 + + failed_task = next(item for item in payload["tasks"] if item["task_id"] == task_ids[0]) + blocked_task = next(item for item in payload["tasks"] if item["task_id"] == task_ids[1]) + review_task = next(item for item in payload["tasks"] if item["task_id"] == task_ids[2]) + + assert failed_task["error_message"] == "detail page should highlight this failure" + assert blocked_task["dependency_ids"] == [task_ids[0]] + assert review_task["status"] == "needs_review" diff --git a/src/tests/test_task_batch_list.py b/src/tests/test_task_batch_list.py index a0bf433..f6fc536 100644 --- a/src/tests/test_task_batch_list.py +++ b/src/tests/test_task_batch_list.py @@ -178,3 +178,9 @@ def test_console_batches_page_is_accessible() -> None: response = client.get("/console/batches") assert response.status_code == 200 assert "Batch Console" in response.text + + +def test_console_batches_assets_include_detail_link_navigation() -> None: + response = client.get("/console/assets/app.js") + assert response.status_code == 200 + assert "/console/batches/${item.batch_id}" in response.text From 02cc4a64812caa8cd72b1589d0bc00e0190877f9 Mon Sep 17 00:00:00 2001 From: Ayu Date: Wed, 1 Apr 2026 16:28:28 +0800 Subject: [PATCH 2/2] Add run detail console page --- src/apps/api/app.py | 5 + src/apps/api/routers/runs.py | 77 ++++++++- src/apps/web/batch-detail.js | 5 + src/apps/web/run-detail.css | 84 ++++++++++ src/apps/web/run-detail.html | 110 +++++++++++++ src/apps/web/run-detail.js | 205 ++++++++++++++++++++++++ src/packages/core/schemas.py | 34 ++++ src/tests/test_batch_detail_page.py | 6 + src/tests/test_run_detail_page.py | 234 ++++++++++++++++++++++++++++ 9 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 src/apps/web/run-detail.css create mode 100644 src/apps/web/run-detail.html create mode 100644 src/apps/web/run-detail.js create mode 100644 src/tests/test_run_detail_page.py diff --git a/src/apps/api/app.py b/src/apps/api/app.py index 5eba0c9..6216e97 100644 --- a/src/apps/api/app.py +++ b/src/apps/api/app.py @@ -44,6 +44,11 @@ def console_batch_detail(batch_id: str) -> FileResponse: return FileResponse(WEB_DIR / "batch-detail.html") +@app.get("/console/runs/{run_id}") +def console_run_detail(run_id: str) -> FileResponse: + return FileResponse(WEB_DIR / "run-detail.html") + + @app.on_event("startup") def bootstrap_defaults() -> None: ensure_builtin_agent_roles() diff --git a/src/apps/api/routers/runs.py b/src/apps/api/routers/runs.py index 1a6e768..98902e0 100644 --- a/src/apps/api/routers/runs.py +++ b/src/apps/api/routers/runs.py @@ -5,8 +5,15 @@ from sqlalchemy.orm import Session from src.apps.api.deps import get_db -from src.packages.core.db.models import ExecutionRunORM, TaskORM -from src.packages.core.schemas import ExecutionRunRead +from src.packages.core.db.models import AgentRoleORM, AssignmentORM, EventLogORM, ExecutionRunORM, TaskORM +from src.packages.core.schemas import ( + ExecutionRunRead, + RunDetailRead, + RunDetailTaskRead, + RunRetryHistoryItemRead, + RunRoutingRead, + TaskEventRead, +) router = APIRouter(tags=["runs"]) @@ -19,6 +26,72 @@ def get_run(run_id: str, db: Session = Depends(get_db)) -> ExecutionRunRead: return ExecutionRunRead.model_validate(run) +@router.get("/runs/{run_id}/detail", response_model=RunDetailRead) +def get_run_detail(run_id: str, db: Session = Depends(get_db)) -> RunDetailRead: + run = db.get(ExecutionRunORM, run_id) + if run is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Execution run not found") + + task = db.get(TaskORM, run.task_id) + if task is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + + assignment = db.scalars( + select(AssignmentORM) + .where(AssignmentORM.task_id == task.id) + .order_by(AssignmentORM.assigned_at.desc(), AssignmentORM.id.desc()) + ).first() + + agent_role_name: str | None = task.assigned_agent_role + agent_role_id: str | None = assignment.agent_role_id if assignment is not None else None + if agent_role_id: + agent_role = db.get(AgentRoleORM, agent_role_id) + if agent_role is not None: + agent_role_name = agent_role.role_name + + runs = db.scalars( + select(ExecutionRunORM) + .where(ExecutionRunORM.task_id == task.id) + .order_by(ExecutionRunORM.started_at.desc(), ExecutionRunORM.id.desc()) + ).all() + events = db.scalars( + select(EventLogORM) + .where(EventLogORM.task_id == task.id) + .order_by(EventLogORM.created_at.asc(), EventLogORM.id.asc()) + ).all() + + return RunDetailRead( + run=ExecutionRunRead.model_validate(run), + task=RunDetailTaskRead( + task_id=task.id, + title=task.title, + task_type=task.task_type, + status=task.status, + assigned_agent_role=task.assigned_agent_role, + retry_count=task.retry_count, + batch_id=task.batch_id, + ), + routing=RunRoutingRead( + routing_reason=assignment.routing_reason if assignment is not None else None, + agent_role_id=agent_role_id, + agent_role_name=agent_role_name, + ), + retry_history=[ + RunRetryHistoryItemRead( + run_id=item.id, + run_status=item.run_status, + started_at=item.started_at, + finished_at=item.finished_at, + latency_ms=item.latency_ms, + error_message=item.error_message, + is_current=item.id == run.id, + ) + for item in runs + ], + events=[TaskEventRead.model_validate(event) for event in events], + ) + + @router.get("/tasks/{task_id}/runs", response_model=list[ExecutionRunRead]) def list_task_runs(task_id: str, db: Session = Depends(get_db)) -> list[ExecutionRunRead]: task = db.get(TaskORM, task_id) diff --git a/src/apps/web/batch-detail.js b/src/apps/web/batch-detail.js index d6f68bb..cb503e4 100644 --- a/src/apps/web/batch-detail.js +++ b/src/apps/web/batch-detail.js @@ -208,6 +208,11 @@ function renderTasks(tasks) { latest run ${escapeHtml(task.latest_run_status ?? "not started")} ${escapeHtml(task.artifact_count)} artifacts + ${ + task.latest_run_id + ? `` + : "" + }
    Dependencies diff --git a/src/apps/web/run-detail.css b/src/apps/web/run-detail.css new file mode 100644 index 0000000..bc2e987 --- /dev/null +++ b/src/apps/web/run-detail.css @@ -0,0 +1,84 @@ +@import url("/console/assets/batch-detail.css"); + +.run-shell { + max-width: 1320px; +} + +.back-links { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.detail-stack, +.log-list, +.history-list, +.event-list { + display: grid; + gap: 12px; +} + +.json-block, +.log-entry, +.history-entry, +.event-entry, +.detail-entry, +.error-entry, +.empty-panel { + padding: 14px; + border-radius: 16px; + background: #fff; + border: 1px solid var(--line); +} + +.json-block { + margin: 0; + min-height: 180px; + white-space: pre-wrap; + word-break: break-word; + font-family: "Courier New", monospace; + font-size: 0.88rem; + color: var(--ink); +} + +.detail-entry strong, +.history-entry strong, +.event-entry strong, +.error-entry strong { + display: block; + margin-bottom: 6px; +} + +.error-entry.failed { + border-color: rgba(164, 63, 47, 0.35); + background: var(--danger-soft); +} + +.error-entry.cancelled { + border-color: rgba(37, 90, 138, 0.24); + background: rgba(219, 232, 244, 0.72); +} + +.muted { + color: var(--muted); +} + +.history-entry.current { + border-color: rgba(25, 76, 61, 0.35); + box-shadow: 0 10px 24px rgba(25, 76, 61, 0.08); +} + +.history-meta, +.event-meta, +.log-meta { + color: var(--muted); + font-size: 0.86rem; +} + +.log-entry p, +.event-entry p, +.history-entry p, +.detail-entry p, +.error-entry p { + margin: 0; +} diff --git a/src/apps/web/run-detail.html b/src/apps/web/run-detail.html new file mode 100644 index 0000000..9e4366e --- /dev/null +++ b/src/apps/web/run-detail.html @@ -0,0 +1,110 @@ + + + + + + Run Detail + + + +
    +
    +

    Operations Console

    +
    +
    + +

    Run Detail

    +

    Loading run detail...

    +
    +
    +
    + +
    +

    Loading run detail...

    +
    + +
    +
    +
    +
    + +

    Current execution

    +
    + pending +
    +
    +
    + +
    +
    +
    + +

    Why this role

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

    Request payload

    +
    +
    +
    Loading...
    +
    + +
    +
    +
    + +

    Execution result

    +
    +
    +
    Loading...
    +
    +
    + +
    +
    +
    +
    + +

    Failure diagnosis

    +
    +
    +
    +
    +
    + +
    +
    +
    + +

    Previous attempts

    +
    +
    +
    +
    +
    + +
    +
    +
    + +

    Timeline

    +
    +
    +
    +
    +
    + + + + diff --git a/src/apps/web/run-detail.js b/src/apps/web/run-detail.js new file mode 100644 index 0000000..784373f --- /dev/null +++ b/src/apps/web/run-detail.js @@ -0,0 +1,205 @@ +const runTitle = document.getElementById("run-title"); +const runSubtitle = document.getElementById("run-subtitle"); +const statusText = document.getElementById("status-text"); +const overviewHeading = document.getElementById("overview-heading"); +const overviewStatus = document.getElementById("overview-status"); +const overviewMetrics = document.getElementById("overview-metrics"); +const routingPanel = document.getElementById("routing-panel"); +const inputSnapshot = document.getElementById("input-snapshot"); +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 eventList = document.getElementById("event-list"); +const backToBatch = document.getElementById("back-to-batch"); + +function formatDate(value) { + if (!value) { + return "n/a"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function runIdFromPath() { + const parts = window.location.pathname.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; +} + +function prettyJson(value) { + if (!value || !Object.keys(value).length) { + return "No data"; + } + return JSON.stringify(value, null, 2); +} + +function renderOverview(detail) { + runTitle.textContent = detail.task.title; + runSubtitle.textContent = `Run ${detail.run.id} for task ${detail.task.task_id}`; + statusText.textContent = `Run ${detail.run.id} is ${detail.run.run_status}.`; + overviewHeading.textContent = `${detail.task.task_type} · ${detail.routing.agent_role_name ?? detail.task.assigned_agent_role ?? "unassigned"}`; + overviewStatus.textContent = detail.run.run_status; + overviewStatus.className = `status-badge status-${detail.run.run_status}`; + backToBatch.href = `/console/batches/${detail.task.batch_id}`; + + const metrics = [ + ["Task status", detail.task.status], + ["Retry count", detail.task.retry_count], + ["Started", formatDate(detail.run.started_at)], + ["Finished", formatDate(detail.run.finished_at)], + ["Latency", detail.run.latency_ms ?? "n/a"], + ["Prompt tokens", detail.run.token_usage?.prompt_tokens ?? 0], + ["Completion tokens", detail.run.token_usage?.completion_tokens ?? 0], + ["Total tokens", detail.run.token_usage?.total_tokens ?? 0], + ]; + overviewMetrics.innerHTML = metrics + .map(([label, value]) => `
    ${label}
    ${escapeHtml(value)}
    `) + .join(""); +} + +function renderRouting(detail) { + routingPanel.innerHTML = ` +
    + Routing reason +

    ${escapeHtml(detail.routing.routing_reason ?? "No routing reason recorded.")}

    +
    +
    + Agent role +

    ${escapeHtml(detail.routing.agent_role_name ?? detail.task.assigned_agent_role ?? "unassigned")}

    +

    ${escapeHtml(detail.routing.agent_role_id ?? "role id unavailable")}

    +
    +
    + Task context +

    ${escapeHtml(detail.task.task_id)}

    +

    ${escapeHtml(detail.task.task_type)}

    +
    + `; +} + +function renderSnapshots(detail) { + inputSnapshot.textContent = prettyJson(detail.run.input_snapshot); + outputSnapshot.textContent = prettyJson(detail.run.output_snapshot); +} + +function renderErrorAndLogs(detail) { + const errorClass = detail.run.run_status === "cancelled" ? "cancelled" : "failed"; + if (detail.run.error_message || detail.run.cancel_reason) { + errorPanel.innerHTML = ` +
    + ${detail.run.run_status === "cancelled" ? "Cancel context" : "Error message"} +

    ${escapeHtml(detail.run.error_message ?? detail.run.cancel_reason)}

    +
    + `; + } else { + errorPanel.innerHTML = `
    No error or cancellation context for this run.
    `; + } + + if (!detail.run.logs.length) { + logList.innerHTML = `
    This run does not have execution logs.
    `; + return; + } + + logList.innerHTML = detail.run.logs + .map( + (log, index) => ` +
    +

    log ${index + 1}

    +

    ${escapeHtml(log)}

    +
    + `, + ) + .join(""); +} + +function renderRetryHistory(detail) { + if (!detail.retry_history.length) { + retryHistory.innerHTML = `
    No retry history available.
    `; + return; + } + + retryHistory.innerHTML = detail.retry_history + .map( + (item) => ` +
    + ${item.is_current ? "Current run" : "Previous run"} +

    ${escapeHtml(item.run_id)}

    +

    ${escapeHtml(item.run_status)} · started ${escapeHtml(formatDate(item.started_at))}

    +

    latency ${escapeHtml(item.latency_ms ?? "n/a")} ms

    + ${item.error_message ? `

    ${escapeHtml(item.error_message)}

    ` : ""} +
    + `, + ) + .join(""); +} + +function renderEvents(detail) { + if (!detail.events.length) { + eventList.innerHTML = `
    No task events available.
    `; + return; + } + + eventList.innerHTML = detail.events + .map( + (event) => ` +
    + ${escapeHtml(event.event_type)} +

    ${escapeHtml(event.message ?? "No message")}

    +

    ${escapeHtml(event.event_status ?? "no status")} · ${escapeHtml(formatDate(event.created_at))}

    +
    + `, + ) + .join(""); +} + +function renderError(message) { + statusText.textContent = message; + routingPanel.innerHTML = `
    ${escapeHtml(message)}
    `; + inputSnapshot.textContent = message; + outputSnapshot.textContent = message; + errorPanel.innerHTML = `
    ${escapeHtml(message)}
    `; + logList.innerHTML = `
    ${escapeHtml(message)}
    `; + retryHistory.innerHTML = `
    ${escapeHtml(message)}
    `; + eventList.innerHTML = `
    ${escapeHtml(message)}
    `; +} + +async function loadRunDetail() { + const runId = runIdFromPath(); + if (!runId) { + renderError("Run id is missing from the URL."); + return; + } + + try { + const response = await fetch(`/runs/${runId}/detail`); + if (response.status === 404) { + throw new Error("Run not found."); + } + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const detail = await response.json(); + renderOverview(detail); + renderRouting(detail); + renderSnapshots(detail); + renderErrorAndLogs(detail); + renderRetryHistory(detail); + renderEvents(detail); + } catch (error) { + renderError(error.message); + } +} + +loadRunDetail(); diff --git a/src/packages/core/schemas.py b/src/packages/core/schemas.py index 47e1954..051e2e1 100644 --- a/src/packages/core/schemas.py +++ b/src/packages/core/schemas.py @@ -270,6 +270,40 @@ class ExecutionRunRead(ExecutionRunCreate): id: str +class RunDetailTaskRead(SchemaModel): + task_id: str + title: str + task_type: str + status: TaskStatus + assigned_agent_role: str | None = None + retry_count: int + batch_id: str + + +class RunRoutingRead(SchemaModel): + routing_reason: str | None = None + agent_role_id: str | None = None + agent_role_name: str | None = None + + +class RunRetryHistoryItemRead(SchemaModel): + run_id: str + run_status: ExecutionRunStatus + started_at: datetime | None = None + finished_at: datetime | None = None + latency_ms: int | None = Field(default=None, ge=0) + error_message: str | None = None + is_current: bool = False + + +class RunDetailRead(SchemaModel): + run: ExecutionRunRead + task: RunDetailTaskRead + routing: RunRoutingRead + retry_history: list[RunRetryHistoryItemRead] = Field(default_factory=list) + events: list[TaskEventRead] = Field(default_factory=list) + + class ReviewCheckpointCreate(SchemaModel): task_id: str reason: str diff --git a/src/tests/test_batch_detail_page.py b/src/tests/test_batch_detail_page.py index 64ed8bf..91141ec 100644 --- a/src/tests/test_batch_detail_page.py +++ b/src/tests/test_batch_detail_page.py @@ -138,6 +138,12 @@ def test_console_batch_detail_page_is_accessible() -> None: assert "/console/assets/batch-detail.js" in response.text +def test_batch_detail_page_can_link_to_run_detail_when_latest_run_exists() -> None: + response = client.get("/console/assets/batch-detail.js") + assert response.status_code == 200 + assert "View run detail" in 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_run_detail_page.py b/src/tests/test_run_detail_page.py new file mode 100644 index 0000000..dbab614 --- /dev/null +++ b/src/tests/test_run_detail_page.py @@ -0,0 +1,234 @@ +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 = "run-detail-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")) + conn.execute(text("DELETE FROM agent_roles")) + + +def _register_agent(client: TestClient, *, role_name: str, supported_task_types: list[str]) -> dict: + payload = { + "role_name": role_name, + "description": "run detail 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 in {201, 400} + if response.status_code == 400: + roles_response = client.get("/agents") + assert roles_response.status_code == 200 + return next(role for role in roles_response.json() if role["role_name"] == role_name) + return response.json() + + +def _batch_payload(task_type: str, suffix: str) -> dict: + return { + "title": f"{TEST_PREFIX}batch-{suffix}", + "description": "run detail batch", + "created_by": "pytest", + "metadata": {"suite": "run-detail"}, + "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 + + +client = TestClient(app) + + +def setup_function() -> None: + _cleanup_database() + + +def teardown_function() -> None: + _cleanup_database() + + +def test_run_detail_endpoint_returns_routing_and_retry_history() -> None: + suffix = uuid.uuid4().hex[:8] + role_name = f"{TEST_PREFIX}worker-{suffix}" + _register_agent(client, role_name=role_name, 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 + base_time = datetime.now(timezone.utc) + first_run = ExecutionRunORM( + task_id=task.id, + agent_role_id=assignment.agent_role_id, + run_status="failed", + started_at=base_time, + finished_at=base_time + timedelta(seconds=1), + input_snapshot={"attempt": 1}, + output_snapshot={}, + logs=["compile started", "compile failed"], + error_message="first failure", + token_usage={"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}, + latency_ms=1000, + ) + second_run = ExecutionRunORM( + task_id=task.id, + agent_role_id=assignment.agent_role_id, + run_status="success", + started_at=base_time + timedelta(seconds=2), + finished_at=base_time + timedelta(seconds=3), + input_snapshot={"attempt": 2}, + output_snapshot={"artifact": "report"}, + logs=["compile started", "compile succeeded"], + token_usage={"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + latency_ms=900, + ) + session.add(first_run) + session.flush() + 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="run_completed", + event_status="success", + message="completed", + payload={"run_id": second_run.id}, + ) + ) + session.commit() + run_id = second_run.id + + detail_response = client.get(f"/runs/{run_id}/detail") + assert detail_response.status_code == 200 + payload = detail_response.json() + assert payload["run"]["id"] == run_id + assert payload["task"]["task_id"] == task_id + assert payload["routing"]["agent_role_name"] == role_name + assert payload["routing"]["routing_reason"] == "matched by task_type=generate" + assert [item["run_status"] for item in payload["retry_history"]] == ["success", "failed"] + assert payload["retry_history"][0]["is_current"] is True + assert payload["run"]["token_usage"]["total_tokens"] == 8 + assert payload["events"][-1]["event_type"] == "run_completed" + + +def test_run_detail_endpoint_handles_cancelled_run_without_logs() -> None: + suffix = uuid.uuid4().hex[:8] + _register_agent(client, role_name="default_worker", supported_task_types=[]) + response = client.post("/task-batches", json=_batch_payload("unmatched_type", 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 + run = ExecutionRunORM( + task_id=task.id, + agent_role_id=assignment.agent_role_id, + run_status="cancelled", + cancel_reason="user requested cancellation", + input_snapshot={"attempt": 1}, + output_snapshot={}, + logs=[], + latency_ms=None, + ) + session.add(run) + session.commit() + run_id = run.id + + detail_response = client.get(f"/runs/{run_id}/detail") + assert detail_response.status_code == 200 + payload = detail_response.json() + assert payload["run"]["run_status"] == "cancelled" + assert payload["run"]["cancel_reason"] == "user requested cancellation" + assert payload["run"]["logs"] == [] + + +def test_console_run_detail_page_is_accessible() -> None: + response = client.get("/console/runs/sample-run-id") + assert response.status_code == 200 + assert "Run Detail" in response.text + assert "/console/assets/run-detail.js" in 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 + assert "/console/runs/" in response.text