feat(web): add "Save to Runs" to the playground and fix run manifests

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.
This commit is contained in:
ALIHAN DIKEL 2026-06-27 01:23:51 +03:00
parent 2297f34f9d
commit 8067b18e04
7 changed files with 303 additions and 15 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

@ -35,7 +35,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 identifier 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
@ -65,7 +85,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: {
identifier: name,
path: workflowPath,

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;
}
}