Skip to content
Draft
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
6 changes: 5 additions & 1 deletion services/hackbot-api/app/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ async def create_run(
@router.get("/runs", response_model=list[RunDoc])
async def list_runs(
limit: int = Query(default=50, ge=1, le=100),
agent: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> list[RunDoc]:
result = await db.execute(select(Run).order_by(Run.created_at.desc()).limit(limit))
query = select(Run).order_by(Run.created_at.desc())
if agent is not None:
query = query.where(Run.agent == agent)
result = await db.execute(query.limit(limit))
return [RunDoc.model_validate(r) for r in result.scalars()]


Expand Down
5 changes: 3 additions & 2 deletions services/hackbot-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ It lets you:
artifacts written to the results bucket, each a **download link** (the browser
is redirected to a short-lived signed GCS URL).

> This is a demo surface, not a system of record. It has no list-runs endpoint
> upstream, so the browser remembers the runs you triggered in `localStorage`.
> This is a demo surface, not a system of record. The recent-runs panel is
> sourced entirely from the upstream `GET /runs` endpoint and filtered to the
> agent selected in the trigger form (carried in the `?agent=` URL param).

## Architecture

Expand Down
7 changes: 4 additions & 3 deletions services/hackbot-ui/app/api/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { getAuthedEmail } from "@/lib/session";

export const dynamic = "force-dynamic";

// GET /api/runs?limit=50
// Returns the most recent runs from hackbot-api (no reconciliation).
// GET /api/runs?limit=50&agent=bug-fix
// Returns the most recent runs from hackbot-api, optionally filtered by agent.
export async function GET(req: NextRequest) {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -15,7 +15,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const raw = Number(searchParams.get("limit") ?? 50);
const limit = Number.isFinite(raw) && raw > 0 ? raw : 50;
const runs = await listRuns(limit);
const agent = searchParams.get("agent") ?? undefined;
const runs = await listRuns(limit, agent);
return NextResponse.json(runs);
} catch (err) {
const status = err instanceof HackbotError ? err.status : 500;
Expand Down
4 changes: 3 additions & 1 deletion services/hackbot-ui/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export default function HomePage() {

<div className="panel">
<h2>Recent runs</h2>
<RecentRuns />
<Suspense fallback={null}>
<RecentRuns />
</Suspense>
</div>
</>
);
Expand Down
81 changes: 39 additions & 42 deletions services/hackbot-ui/components/RecentRuns.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
"use client";

import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";

import { isTerminal } from "@/lib/types";
import { loadRuns, type TrackedRun, updateRunStatus } from "@/lib/store";
import { parseAgent } from "@/lib/agents";
import { isTerminal, type RunStatus } from "@/lib/types";
import { StatusBadge } from "./StatusBadge";

// Polls the status of any non-terminal tracked runs so the dashboard stays live.
// Polls while any run is non-terminal so the dashboard stays live.
const POLL_MS = 5000;

interface RunRow {
run_id: string;
agent: string;
status: RunStatus;
// Human-readable summary of the inputs, e.g. "bug 1846789".
label: string;
created_at: string;
}

function labelFromInputs(inputs: Record<string, unknown>): string {
if (typeof inputs.bug_id === "number") return `bug ${inputs.bug_id}`;
if (typeof inputs.git_commit === "string") {
return `commit ${inputs.git_commit.slice(0, 12)}`;
}
if (typeof inputs.feature_name === "string") return inputs.feature_name;
return "inline report";
}

async function fetchRuns(): Promise<TrackedRun[]> {
const res = await fetch("/api/runs?limit=50");
// The runs list is sourced entirely from hackbot-api (no localStorage), filtered
// server-side to the agent selected in the trigger form (carried in `?agent=`).
async function fetchRuns(agent: string): Promise<RunRow[]> {
const params = new URLSearchParams({ limit: "50", agent });
const res = await fetch(`/api/runs?${params.toString()}`);
if (!res.ok) return [];
const docs = (await res.json()) as Array<{
run_id: string;
agent: string;
status: TrackedRun["status"];
status: RunStatus;
inputs: Record<string, unknown>;
created_at: string;
}>;
Expand All @@ -34,53 +51,33 @@ async function fetchRuns(): Promise<TrackedRun[]> {
}));
}

function mergeRuns(
apiRuns: TrackedRun[],
localRuns: TrackedRun[]
): TrackedRun[] {
const seen = new Set(apiRuns.map((r) => r.run_id));
const extras = localRuns.filter((r) => !seen.has(r.run_id));
return [...extras, ...apiRuns];
}

export function RecentRuns() {
const [runs, setRuns] = useState<TrackedRun[] | null>(null);
const params = useSearchParams();
const agent = parseAgent(params.get("agent"));
const [runs, setRuns] = useState<RunRow[] | null>(null);

useEffect(() => {
fetchRuns().then((apiRuns) => {
setRuns(mergeRuns(apiRuns, loadRuns()));
let cancelled = false;
setRuns(null);
fetchRuns(agent).then((rows) => {
if (!cancelled) setRuns(rows);
});
}, []);
return () => {
cancelled = true;
};
}, [agent]);

useEffect(() => {
if (!runs) return;
const active = runs.filter((r) => !isTerminal(r.status));
if (active.length === 0) return;
if (!runs.some((r) => !isTerminal(r.status))) return;

const timer = setInterval(async () => {
let changed = false;
for (const r of active) {
try {
const res = await fetch(`/api/runs/${r.run_id}`);
if (!res.ok) continue;
const doc = await res.json();
if (doc.status && doc.status !== r.status) {
updateRunStatus(r.run_id, doc.status);
changed = true;
}
} catch {
// transient; try again next tick
}
}
if (changed) {
fetchRuns().then((apiRuns) => {
setRuns(mergeRuns(apiRuns, loadRuns()));
});
}
const rows = await fetchRuns(agent);
setRuns(rows);
}, POLL_MS);

return () => clearInterval(timer);
}, [runs]);
}, [runs, agent]);

if (runs === null) {
return <p className="muted">Loading…</p>;
Expand All @@ -89,7 +86,7 @@ export function RecentRuns() {
if (runs.length === 0) {
return (
<p className="muted">
No runs yet. Use the form above to trigger an agent.
No runs yet for the {agent} agent. Use the form above to trigger one.
</p>
);
}
Expand Down
2 changes: 0 additions & 2 deletions services/hackbot-ui/components/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";

import { updateRunStatus } from "@/lib/store";
import { isTerminal, type RunAction, type RunDoc } from "@/lib/types";
import { FindingsView } from "./FindingsView";
import { Markdown } from "./Markdown";
Expand Down Expand Up @@ -63,7 +62,6 @@ export function RunDetail({ runId }: { runId: string }) {
const doc = body as RunDoc;
setRun(doc);
setError(null);
updateRunStatus(runId, doc.status);
if (isTerminal(doc.status)) {
setPolling(false);
return false;
Expand Down
47 changes: 12 additions & 35 deletions services/hackbot-ui/components/TriggerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,9 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";

import { saveRun } from "@/lib/store";
import { AGENTS, type AgentValue, parseAgent } from "@/lib/agents";
import type { RunRef } from "@/lib/types";

const AGENTS = [
{ value: "bug-fix", label: "bug-fix" },
{ value: "autowebcompat-repro", label: "autowebcompat-repro" },
{ value: "build-repair", label: "build-repair" },
{ value: "frontend-triage", label: "frontend-triage" },
{ value: "test-plan-generator", label: "test-plan-generator" },
] as const;

type AgentValue = (typeof AGENTS)[number]["value"];

function parseAgent(value: string | null): AgentValue {
return AGENTS.some((a) => a.value === value)
? (value as AgentValue)
: "bug-fix";
}

export function TriggerForm() {
const router = useRouter();
const params = useSearchParams();
Expand Down Expand Up @@ -54,6 +38,16 @@ export function TriggerForm() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

// Reflect the selected agent in the URL so the recent-runs panel can filter
// to it without any shared client state.
function selectAgent(value: AgentValue) {
setAgent(value);
setError(null);
const next = new URLSearchParams(params.toString());
next.set("agent", value);
router.replace(`?${next.toString()}`, { scroll: false });
}

const isReproAgent = agent === "autowebcompat-repro";
const isBuildRepairAgent = agent === "build-repair";
const isTestPlanAgent = agent === "test-plan-generator";
Expand Down Expand Up @@ -150,20 +144,6 @@ export function TriggerForm() {
throw new Error(body?.error ?? `Request failed (${res.status})`);
}
const run = body as RunRef;
const label = isBuildRepairAgent
? `commit ${gitCommit.trim().slice(0, 12)}`
: isTestPlanAgent
? featureName.trim()
: hasBugId
? `bug ${parsedBugId}`
: "inline report";
saveRun({
run_id: run.run_id,
agent: run.agent,
status: run.status,
label,
created_at: new Date().toISOString(),
});
router.push(`/runs/${run.run_id}`);
} catch (err) {
setError((err as Error).message);
Expand All @@ -180,10 +160,7 @@ export function TriggerForm() {
<select
id="agent"
value={agent}
onChange={(e) => {
setAgent(e.target.value as AgentValue);
setError(null);
}}
onChange={(e) => selectAgent(e.target.value as AgentValue)}
>
{AGENTS.map((a) => (
<option key={a.value} value={a.value}>
Expand Down
21 changes: 21 additions & 0 deletions services/hackbot-ui/lib/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The agents the launchpad can trigger. Shared between the trigger form and the
// recent-runs panel so both agree on the selected agent (carried in the
// `?agent=` URL search param).

export const AGENTS = [
{ value: "bug-fix", label: "bug-fix" },
{ value: "autowebcompat-repro", label: "autowebcompat-repro" },
{ value: "build-repair", label: "build-repair" },
{ value: "frontend-triage", label: "frontend-triage" },
{ value: "test-plan-generator", label: "test-plan-generator" },
] as const;

export type AgentValue = (typeof AGENTS)[number]["value"];

export const DEFAULT_AGENT: AgentValue = "bug-fix";

export function parseAgent(value: string | null): AgentValue {
return AGENTS.some((a) => a.value === value)
? (value as AgentValue)
: DEFAULT_AGENT;
}
6 changes: 4 additions & 2 deletions services/hackbot-ui/lib/hackbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ export function getRun(runId: string): Promise<RunDoc> {
return request<RunDoc>(`/runs/${encodeURIComponent(runId)}`);
}

export function listRuns(limit = 50): Promise<RunDoc[]> {
return request<RunDoc[]>(`/runs?limit=${limit}`);
export function listRuns(limit = 50, agent?: string): Promise<RunDoc[]> {
const params = new URLSearchParams({ limit: String(limit) });
if (agent) params.set("agent", agent);
return request<RunDoc[]>(`/runs?${params.toString()}`);
}

export function listRunActions(runId: string): Promise<RunAction[]> {
Expand Down
55 changes: 0 additions & 55 deletions services/hackbot-ui/lib/store.ts

This file was deleted.