diff --git a/apps/fabro-web/app/components/playground/files/render-toml.test.ts b/apps/fabro-web/app/components/playground/files/render-toml.test.ts index 22b1437112..a1599dee2f 100644 --- a/apps/fabro-web/app/components/playground/files/render-toml.test.ts +++ b/apps/fabro-web/app/components/playground/files/render-toml.test.ts @@ -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"), ); }); diff --git a/apps/fabro-web/app/components/playground/files/render-toml.ts b/apps/fabro-web/app/components/playground/files/render-toml.ts index 7218f82e45..f680d107d9 100644 --- a/apps/fabro-web/app/components/playground/files/render-toml.ts +++ b/apps/fabro-web/app/components/playground/files/render-toml.ts @@ -12,9 +12,11 @@ import type { WorkflowDraft } from "../state/draft"; /** * The contents of `.fabro/workflows//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"); } diff --git a/apps/fabro-web/app/components/playground/playground.tsx b/apps/fabro-web/app/components/playground/playground.tsx index 98a6808c17..ea6733670f 100644 --- a/apps/fabro-web/app/components/playground/playground.tsx +++ b/apps/fabro-web/app/components/playground/playground.tsx @@ -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({
+ {!isChatOpen && ( +
+ ); +} + +async function readErrorDetail(response: Response): Promise { + 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; + } +} 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 0000000000..774219f6ec --- /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 b1582dbefc..05a0917b6c 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 d88fdd3af9..46c8d82f3a 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 d51c1210a8..f132b4b97f 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"; }