feat(web): allow retrying any terminal run from the runs list

The row actions menu only offered Retry for `failed`/`dead` runs, even
though the server re-creates a fresh run from the stored spec for any
terminal, non-archived run — succeeded ones included.

Add a string-status `canRetryStatus` predicate (mirroring the existing
`canRetry`, which needs the full lifecycle object) for list rows that
carry only the flattened `lifecycleStatus`, and use it to gate the Retry
action. Archived rows surface as `"archived"` and stay excluded.
This commit is contained in:
ALIHAN DIKEL 2026-06-27 01:22:34 +03:00
parent 6529f120de
commit 2297f34f9d
4 changed files with 136 additions and 1 deletions

View file

@ -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(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
<ToastProvider>{node}</ToastProvider>
</SWRConfig>,
);
});
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(
<RowActionsMenu run={makeRunWithStatus({ kind: "succeeded", reason: "completed" })} />,
);
expect(menuItemLabels(tree)).toContain("Retry");
});
test("still offers Retry for failed and dead runs", () => {
const failed = render(
<RowActionsMenu run={makeRunWithStatus({ kind: "failed", reason: "workflow_error" })} />,
);
expect(menuItemLabels(failed)).toContain("Retry");
const dead = render(<RowActionsMenu run={makeRunWithStatus({ kind: "dead" })} />);
expect(menuItemLabels(dead)).toContain("Retry");
});
test("does not offer Retry for an archived (non-retryable) run", () => {
const tree = render(
<RowActionsMenu run={makeRunWithStatus({ kind: "succeeded", reason: "completed" }, true)} />,
);
expect(menuItemLabels(tree)).not.toContain("Retry");
});
test("does not offer Retry for a still-running run", () => {
const tree = render(<RowActionsMenu run={makeRunWithStatus({ kind: "running" })} />);
expect(menuItemLabels(tree)).not.toContain("Retry");
});
});

View file

@ -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);

View file

@ -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", () => {

View file

@ -155,6 +155,16 @@ export function canRetry(run: Pick<Run, "lifecycle"> | 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";
}