From 65f096b00a98b23eb724aeb18985af0c1e50548f Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Mon, 19 Oct 2026 13:28:44 -0400 Subject: [PATCH] Restore Retry for terminal runs in the web UI --- .../app/components/retry-run-dialog.tsx | 64 +++++++++++++++ .../components/runs-list/row-actions-menu.tsx | 21 ++++- apps/fabro-web/app/components/ui.tsx | 4 +- apps/fabro-web/app/lib/run-actions.test.ts | 31 ++++++++ apps/fabro-web/app/lib/run-actions.ts | 18 ++++- apps/fabro-web/app/routes/run-detail.test.ts | 77 ++++++++++++++++++- apps/fabro-web/app/routes/run-detail.tsx | 16 ++++ .../app/routes/run-detail/lifecycle-toasts.ts | 2 + 8 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 apps/fabro-web/app/components/retry-run-dialog.tsx diff --git a/apps/fabro-web/app/components/retry-run-dialog.tsx b/apps/fabro-web/app/components/retry-run-dialog.tsx new file mode 100644 index 000000000..ee2a33769 --- /dev/null +++ b/apps/fabro-web/app/components/retry-run-dialog.tsx @@ -0,0 +1,64 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router"; +import { useSWRConfig } from "swr"; + +import { mutateRunListCaches } from "../lib/board-cache"; +import { queryKeys } from "../lib/query-keys"; +import { mapError, retryRun } from "../lib/run-actions"; +import { ConfirmDialog, ErrorMessage } from "./ui"; + +export function RetryRunDialog({ + runId, + onClose, +}: { + runId: string; + onClose: () => void; +}) { + const navigate = useNavigate(); + const { mutate } = useSWRConfig(); + const inFlight = useRef(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function handleRetry() { + // Lock synchronously: two clicks can arrive before React renders pending. + if (inFlight.current) return; + inFlight.current = true; + setPending(true); + setError(null); + try { + const run = await retryRun(runId); + // Retry returns a different run; never replace the original's detail cache. + void mutate(queryKeys.runs.detail(run.id), run, { revalidate: false }); + mutateRunListCaches(mutate); + onClose(); + void navigate(`/runs/${encodeURIComponent(run.id)}`); + } catch (error) { + setError(mapError(error, "retry")); + } finally { + inFlight.current = false; + setPending(false); + } + } + + return ( + +

Retry creates a new run from the saved specification and starts from the beginning.

