This commit is contained in:
Alihan 2026-08-27 12:00:10 +00:00 committed by GitHub
commit 4d115c17ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 582 additions and 16 deletions

View file

@ -4,7 +4,10 @@ import { createInitialDraft } from "../state/draft";
import { renderProjectToml, renderWorkflowToml } from "./render-toml";
describe("renderWorkflowToml", () => {
test("points the workflow at workflow.fabro and pins sandbox to local", () => {
test("points the workflow at its graph without an unsupported [run.sandbox] section", () => {
// The server's RunLayer parses workflow.toml with deny_unknown_fields and
// has no `sandbox` field, so a `[run.sandbox]` section makes the manifest
// unparseable. Sandbox selection lives in project.toml instead.
expect(renderWorkflowToml(createInitialDraft())).toBe(
[
"_version = 1",
@ -12,16 +15,13 @@ describe("renderWorkflowToml", () => {
"[workflow]",
'graph = "workflow.fabro"',
"",
"[run.sandbox]",
'provider = "local"',
"",
].join("\n"),
);
});
});
describe("renderProjectToml", () => {
test("enables draft PRs by default", () => {
test("enables draft PRs and pins the default environment to the local sandbox", () => {
expect(renderProjectToml(createInitialDraft())).toBe(
[
"_version = 1",
@ -30,6 +30,9 @@ describe("renderProjectToml", () => {
"enabled = true",
"draft = true",
"",
"[environments.default]",
'provider = "local"',
"",
].join("\n"),
);
});

View file

@ -12,9 +12,11 @@ import type { WorkflowDraft } from "../state/draft";
/**
* The contents of `.fabro/workflows/<name>/workflow.toml`.
*
* Points the workflow at its `.fabro` graph and pins the sandbox provider to
* `local` so the downloaded artifact runs against the user's own machine
* without any further setup.
* Points the workflow at its `.fabro` graph. Sandbox selection deliberately
* does NOT live here: the server parses this file into a `RunLayer` with
* `deny_unknown_fields` and no `sandbox` field, so a `[run.sandbox]` section
* makes the whole run manifest unparseable. The local sandbox is pinned at the
* project level instead see `renderProjectToml`.
*/
export function renderWorkflowToml(_draft: WorkflowDraft): string {
return [
@ -23,17 +25,17 @@ export function renderWorkflowToml(_draft: WorkflowDraft): string {
"[workflow]",
'graph = "workflow.fabro"',
"",
"[run.sandbox]",
'provider = "local"',
"",
].join("\n");
}
/**
* The contents of `.fabro/project.toml`.
*
* Mirrors the defaults shown in the explainer: PRs enabled and draft, so
* a successful run opens a draft PR the user can review.
* Mirrors the defaults shown in the explainer: PRs enabled and draft, so a
* successful run opens a draft PR the user can review. Also pins the default
* environment to the `local` sandbox so the workflow runs against the user's
* own machine without any further setup (the supported home for sandbox
* selection, unlike workflow.toml's rejected `[run.sandbox]`).
*/
export function renderProjectToml(_draft: WorkflowDraft): string {
return [
@ -43,5 +45,8 @@ export function renderProjectToml(_draft: WorkflowDraft): string {
"enabled = true",
"draft = true",
"",
"[environments.default]",
'provider = "local"',
"",
].join("\n");
}

View file

@ -14,6 +14,7 @@ import DownloadButton from "./ui/download-button";
import NodeInspector from "./ui/node-inspector";
import ResetButton from "./ui/reset-button";
import RunForRealButton, { type RealRunRedirect } from "./ui/run-for-real-button";
import SaveToRunsButton from "./ui/save-to-runs-button";
import RunTrace from "./ui/run-trace";
import SimulationControls from "./ui/simulation-controls";
import WorkflowHeader from "./ui/workflow-header";
@ -113,6 +114,7 @@ export default function Playground({
<div className="ml-auto flex items-center gap-2">
<ResetButton onReset={handleReset} />
<DownloadButton draft={draft} />
<SaveToRunsButton draft={draft} />
<RunForRealButton draft={draft} redirect={realRunRedirect} />
{!isChatOpen && (
<button

View file

@ -34,7 +34,21 @@ describe("buildRunManifest", () => {
expect(workflow!.source).toContain("digraph");
expect(workflow!.source).toContain("start ->");
expect(workflow!.config?.path).toBe("workflow.toml");
expect(workflow!.config?.source).toContain("[run.sandbox]");
// workflow.toml must stay parseable by the server's RunLayer, which
// rejects the unknown `[run.sandbox]` section.
expect(workflow!.config?.source).not.toContain("[run.sandbox]");
expect(workflow!.config?.source).toContain("[workflow]");
});
test("clamps an over-long goal so the title stays within the server's 100-char limit", () => {
const goal = "a".repeat(150);
const draft = { ...createInitialDraft(), name: "release_notes", goal };
const manifest = buildRunManifest(draft);
expect(manifest.title).toBeDefined();
// Server caps RunManifest.title at 100 Unicode scalar values; count by
// code point (not UTF-16 units) to match its `chars().count()` check.
expect(Array.from(manifest.title!).length).toBeLessThanOrEqual(100);
expect(manifest.title!.endsWith("…")).toBe(true);
});
test("named draft → title and target path use the snake_case name", () => {

View file

@ -22,6 +22,26 @@ import {
*/
const PLAYGROUND_CWD = "/tmp/fabro-playground";
/**
* Server-side cap on `RunManifest.title` (`MAX_RUN_TITLE_CHARS`), counted in
* Unicode scalar values. Titles longer than this are rejected at run creation,
* so we clamp the goal-derived title here to keep "Save to Runs" working for
* long goals.
*/
const MAX_TITLE_CHARS = 100;
/**
* Clamp a candidate title to the server's character limit, counting by code
* point (matching Rust's `chars().count()`) rather than UTF-16 units. When it
* overflows, keep the first `MAX_TITLE_CHARS - 1` code points and append an
* ellipsis so the result is exactly at the limit.
*/
function clampTitle(title: string): string {
const codePoints = Array.from(title);
if (codePoints.length <= MAX_TITLE_CHARS) return title;
return `${codePoints.slice(0, MAX_TITLE_CHARS - 1).join("")}`;
}
/**
* Minimal subset of `RunManifest` the playground needs to send. The
* generated `RunManifest` type from `@qltysh/fabro-api-client` accepts
@ -64,7 +84,7 @@ export function buildRunManifest(draft: WorkflowDraft): PlaygroundRunManifest {
return {
version: 1,
cwd: PLAYGROUND_CWD,
title: draft.goal && draft.goal.length > 0 ? draft.goal : `Playground: ${name}`,
title: draft.goal && draft.goal.length > 0 ? clampTitle(draft.goal) : `Playground: ${name}`,
target: {
path: workflowPath,
},

View file

@ -0,0 +1,131 @@
import { afterEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import RunForRealModal from "./run-for-real-modal";
import type { WorkflowDraft } from "../state/draft";
function withPlan(): WorkflowDraft {
return {
name: "release_notes",
goal: "Generate release notes.",
nodes: [
{ id: "start", label: "Start", shape: "mdiamond" },
{ id: "exit", label: "Exit", shape: "msquare" },
{ id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "exit" },
],
};
}
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(node as TestRenderer.ReactTestRendererJSON);
});
return tree!;
}
type CapturedRequest = { url: string; method?: string };
function stubFetch(
responder: (req: CapturedRequest) => {
ok: boolean;
status: number;
statusText?: string;
body?: unknown;
},
): { requests: CapturedRequest[] } {
const requests: CapturedRequest[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const req: CapturedRequest = { url: String(url), method: init?.method };
requests.push(req);
const res = responder(req);
const payload = res.body ?? null;
return {
ok: res.ok,
status: res.status,
statusText: res.statusText ?? "",
json: async () => payload,
clone: () => ({ json: async () => payload }),
} as unknown as Response;
}) as typeof fetch;
return { requests };
}
const originalFetch = globalThis.fetch;
/** Install a minimal `window` whose `location.assign` records the redirect. */
function stubWindowLocation(): { assigned: string[] } {
const assigned: string[] = [];
const stub = { location: { assign: (url: string) => void assigned.push(url) } };
Object.defineProperty(globalThis, "window", {
value: stub, writable: true, configurable: true,
});
return { assigned };
}
function launchButton(tree: TestRenderer.ReactTestRenderer) {
return tree.root
.findAll((n) => n.type === "button" && n.props.children === "Run in sandbox")[0]!;
}
async function clickAndSettle(el: TestRenderer.ReactTestInstance): Promise<void> {
await act(async () => {
el.props.onClick();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe("RunForRealModal", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
delete (globalThis as { window?: unknown }).window;
});
test("creates the run, starts it, then redirects to its run page", async () => {
const { assigned } = stubWindowLocation();
const stub = stubFetch((req) =>
req.url.endsWith("/start")
? { ok: true, status: 200 }
: { ok: true, status: 201, body: { id: "run-7" } },
);
const tree = render(<RunForRealModal draft={withPlan()} onClose={() => {}} />);
await clickAndSettle(launchButton(tree));
// create first, then start — both POST, in order.
expect(stub.requests.map((r) => `${r.method?.toUpperCase()} ${r.url}`)).toEqual([
"POST /api/v1/runs",
"POST /api/v1/runs/run-7/start",
]);
// Only redirects once the run has actually been started.
expect(assigned).toEqual(["/runs/run-7"]);
});
test("surfaces a start failure and does not redirect", async () => {
const { assigned } = stubWindowLocation();
stubFetch((req) =>
req.url.endsWith("/start")
? {
ok: false,
status: 409,
statusText: "Conflict",
body: { errors: [{ status: "409", title: "Conflict", detail: "Run is not startable" }] },
}
: { ok: true, status: 201, body: { id: "run-7" } },
);
const tree = render(<RunForRealModal draft={withPlan()} onClose={() => {}} />);
await clickAndSettle(launchButton(tree));
expect(tree.root.findByProps({ className: "break-words" }).props.children).toContain(
"Run is not startable",
);
expect(assigned).toHaveLength(0);
});
});

View file

@ -49,6 +49,18 @@ export default function RunForRealModal({
if (!body.id) {
throw new Error("Server did not return a run id.");
}
// `POST /runs` only creates the run in `submitted` status; "Run for real"
// is a launch action ("…redirecting you to its run page when it starts"),
// so kick off execution before redirecting. Without this the run sits
// `submitted` forever — the run page has no start affordance.
const startResponse = await fetch(`/api/v1/runs/${body.id}/start`, {
method: "POST",
credentials: "same-origin",
});
if (!startResponse.ok) {
const detail = await readErrorDetail(startResponse);
throw new Error(detail ?? `${startResponse.status} ${startResponse.statusText}`);
}
window.location.assign(`/runs/${body.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));

View file

@ -0,0 +1,132 @@
import { afterEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import SaveToRunsButton from "./save-to-runs-button";
import { createInitialDraft, type WorkflowDraft } from "../state/draft";
function withPlan(): WorkflowDraft {
return {
name: "release_notes",
goal: "Generate release notes.",
nodes: [
{ id: "start", label: "Start", shape: "mdiamond" },
{ id: "exit", label: "Exit", shape: "msquare" },
{ id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "exit" },
],
};
}
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(node as TestRenderer.ReactTestRendererJSON);
});
return tree!;
}
type CapturedRequest = { url: string; method?: string; body: unknown };
function stubFetch(
responder: (req: CapturedRequest) => {
ok: boolean;
status: number;
statusText?: string;
body?: unknown;
},
): { requests: CapturedRequest[] } {
const requests: CapturedRequest[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const req: CapturedRequest = {
url: String(url),
method: init?.method,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
requests.push(req);
const res = responder(req);
const payload = res.body ?? null;
return {
ok: res.ok,
status: res.status,
statusText: res.statusText ?? "",
json: async () => payload,
clone: () => ({ json: async () => payload }),
} as unknown as Response;
}) as typeof fetch;
return { requests };
}
const originalFetch = globalThis.fetch;
function findButton(tree: TestRenderer.ReactTestRenderer) {
return tree.root.findByProps({ "aria-label": "Save to Runs" });
}
async function clickAndSettle(
el: TestRenderer.ReactTestInstance,
): Promise<void> {
await act(async () => {
el.props.onClick();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe("SaveToRunsButton", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("POSTs the manifest to /api/v1/runs and links to the new run without starting it", async () => {
const stub = stubFetch(() => ({
ok: true,
status: 201,
body: { id: "run-9" },
}));
const tree = render(<SaveToRunsButton draft={withPlan()} />);
await clickAndSettle(findButton(tree));
// Exactly one request: create. No /start call (create-only).
expect(stub.requests).toHaveLength(1);
expect(stub.requests[0]!.url).toBe("/api/v1/runs");
expect(stub.requests[0]!.method?.toUpperCase()).toBe("POST");
expect(stub.requests.some((r) => r.url.includes("/start"))).toBe(false);
const manifest = stub.requests[0]!.body as {
version: number;
target: { identifier: string };
};
expect(manifest.version).toBe(1);
expect(manifest.target.identifier).toBe("release_notes");
// Success surfaces a link into the runs list for the created run.
expect(tree.root.findByProps({ href: "/runs/run-9" })).toBeDefined();
});
test("is disabled in the welcome state", () => {
const tree = render(<SaveToRunsButton draft={createInitialDraft()} />);
expect(findButton(tree).props.disabled).toBe(true);
});
test("surfaces the server error detail and creates no run link on failure", async () => {
const stub = stubFetch(() => ({
ok: false,
status: 400,
statusText: "Bad Request",
body: { errors: [{ status: "400", title: "Bad Request", detail: "Validation failed" }] },
}));
const tree = render(<SaveToRunsButton draft={withPlan()} />);
await clickAndSettle(findButton(tree));
expect(stub.requests).toHaveLength(1);
const alert = tree.root.findByProps({ role: "alert" });
expect(alert.props.title).toContain("Validation failed");
expect(tree.root.findAllByProps({ href: "/runs/run-9" })).toHaveLength(0);
});
});

View file

@ -0,0 +1,112 @@
import { useState } from "react";
import {
ArchiveBoxArrowDownIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { buildRunManifest } from "../state/build-manifest";
import type { WorkflowDraft } from "../state/draft";
import { isWelcomeState } from "../state/draft";
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved"; runId: string }
| { kind: "error"; message: string };
const BUTTON_CLASS =
"inline-flex items-center gap-1.5 rounded-md bg-sky-500/10 px-3 py-1.5 text-sm font-medium text-sky-200 ring-1 ring-sky-500/30 transition-colors hover:bg-sky-500/20 hover:text-sky-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-sky-500/10 disabled:hover:text-sky-200";
/**
* "Save to Runs" toolbar button.
*
* Submits the finalized playground pipeline to `POST /api/v1/runs`, which
* creates the run in the `submitted` state and lands it in the runs list
* without starting execution (that stays a deliberate, separate action from
* the run page). On success the button morphs into a link into the runs list;
* on failure it surfaces the server's error detail inline.
*
* Disabled in the welcome state saving an empty `start → exit` skeleton is
* pointless.
*/
export default function SaveToRunsButton({ draft }: { draft: WorkflowDraft }) {
const [state, setState] = useState<SaveState>({ kind: "idle" });
const isWelcome = isWelcomeState(draft);
const disabled = isWelcome || state.kind === "saving";
const save = async () => {
setState({ kind: "saving" });
try {
const manifest = buildRunManifest(draft);
const response = await fetch("/api/v1/runs", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
});
if (!response.ok) {
const detail = await readErrorDetail(response);
throw new Error(detail ?? `${response.status} ${response.statusText}`);
}
const body = (await response.json()) as { id?: string };
if (!body.id) {
throw new Error("Server did not return a run id.");
}
setState({ kind: "saved", runId: body.id });
} catch (e) {
setState({ kind: "error", message: e instanceof Error ? e.message : String(e) });
}
};
if (state.kind === "saved") {
return (
<a
href={`/runs/${state.runId}`}
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-3 py-1.5 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30 transition-colors hover:bg-emerald-500/20 hover:text-emerald-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500"
>
<CheckCircleIcon className="size-4" />
Saved view in Runs
</a>
);
}
return (
<div className="flex items-center gap-2">
{state.kind === "error" && (
<span
role="alert"
className="inline-flex max-w-56 items-center gap-1 truncate text-xs text-rose-200"
title={state.message}
>
<ExclamationTriangleIcon className="size-3.5 shrink-0" aria-hidden="true" />
{state.message}
</span>
)}
<button
type="button"
aria-label="Save to Runs"
disabled={disabled}
title={isWelcome ? "Add at least one node first" : undefined}
onClick={save}
className={BUTTON_CLASS}
>
<ArchiveBoxArrowDownIcon className="size-4" />
{state.kind === "saving" ? "Saving…" : "Save to Runs"}
</button>
</div>
);
}
async function readErrorDetail(response: Response): Promise<string | null> {
try {
const body = (await response.clone().json()) as {
errors?: { detail?: string; title?: string }[];
};
const first = body.errors?.[0];
return first?.detail ?? first?.title ?? null;
} catch {
return null;
}
}

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,
cancellationActionLabel,
cancellationSuccessMessage,
@ -44,7 +45,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,
cancellationActionLabel,
cancellationSuccessMessage,
@ -430,6 +431,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

@ -158,6 +158,16 @@ export function isTerminalRunStatus(
return !!status && TERMINAL_RUN_STATUSES.has(status as RunStatus);
}
/**
* 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";
}