From 8067b18e04afbb26409597254ff61c25bced9eea Mon Sep 17 00:00:00 2001 From: ALIHAN DIKEL Date: Sat, 27 Jun 2026 01:23:51 +0300 Subject: [PATCH] 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 22b143711..a1599dee2 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 7218f82e4..f680d107d 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 98a6808c1..ea6733670 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; + } +}