diff --git a/src/apps/api/app.py b/src/apps/api/app.py
index c6ae08b..6216e97 100644
--- a/src/apps/api/app.py
+++ b/src/apps/api/app.py
@@ -39,6 +39,16 @@ 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.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/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)}
+
`,
)
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
+
+
+
+
+
+
+
+
+
+
+ Loading batch summary...
+
+
+
+
+
+
+
Batch overview
+
Current state
+
+
pending
+
+
+
+
+
+
+
+
Risk focus
+
Immediate attention
+
+
+
+
+
+
+
+
+
+
+
Dependencies
+
Task graph
+
+
+
+
+
+
+
+
+
Artifacts
+
Batch outputs
+
+
+
+
+
+
+
+
+
+
Tasks
+
Execution detail
+
+
+
+
+
+
+
+
+
diff --git a/src/apps/web/batch-detail.js b/src/apps/web/batch-detail.js
new file mode 100644
index 0000000..cb503e4
--- /dev/null
+++ b/src/apps/web/batch-detail.js
@@ -0,0 +1,291 @@
+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}
+
+ ${group.items.map((task) => `- ${escapeHtml(task.title)} (${escapeHtml(task.task_id)})
`).join("")}
+
+
+ `,
+ )
+ .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"}.
+
+
+
+ `;
+ })
+ .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 `
+
+
+
+
+ Routing
+
+ agent ${escapeHtml(task.assigned_agent_role ?? "unassigned")}
+ latest run ${escapeHtml(task.latest_run_status ?? "not started")}
+ ${escapeHtml(task.artifact_count)} artifacts
+
+ ${
+ task.latest_run_id
+ ? `View run detail
`
+ : ""
+ }
+
+
+ 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/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...
+
+
+
+
+
+
+
Run overview
+
Current execution
+
+
pending
+
+
+
+
+
+
+
+
Routing context
+
Why this role
+
+
+
+
+
+
+
+
+
+
+
Input snapshot
+
Request payload
+
+
+ Loading...
+
+
+
+
+
+
Output snapshot
+
Execution result
+
+
+ Loading...
+
+
+
+
+
+
+
+
Logs & errors
+
Failure diagnosis
+
+
+
+
+
+
+
+
+
+
Retry history
+
Previous attempts
+
+
+
+
+
+
+
+
+
+
Task events
+
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/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..051e2e1 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
@@ -269,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
new file mode 100644
index 0000000..91141ec
--- /dev/null
+++ b/src/tests/test_batch_detail_page.py
@@ -0,0 +1,197 @@
+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_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=[])
+ 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_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
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