From 85559ead9601de513b8f19d5aac6db826e8551ba Mon Sep 17 00:00:00 2001 From: Suhaib Mujahid Date: Sat, 1 Aug 2026 23:56:29 -0400 Subject: [PATCH] Support re-triggering a failed run from the Hackbot UI Failed runs previously had to be retried by re-entering every input in the trigger form, which is tedious for agents with non-trivial inputs such as build-repair's failure_tasks JSON. Add a "Re-run with same inputs" button to the run detail page for failed and timed-out runs. It posts to a new /api/runs/:runId/retrigger route that reads the stored run server-side and starts a fresh run with the same agent and inputs, attributed to the signed-in user. The original run is left untouched, and the new run gets its own id and results prefix. Key RunDetail on the run id so following the new run remounts with fresh state instead of rendering the previous run's doc, error and actions. --- .../app/api/runs/[runId]/retrigger/route.ts | 37 ++++++++++++++ services/hackbot-ui/app/runs/[runId]/page.tsx | 3 +- services/hackbot-ui/components/RecentRuns.tsx | 4 +- services/hackbot-ui/components/RunDetail.tsx | 51 +++++++++++++++++-- services/hackbot-ui/lib/types.ts | 4 ++ 5 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 services/hackbot-ui/app/api/runs/[runId]/retrigger/route.ts 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;