Skip to content
Open
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
37 changes: 37 additions & 0 deletions services/hackbot-ui/app/api/runs/[runId]/retrigger/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
3 changes: 2 additions & 1 deletion services/hackbot-ui/app/runs/[runId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default async function RunPage({
params: Promise<{ runId: string }>;
}) {
const { runId } = await params;
return <RunDetail runId={runId} />;

return <RunDetail key={runId} runId={runId} />;
}
4 changes: 2 additions & 2 deletions services/hackbot-ui/components/RecentRuns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions services/hackbot-ui/components/RunDetail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -46,12 +53,15 @@ function extractLog(run: RunDoc): string | null {
}

export function RunDetail({ runId }: { runId: string }) {
const router = useRouter();
const [run, setRun] = useState<RunDoc | null>(null);
const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState(true);
const [actions, setActions] = useState<RunAction[] | null>(null);
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [retriggering, setRetriggering] = useState(false);
const [retriggerError, setRetriggerError] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

const fetchRun = useCallback(async () => {
Expand Down Expand Up @@ -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 <div className="error-banner">{error}</div>;
}
Expand Down Expand Up @@ -159,12 +187,27 @@ export function RunDetail({ runId }: { runId: string }) {
</span>
)}
</div>
<Link href="/" className="muted">
← all runs
</Link>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{isFailed(run.status) && (
<button
type="button"
className="secondary"
onClick={retrigger}
disabled={retriggering}
>
{retriggering ? "Re-running…" : "Re-run with same inputs"}
</button>
)}
<Link href="/" className="muted">
← all runs
</Link>
</div>
</div>

{error && <div className="error-banner">Refresh error: {error}</div>}
{retriggerError && (
<div className="error-banner">Re-run failed: {retriggerError}</div>
)}

<div className="panel">
<h2>Run</h2>
Expand Down
4 changes: 4 additions & 0 deletions services/hackbot-ui/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down