mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
2297f34f9d
commit
8067b18e04
7 changed files with 303 additions and 15 deletions
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue