diff --git a/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx b/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx
new file mode 100644
index 000000000..774219f6e
--- /dev/null
+++ b/apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { createElement } from "react";
+import TestRenderer, { act } from "react-test-renderer";
+import { SWRConfig } from "swr";
+
+import { ToastProvider } from "../toast";
+import { setupReactTestEnv } from "../../lib/test-utils";
+import type { RunWithStatus } from "../../data/runs";
+import { RowActionsMenu } from "./row-actions-menu";
+
+// Render Headless UI's Menu primitives inline so menu items are always in the
+// tree regardless of open state — we only care which actions the menu offers
+// for a given run status, not the open/close interaction.
+mock.module("@headlessui/react", () => ({
+ Menu: ({ children }: any) =>
+ createElement("div", null, typeof children === "function" ? children({ open: true }) : children),
+ MenuButton: ({ children, ...props }: any) =>
+ createElement("button", props, typeof children === "function" ? children({ open: true }) : children),
+ MenuItems: ({ children }: any) =>
+ createElement("div", null, typeof children === "function" ? children({ open: true }) : children),
+ MenuItem: ({ children }: any) =>
+ createElement("div", null, typeof children === "function" ? children({ close: () => {}, active: false }) : children),
+ Dialog: ({ open, children }: any) => (open ? createElement("div", { role: "dialog" }, children) : null),
+ DialogPanel: ({ children, ...props }: any) => createElement("div", props, children),
+ DialogTitle: ({ children, ...props }: any) => createElement("h2", props, children),
+}));
+
+let teardownReactEnv: (() => void) | undefined;
+
+function makeRunWithStatus(
+ status: { kind: string; reason?: string },
+ archived = false,
+): RunWithStatus {
+ return {
+ id: "run-1",
+ title: "Fix the build",
+ lifecycleStatus: archived ? "archived" : status.kind,
+ pendingApproval: false,
+ lifecycle: {
+ status,
+ approval: null,
+ pending_control: null,
+ queue_position: null,
+ error: null,
+ archived,
+ archived_at: archived ? "2026-04-20T12:05:00Z" : null,
+ },
+ } as unknown as RunWithStatus;
+}
+
+function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
+ let tree: TestRenderer.ReactTestRenderer | undefined;
+ act(() => {
+ tree = TestRenderer.create(
+ new Map(), dedupingInterval: 0 }}>
+ {node}
+ ,
+ );
+ });
+ return tree!;
+}
+
+function instanceText(instance: TestRenderer.ReactTestInstance): string {
+ const parts: string[] = [];
+ for (const child of instance.children) {
+ if (typeof child === "string") parts.push(child);
+ else parts.push(instanceText(child));
+ }
+ return parts.join("");
+}
+
+function menuItemLabels(tree: TestRenderer.ReactTestRenderer): string[] {
+ return tree.root.findAllByType("button").map((b) => instanceText(b).trim());
+}
+
+describe("RowActionsMenu retry gating", () => {
+ beforeEach(() => {
+ teardownReactEnv = setupReactTestEnv();
+ });
+ afterEach(() => {
+ teardownReactEnv?.();
+ teardownReactEnv = undefined;
+ });
+
+ test("offers Retry for a succeeded run", () => {
+ const tree = render(
+ ,
+ );
+ expect(menuItemLabels(tree)).toContain("Retry");
+ });
+
+ test("still offers Retry for failed and dead runs", () => {
+ const failed = render(
+ ,
+ );
+ expect(menuItemLabels(failed)).toContain("Retry");
+
+ const dead = render();
+ expect(menuItemLabels(dead)).toContain("Retry");
+ });
+
+ test("does not offer Retry for an archived (non-retryable) run", () => {
+ const tree = render(
+ ,
+ );
+ expect(menuItemLabels(tree)).not.toContain("Retry");
+ });
+
+ test("does not offer Retry for a still-running run", () => {
+ const tree = render();
+ expect(menuItemLabels(tree)).not.toContain("Retry");
+ });
+});
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 b1582dbef..05a0917b6 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,
+ canRetryStatus,
canUnarchive,
cancelRun,
deleteRun,
@@ -39,7 +40,10 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) {
const status = run.lifecycleStatus;
const showApprove = run.pendingApproval === true;
const showDeny = run.pendingApproval === true;
- const showRetry = status === "failed" || status === "dead";
+ // Any terminal, non-archived run can be retried (the server re-creates a
+ // fresh run from the stored spec) — including succeeded ones. Row data is the
+ // flattened lifecycle status string, so use the string-form predicate.
+ const showRetry = canRetryStatus(status);
const showArchive = canArchive(status);
const showUnarchive = canUnarchive(status);
const showCancel = canCancel(status);
diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts
index d88fdd3af..46c8d82f3 100644
--- a/apps/fabro-web/app/lib/run-actions.test.ts
+++ b/apps/fabro-web/app/lib/run-actions.test.ts
@@ -14,6 +14,7 @@ import {
canApprove,
canCancel,
canRetry,
+ canRetryStatus,
canUnarchive,
cancelRun,
deleteRuns,
@@ -409,6 +410,13 @@ describe("run lifecycle actions", () => {
expect(canUnarchive("archived")).toBe(true);
expect(canUnarchive("failed")).toBe(false);
+
+ expect(canRetryStatus("succeeded")).toBe(true);
+ expect(canRetryStatus("failed")).toBe(true);
+ expect(canRetryStatus("dead")).toBe(true);
+ expect(canRetryStatus("running")).toBe(false);
+ expect(canRetryStatus("archived")).toBe(false);
+ expect(canRetryStatus(null)).toBe(false);
});
test("approval predicate requires pending status and pending approval state", () => {
diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts
index d51c1210a..f132b4b97 100644
--- a/apps/fabro-web/app/lib/run-actions.ts
+++ b/apps/fabro-web/app/lib/run-actions.ts
@@ -155,6 +155,16 @@ export function canRetry(run: Pick | null | undefined): boolea
return status.kind === "succeeded" || status.kind === "failed" || status.kind === "dead";
}
+/**
+ * String-status form of {@link canRetry} for list rows that only carry the
+ * flattened `lifecycleStatus` (no full `lifecycle` object). A run is retryable
+ * once it reaches a terminal, non-archived state — archived rows surface as
+ * `"archived"`, so they're naturally excluded.
+ */
+export function canRetryStatus(status: string | null | undefined): boolean {
+ return status === "succeeded" || status === "failed" || status === "dead";
+}
+
export function canDelete(status: string | null | undefined): boolean {
return status === "archived";
}