Skip to content

Commit 5019719

Browse files
pfbyjyclaude
andauthored
Add task detail page + preview-deploy fixes (#103)
* Pin alembic search_path=public for preview branches Freshly-created Supabase preview branches reached through the Supavisor session pooler hand alembic a backend whose default ``search_path`` is empty, so the first DDL alembic emits — ``CREATE TABLE alembic_version_{oddish,backend}`` — dies with ``InvalidSchemaNameError: no schema has been selected to create in``, even though ``public`` exists and the role can read/write rows there. Pass ``server_settings={"search_path": "public"}`` to asyncpg in both alembic env.py files so every connection sets search_path at the protocol level on startup, before any DDL runs. This is the default search_path postgres ships with, so it's a no-op on working configurations. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Add task detail page with version switcher and per-agent trial cards Adds /tasks/[task_id], a task-centric view linked from the task browser showing task-wide and per-version cost rollups, a version switcher, and a per-agent breakdown of trial chips that opens the existing task/trial drawers. Adds a /tasks/{task_id}/detail backend endpoint that bundles the task with trials, all version summaries, and cost totals so the page renders in one round trip. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Update Vercel preview env whenever Modal redeploys, not just on first branch Gating ``Link Vercel project`` / ``Point Vercel preview to Modal preview`` / ``Redeploy Vercel preview with updated env`` on ``branch_was_created == 'true'`` was too narrow. When a fresh-branch run failed mid-flight (e.g. alembic flake on first push), the Vercel env was never pointed at the PR's Modal URL, and every subsequent push for the rest of the PR's lifetime had ``branch_was_created=false`` so the preview silently served prod for the entire PR. Switch the gate to ``steps.deploy_modal.outputs.modal_api_url != ''`` so the Vercel env is refreshed any time Modal was redeployed in this run. ``vercel env add --force`` is idempotent, so re-pointing at the same URL is a no-op when nothing changed. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Aggregate task detail across every version, not just current The previous detail endpoint reused get_task_status_core, which filters trials to ``task.current_version_id`` (matches /tasks list semantics). That meant every v1/v2/v3 trial was dropped before reaching the per-version aggregator and the task-wide ``totals`` rollup, so the detail page showed "0 trials · \$0" for every non-current version and the total-spent KPI only counted the current version's trials — even when each version had real trials behind it. Load the task directly with all non-superseded trials and feed the full set into ``task_status.trials``. The canonical header counts (total/completed/failed/reward_*) stay scoped to the current version so the header KPI block keeps matching the /tasks list — but the trials list now spans every version, which is what the in-memory version switcher needs and what the per-version chips + ``totals`` roll-up read from. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Surface verdict, pass rate, per-agent cost/duration; version dropdown - Replace the chip-row version switcher with a dropdown that scales past a handful of versions and lifts the version message into each option. - Render the task verdict (with confidence, primary issue / reasoning, failure detail) above the KPI bar when run_analysis is set. - Add pass-rate (passed / scored) next to avg score in the KPI bar. - Add avg cost per priced trial and avg wall-clock duration to each per-agent card. - Trim over-explanatory comments left from earlier commits. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Dedup formatCostUsd, trial aggregation, verdict block; fix version dropdown - Extract formatCostUsd, formatDurationSec, trialDurationSec into lib/format.ts; rewire task-detail-client + experiment-detail-view. - Extract TrialAggregate / accumulateTrial / summarizeTrials into lib/trial-aggregation.ts; rewire task-detail-client and refactor experiment-detail-view's buildExperimentSummary to compose them. - Extract a TaskVerdictBadge component with ``card`` (drawer panel) and ``inline`` (task detail page) variants; replace the duplicated blocks in task-files-panel and task-detail-client. - Switch the version picker from shadcn Select to DropdownMenu so rich items (label + sub-line) render correctly. - Drop the conceptually muddled "All versions" entry from the picker; cross-version cost lives in its own KPI tile. - Switch version selection from router.replace (which triggers a Next soft-navigation and re-runs the page) to local state + history.replaceState for URL persistence — selection is now a single React render. Net: -311 lines across the three call-sites, +240 into the new shared modules, with three sites now on a single source of truth. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Add Run judge button to task verdict block The verdict pipeline is opt-in (TaskSubmission.run_analysis defaults to False), so most tasks render the verdict badge with no content. Wire an on-demand "Run judge" button that POSTs to the existing /tasks/{task_id}/verdict/retry endpoint, switches the badge to a queuing state, and revalidates SWR so the pending → complete transition reflects automatically. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Surface upstream status + body when verdict/retry proxy hits non-JSON The /api/tasks/[task_id]/verdict/retry proxy blindly JSON.parse'd the upstream body, so any non-JSON response (e.g. a Modal infra error page) bubbled up to the UI as "Unexpected token 'm' ... is not valid JSON", obscuring the actual upstream status. Catch the parse failure and return the truncated body with its real status instead. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Point Run judge at analysis/retry; revert stray uv.lock churn The verdict endpoint is gated on every trial analysis having finished, so calling /verdict/retry on a task that was submitted with run_analysis=False (the default) always 400s with "All trial analyses must finish before running a task verdict". Switch the Run judge button to /tasks/{id}/analysis/retry — that endpoint queues the per-trial analyses and flips task.run_analysis=True, and the verdict auto-enqueues from the cleanup worker once the analyses complete (see workers/queue/cleanup.py:379-394). Also harden the /analysis/retry proxy the same way as /verdict/retry so a non-JSON upstream body surfaces with its actual status, and revert the accidental backend/uv.lock churn that snuck into b1b7995 from a syntax-check ``uv sync``. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * fix(verdict): show analyzing state while trial analyses are in flight The neutral "Verdict pending" state was rendered both when nothing had been run yet and while trial analyses were actively running, hiding the fact that work was in progress and letting users click Run judge into a guaranteed 400. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 * Address PR review: drop N+1 in detail endpoint and add tests - Plumb the already-fetched TaskModel into list_task_versions_core so get_task_detail_core stops re-running get_task_for_org_core (and its extra SELECT) just to satisfy the version listing helper. - Extract the rollup/aggregation block from get_task_detail_core into _aggregate_task_detail_rollups so the cost-totals + per-version bucketing is unit-testable without standing up the query stack. - Add tests/test_task_detail_endpoint.py covering: the N+1 fix (with regression guard for the non-task path), cross-org 404 via get_task_detail_core, the happy aggregation path, and the orphan task_version_id edge case. https://claude.ai/code/session_01KDGmgDLbXCX89RgwmPfoE8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9504273 commit 5019719

20 files changed

Lines changed: 2011 additions & 207 deletions

File tree

.github/workflows/modal-preview.yml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -234,18 +234,17 @@ jobs:
234234
# because the CLI ignores MODAL_ENVIRONMENT for this subcommand.
235235
run: timeout --preserve-status 45s uv run modal app logs --env "$MODAL_ENVIRONMENT" --timestamps "$MODAL_APP_NAME" 2>&1 | tail -300 || true
236236

237-
# Vercel setup runs only on the first deploy (newly-created Supabase
238-
# branch). MODAL_APP_NAME is keyed on the PR number, so the Modal URL
239-
# is stable across subsequent pushes — Vercel's GitHub integration
240-
# auto-deploys those with the already-set NEXT_PUBLIC_API_URL.
241-
# Node + Vercel CLI are pre-installed in the base image.
237+
# Run any time Modal redeployed (not just on first branch creation):
238+
# if the first run failed before reaching here, every later push had
239+
# branch_was_created=false and the preview kept serving prod. The
240+
# vercel env add / redeploy below are idempotent.
242241
- name: Link Vercel project
243-
if: ${{ steps.supabase_branch.outputs.branch_was_created == 'true' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
242+
if: ${{ steps.deploy_modal.outputs.modal_api_url != '' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
244243
working-directory: frontend
245244
run: vercel pull --yes --environment=preview --git-branch="$VERCEL_GIT_BRANCH" --token="$VERCEL_TOKEN"
246245

247246
- name: Point Vercel preview to Modal preview
248-
if: ${{ steps.supabase_branch.outputs.branch_was_created == 'true' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
247+
if: ${{ steps.deploy_modal.outputs.modal_api_url != '' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
249248
working-directory: frontend
250249
env:
251250
MODAL_API_URL: ${{ steps.deploy_modal.outputs.modal_api_url }}
@@ -256,7 +255,7 @@ jobs:
256255

257256
- name: Redeploy Vercel preview with updated env
258257
id: redeploy_vercel
259-
if: ${{ steps.supabase_branch.outputs.branch_was_created == 'true' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
258+
if: ${{ steps.deploy_modal.outputs.modal_api_url != '' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' }}
260259
run: python "$GITHUB_WORKSPACE/.github/scripts/preview/redeploy_vercel.py"
261260

262261
- name: Summarize preview URLs

backend/alembic/env.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,14 @@ async def run_async_migrations() -> None:
101101
# Use the asyncpg URL to avoid psycopg2 dependency.
102102
connectable = create_async_engine(
103103
db_url,
104-
# Disable prepared statement caching for compatibility with
105-
# transaction/statement poolers (PgBouncer, Supavisor, etc).
106-
connect_args={"statement_cache_size": 0},
104+
connect_args={
105+
# Disable prepared statement caching for compatibility with
106+
# transaction/statement poolers (PgBouncer, Supavisor, etc).
107+
"statement_cache_size": 0,
108+
# Supabase preview pooler hands out backends with an empty
109+
# search_path; pin it at connect time so the first DDL works.
110+
"server_settings": {"search_path": "public"},
111+
},
107112
poolclass=pool.NullPool,
108113
)
109114

backend/api/routers/tasks.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from oddish.core.endpoints import (
1717
browse_tasks_core,
1818
create_task_sweep_core,
19+
get_task_detail_core,
1920
get_task_for_org_core,
2021
get_task_status_core,
2122
get_task_version_core,
@@ -52,6 +53,7 @@
5253
from oddish.schemas import (
5354
TaskBrowseResponse,
5455
TaskBatchCancelRequest,
56+
TaskDetailResponse,
5557
TaskUploadCompleteRequest,
5658
TaskUploadInitRequest,
5759
TaskUploadInitResponse,
@@ -622,6 +624,20 @@ async def get_task_status(
622624
)
623625

624626

627+
@router.get("/tasks/{task_id}/detail", response_model=TaskDetailResponse)
628+
async def get_task_detail(
629+
task_id: str,
630+
auth: Annotated[AuthContext, Depends(require_auth)],
631+
) -> TaskDetailResponse:
632+
"""Task detail bundle: task + trials + per-version + cost rollups."""
633+
auth.require_scope(APIKeyScope.READ)
634+
635+
async with get_session() as session:
636+
return await get_task_detail_core(
637+
session, task_id=task_id, org_id=auth.org_id
638+
)
639+
640+
625641
# =============================================================================
626642
# Task Versions
627643
# =============================================================================
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { auth } from "@clerk/nextjs/server";
2+
import {
3+
getAuthHeaders,
4+
getBackendUrl,
5+
getClerkToken,
6+
} from "@/lib/backend-config";
7+
import type { TaskDetailResponse } from "@/lib/types";
8+
import { TaskDetailClient } from "./task-detail-client";
9+
10+
async function getInitialTaskDetail(
11+
taskId: string,
12+
): Promise<TaskDetailResponse | null> {
13+
try {
14+
const authObj = await auth();
15+
if (!authObj?.userId) return null;
16+
17+
const token = await getClerkToken(authObj.getToken);
18+
if (!token) return null;
19+
20+
const url = getBackendUrl("tasks", `/${taskId}/detail`);
21+
const response = await fetch(url, {
22+
cache: "no-store",
23+
headers: getAuthHeaders(token),
24+
});
25+
if (!response.ok) {
26+
console.error(
27+
`[tasks/[task_id]/page] Failed initial task detail fetch: ${response.status}`,
28+
);
29+
return null;
30+
}
31+
32+
return (await response.json()) as TaskDetailResponse;
33+
} catch (error) {
34+
console.error("[tasks/[task_id]/page] Initial task detail fetch failed", error);
35+
return null;
36+
}
37+
}
38+
39+
export default async function TaskDetailPage({
40+
params,
41+
searchParams,
42+
}: {
43+
params: Promise<{ task_id: string }>;
44+
searchParams?: Promise<{ version?: string | string[] }>;
45+
}) {
46+
const { task_id } = await params;
47+
const initialDetail = await getInitialTaskDetail(task_id);
48+
const sp = await searchParams;
49+
const versionParam = sp?.version;
50+
const initialVersionId = Array.isArray(versionParam)
51+
? versionParam[0]
52+
: versionParam;
53+
54+
return (
55+
<TaskDetailClient
56+
taskId={task_id}
57+
initialDetail={initialDetail}
58+
initialVersionId={initialVersionId ?? null}
59+
/>
60+
);
61+
}

0 commit comments

Comments
 (0)