From 2297f34f9db16cecae1d278e2c1d3e812bda7ce4 Mon Sep 17 00:00:00 2001 From: ALIHAN DIKEL Date: Sat, 27 Jun 2026 01:22:34 +0300 Subject: [PATCH 1/3] feat(web): allow retrying any terminal run from the runs list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../runs-list/row-actions-menu.test.tsx | 113 ++++++++++++++++++ .../components/runs-list/row-actions-menu.tsx | 6 +- apps/fabro-web/app/lib/run-actions.test.ts | 8 ++ apps/fabro-web/app/lib/run-actions.ts | 10 ++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/fabro-web/app/components/runs-list/row-actions-menu.test.tsx 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"; } From 8067b18e04afbb26409597254ff61c25bced9eea Mon Sep 17 00:00:00 2001 From: ALIHAN DIKEL Date: Sat, 27 Jun 2026 01:23:51 +0300 Subject: [PATCH 2/3] feat(web): add "Save to Runs" to the playground and fix run manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Save to Runs" button that creates a run from the current playground draft (create-only — no execution) and surfaces server errors inline, alongside the existing Download/Run-for-real actions. Two server-compatibility fixes make playground-built manifests actually accepted at run creation: - Drop `[run.sandbox]` from the generated workflow.toml. The server parses that file into a `RunLayer` with `deny_unknown_fields` and no `sandbox` field, so the section made the whole manifest unparseable (400). Sandbox selection moves to project.toml's `[environments.default]`, the supported home for it. - Clamp the goal-derived run title to the server's 100 code-point limit (counting by code point to match Rust's `chars().count()`), so long goals no longer get rejected. --- .../playground/files/render-toml.test.ts | 13 +- .../playground/files/render-toml.ts | 21 +-- .../app/components/playground/playground.tsx | 2 + .../playground/state/build-manifest.test.ts | 16 ++- .../playground/state/build-manifest.ts | 22 ++- .../ui/save-to-runs-button.test.tsx | 132 ++++++++++++++++++ .../playground/ui/save-to-runs-button.tsx | 112 +++++++++++++++ 7 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 apps/fabro-web/app/components/playground/ui/save-to-runs-button.test.tsx create mode 100644 apps/fabro-web/app/components/playground/ui/save-to-runs-button.tsx 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; + } +} From 2fa4ee9899258f3bfe525ee8905d4c18b39a4cd4 Mon Sep 17 00:00:00 2001 From: ALIHAN DIKEL Date: Sat, 27 Jun 2026 01:24:15 +0300 Subject: [PATCH 3/3] feat(web): start the run when launching "Run for real" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal copy promises to redirect "to its run page when it starts", but `POST /api/v1/runs` only creates the run in `submitted` status and the run page has no start affordance — so launched runs sat `submitted` forever. After creating the run, POST `/api/v1/runs/{id}/start` and only redirect once it has actually started. A start failure is surfaced inline (e.g. 409 "not startable") instead of silently redirecting to a stuck run. --- .../playground/ui/run-for-real-modal.test.tsx | 131 ++++++++++++++++++ .../playground/ui/run-for-real-modal.tsx | 12 ++ 2 files changed, 143 insertions(+) create mode 100644 apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx new file mode 100644 index 0000000000..c7e3983b66 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.test.tsx @@ -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 { + 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( {}} />); + 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( {}} />); + await clickAndSettle(launchButton(tree)); + + expect(tree.root.findByProps({ className: "break-words" }).props.children).toContain( + "Run is not startable", + ); + expect(assigned).toHaveLength(0); + }); +}); diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx index d31c57cae6..a377c600bb 100644 --- a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx @@ -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));