diff --git a/services/hackbot-ui/app/api/runs/[runId]/retrigger/route.ts b/services/hackbot-ui/app/api/runs/[runId]/retrigger/route.ts new file mode 100644 index 0000000000..f5cb3dee9b --- /dev/null +++ b/services/hackbot-ui/app/api/runs/[runId]/retrigger/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +import { createRun, getRun, HackbotError } from "@/lib/hackbot"; +import { getAuthedEmail } from "@/lib/session"; +import { isFailed } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +// POST /api/runs/:runId/retrigger: start a new run with the same inputs as a +// failed one. Inputs are read server-side, so the browser only sends a run id. +// Like POST /api/runs, the new run is attributed to the signed-in user rather +// than to the original run's requester. +export async function POST( + _req: Request, + { params }: { params: Promise<{ runId: string }> } +) { + const email = await getAuthedEmail(); + if (!email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { runId } = await params; + try { + const doc = await getRun(runId); + if (!isFailed(doc.status)) { + return NextResponse.json( + { error: `Only failed runs can be re-run (this one is ${doc.status})` }, + { status: 409 } + ); + } + const run = await createRun(doc.agent, doc.inputs, email); + return NextResponse.json(run, { status: 201 }); + } catch (err) { + const status = err instanceof HackbotError ? err.status : 500; + return NextResponse.json({ error: (err as Error).message }, { status }); + } +} diff --git a/services/hackbot-ui/app/runs/[runId]/page.tsx b/services/hackbot-ui/app/runs/[runId]/page.tsx index 3fe6e24205..244da0dbd5 100644 --- a/services/hackbot-ui/app/runs/[runId]/page.tsx +++ b/services/hackbot-ui/app/runs/[runId]/page.tsx @@ -6,5 +6,6 @@ export default async function RunPage({ params: Promise<{ runId: string }>; }) { const { runId } = await params; - return ; + + return ; } diff --git a/services/hackbot-ui/components/RecentRuns.tsx b/services/hackbot-ui/components/RecentRuns.tsx index 4df33174d3..476dbed643 100644 --- a/services/hackbot-ui/components/RecentRuns.tsx +++ b/services/hackbot-ui/components/RecentRuns.tsx @@ -6,7 +6,7 @@ import { Fragment, useCallback, useEffect, useState } from "react"; import { AGENT_NAMES } from "@/lib/agents"; import { useSession } from "@/lib/auth-client"; -import { isTerminal, type RunDoc, type RunStatus } from "@/lib/types"; +import { isFailed, isTerminal, type RunDoc, type RunStatus } from "@/lib/types"; import { StatusBadge } from "./StatusBadge"; // Poll the status of any non-terminal, currently-loaded runs so the dashboard @@ -61,7 +61,7 @@ function toRow(d: RunDoc): RunRow { } function hasErrorDetail(r: RunRow): boolean { - return (r.status === "failed" || r.status === "timed_out") && !!r.error; + return isFailed(r.status) && !!r.error; } // Compact requester cell: the email's local part (full email on hover). Runs diff --git a/services/hackbot-ui/components/RunDetail.tsx b/services/hackbot-ui/components/RunDetail.tsx index 3c846f6407..30bd9f509f 100644 --- a/services/hackbot-ui/components/RunDetail.tsx +++ b/services/hackbot-ui/components/RunDetail.tsx @@ -1,10 +1,17 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { updateRunStatus } from "@/lib/store"; -import { isTerminal, type RunAction, type RunDoc } from "@/lib/types"; +import { + isFailed, + isTerminal, + type RunAction, + type RunDoc, + type RunRef, +} from "@/lib/types"; import { FindingsView } from "./FindingsView"; import { Markdown } from "./Markdown"; import { StatusBadge } from "./StatusBadge"; @@ -46,12 +53,15 @@ function extractLog(run: RunDoc): string | null { } export function RunDetail({ runId }: { runId: string }) { + const router = useRouter(); const [run, setRun] = useState(null); const [error, setError] = useState(null); const [polling, setPolling] = useState(true); const [actions, setActions] = useState(null); const [applying, setApplying] = useState(false); const [applyError, setApplyError] = useState(null); + const [retriggering, setRetriggering] = useState(false); + const [retriggerError, setRetriggerError] = useState(null); const timer = useRef | null>(null); const fetchRun = useCallback(async () => { @@ -124,6 +134,24 @@ export function RunDetail({ runId }: { runId: string }) { } }, [runId]); + const retrigger = useCallback(async () => { + setRetriggering(true); + setRetriggerError(null); + try { + const res = await fetch(`/api/runs/${runId}/retrigger`, { + method: "POST", + }); + const body = await res.json(); + if (!res.ok) + throw new Error(body?.error ?? `Request failed (${res.status})`); + // Left disabled through the navigation; the remount clears it. + router.push(`/runs/${(body as RunRef).run_id}`); + } catch (err) { + setRetriggerError((err as Error).message); + setRetriggering(false); + } + }, [runId, router]); + if (!run && error) { return
{error}
; } @@ -159,12 +187,27 @@ export function RunDetail({ runId }: { runId: string }) { )} - - ← all runs - +
+ {isFailed(run.status) && ( + + )} + + ← all runs + +
{error &&
Refresh error: {error}
} + {retriggerError && ( +
Re-run failed: {retriggerError}
+ )}

Run

diff --git a/services/hackbot-ui/lib/types.ts b/services/hackbot-ui/lib/types.ts index 1fc07ab664..1f586f116e 100644 --- a/services/hackbot-ui/lib/types.ts +++ b/services/hackbot-ui/lib/types.ts @@ -17,6 +17,10 @@ export function isTerminal(status: RunStatus): boolean { return TERMINAL_STATUSES.includes(status); } +export function isFailed(status: RunStatus): boolean { + return status === "failed" || status === "timed_out"; +} + export interface AgentDescriptor { name: string; description: string;