+ {error && } + + } + confirmLabel="Retry run" + pendingLabel="Creating run…" + tone="primary" + pending={pending} + onConfirm={() => void handleRetry()} + onCancel={() => { + if (!inFlight.current) onClose(); + }} + /> + ); +} diff --git a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx index 717a83fee..e22ab9328 100644 --- a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx +++ b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx @@ -11,6 +11,7 @@ import { canArchive, canCancel, canDelete, + canRetry, canUnarchive, cancellationActionLabel, cancellationSuccessMessage, @@ -24,6 +25,7 @@ import { } from "../../lib/run-actions"; import type { LifecycleAction } from "../../lib/run-actions"; import { useToast } from "../toast"; +import { RetryRunDialog } from "../retry-run-dialog"; import { ConfirmDialog } from "../ui"; const MENU_ITEM_CLASS = @@ -37,12 +39,14 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { const { push } = useToast(); const [pendingAction, setPendingAction] = useState(null); const [optimisticallyCancelled, setOptimisticallyCancelled] = useState(false); + const [retryDialogOpen, setRetryDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [idCopied, setIdCopied] = useState(false); const status = run.lifecycleStatus; const showApprove = run.pendingApproval === true; const showDeny = run.pendingApproval === true; + const showRetry = canRetry(status); const showArchive = canArchive(status); const showUnarchive = canUnarchive(status); const showCancel = canCancel(status); @@ -54,7 +58,7 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { ); const pending = pendingAction !== null || cancellationPending; - const hasLifecycle = showArchive || showUnarchive; + const hasLifecycle = showRetry || showArchive || showUnarchive; const hasDestructive = showDeny || showCancel || showDelete; async function runAction( @@ -160,6 +164,18 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { )} + {showRetry && ( + + + + )} {showArchive && ( diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index fc045cc7e..6e1d9476f 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -11,6 +11,8 @@ import { archiveRun, archiveRuns, canArchive, + canRetry, + retryRun, canApprove, canCancel, canUnarchive, @@ -460,3 +462,32 @@ describe("run lifecycle actions", () => { expect(cancellationActionLabel(false)).toBe("Cancel"); }); }); + + +describe("retryRun", () => { + test("posts to retry and returns the new run", async () => { + const run = { ...makeRun({ kind: "pending" }), id: "new-run" }; + const { requests } = stubGeneratedAxiosOnce({ status: 201, body: run }); + expect(await retryRun("run-1")).toEqual(run); + expect(requests).toEqual([{ url: "/api/v1/runs/run-1/retry", method: "post", data: undefined }]); + }); + + test("preserves API errors for the retry dialog", async () => { + const errors = [{ status: "422", title: "Invalid run", detail: "Saved environment is unavailable." }]; + stubGeneratedAxiosOnce({ status: 422, body: { errors } }); + await expect(retryRun("run-1")).rejects.toEqual({ status: 422, errors }); + }); + + test("eligibility includes all terminal states and excludes archived or unknown states", () => { + for (const status of ["succeeded", "failed", "dead"]) expect(canRetry(status)).toBe(true); + for (const status of ["archived", "submitted", "pending", "runnable", "starting", "running", "paused", "blocked", "unknown", null, undefined]) { + expect(canRetry(status)).toBe(false); + } + }); + + test("maps retry failures with actionable fallback messages", () => { + expect(mapError({ status: 404, errors: [] }, "retry")).toBe("This run no longer exists."); + expect(mapError({ status: 409, errors: [] }, "retry")).toBe("Only terminal, non-archived runs can be retried."); + expect(mapError(new Error("offline"), "retry")).toBe("Couldn't retry the run right now. Try again."); + }); +}); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index 75512c358..c93f23ff9 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -46,6 +46,18 @@ const TERMINAL_RUN_STATUSES = new Set([ "dead", ]); +export async function retryRun(id: string): Promise { + try { + return await apiData(() => runsApi.retryRun(id)); + } catch (error) { + throw lifecycleActionErrorFromError(error); + } +} + +export function canRetry(status: string | null | undefined): boolean { + return isTerminalRunStatus(status); +} + export async function cancelRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "cancel", request); } @@ -197,13 +209,15 @@ export function deleteErrorMessage(error: unknown): string { return "Couldn't delete the run right now. Try again."; } -export function mapError(error: unknown, action: LifecycleAction): string { +export function mapError(error: unknown, action: LifecycleAction | "retry"): string { if (isLifecycleActionError(error)) { if (error.status === 404) { return "This run no longer exists."; } if (error.status === 409) { switch (action) { + case "retry": + return "Only terminal, non-archived runs can be retried."; case "cancel": return "This run can no longer be cancelled."; case "approve": @@ -223,6 +237,8 @@ export function mapError(error: unknown, action: LifecycleAction): string { } switch (action) { + case "retry": + return "Couldn't retry the run right now. Try again."; case "cancel": return "Couldn't cancel the run right now. Try again."; case "approve": diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index add888ba6..5c54733d9 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -20,6 +20,10 @@ let currentRunSummary: any = null; let currentRunState: any = null; let currentQuestions: any[] = []; let currentBoardRuns: any[] = []; +let retryRunApiResult: Promise | null = null; +const retryRunApiMock = mock((_id: string) => + retryRunApiResult ?? Promise.resolve({ data: null }), +); let deleteRunApiResult: Promise | null = null; const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; @@ -110,6 +114,7 @@ mock.module("../lib/api-client", () => ({ requestSignalOptions: () => undefined, runsApi: { deleteRun: deleteRunApiMock, + retryRun: retryRunApiMock, }, ApiError: class ApiError extends Error { readonly status: number; @@ -200,6 +205,8 @@ const { default: RunDetail, resolveDockClearance, } = await import("./run-detail"); +const { RowActionsMenu } = await import("../components/runs-list/row-actions-menu"); +const { toRunWithStatus } = await import("../data/runs"); const { default: Runs } = await import("./runs"); mock.restore(); type LifecycleToastState = import("./run-detail/lifecycle-toasts").LifecycleToastState; @@ -346,6 +353,10 @@ async function renderRunDetailHarness({ const router = createMemoryRouter( [ + { + path: "/row", + element: h(RowActionsMenu, { run: toRunWithStatus(currentRunSummary) }), + }, { path: "/runs", element: h(Runs), @@ -638,7 +649,7 @@ describe("handleLifecycleToastResult", () => { }); }); -describe("RunDetail full-height child routes", () => { +describe("RunDetail and run actions", () => { afterEach(() => { act(() => { for (const renderer of mountedRenderers.splice(0)) { @@ -653,6 +664,8 @@ describe("RunDetail full-height child routes", () => { questionQueryMock.mockClear(); submitAnswerMock.mockClear(); submitAnswerHookMock.mockClear(); + retryRunApiResult = null; + retryRunApiMock.mockClear(); deleteRunApiResult = null; deleteRunApiMock.mockClear(); mutateRunListCachesMock.mockClear(); @@ -660,6 +673,68 @@ describe("RunDetail full-height child routes", () => { delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; }); + for (const surface of ["row", "header"] as const) { + const initialEntry = surface === "row" ? "/row" : "/runs/run_1"; + for (const status of ["submitted", "pending", "runnable", "starting", "running", "paused", "blocked", "succeeded", "failed", "dead", "archived"]) { + test(`${surface} offers Retry only for terminal, non-archived runs: ${status}`, async () => { + const { renderer } = await renderRunDetailHarness({ initialEntry, status }); + expect(findButtonsByText(renderer, "Retry")).toHaveLength( + ["succeeded", "failed", "dead"].includes(status) ? 1 : 0, + ); + if (["succeeded", "failed", "dead"].includes(status)) { + expect(findButtonByText(renderer, "Archive")).toBeDefined(); + } + }); + } + + test(`${surface} retries once while pending and opens the returned new run`, async () => { + const response = deferred(); + retryRunApiResult = response.promise; + const { renderer, router } = await renderRunDetailHarness({ initialEntry, status: "failed" }); + await act(async () => { findButtonByText(renderer, "Retry")!.props.onClick(); }); + expect(textFromNode(renderer.toJSON())).toContain( + "Retry creates a new run from the saved specification and starts from the beginning.", + ); + const confirm = findButtonByText(renderer, "Retry run")!; + await act(async () => { + confirm.props.onClick(); + confirm.props.onClick(); + }); + expect(retryRunApiMock).toHaveBeenCalledTimes(1); + expect(retryRunApiMock.mock.calls[0]?.[0]).toBe("run_1"); + expect(findButtonByText(renderer, "Creating run…")!.props.disabled).toBe(true); + expect(findButtonsByText(renderer, "Cancel").at(-1)!.props.disabled).toBe(true); + expect(router.state.location.pathname).toBe(initialEntry); + const newRun = { ...makeRunSummary({ status: "pending" }), id: "new-run" }; + await act(async () => { response.resolve({ data: newRun }); }); + expect(router.state.location.pathname).toBe("/runs/new-run"); + expect(mutateRunListCachesMock).toHaveBeenCalledTimes(1); + expect(swrMutateMock.mock.calls).toContainEqual([ + ["runs", "detail", "new-run"], newRun, { revalidate: false }, + ]); + expect(swrMutateMock.mock.calls.some(([key]) => + JSON.stringify(key) === JSON.stringify(["runs", "detail", "run_1"]), + )).toBe(false); + }); + + test(`${surface} shows retry errors, stays on the source run, and allows recovery`, async () => { + const response = deferred(); + retryRunApiResult = response.promise; + const { renderer, router } = await renderRunDetailHarness({ initialEntry, status: "succeeded" }); + await act(async () => { findButtonByText(renderer, "Retry")!.props.onClick(); }); + await act(async () => { findButtonByText(renderer, "Retry run")!.props.onClick(); }); + await act(async () => { response.reject({ status: 422, errors: [{ detail: "Saved environment is unavailable." }] }); }); + expect(textFromTestNode(renderer.root.findByProps({ role: "alert" }))).toBe("Saved environment is unavailable."); + expect(router.state.location.pathname).toBe(initialEntry); + expect(mutateRunListCachesMock).not.toHaveBeenCalled(); + expect(findButtonByText(renderer, "Retry run")!.props.disabled).toBe(false); + retryRunApiResult = Promise.resolve({ data: { ...makeRunSummary(), id: "recovered-run" } }); + await act(async () => { findButtonByText(renderer, "Retry run")!.props.onClick(); }); + expect(retryRunApiMock).toHaveBeenCalledTimes(2); + expect(router.state.location.pathname).toBe("/runs/recovered-run"); + }); + } + test("Answer Question on a blocked board card opens that run's pending interview", async () => { currentBoardRuns = [ { ...makeRunSummary(), id: "other-run" }, diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 323e5b5ff..4ebf6ed03 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -12,6 +12,7 @@ import { import { AgentSessionActivity } from "@qltysh/fabro-api-client"; import { type SteerBarHandle } from "../components/steer-bar"; +import { RetryRunDialog } from "../components/retry-run-dialog"; import { ErrorState } from "../components/state"; import { useToast } from "../components/toast"; import { @@ -114,6 +115,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { const interruptMutation = useInterruptRun(params.id); const navigate = useNavigate(); const { mutate } = useSWRConfig(); + const [retryDialogOpen, setRetryDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletePending, setDeletePending] = useState(false); const [dockMeasurement, setDockMeasurement] = @@ -279,6 +281,13 @@ export default function RunDetail({ params }: { params: { id: string } }) { }, ], lifecycle: [ + ...(visibility.showRetry + ? [{ + key: "retry", + label: "Retry", + onSelect: () => setRetryDialogOpen(true), + }] + : []), ...(visibility.showArchive ? [{ key: "archive", @@ -373,6 +382,13 @@ export default function RunDetail({ params }: { params: { id: string } }) { }} /> + {retryDialogOpen && ( + setRetryDialogOpen(false)} + /> + )}