Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/apps/api/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from __future__ import annotations

from pathlib import Path

from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from src.apps.api.bootstrap import ensure_builtin_agent_roles
from src.apps.api.routers import (
Expand All @@ -26,6 +30,14 @@
app.include_router(runs_router)
app.include_router(reviews_router)

WEB_DIR = Path(__file__).resolve().parents[1] / "web"
app.mount("/console/assets", StaticFiles(directory=WEB_DIR), name="console-assets")


@app.get("/console/batches")
def console_batches() -> FileResponse:
return FileResponse(WEB_DIR / "index.html")


@app.on_event("startup")
def bootstrap_defaults() -> None:
Expand Down
29 changes: 29 additions & 0 deletions src/apps/api/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@


BUILTIN_ROLES: tuple[dict, ...] = (
{
"role_name": "search_agent",
"description": "Built-in search agent for research-oriented tasks",
"capabilities": ["task:search", "task:research_topic"],
"input_schema": {
"supported_task_types": [],
"input_requirements": {"properties": {"query": {"type": "string"}}},
"supports_concurrency": True,
"allows_auto_retry": False,
},
"output_schema": {"output_contract": {"type": "object"}},
},
{
"role_name": "code_agent",
"description": "Built-in code agent for implementation-oriented tasks",
"capabilities": ["task:code", "task:implement_feature"],
"input_schema": {
"supported_task_types": [],
"input_requirements": {
"properties": {
"prompt": {"type": "string"},
"language": {"type": "string"},
}
},
"supports_concurrency": True,
"allows_auto_retry": False,
},
"output_schema": {"output_contract": {"type": "object"}},
},
{
"role_name": "planner_agent",
"description": "Built-in planner for demo preprocessing",
Expand Down
85 changes: 84 additions & 1 deletion src/apps/api/routers/task_batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from collections import deque

from fastapi import APIRouter, Depends, HTTPException, status
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session

Expand All @@ -22,6 +24,8 @@
BatchCountsRead,
BatchProgressRead,
BatchTaskResultRead,
TaskBatchListItemRead,
TaskBatchListResponse,
TaskBatchRead,
TaskBatchSummaryRead,
TaskBatchSubmitRequest,
Expand All @@ -34,6 +38,10 @@
router = APIRouter(prefix="/task-batches", tags=["task-batches"])


def _now() -> datetime:
return datetime.now(timezone.utc)


def _build_batch_counts(tasks: list[TaskORM]) -> BatchCountsRead:
counts = {
"pending_count": 0,
Expand Down Expand Up @@ -132,6 +140,32 @@ def _load_batch_artifacts(task_ids: list[str], db: Session) -> tuple[list[Artifa
return artifacts, artifact_counts


def _batch_updated_at(task_batch: TaskBatchORM, tasks: list[TaskORM]) -> datetime:
if not tasks:
return task_batch.created_at
return max(task.updated_at for task in tasks)


def _build_batch_list_item(task_batch: TaskBatchORM, tasks: list[TaskORM]) -> TaskBatchListItemRead:
counts = _build_batch_counts(tasks)
progress = _build_batch_progress(tasks)
total_tasks = task_batch.total_tasks or len(tasks)
success_rate = 0.0 if total_tasks == 0 else round((counts.success_count / total_tasks) * 100, 2)
return TaskBatchListItemRead(
batch_id=task_batch.id,
title=task_batch.title,
created_at=task_batch.created_at,
updated_at=_batch_updated_at(task_batch, tasks),
total_tasks=total_tasks,
derived_status=_derive_batch_status(tasks),
success_rate=success_rate,
completed_count=progress.completed_count,
success_count=counts.success_count,
failed_count=counts.failed_count,
cancelled_count=counts.cancelled_count,
)


def _validate_unique_client_task_ids(payload: TaskBatchSubmitRequest) -> None:
client_task_ids = [task.client_task_id for task in payload.tasks]
duplicated = {task_id for task_id in client_task_ids if client_task_ids.count(task_id) > 1}
Expand Down Expand Up @@ -339,6 +373,55 @@ def create_task_batch(
raise


@router.get("", response_model=TaskBatchListResponse)
def list_task_batches(
status_filter: str | None = Query(default=None, alias="status"),
search: str | None = None,
sort: str = "created_at_desc",
db: Session = Depends(get_db),
) -> TaskBatchListResponse:
query = select(TaskBatchORM)

if search:
query = query.where(TaskBatchORM.title.ilike(f"%{search.strip()}%"))

sort_mapping = {
"created_at_desc": TaskBatchORM.created_at.desc(),
"created_at_asc": TaskBatchORM.created_at.asc(),
"updated_at_desc": TaskBatchORM.created_at.desc(),
"updated_at_asc": TaskBatchORM.created_at.asc(),
}
batches = db.scalars(query.order_by(sort_mapping.get(sort, TaskBatchORM.created_at.desc()))).all()
if not batches:
return TaskBatchListResponse(items=[])

batch_ids = [batch.id for batch in batches]
tasks = db.scalars(
select(TaskORM)
.where(TaskORM.batch_id.in_(batch_ids))
.order_by(TaskORM.created_at.asc(), TaskORM.id.asc())
).all()

tasks_by_batch: dict[str, list[TaskORM]] = {batch_id: [] for batch_id in batch_ids}
for task in tasks:
tasks_by_batch.setdefault(task.batch_id, []).append(task)

items = [
_build_batch_list_item(task_batch, tasks_by_batch.get(task_batch.id, []))
for task_batch in batches
]

if status_filter:
items = [item for item in items if item.derived_status == status_filter]

if sort == "updated_at_desc":
items.sort(key=lambda item: item.updated_at, reverse=True)
elif sort == "updated_at_asc":
items.sort(key=lambda item: item.updated_at)

return TaskBatchListResponse(items=items)


@router.get("/{batch_id}", response_model=TaskBatchRead)
def get_task_batch(batch_id: str, db: Session = Depends(get_db)) -> TaskBatchRead:
task_batch = db.get(TaskBatchORM, batch_id)
Expand Down
93 changes: 93 additions & 0 deletions src/apps/web/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const batchGrid = document.getElementById("batch-grid");
const statusText = document.getElementById("status-text");
const searchInput = document.getElementById("search-input");
const statusSelect = document.getElementById("status-select");
const sortSelect = document.getElementById("sort-select");
const refreshButton = document.getElementById("refresh-button");

function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}

function renderEmpty(message) {
batchGrid.innerHTML = `<article class="empty-state">${message}</article>`;
}

function renderBatches(items) {
if (!items.length) {
renderEmpty("No batches matched the current filters.");
return;
}

batchGrid.innerHTML = items
.map(
(item) => `
<article class="batch-card">
<div class="card-top">
<div>
<h2>${item.title}</h2>
<p class="batch-id">${item.batch_id}</p>
</div>
<span class="status-badge status-${item.derived_status}">${item.derived_status}</span>
</div>
<dl class="metrics">
<div><dt>Total tasks</dt><dd>${item.total_tasks}</dd></div>
<div><dt>Success rate</dt><dd>${item.success_rate}%</dd></div>
<div><dt>Completed</dt><dd>${item.completed_count}</dd></div>
<div><dt>Success</dt><dd>${item.success_count}</dd></div>
<div><dt>Failed</dt><dd>${item.failed_count}</dd></div>
<div><dt>Cancelled</dt><dd>${item.cancelled_count}</dd></div>
</dl>
<div class="timestamps">
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
</div>
</article>
`,
)
.join("");
}

async function loadBatches() {
const params = new URLSearchParams();
if (searchInput.value.trim()) {
params.set("search", searchInput.value.trim());
}
if (statusSelect.value) {
params.set("status", statusSelect.value);
}
params.set("sort", sortSelect.value);

statusText.textContent = "Loading batches...";
try {
const response = await fetch(`/task-batches?${params.toString()}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const payload = await response.json();
statusText.textContent = `${payload.items.length} batch${payload.items.length === 1 ? "" : "es"} shown`;
renderBatches(payload.items);
} catch (error) {
statusText.textContent = "Unable to load batches.";
renderEmpty(error.message);
}
}

searchInput.addEventListener("input", () => {
loadBatches();
});
statusSelect.addEventListener("change", () => {
loadBatches();
});
sortSelect.addEventListener("change", () => {
loadBatches();
});
refreshButton.addEventListener("click", () => {
loadBatches();
});

loadBatches();
56 changes: 56 additions & 0 deletions src/apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Batch Console</title>
<link rel="stylesheet" href="/console/assets/styles.css">
</head>
<body>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Operations Console</p>
<h1>Batch Console</h1>
<p class="subtitle">Inspect task batches, isolate unhealthy runs, and track overall throughput.</p>
</section>

<section class="toolbar">
<label class="field">
<span>Search</span>
<input id="search-input" type="search" placeholder="Find batch by title">
</label>
<label class="field">
<span>Status</span>
<select id="status-select">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="needs_review">Needs review</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
<option value="partially_failed">Partially failed</option>
</select>
</label>
<label class="field">
<span>Sort</span>
<select id="sort-select">
<option value="created_at_desc">Newest created</option>
<option value="created_at_asc">Oldest created</option>
<option value="updated_at_desc">Recently updated</option>
<option value="updated_at_asc">Least recently updated</option>
</select>
</label>
<button id="refresh-button" class="refresh-button" type="button">Refresh</button>
</section>

<section class="status-row">
<p id="status-text">Loading batches...</p>
</section>

<section id="batch-grid" class="batch-grid" aria-live="polite"></section>
</main>

<script src="/console/assets/app.js"></script>
</body>
</html>
Loading
Loading