feat: chat-driven workflow builder at /playground (#450)

## Summary

Adds a new `/playground` route where users build a Fabro workflow by
chatting with Ask Fabro on the right while watching a live canvas
re-render on the left. The workflow can be downloaded as a `.fabro.zip`
or — eventually — launched as a real Fabro run; today the "Run for
real" button POSTs to `/api/v1/runs` and redirects to the resulting
`/runs/{id}` page, with a placeholder project/repo/folder picker.

The feature is built as a standalone component subtree under
`apps/fabro-web/app/components/playground/` with no `AppShell` or
`react-router` dependencies, so it can be re-embedded in other contexts
later by passing `chatEndpoint`, `authMode`, and an optional
`realRunRedirect` prop.

## What changed

**Frontend (`apps/fabro-web/`)**

- New `/playground` route + `<Playground>` component tree.
- Live SVG canvas via `@viz-js/viz` with click-to-inspect (read-only
  node detail panel), pan, zoom, fit-to-window, and a simulated walk
  through the graph driven by a Play button.
- Docked chat sidebar (assistant-ui) wired to the new
  `/api/v1/playground/chat` endpoint, with auto-retry on parse failure
  and a playground-specific tool-call summary that reads
  `Wrote workflow.fabro (N nodes, M edges)`.
- File tabs (`workflow.fabro` / `workflow.toml` / `README.md`),
  `.fabro.zip` download via `fflate`, and a "Run for real" toolbar
  button that POSTs an inline `RunManifest` to `/api/v1/runs`.
- Draft persists across page refreshes via `localStorage`.

**Backend (`lib/crates/fabro-server/`)**

- New `POST /api/v1/playground/chat` SSE endpoint. Server is stateless
  across turns: each request carries the full draft, the server runs
  the LLM with a single `write_workflow_file` tool, streams
  `StreamEvent` frames back, and lets the client own diffing/animating
  the result into the canvas.
- Request-size caps before the LLM call (50 messages, 100 nodes, 200
  edges) so a misbehaving or malicious client can't drag multi-MB
  transcripts through token billing.

**Spec / wire contract**

- OpenAPI: new `playground/chat` operation + four new schemas
  (`CreatePlaygroundChatRequest`, `PlaygroundWorkflowDraft`,
  `PlaygroundWorkflowNode`, `PlaygroundWorkflowEdge`).
- `lib/packages/fabro-api-client` not regenerated yet (the playground
  uses raw `fetch`); reviewers who want the TS client to pick up the
  new types can run `bun run generate` in that package.

## Key design decisions

1. **Single `write_workflow_file` tool, not six per-op tools.** The
   first cut exposed `add_node`/`update_node`/`connect`/etc. as
   discrete tool calls. The model would routinely add nodes without
   wiring them up, leaving the canvas in a broken half-state. Pivoted
   to a single tool that takes the full new `workflow.fabro` content;
   the browser parses the DOT, diffs it against the local draft, and
   animates the resulting reducer ops in. The model only has to "get
   the file right", and the canvas still paints node-by-node thanks
   to the client-side animator.

2. **Stateless server.** Each chat turn POSTs the full current draft;
   nothing is persisted server-side. Keeps the endpoint cheap, makes
   refresh-resumption trivial (browser owns the truth), and means the
   same endpoint can later sit behind a rate-limited anonymous variant
   without growing per-session state.

3. **Standalone component subtree.** `<Playground>` has no
   `AppShell`/router/store dependencies. All cross-cutting concerns
   flow in as props (`chatEndpoint`, `authMode`, `realRunRedirect`).
   This is the structural hook that makes future re-embedding possible
   without a refactor.

4. **Chat is the only mutation path.** Click-to-inspect on the canvas
   is read-only. Bi-directional canvas editing was explicitly cut from
   scope to keep one source of truth for "how the workflow changed."

5. **Inline `RunManifest` instead of temp-dir-then-clone.** The
   playground has no project to run against, so the `Run for real`
   modal builds a `RunManifest` that carries the full DOT and
   `workflow.toml` source inline (`workflows[key].{source, config}`).
   `cwd` is pinned to a fixed `/tmp/fabro-playground` constant — no
   LLM-controlled segment in a filesystem-looking field.

6. **React effects policy compliance.** All `useEffect` calls in
   playground component code go through the existing primitives in
   `app/hooks/effects.ts` (`useDocumentEvent`, `useInterval`) or a
   purpose-named hook (`useCanvasRender`).

## Still outstanding (planned follow-ups)

- [ ] **Actually kicking off the ad-hoc run.** "Run for real" today
      POSTs a manifest with a placeholder project/repo/folder
      fieldset. The intent is to reuse the project-picker pattern
      being introduced on the in-flight automations branch — once
      that pattern lands, the disabled inputs in
      `run-for-real-modal.tsx` become the live surface.
- [ ] **Header link to `/playground`.** No nav entry yet; users have
      to type the URL directly.
- [ ] **Live SSE-driven canvas overlay** via
      `GET /api/v1/runs/{id}/attach` — currently the modal redirects
      to the standard run-view page; the "watch it build on the
      playground canvas" experience comes when the `stage.*` events
      are wired through.
- [ ] **Regenerate `lib/packages/fabro-api-client`** so the new types
      ship to TS consumers.
- [ ] **Smoke test:** end-to-end download → unzip →
      `fabro run <name>` round-trip.
- [ ] **`scripts/build.ts` dist-symlink bug:** `pruneOldBuilds` can
      delete the directory `apps/fabro-web/dist` points at, which
      pins the dev server in 503 "build in progress" forever.
      Workaround documented; the real fix is a separate PR.

## Test plan

- [ ] `cd apps/fabro-web && bun run test app/components/playground/` —
111 tests pass
- [ ] `cd apps/fabro-web && bun run typecheck` — clean
- [ ] `cargo test -p fabro-server playground` — 6 tests pass
- [ ] Visit `/playground`; the canvas renders the welcome `start → ??? →
exit` ghost.
- [ ] Type "build me a release-notes workflow" in chat; nodes/edges
animate in; ack reads `Wrote workflow.fabro (N nodes, M edges)`.
- [ ] Click a node → inspector panel populates; click empty canvas →
deselects.
- [ ] Click `Simulate`; nodes light up `start → ... → exit` along the
resolved path.
- [ ] Click `Download .fabro`; unzip; `cd <unzipped> && fabro run
<name>` runs locally.
- [ ] Click `Run for real` → modal opens → confirm → POST succeeds →
redirected to `/runs/{id}` → run executes.
- [ ] Refresh the page; the draft persists from localStorage.
- [ ] Click `Start over` → `Yes`; canvas resets to welcome state.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-09 11:24:56 -04:00 committed by GitHub
parent eb5d8c53f9
commit d590122531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 6788 additions and 234 deletions

View file

@ -0,0 +1,253 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import type { WorkflowDraft } from "../state/draft";
import { renderCanvasDot } from "./render-canvas";
import type { SimulationState } from "./simulation";
import { useCanvasRender } from "./use-canvas-render";
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
const DEFAULT_ZOOM_INDEX = 3; // 100%
/**
* Read a node's id from its SVG `<title>` element. Graphviz writes the
* node's DOT identifier into a child `<title>` inside each `<g
* class="node">`, which we keep around precisely so click handlers can
* recover the id from a hit-test target.
*/
function nodeIdFromGroup(group: Element): string | null {
const title = group.querySelector(":scope > title");
return title?.textContent?.trim() || null;
}
/** Inline-style selection highlight. We do this via inline styles
* (rather than a CSS class) because Graphviz writes `stroke=...`
* attributes directly on each shape element and CSS classes alone
* can't override them without `!important`. */
const SELECT_STROKE = "rgb(45 212 191)"; // teal-400, matches fabro-web accent
const SELECT_STROKE_WIDTH = "2";
function applySelectionHighlight(svg: SVGSVGElement, selectedId: string | null) {
for (const group of svg.querySelectorAll<SVGGElement>("g.node")) {
const id = nodeIdFromGroup(group);
const isSelected = id !== null && id === selectedId;
group.classList.toggle("is-selected", isSelected);
group.style.cursor = "pointer";
const shapes = group.querySelectorAll<SVGElement>("polygon, ellipse, path");
for (const shape of shapes) {
if (isSelected) {
shape.style.stroke = SELECT_STROKE;
shape.style.strokeWidth = SELECT_STROKE_WIDTH;
shape.style.filter = "drop-shadow(0 0 6px rgb(20 184 166 / 0.45))";
} else {
shape.style.stroke = "";
shape.style.strokeWidth = "";
shape.style.filter = "";
}
}
}
}
/**
* Canvas for the playground. Re-renders whenever the draft changes by piping
* a themed DOT (see `render-canvas`) through `@viz-js/viz` the same
* Graphviz layout engine Fabro uses, so what the user sees here is exactly
* what their downloaded `.fabro` graph will lay out as.
*/
export default function PlaygroundCanvas({
draft,
simulation,
selectedNodeId,
onSelectNode,
}: {
draft: WorkflowDraft;
simulation?: SimulationState;
/** Currently-inspected node id, or null. Drives the SVG highlight. */
selectedNodeId?: string | null;
/** Click-to-inspect callback. Null means the user clicked empty canvas (deselect). */
onSelectNode?: (id: string | null) => void;
}) {
const dot = useMemo(
() => renderCanvasDot(draft, simulation),
[draft, simulation],
);
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragState = useRef<{
startX: number;
startY: number;
startPanX: number;
startPanY: number;
moved: boolean;
} | null>(null);
const zoom = ZOOM_STEPS[zoomIndex]!;
const { svgRef, error } = useCanvasRender(
innerRef,
dot,
selectedNodeId ?? null,
applySelectionHighlight,
);
const onPointerDown = useCallback(
(event: React.PointerEvent) => {
if ((event.target as HTMLElement).closest("button")) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = {
startX: event.clientX,
startY: event.clientY,
startPanX: pan.x,
startPanY: pan.y,
moved: false,
};
},
[pan],
);
const onPointerMove = useCallback((event: React.PointerEvent) => {
const drag = dragState.current;
if (!drag) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true;
}
setPan({
x: drag.startPanX + dx,
y: drag.startPanY + dy,
});
}, []);
const onPointerUp = useCallback(
(event: React.PointerEvent) => {
const drag = dragState.current;
dragState.current = null;
if (!onSelectNode || !drag || drag.moved) return;
// `event.target` is the pointer-capture target (the container div),
// not the element under the cursor. `elementFromPoint` does a fresh
// hit-test that ignores capture.
const hit = document.elementFromPoint(event.clientX, event.clientY);
const group = hit?.closest("g.node");
if (group) {
const id = nodeIdFromGroup(group);
if (id) onSelectNode(id);
return;
}
// Clicked empty canvas — deselect.
onSelectNode(null);
},
[onSelectNode],
);
const fitToWindow = useCallback(() => {
const svg = svgRef.current;
const container = containerRef.current;
if (!svg || !container) return;
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
const padPx = 48;
const containerW = container.clientWidth - padPx;
const containerH = container.clientHeight - padPx;
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
let best = 0;
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
if (ZOOM_STEPS[i]! <= fitPct) {
best = i;
break;
}
}
setZoomIndex(best);
setPan({ x: 0, y: 0 });
}, []);
return (
<div className="relative isolate flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40">
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Fit to window"
aria-label="Fit diagram to window"
onClick={fitToWindow}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
>
<svg
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
className="size-3.5"
aria-hidden="true"
>
<rect
x="1"
y="1"
width="12"
height="12"
rx="1.5"
strokeWidth="1.5"
strokeDasharray="3 2"
/>
</svg>
</button>
</div>
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Zoom out"
aria-label="Zoom out"
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
disabled={zoomIndex === 0}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<MinusIcon className="size-4" />
</button>
<span className="px-1 font-mono text-[11px] tabular-nums text-fg-muted">
{zoom}%
</span>
<button
type="button"
title="Zoom in"
aria-label="Zoom in"
onClick={() =>
setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))
}
disabled={zoomIndex === ZOOM_STEPS.length - 1}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<PlusIcon className="size-4" />
</button>
</div>
</div>
{error ? (
<p className="m-6 text-sm text-coral">{error}</p>
) : (
<div
ref={containerRef}
className="flex flex-1 overflow-hidden p-6"
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
ref={innerRef}
className="m-auto"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`,
transformOrigin: "center center",
}}
>
<p className="text-sm text-fg-muted">Loading canvas&hellip;</p>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "../state/draft";
import { applyToolCalls } from "../state/reducer";
import { renderCanvasDot } from "./render-canvas";
describe("renderCanvasDot — welcome state", () => {
test("includes a ghost placeholder between start and exit", () => {
const dot = renderCanvasDot(createInitialDraft());
expect(dot).toContain("__ghost__");
expect(dot).toContain("start -> __ghost__");
expect(dot).toContain("__ghost__ -> exit");
expect(dot).toContain("your workflow goes here");
});
test("does not include the user's start -> exit fallback edge", () => {
const dot = renderCanvasDot(createInitialDraft());
// We render start -> ghost -> exit instead — the implicit start->exit
// edge in the welcome draft is suppressed in the canvas view.
expect(dot).not.toMatch(/^\s*start -> exit\s*$/m);
});
});
describe("renderCanvasDot — populated draft", () => {
test("drops the ghost the moment a user node lands", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
},
]);
const dot = renderCanvasDot(draft);
expect(dot).not.toContain("__ghost__");
expect(dot).toContain("plan ");
});
test("renders user-added nodes and edges with theme attrs around them", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "set_workflow_meta",
args: { name: "release_notes", goal: "Generate release notes" },
},
{
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
},
{ name: "connect", args: { from: "start", to: "plan" } },
{ name: "connect", args: { from: "plan", to: "exit" } },
]);
const dot = renderCanvasDot(draft);
expect(dot).toContain('graph [goal="Generate release notes"]');
expect(dot).toContain('shape=box, label="Plan"');
expect(dot).toContain("start -> plan");
expect(dot).toContain("plan -> exit");
// Theme bits we inject so the canvas matches fabro-web styling.
expect(dot).toContain('bgcolor="transparent"');
expect(dot).toContain('node [');
expect(dot).toContain('edge [');
});
test("omits prompts from node bodies (they're surfaced in the chat trace)", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: {
id: "plan",
label: "Plan",
shape: "box",
prompt: "Long winded prompt that would clutter the canvas",
},
},
]);
expect(renderCanvasDot(draft)).not.toContain("Long winded prompt");
});
test("renders edge labels and conditions", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: { id: "gate", label: "Pass?", shape: "diamond" },
},
{
name: "connect",
args: {
from: "gate",
to: "exit",
label: "Yes",
condition: "outcome=succeeded",
},
},
]);
const dot = renderCanvasDot(draft);
expect(dot).toContain('label="Yes"');
expect(dot).toContain('condition="outcome=succeeded"');
});
});

View file

@ -0,0 +1,170 @@
/**
* Canvas-flavoured DOT renderer.
*
* Layers on top of `files/render-fabro.ts` to:
*
* 1. Inject the playground's graph theme (`graphTheme` from `lib/graph-theme`)
* so the rendered SVG matches the rest of fabro-web rather than
* Graphviz's defaults.
* 2. In the welcome state (`start → exit` with no user nodes), splice in a
* dashed ghost `???` node so the canvas doesn't look like an empty page.
*
* The canvas-only decorations live here so the download artifact
* (`render-fabro.ts`) stays a clean, vendor-neutral `.fabro` file with no
* theme attributes baked in.
*/
import { graphTheme } from "../../../lib/graph-theme";
import { isWelcomeState, type WorkflowDraft } from "../state/draft";
import type { SimulationState } from "./simulation";
const GHOST_ID = "__ghost__";
/** Highlight overlay derived from the live simulation state. */
function simulationOverlay(node: string, sim?: SimulationState): string | null {
if (!sim) return null;
if (sim.active === node) {
return ` ${node} [fillcolor="${graphTheme.runningFill}", color="${graphTheme.runningBorder}", fontcolor="${graphTheme.runningText}", penwidth=2]`;
}
if (sim.done.includes(node)) {
return ` ${node} [fillcolor="${graphTheme.completedFill}", color="${graphTheme.completedBorder}", fontcolor="${graphTheme.completedText}"]`;
}
return null;
}
/** Defaults injected at the top of the DOT, mirroring `automation-diagram`. */
function styleHeader(): string {
return [
" bgcolor=\"transparent\"",
" pad=0.5",
"",
" node [",
" fontname=\"ui-sans-serif, system-ui\"",
" fontsize=12",
` fontcolor="${graphTheme.nodeText}"`,
` color="${graphTheme.edgeColor}"`,
` fillcolor="${graphTheme.nodeFill}"`,
" style=filled",
" penwidth=1.2",
" ]",
" edge [",
" fontname=\"ui-monospace, monospace\"",
" fontsize=10",
` fontcolor="${graphTheme.fontcolor}"`,
` color="${graphTheme.edgeColor}"`,
" arrowsize=0.7",
" penwidth=1.2",
" ]",
].join("\n");
}
/** Per-shape theming applied to specific node ids. */
function styleNode(id: string, kind: "start" | "exit" | "ghost"): string {
if (kind === "start") {
return ` ${id} [fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]`;
}
if (kind === "exit") {
return ` ${id} [fillcolor="${graphTheme.completedFill}", color="${graphTheme.completedBorder}", fontcolor="${graphTheme.completedText}"]`;
}
// ghost
return ` ${id} [shape=box, label="your workflow goes here", style="dashed,filled", fillcolor="${graphTheme.nodeFill}", color="${graphTheme.fontcolor}", fontcolor="${graphTheme.fontcolor}"]`;
}
/**
* Build the DOT shown in the canvas. Behaviour:
*
* - Welcome state emits a small `start → ghost → exit` flow with the
* ghost styled as a dashed placeholder.
* - Non-welcome state emits the user's actual graph, themed for fabro-web.
*
* The output is deliberately not 1:1 with `render-fabro` themed attrs and
* the welcome ghost are canvas-only concerns and must never leak into the
* downloaded zip.
*/
export function renderCanvasDot(
draft: WorkflowDraft,
sim?: SimulationState,
): string {
const lines: string[] = [];
lines.push("digraph Playground {");
if (draft.goal.length > 0) {
lines.push(` graph [goal="${escapeDot(draft.goal)}"]`);
}
lines.push(" rankdir=LR");
lines.push(styleHeader());
lines.push("");
// Terminals.
lines.push(' start [shape=Mdiamond, label="Start"]');
lines.push(' exit [shape=Msquare, label="Exit"]');
lines.push(styleNode("start", "start"));
lines.push(styleNode("exit", "exit"));
lines.push("");
if (isWelcomeState(draft)) {
// Replace the implicit start → exit with start → ghost → exit so the
// canvas has something to look at on first load.
lines.push(styleNode(GHOST_ID, "ghost"));
lines.push(` start -> ${GHOST_ID} -> exit`);
} else {
for (const node of draft.nodes) {
if (node.id === "start" || node.id === "exit") continue;
lines.push(` ${node.id} [${nodeBody(node)}]`);
}
if (draft.nodes.length > 2) lines.push("");
for (const edge of draft.edges) {
lines.push(` ${edge.from} -> ${edge.to}${edgeBody(edge)}`);
}
}
// Apply simulation overlays last so they win over the base theming.
if (sim) {
lines.push("");
for (const node of draft.nodes) {
const overlay = simulationOverlay(node.id, sim);
if (overlay) lines.push(overlay);
}
}
lines.push("}");
return lines.join("\n");
}
function escapeDot(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function renderAttrValue(value: string | number | boolean): string {
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : '"NaN"';
}
return `"${escapeDot(value)}"`;
}
function nodeBody(node: WorkflowDraft["nodes"][number]): string {
const parts: string[] = [`shape=${node.shape}`];
if (node.label !== undefined) parts.push(`label=${renderAttrValue(node.label)}`);
if (node.attrs) {
for (const [k, v] of Object.entries(node.attrs)) {
// Skip prompts in the canvas — they often run multi-line and clutter
// node bodies. The chat trace already shows what each node is for.
parts.push(`${k}=${renderAttrValue(v)}`);
}
}
return parts.join(", ");
}
function edgeBody(edge: WorkflowDraft["edges"][number]): string {
const parts: string[] = [];
if (edge.label !== undefined) parts.push(`label=${renderAttrValue(edge.label)}`);
if (edge.condition !== undefined) {
parts.push(`condition=${renderAttrValue(edge.condition)}`);
}
if (edge.attrs) {
for (const [k, v] of Object.entries(edge.attrs)) {
parts.push(`${k}=${renderAttrValue(v)}`);
}
}
return parts.length === 0 ? "" : ` [${parts.join(", ")}]`;
}

View file

@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "../state/draft";
import { applyToolCalls } from "../state/reducer";
import {
advance,
initialSimulation,
startSimulation,
type SimulationState,
} from "./simulation";
function walkToCompletion(
state: SimulationState,
draft: ReturnType<typeof createInitialDraft>,
stepLimit = 50,
): SimulationState {
let cur = state;
let now = 0;
while (!cur.finished && stepLimit-- > 0) {
now += 100;
cur = advance(cur, draft, now, 0);
}
return cur;
}
describe("simulation", () => {
test("startSimulation lights up `start` and records a trace entry", () => {
const draft = createInitialDraft();
const state = startSimulation(draft, 0);
expect(state.active).toBe("start");
expect(state.trace).toHaveLength(1);
expect(state.trace[0]).toMatchObject({ nodeId: "start", index: 0 });
});
test("walks linear start -> plan -> exit", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{ name: "add_node", args: { id: "plan", label: "Plan", shape: "box" } },
{ name: "connect", args: { from: "start", to: "plan" } },
{ name: "connect", args: { from: "plan", to: "exit" } },
{ name: "disconnect", args: { from: "start", to: "exit" } },
]);
const end = walkToCompletion(startSimulation(draft, 0), draft);
expect(end.finished).toBe(true);
expect(end.trace.map((s) => s.nodeId)).toEqual(["start", "plan", "exit"]);
});
test("prefers conditional edges on a diamond branch", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: { id: "gate", label: "Pass?", shape: "diamond" },
},
{ name: "add_node", args: { id: "fix", label: "Fix", shape: "box" } },
{ name: "connect", args: { from: "start", to: "gate" } },
// Non-conditional retry edge from gate to fix; conditional happy path
// to exit. Simulator should prefer the conditional one.
{ name: "connect", args: { from: "gate", to: "fix" } },
{ name: "connect", args: { from: "fix", to: "gate" } },
{
name: "connect",
args: { from: "gate", to: "exit", condition: "outcome=ok" },
},
{ name: "disconnect", args: { from: "start", to: "exit" } },
]);
const end = walkToCompletion(startSimulation(draft, 0), draft);
expect(end.finished).toBe(true);
expect(end.trace.map((s) => s.nodeId)).toEqual(["start", "gate", "exit"]);
});
test("honours max_visits on a loop", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: {
id: "impl",
label: "Implement",
shape: "box",
attrs: { max_visits: 2 },
},
},
{
name: "add_node",
args: { id: "test", label: "Test", shape: "parallelogram" },
},
{ name: "connect", args: { from: "start", to: "impl" } },
{ name: "connect", args: { from: "impl", to: "test" } },
{ name: "connect", args: { from: "test", to: "impl", label: "retry" } },
{ name: "connect", args: { from: "test", to: "exit", label: "done" } },
{ name: "disconnect", args: { from: "start", to: "exit" } },
]);
const end = walkToCompletion(startSimulation(draft, 0), draft);
const visits = end.trace.filter((s) => s.nodeId === "impl").length;
expect(visits).toBeLessThanOrEqual(2);
expect(end.finished).toBe(true);
expect(end.trace[end.trace.length - 1]?.nodeId).toBe("exit");
});
test("halts when active is null (no walk in progress)", () => {
const draft = createInitialDraft();
const idle = initialSimulation();
const after = advance(idle, draft, 100, 0);
expect(after).toBe(idle);
});
test("safety cap: pathological cycle still halts", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{ name: "add_node", args: { id: "a", label: "A", shape: "box" } },
{ name: "connect", args: { from: "start", to: "a" } },
{ name: "connect", args: { from: "a", to: "a" } }, // rejected (self-loop)
]);
// Self-loop was rejected, but make a cycle through start as the only path.
const end = walkToCompletion(startSimulation(draft, 0), draft, 200);
expect(end.finished).toBe(true);
});
});

View file

@ -0,0 +1,163 @@
/**
* Pure-frontend simulation of a workflow walk.
*
* Given a draft + a cursor (which node is "active" right now and which have
* been "done"), `nextStep` returns the next node to visit. The semantics
* are intentionally lightweight the goal is to give the canvas something
* to animate, not to faithfully replay every Fabro engine behaviour:
*
* - Diamond / multiple-out branches pick the first outgoing edge with a
* `condition`, falling back to the first non-self-loop edge.
* - Hexagon (human gate) walk through, no pause. Pause UX lives in v2.
* - Loop edges (a node visited more than once via the same edge) take
* the first outgoing edge whose target hasn't hit `max_visits` yet.
* - Cycle break stop after `MAX_TOTAL_STEPS` total visits in case the
* graph has no path to `exit`.
*
* Pure function; the React layer's play-button drives the cadence with
* `setInterval`.
*/
import { EXIT_ID, START_ID, type WorkflowDraft } from "../state/draft";
/** A single recorded step in the simulation trace. */
export interface SimulationStep {
/** Monotonic id within a single run. */
index: number;
/** Node visited at this step. */
nodeId: string;
/** Human-friendly node label, for the RUN TRACE pane. */
label: string;
/** Wall-clock ms since simulation start. */
elapsedMs: number;
}
export interface SimulationState {
/** Node currently lit on the canvas, or `null` if not running. */
active: string | null;
/** Nodes already walked through, used for `is-done` styling + visit counts. */
done: string[];
/** Trace lines for the RUN TRACE pane. */
trace: SimulationStep[];
/** Whether the walk has reached `exit` or otherwise halted. */
finished: boolean;
}
/** Safety cap so a pathological graph can't lock the simulator. */
const MAX_TOTAL_STEPS = 64;
export function initialSimulation(): SimulationState {
return { active: null, done: [], trace: [], finished: false };
}
/** Drop simulation state and re-arm at the start. */
export function resetSimulation(): SimulationState {
return initialSimulation();
}
/**
* Start a fresh run. Returns the state immediately after lighting up
* `start`. Subsequent steps come from `advance`.
*/
export function startSimulation(
draft: WorkflowDraft,
startedAtMs: number,
): SimulationState {
const startNode = draft.nodes.find((n) => n.id === START_ID);
const label = startNode?.label ?? "Start";
return {
active: START_ID,
done: [],
trace: [{ index: 0, nodeId: START_ID, label, elapsedMs: 0 }],
finished: false,
};
}
/**
* Advance one step. Picks the next node from the current `active` node's
* outgoing edges, retires `active` to `done`, lights the next node.
*
* If `active` is `exit`, marks the run finished and returns unchanged.
*/
export function advance(
state: SimulationState,
draft: WorkflowDraft,
nowMs: number,
startedAtMs: number,
): SimulationState {
if (state.finished || state.active == null) return state;
if (state.active === EXIT_ID) {
return { ...state, finished: true };
}
if (state.trace.length >= MAX_TOTAL_STEPS) {
return { ...state, finished: true };
}
const visitCounts = countVisits(state);
const next = pickNext(draft, state.active, visitCounts);
if (next === null) {
return { ...state, finished: true };
}
const nextNode = draft.nodes.find((n) => n.id === next);
const label = nextNode?.label ?? next;
const done = state.done.includes(state.active)
? state.done
: [...state.done, state.active];
return {
active: next,
done,
trace: [
...state.trace,
{
index: state.trace.length,
nodeId: next,
label,
elapsedMs: Math.max(0, nowMs - startedAtMs),
},
],
finished: next === EXIT_ID,
};
}
function countVisits(state: SimulationState): Map<string, number> {
const counts = new Map<string, number>();
for (const step of state.trace) {
counts.set(step.nodeId, (counts.get(step.nodeId) ?? 0) + 1);
}
return counts;
}
function pickNext(
draft: WorkflowDraft,
from: string,
visitCounts: Map<string, number>,
): string | null {
const outgoing = draft.edges.filter((e) => e.from === from && e.to !== from);
if (outgoing.length === 0) return null;
// First try edges with a `condition` (diamond / branch) — they're the
// intentional path the user defined. If none of the outgoing edges has
// a condition, every outgoing edge is a candidate.
const conditional = outgoing.filter((e) => e.condition !== undefined);
const candidates = conditional.length > 0 ? conditional : outgoing;
// Skip any candidate whose target node has been visited at or past its
// declared `max_visits`. This implements the common Fabro pattern
// `impl [max_visits=3]` for bounded retry loops.
for (const edge of candidates) {
if (!isTargetCapped(draft, edge.to, visitCounts)) return edge.to;
}
// Every candidate is past its cap — pick the first one anyway; the
// top-level MAX_TOTAL_STEPS guard will eventually halt.
return candidates[0]?.to ?? null;
}
function isTargetCapped(
draft: WorkflowDraft,
target: string,
visitCounts: Map<string, number>,
): boolean {
const attr = draft.nodes.find((n) => n.id === target)?.attrs?.max_visits;
if (typeof attr !== "number" || !Number.isFinite(attr)) return false;
return (visitCounts.get(target) ?? 0) >= attr;
}

View file

@ -0,0 +1,71 @@
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* Synchronizes a DOM container with a Graphviz-rendered SVG. Pipes the
* supplied DOT string through `@viz-js/viz` (the same layout engine Fabro
* uses on the server) and mounts the resulting `<svg>` into `containerRef`,
* stripping Graphviz's auto-inserted graph `<title>` so it doesn't surface
* as a browser tooltip. Re-renders whenever `dot` changes; re-applies the
* selection highlight on `selectedNodeId` change without paying the layout
* cost again.
*
* Returns the current `<svg>` element via a ref (handy for fit-to-window
* measurements) and an error string when rendering fails.
*/
export function useCanvasRender(
containerRef: RefObject<HTMLDivElement | null>,
dot: string,
selectedNodeId: string | null,
applyHighlight: (svg: SVGSVGElement, selectedId: string | null) => void,
): {
svgRef: RefObject<SVGSVGElement | null>;
error: string | null;
} {
const svgRef = useRef<SVGSVGElement | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { instance } = await import("@viz-js/viz");
const viz = await instance();
if (cancelled) return;
const svg = viz.renderSVGElement(dot);
stripGraphTitle(svg);
svgRef.current = svg;
if (containerRef.current) {
containerRef.current.replaceChildren(svg);
}
applyHighlight(svg, selectedNodeId);
setError(null);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to render canvas");
}
}
})();
return () => {
cancelled = true;
};
}, [dot]);
useEffect(() => {
const svg = svgRef.current;
if (svg) applyHighlight(svg, selectedNodeId);
}, [selectedNodeId, applyHighlight]);
return { svgRef, error };
}
function stripGraphTitle(svg: SVGSVGElement) {
const title = svg.querySelector(".graph > title");
if (!title) return;
let sibling = title.nextElementSibling;
while (sibling && sibling.tagName === "text") {
const next = sibling.nextElementSibling;
sibling.remove();
sibling = next;
}
title.remove();
}

View file

@ -0,0 +1,79 @@
/**
* React hook that drives a workflow simulation at a fixed cadence.
*
* Pure simulation math lives in `./simulation`; this hook handles the
* `setInterval` plumbing, exposes Play / Reset actions, and re-arms the
* cursor when the underlying draft changes (so a paused or finished run
* doesn't paint stale node highlights when the user keeps editing).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { isWelcomeState, type WorkflowDraft } from "../state/draft";
import {
advance,
initialSimulation,
startSimulation,
type SimulationState,
} from "./simulation";
export const DEFAULT_STEP_MS = 1200;
export const MIN_STEP_MS = 500;
export const MAX_STEP_MS = 3000;
export interface PlaygroundSimulation {
state: SimulationState;
isRunning: boolean;
isPlayable: boolean;
stepMs: number;
setStepMs: (ms: number) => void;
play: () => void;
reset: () => void;
}
export function useSimulation(draft: WorkflowDraft): PlaygroundSimulation {
const [state, setState] = useState<SimulationState>(initialSimulation);
const [stepMs, setStepMs] = useState(DEFAULT_STEP_MS);
const isPlayable = !isWelcomeState(draft);
const draftRef = useRef(draft);
draftRef.current = draft;
const startedAtRef = useRef(0);
const reset = useCallback(() => setState(initialSimulation()), []);
const play = useCallback(() => {
if (!isPlayable) return;
startedAtRef.current = performance.now();
setState(startSimulation(draftRef.current, startedAtRef.current));
}, [isPlayable]);
const isRunning = state.active !== null && !state.finished;
// Reset whenever the draft mutates underneath a paused/finished run, so
// we never display node highlights on a graph that no longer matches the
// last walked path.
useEffect(() => {
setState(initialSimulation());
}, [draft]);
useEffect(() => {
if (!isRunning) return undefined;
const id = window.setInterval(() => {
setState((prev) =>
advance(prev, draftRef.current, performance.now(), startedAtRef.current),
);
}, stepMs);
return () => window.clearInterval(id);
}, [isRunning, stepMs]);
return {
state,
isRunning,
isPlayable,
stepMs,
setStepMs,
play,
reset,
};
}

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "../state/draft";
import { renderFabro } from "../files/render-fabro";
import { createPlaygroundAdapter } from "./runtime";
type AdapterRunInput = Parameters<
ReturnType<typeof createPlaygroundAdapter>["run"]
>[0];
function runInput(text: string): AdapterRunInput {
return {
messages: [
{
role: "user",
content: [{ type: "text", text }],
},
],
abortSignal: new AbortController().signal,
} as unknown as AdapterRunInput;
}
async function drain(iter: AsyncGenerator<unknown>): Promise<void> {
// eslint-disable-next-line no-empty
for await (const _ of iter) {
}
}
describe("createPlaygroundAdapter request body", () => {
test("posts the rendered workflow.fabro under workflow_fabro", async () => {
const draft = createInitialDraft();
let captured: { url: string; body: unknown } | null = null;
const adapter = createPlaygroundAdapter({
chatEndpoint: "/api/v1/playground/chat",
getWorkflow: () => draft,
dispatch: () => {},
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
captured = {
url: String(url),
body: JSON.parse(String(init?.body)),
};
return new Response("", {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}) as typeof fetch,
});
await drain(adapter.run(runInput("build me a workflow")));
expect(captured).not.toBeNull();
const { url, body } = captured! as {
url: string;
body: {
workflow_fabro: string;
workflow?: unknown;
messages: unknown[];
};
};
expect(url).toBe("/api/v1/playground/chat");
expect(body.workflow_fabro).toBe(renderFabro(draft));
expect(body.workflow_fabro).toContain("digraph Untitled");
// The structured draft no longer rides in the request.
expect(body.workflow).toBeUndefined();
expect(body.messages).toHaveLength(1);
});
});

View file

@ -0,0 +1,361 @@
/**
* assistant-ui adapter for the playground chat.
*
* Posts the rendered `workflow.fabro` contents alongside the message
* history to `POST /api/v1/playground/chat` on each turn (the server is
* stateless and embeds the file verbatim in its system prompt), then
* streams the resulting SSE: text deltas accumulate into the assistant
* transcript, and the model's `write_workflow_file` tool call carries
* the full new `workflow.fabro` content. We parse the content, diff it
* against the current draft, and animate the resulting reducer ops into
* the canvas so the user sees the new graph build in node-by-node
* instead of replacing instantly.
*/
import type {
ChatModelAdapter,
ChatModelRunResult,
ThreadAssistantMessagePart,
} from "@assistant-ui/react";
import type { WorkflowDraft } from "../state/draft";
import { renderFabro } from "../files/render-fabro";
import { animateOps } from "../state/animate";
import { diffDrafts } from "../state/diff";
import { parseFabro } from "../state/parse-fabro";
import type { ToolCall } from "../state/reducer";
type AdapterMessage = Parameters<ChatModelAdapter["run"]>[0]["messages"][number];
type StreamEvent =
| { type: "stream_start" }
| { type: "text_delta"; delta: string; text_id?: string | null }
| { type: "tool_call_end"; tool_call: WireToolCall }
| { type: "finish" }
| { type: "error"; error: unknown };
interface WireToolCall {
id: string;
name: string;
arguments: Record<string, unknown> | string;
}
interface WriteWorkflowFileArgs {
file_name?: string;
content?: string;
}
export interface PlaygroundAdapterOptions {
chatEndpoint: string;
/**
* Reads the latest draft. Rendered to `workflow.fabro` text for the
* request body, and read again per `write_workflow_file` to compute
* the diff.
*/
getWorkflow: () => WorkflowDraft;
/** Apply a single reducer op. Called repeatedly as the animation runs. */
dispatch: (call: ToolCall) => void;
/**
* Called when the model's emitted DOT cannot be parsed. The caller is
* expected to inform the user and optionally submit a synthetic
* follow-up turn asking the model to re-emit a valid file.
*/
onParseFailure?: (info: { message: string; rawContent: string }) => void;
/** Called when the model's DOT parses successfully — handy for resetting auto-retry counters. */
onParseSuccess?: () => void;
/** Milliseconds between animation steps. Default 220ms. */
stepDelayMs?: number;
/** Override fetch for tests. */
fetchImpl?: typeof fetch;
}
export function createPlaygroundAdapter(
options: PlaygroundAdapterOptions,
): ChatModelAdapter {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async *run({ messages, abortSignal }) {
const body = {
messages: serializeMessages(messages),
workflow_fabro: renderFabro(options.getWorkflow()),
};
const response = await fetchImpl(options.chatEndpoint, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: abortSignal,
});
if (!response.ok) {
throw new Error(
`playground chat failed: ${response.status} ${response.statusText}`,
);
}
const parts: ThreadAssistantMessagePart[] = [];
let activeTextIndex: number | null = null;
const snapshot = (): ChatModelRunResult => ({ content: parts.slice() });
const reader = response.body?.getReader();
if (!reader) {
yield snapshot();
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
// react-doctor-disable-next-line react-doctor/async-await-in-loop -- SSE chunks must be drained sequentially to preserve event order.
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let cursor = 0;
while (true) {
const match = /\r?\n\r?\n/g.exec(buffer.slice(cursor));
if (!match) break;
const next = cursor + match.index;
const frame = buffer.slice(cursor, next);
cursor = next + match[0].length;
const event = parseFrame(frame);
if (!event) continue;
if (event.type === "text_delta") {
const delta = event.delta ?? "";
if (!delta) continue;
if (activeTextIndex === null) {
parts.push({ type: "text", text: delta });
activeTextIndex = parts.length - 1;
} else {
const part = parts[activeTextIndex];
if (part && part.type === "text") {
parts[activeTextIndex] = { ...part, text: part.text + delta };
}
}
yield snapshot();
} else if (event.type === "tool_call_end") {
const handled = handleToolCallEnd(event.tool_call, options);
parts.push({
type: "tool-call",
toolCallId: event.tool_call.id,
toolName: event.tool_call.name,
args: handled.args as never,
argsText: JSON.stringify(handled.args),
isError: handled.isError,
});
activeTextIndex = null;
yield snapshot();
} else if (event.type === "error") {
throw new Error(
`playground chat stream error: ${JSON.stringify(event.error)}`,
);
}
}
buffer = buffer.slice(cursor);
}
// Surface a non-empty result even on an empty turn so assistant-ui
// doesn't get stuck waiting for one.
yield snapshot();
},
};
}
interface HandledToolCall {
args: Record<string, unknown>;
isError: boolean;
}
function handleToolCallEnd(
wire: WireToolCall,
options: PlaygroundAdapterOptions,
): HandledToolCall {
const args = parseArgs(wire.arguments);
if (wire.name !== "write_workflow_file") {
// Ignore unrecognised tools — log to console for diagnostic but
// don't crash the turn.
console.warn(`playground: ignoring unknown tool call "${wire.name}"`);
return { args, isError: true };
}
const writeArgs = args as WriteWorkflowFileArgs;
const content = typeof writeArgs.content === "string" ? writeArgs.content : "";
if (!content) {
options.onParseFailure?.({
message: "write_workflow_file emitted with no `content` argument.",
rawContent: "",
});
return { args, isError: true };
}
const parsed = parseFabro(content);
if (parsed.ok === false) {
options.onParseFailure?.({
message: parsed.error,
rawContent: content,
});
return { args, isError: true };
}
options.onParseSuccess?.();
const prev = options.getWorkflow();
const ops = diffDrafts(prev, parsed.draft);
if (ops.length === 0) {
// Model wrote a workflow identical to the current state — nothing
// to animate, just surface the ack.
return { args, isError: false };
}
animateOps(ops, {
dispatch: options.dispatch,
stepDelayMs: options.stepDelayMs,
});
return { args, isError: false };
}
function parseArgs(raw: Record<string, unknown> | string): Record<string, unknown> {
if (typeof raw === "string") {
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
if (raw && typeof raw === "object") return raw;
return {};
}
function parseFrame(frame: string): StreamEvent | null {
const dataLine = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!dataLine) return null;
try {
return JSON.parse(dataLine) as StreamEvent;
} catch {
return null;
}
}
type SerializedPart =
| { kind: "text"; data: string }
| {
kind: "tool_call";
data: {
id: string;
name: string;
type: string;
arguments: Record<string, unknown>;
};
}
| {
kind: "tool_result";
data: {
tool_call_id: string;
content: unknown;
is_error: boolean;
};
};
interface SerializedMessage {
role: "user" | "assistant" | "system";
content: SerializedPart[];
}
/**
* Stateful pass over the assistant-ui message history to produce the
* Anthropic-friendly wire format.
*
* Two non-obvious things this handles:
*
* 1. Assistant turns with `tool-call` parts are serialized as
* proper `kind: "tool_call"` content blocks (carrying id, name,
* arguments) so the model gets to see what it actually wrote
* last turn instead of just the surrounding text.
*
* 2. Anthropic requires every `tool_use` block in an assistant
* message to be matched by a `tool_result` block in the next
* user message. The playground reducer doesn't surface real
* tool results (everything is pure-write client-side), so we
* synthesize `{ok: true, applied: true}` results and prepend
* them to the next user message's content array.
*/
function serializeMessages(messages: readonly AdapterMessage[]): SerializedMessage[] {
const out: SerializedMessage[] = [];
let pendingToolResults: SerializedPart[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const content: SerializedPart[] = [];
const toolCallIds: string[] = [];
for (const part of msg.content as readonly { type: string; [k: string]: unknown }[]) {
if (
part.type === "text" &&
typeof part.text === "string" &&
part.text.length > 0
) {
content.push({ kind: "text", data: part.text });
} else if (part.type === "tool-call") {
const id = String(part.toolCallId ?? "");
const name = String(part.toolName ?? "");
const rawArgs = part.args;
const args =
rawArgs && typeof rawArgs === "object"
? (rawArgs as Record<string, unknown>)
: {};
content.push({
kind: "tool_call",
data: { id, name, type: "function", arguments: args },
});
toolCallIds.push(id);
}
}
if (content.length === 0) continue;
out.push({ role: "assistant", content });
pendingToolResults = toolCallIds.map((id) => ({
kind: "tool_result",
data: {
tool_call_id: id,
content: { ok: true, applied: true },
is_error: false,
},
}));
continue;
}
if (msg.role === "user") {
const text = extractText(msg);
const content: SerializedPart[] = [...pendingToolResults];
if (text.length > 0) content.push({ kind: "text", data: text });
pendingToolResults = [];
if (content.length === 0) continue;
out.push({ role: "user", content });
continue;
}
// system / fallback
const text = extractText(msg);
if (text.length > 0) {
out.push({ role: msg.role, content: [{ kind: "text", data: text }] });
}
}
return out;
}
function extractText(message: AdapterMessage): string {
const segments: string[] = [];
for (const part of message.content) {
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text);
}
}
return segments.join("\n");
}

View file

@ -0,0 +1,201 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { AssistantRuntime } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
useLocalRuntime,
} from "@assistant-ui/react";
import { Thread, makeMarkdownText } from "@assistant-ui/react-ui";
import { XMarkIcon } from "@heroicons/react/24/outline";
import remarkGfm from "remark-gfm";
import SidebarComposer from "../../chats/sidebar-composer";
import type { WorkflowDraft } from "../state/draft";
import type { ToolCall } from "../state/reducer";
import { createPlaygroundAdapter } from "./runtime";
import PlaygroundToolCallSummary from "./tool-call-summary";
import PlaygroundWelcome from "./welcome";
const MarkdownText = makeMarkdownText({ remarkPlugins: [remarkGfm] });
const SIDEBAR_WIDTH = 420;
const SIDEBAR_MAX_WIDTH = SIDEBAR_WIDTH * 2;
/**
* Playground-flavoured Ask Fabro sidebar. Mirrors `AskFabroSidebar`'s
* look and feel (left-edge drag handle, animated width, stripped composer),
* but talks to `/api/v1/playground/chat` via `createPlaygroundAdapter`
* instead of the session-scoped Ask Fabro runtime.
*
* Each turn the model emits one `write_workflow_file` tool call with the
* full new DOT contents; the adapter parses, diffs against the current
* draft, and animates the resulting reducer ops into the canvas. `dispatch`
* is called once per animated op; `onParseFailure` fires if the model's
* DOT couldn't be parsed.
*/
/** Max consecutive auto-retries when the model's DOT fails to parse. */
const MAX_AUTO_RETRIES = 2;
export default function PlaygroundChatSidebar({
isOpen,
onClose,
chatEndpoint,
getWorkflow,
dispatch,
onParseFailure,
width,
onWidthChange,
}: {
isOpen: boolean;
onClose: () => void;
chatEndpoint: string;
getWorkflow: () => WorkflowDraft;
dispatch: (call: ToolCall) => void;
onParseFailure?: (info: { message: string; rawContent: string }) => void;
width: number;
onWidthChange: (width: number) => void;
}) {
// The runtime is created from the adapter (chicken-and-egg) so we stash
// it in a ref after creation. The adapter's onParseFailure callback
// reads from this ref to call `runtime.thread.append` for auto-retry.
const runtimeRef = useRef<AssistantRuntime | null>(null);
const autoRetriesRef = useRef(0);
const handleParseFailure = useCallback(
(info: { message: string; rawContent: string }) => {
if (
runtimeRef.current !== null &&
autoRetriesRef.current < MAX_AUTO_RETRIES
) {
autoRetriesRef.current++;
runtimeRef.current.thread.append({
role: "user",
content: [
{
type: "text",
text:
`The DOT you wrote couldn't be parsed: ${info.message}. ` +
`Please re-emit a complete \`workflow.fabro\` with valid syntax.`,
},
],
});
}
onParseFailure?.(info);
},
[onParseFailure],
);
const handleParseSuccess = useCallback(() => {
autoRetriesRef.current = 0;
}, []);
// The adapter is referentially-stable across renders because it reads the
// draft via `getWorkflow` on each turn — no need to memoise on draft.
const adapter = useMemo(
() =>
createPlaygroundAdapter({
chatEndpoint,
getWorkflow,
dispatch,
onParseFailure: handleParseFailure,
onParseSuccess: handleParseSuccess,
}),
[chatEndpoint, getWorkflow, dispatch, handleParseFailure, handleParseSuccess],
);
const runtime = useLocalRuntime(adapter);
runtimeRef.current = runtime;
const [isDragging, setIsDragging] = useState(false);
const dragOrigin = useRef<{ x: number; width: number } | null>(null);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragOrigin.current = { x: event.clientX, width };
setIsDragging(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const origin = dragOrigin.current;
if (!origin) return;
const next = origin.width + (origin.x - event.clientX);
onWidthChange(Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_WIDTH, next)));
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragOrigin.current) return;
event.currentTarget.releasePointerCapture(event.pointerId);
dragOrigin.current = null;
setIsDragging(false);
};
return (
<aside
aria-label="Ask Fabro"
aria-hidden={!isOpen}
style={{ width: isOpen ? width : 0 }}
className={`h-full shrink-0 overflow-hidden ${
isDragging
? ""
: "transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
}`}
>
<div
className={`fabro-chat ask-fabro-sidebar relative isolate flex h-full flex-col border-l border-line bg-panel/40 backdrop-blur-sm ${
isDragging ? "select-none" : ""
}`}
style={{ width }}
>
{/* react-doctor-disable-next-line react-doctor/prefer-tag-over-role -- Interactive draggable splitter; <hr> wouldn't convey resize. */}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Ask Fabro panel"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="group absolute inset-y-0 left-0 z-20 w-2 cursor-col-resize touch-none"
>
<span
aria-hidden
className={`absolute inset-y-0 left-0 w-0.5 transition-colors ${
isDragging
? "bg-teal-500"
: "bg-transparent group-hover:bg-teal-500/60"
}`}
/>
</div>
<header className="flex h-12 shrink-0 items-center justify-end px-2">
<button
type="button"
onClick={onClose}
aria-label="Close assistant"
className="inline-flex size-8 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<XMarkIcon className="size-4" />
</button>
</header>
<div className="min-h-0 flex-1">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
components={{
Composer: SidebarComposer,
ThreadWelcome: PlaygroundWelcome,
}}
assistantMessage={{
components: { Text: MarkdownText, ToolFallback: PlaygroundToolCallSummary },
allowCopy: false,
allowReload: false,
allowSpeak: false,
allowFeedbackPositive: false,
allowFeedbackNegative: false,
}}
/>
</AssistantRuntimeProvider>
</div>
</div>
</aside>
);
}
export { SIDEBAR_WIDTH };

View file

@ -0,0 +1,119 @@
import type {
ThreadAssistantMessagePart,
ToolCallMessagePart,
ToolCallMessagePartProps,
} from "@assistant-ui/react";
import { useMessage } from "@assistant-ui/react";
import {
DocumentTextIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { parseFabro } from "../state/parse-fabro";
const EMPTY_PARTS: readonly ThreadAssistantMessagePart[] = [];
/**
* Playground-flavoured tool-call renderer. Each assistant turn emits
* exactly one `write_workflow_file` tool call carrying the full new
* `workflow.fabro` DOT; this renderer parses that content at render
* time and surfaces compact, informative counts
* `Wrote workflow.fabro (6 nodes, 7 edges)` instead of the generic
* "N tool calls" aggregate the Ask Fabro chat uses.
*
* Like `app/components/chats/tool-call-summary.tsx`, this renders once
* per assistant message anchored to the first tool-call part, so the
* whole message contributes one compact line.
*/
export default function PlaygroundToolCallSummary(props: ToolCallMessagePartProps) {
const content = useMessage((message) =>
message.role === "assistant" ? message.content : EMPTY_PARTS,
);
const toolCalls = content.filter(
(part): part is ToolCallMessagePart => part.type === "tool-call",
);
// Anchor render to the first tool-call part so we only emit one line per message.
if (toolCalls[0]?.toolCallId !== props.toolCallId) {
return null;
}
const writeCall = toolCalls.find((tc) => tc.toolName === "write_workflow_file");
const erroredCall = toolCalls.find((tc) => tc.isError);
if (erroredCall) {
return (
<SummaryChip
icon={<ExclamationTriangleIcon className="size-3.5" aria-hidden="true" />}
tone="error"
>
Couldn't apply workflow update
</SummaryChip>
);
}
if (!writeCall) {
// Unknown / unhandled tool — fall back to the bare count so the message
// doesn't disappear entirely.
return <SummaryChip>{toolCalls.length} tool calls</SummaryChip>;
}
const counts = countsFromContent(writeCall.args);
if (!counts) {
return <SummaryChip>Wrote workflow.fabro</SummaryChip>;
}
return (
<SummaryChip
icon={<DocumentTextIcon className="size-3.5" aria-hidden="true" />}
>
Wrote workflow.fabro ({counts.nodes} {pluralize(counts.nodes, "node")},{" "}
{counts.edges} {pluralize(counts.edges, "edge")})
</SummaryChip>
);
}
function SummaryChip({
icon,
tone,
children,
}: {
icon?: React.ReactNode;
tone?: "default" | "error";
children: React.ReactNode;
}) {
const palette =
tone === "error"
? "border-rose-500/30 bg-rose-500/10 text-rose-200"
: "border-line bg-overlay/60 text-fg-muted";
return (
<div
className={`my-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${palette}`}
>
{icon}
<span>{children}</span>
</div>
);
}
function pluralize(n: number, singular: string): string {
return n === 1 ? singular : `${singular}s`;
}
/**
* Extract user-visible node and edge counts from a `write_workflow_file`
* tool call's args. Counts exclude the reserved `start` / `exit`
* terminals so the number matches what the user thinks of as "their
* workflow" a six-node pipeline counts as 6, not 8.
*/
function countsFromContent(args: unknown): { nodes: number; edges: number } | null {
if (!args || typeof args !== "object") return null;
const content = (args as { content?: unknown }).content;
if (typeof content !== "string" || content.length === 0) return null;
const result = parseFabro(content);
if (result.ok === false) return null;
const userNodes = result.draft.nodes.filter(
(n) => n.id !== "start" && n.id !== "exit",
).length;
return { nodes: userNodes, edges: result.draft.edges.length };
}

View file

@ -0,0 +1,84 @@
import { ThreadPrimitive } from "@assistant-ui/react";
import {
ArrowPathIcon,
ClipboardDocumentCheckIcon,
DocumentTextIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/16/solid";
/**
* Suggestion chips on the empty playground thread. Per the spec, clicking
* a chip *fills the composer* rather than auto-sending the user sees
* their prompt go in and can tweak before hitting send.
*
* `ThreadPrimitive.Suggestion` without the `send` prop does exactly that.
*/
const SUGGESTIONS = [
{
Icon: ClipboardDocumentCheckIcon,
heading: "Daily standup summary",
description: "Summarize yesterday's commits into a standup post.",
prompt:
"Build a workflow that pulls yesterday's git commits and summarises them into a Slack-ready daily standup post.",
},
{
Icon: WrenchScrewdriverIcon,
heading: "Lint, test, and open a PR",
description: "Test the diff and ship it as a draft PR if it passes.",
prompt:
"Build a workflow that lints, runs the tests, and opens a draft pull request only if everything passes.",
},
{
Icon: DocumentTextIcon,
heading: "Release notes",
description: "Generate notes from git log between two tags.",
prompt:
"Build a workflow that takes two git tags and produces release notes from the commits between them.",
},
{
Icon: ArrowPathIcon,
heading: "Triage a GitHub issue",
description: "Label, summarise, and assign new issues.",
prompt:
"Build a workflow that takes a fresh GitHub issue and triages it: choose labels, summarise the report, and assign an owner.",
},
];
export default function PlaygroundWelcome() {
return (
<ThreadPrimitive.Empty>
<div className="flex flex-col gap-6 px-4 py-8">
<div>
<h2 className="text-base font-semibold text-fg">
What workflow are you trying to build?
</h2>
<p className="mt-1 text-xs text-fg-3">
Describe it and I&apos;ll sketch the graph on the canvas. Pick one
below to start, or write your own.
</p>
</div>
<ul className="flex flex-col gap-3">
{SUGGESTIONS.map((s) => (
<li key={s.heading}>
<ThreadPrimitive.Suggestion asChild prompt={s.prompt}>
<button
type="button"
className="flex w-full items-start gap-3 rounded-xl bg-panel-alt/60 px-4 py-3.5 text-left ring-1 ring-line transition-colors hover:bg-panel-alt hover:ring-line-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<s.Icon
aria-hidden="true"
className="size-4 h-lh shrink-0 fill-teal-300"
/>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-fg">{s.heading}</p>
<p className="text-xs text-fg-3">{s.description}</p>
</div>
</button>
</ThreadPrimitive.Suggestion>
</li>
))}
</ul>
</div>
</ThreadPrimitive.Empty>
);
}

View file

@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { unzipSync, strFromU8 } from "fflate";
import { createInitialDraft } from "../state/draft";
import { applyToolCalls } from "../state/reducer";
import { buildDownloadBundle, resolveWorkflowName } from "./download";
describe("resolveWorkflowName", () => {
test("returns the fallback when the draft is still 'untitled'", () => {
expect(resolveWorkflowName(createInitialDraft())).toBe(
"playground-workflow",
);
});
test("returns the draft name when it's valid snake_case", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{ name: "set_workflow_meta", args: { name: "release_notes" } },
]);
expect(resolveWorkflowName(draft)).toBe("release_notes");
});
});
describe("buildDownloadBundle", () => {
test("welcome state still produces a runnable artifact", () => {
const bundle = buildDownloadBundle(createInitialDraft());
expect(bundle.workflowName).toBe("playground-workflow");
expect(bundle.zipFilename).toBe("playground-workflow.fabro.zip");
expect(bundle.bytes.length).toBeGreaterThan(0);
const entries = unzipSync(bundle.bytes);
expect(
strFromU8(
entries[".fabro/workflows/playground-workflow/workflow.fabro"]!,
),
).toContain("digraph");
expect(
strFromU8(
entries[".fabro/workflows/playground-workflow/workflow.toml"]!,
),
).toContain('graph = "workflow.fabro"');
expect(strFromU8(entries[".fabro/project.toml"]!)).toContain(
"[run.pull_request]",
);
expect(strFromU8(entries["README.md"]!)).toContain(
"fabro run playground-workflow",
);
});
test("named workflow zip layout uses the snake_case name", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{ name: "set_workflow_meta", args: { name: "release_notes" } },
{
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it" },
},
]);
const bundle = buildDownloadBundle(draft);
expect(bundle.workflowName).toBe("release_notes");
expect(bundle.zipFilename).toBe("release_notes.fabro.zip");
const entries = unzipSync(bundle.bytes);
const expectedPaths = [
".fabro/project.toml",
".fabro/workflows/release_notes/workflow.fabro",
".fabro/workflows/release_notes/workflow.toml",
"README.md",
];
for (const path of expectedPaths) {
expect(entries[path]).toBeDefined();
}
expect(
strFromU8(entries[".fabro/workflows/release_notes/workflow.fabro"]!),
).toContain("plan [shape=box, label=\"Plan\", prompt=\"Plan it\"]");
});
});

View file

@ -0,0 +1,102 @@
/**
* Build the downloadable `.fabro.zip` from a `WorkflowDraft`.
*
* Layout matches the dot-fabro contract drop the unzipped folder into any
* repo and `fabro run <name>` against it locally:
*
* .fabro/
* project.toml
* workflows/<name>/
* workflow.fabro
* workflow.toml
* README.md
*
* `<name>` falls back to `playground-workflow` while the draft is still
* `untitled`, so a user who downloads before the model has named anything
* still gets a runnable artifact.
*/
import { zipSync, strToU8 } from "fflate";
import {
DEFAULT_NAME,
FALLBACK_DOWNLOAD_NAME,
isValidWorkflowName,
type WorkflowDraft,
} from "../state/draft";
import { renderFabro } from "./render-fabro";
import { renderProjectToml, renderWorkflowToml } from "./render-toml";
import { renderReadme } from "./render-readme";
export type DownloadBundle = {
/** Snake_case workflow name used in the zip layout and the filename. */
workflowName: string;
/** Suggested filename for the download (e.g. `release_notes.fabro.zip`). */
zipFilename: string;
/** Zip body as a `Uint8Array`, ready to wrap in a `Blob`. */
bytes: Uint8Array;
};
/**
* Resolve a safe workflow name for the zip layout and filename. Strict
* snake_case names are kept as-is; anything else (including the default
* `"untitled"`) collapses to a stable fallback.
*/
export function resolveWorkflowName(draft: WorkflowDraft): string {
if (draft.name !== DEFAULT_NAME && isValidWorkflowName(draft.name)) {
return draft.name;
}
return FALLBACK_DOWNLOAD_NAME;
}
/**
* Synchronously build the zip. Done on the main thread because the four
* files are tiny (a few KB total) and `zipSync` finishes in microseconds
* the async variant + worker plumbing would dwarf the actual work.
*/
export function buildDownloadBundle(draft: WorkflowDraft): DownloadBundle {
const workflowName = resolveWorkflowName(draft);
const bytes = zipSync({
".fabro": {
"project.toml": strToU8(renderProjectToml(draft)),
workflows: {
[workflowName]: {
"workflow.fabro": strToU8(renderFabro(draft)),
"workflow.toml": strToU8(renderWorkflowToml(draft)),
},
},
},
"README.md": strToU8(renderReadme(draft)),
});
return {
workflowName,
zipFilename: `${workflowName}.fabro.zip`,
bytes,
};
}
/**
* Browser-side: trigger a download for the given bundle by creating a
* one-shot blob URL and clicking a synthetic `<a download>`.
*
* Split from `buildDownloadBundle` so the bundle can be tested without a
* DOM, and so a future server-side or CLI flow can reuse the bytes.
*/
export function triggerDownload(bundle: DownloadBundle): void {
if (typeof window === "undefined") return;
// Copy into a fresh ArrayBuffer so we never hand the underlying SharedArrayBuffer
// (or stale slab) to Blob; defensive but cheap.
const buffer = new Uint8Array(bundle.bytes);
const blob = new Blob([buffer.buffer], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = bundle.zipFilename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
// Give the browser a tick to start the download before revoking the URL.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View file

@ -0,0 +1,164 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "../state/draft";
import { applyToolCalls } from "../state/reducer";
import { renderFabro } from "./render-fabro";
describe("renderFabro", () => {
test("welcome state renders start and exit with the implicit edge", () => {
expect(renderFabro(createInitialDraft())).toBe(
[
"digraph Untitled {",
" rankdir=LR",
"",
' start [shape=Mdiamond, label="Start"]',
' exit [shape=Msquare, label="Exit"]',
"",
" start -> exit",
"}",
"",
].join("\n"),
);
});
test("linear workflow with prompt + goal", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "set_workflow_meta",
args: { name: "release_notes", goal: "Generate release notes" },
},
{
name: "add_node",
args: {
id: "plan",
label: "Plan",
shape: "box",
prompt: "Plan the work.",
},
},
{ name: "connect", args: { from: "start", to: "plan" } },
{ name: "connect", args: { from: "plan", to: "exit" } },
{ name: "disconnect", args: { from: "start", to: "exit" } },
]);
expect(renderFabro(draft)).toBe(
[
"digraph ReleaseNotes {",
' graph [goal="Generate release notes"]',
" rankdir=LR",
"",
' start [shape=Mdiamond, label="Start"]',
' exit [shape=Msquare, label="Exit"]',
"",
' plan [shape=box, label="Plan", prompt="Plan the work."]',
"",
" start -> plan",
" plan -> exit",
"}",
"",
].join("\n"),
);
});
test("branch with diamond + edge labels and conditions", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{ name: "set_workflow_meta", args: { name: "branch_demo" } },
{
name: "add_node",
args: { id: "validate", label: "Validate", shape: "box" },
},
{
name: "add_node",
args: { id: "gate", label: "Tests passing?", shape: "diamond" },
},
{ name: "connect", args: { from: "start", to: "validate" } },
{ name: "connect", args: { from: "validate", to: "gate" } },
{
name: "connect",
args: {
from: "gate",
to: "exit",
condition: "outcome=succeeded",
label: "Yes",
},
},
{
name: "connect",
args: { from: "gate", to: "validate", label: "No" },
},
{ name: "disconnect", args: { from: "start", to: "exit" } },
]);
expect(renderFabro(draft)).toBe(
[
"digraph BranchDemo {",
" rankdir=LR",
"",
' start [shape=Mdiamond, label="Start"]',
' exit [shape=Msquare, label="Exit"]',
"",
' validate [shape=box, label="Validate"]',
' gate [shape=diamond, label="Tests passing?"]',
"",
" start -> validate",
" validate -> gate",
' gate -> exit [label="Yes", condition="outcome=succeeded"]',
' gate -> validate [label="No"]',
"}",
"",
].join("\n"),
);
});
test("node attrs render with their declared types", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: {
id: "implement",
label: "Implement",
shape: "box",
attrs: { max_visits: 3, goal_gate: true, timeout: "900s" },
},
},
]);
const dot = renderFabro(draft);
expect(dot).toContain("max_visits=3");
expect(dot).toContain("goal_gate=true");
expect(dot).toContain('timeout="900s"');
});
test("escapes embedded quotes and backslashes in attribute strings", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: {
id: "tricky",
label: 'Say "hi" \\o/',
shape: "box",
prompt: 'Write: "hello"',
},
},
]);
const dot = renderFabro(draft);
expect(dot).toContain('label="Say \\"hi\\" \\\\o/"');
expect(dot).toContain('prompt="Write: \\"hello\\""');
});
test("preserves literal newlines inside prompt strings", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: {
id: "multiline",
label: "Multi",
shape: "box",
prompt: "line one\nline two",
},
},
]);
expect(renderFabro(draft)).toContain('prompt="line one\nline two"');
});
});

View file

@ -0,0 +1,142 @@
/**
* Render a `WorkflowDraft` to a `.fabro` (Graphviz DOT) document.
*
* Output mirrors the canonical style used in this repo's
* `.fabro/workflows/<name>/workflow.fabro` files: `Mdiamond` / `Msquare` for
* the start / exit terminals (capital M), lowercase names for all other
* shapes, attributes inside a single `[ ... ]` bracket, one edge per line.
*/
import type {
AttrValue,
Edge,
Node,
Shape,
WorkflowDraft,
} from "../state/draft";
/** Map our internal `Shape` literal to the on-disk Graphviz spelling. */
function dotShape(shape: Shape): string {
switch (shape) {
case "mdiamond":
return "Mdiamond";
case "msquare":
return "Msquare";
default:
return shape;
}
}
/** Escape a string for inclusion inside a double-quoted DOT attribute. */
function escapeDot(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/** Render a single attribute value with DOT's quoting rules. */
function renderAttrValue(value: AttrValue): string {
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : '"NaN"';
return `"${escapeDot(value)}"`;
}
function renderAttrs(entries: [string, AttrValue | undefined][]): string {
const present = entries.filter(
(entry): entry is [string, AttrValue] => entry[1] !== undefined,
);
if (present.length === 0) return "";
return present.map(([k, v]) => `${k}=${renderAttrValue(v)}`).join(", ");
}
function renderNode(node: Node): string {
// Order is chosen to match the canonical .fabro/workflows/* style:
// shape first (and unquoted — it's a DOT identifier, not a string),
// then label, then prompt, then user attrs.
const parts: string[] = [`shape=${dotShape(node.shape)}`];
if (node.label !== undefined) parts.push(`label=${renderAttrValue(node.label)}`);
if (node.prompt !== undefined) parts.push(`prompt=${renderAttrValue(node.prompt)}`);
if (node.attrs) {
for (const [k, v] of Object.entries(node.attrs)) {
parts.push(`${k}=${renderAttrValue(v)}`);
}
}
return `${node.id} [${parts.join(", ")}]`;
}
function renderEdge(edge: Edge): string {
const entries: [string, AttrValue | undefined][] = [];
if (edge.label !== undefined) entries.push(["label", edge.label]);
if (edge.condition !== undefined) entries.push(["condition", edge.condition]);
if (edge.attrs) {
for (const [k, v] of Object.entries(edge.attrs)) {
entries.push([k, v]);
}
}
const body = renderAttrs(entries);
const base = `${edge.from} -> ${edge.to}`;
return body.length === 0 ? base : `${base} [${body}]`;
}
/** Convert a snake_case workflow name to a Pascal-case DOT digraph id. */
function pascalCase(snake: string): string {
return snake
.split("_")
.filter((part) => part.length > 0)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join("");
}
/** Pad an array of node lines so attribute lists left-align. */
function alignAfterId(lines: string[]): string[] {
if (lines.length === 0) return lines;
// Each line looks like `id [...]`; align the `[` column.
const widest = lines.reduce((max, line) => {
const idEnd = line.indexOf(" [");
return idEnd > max ? idEnd : max;
}, 0);
return lines.map((line) => {
const idEnd = line.indexOf(" [");
if (idEnd === -1) return line;
const pad = " ".repeat(widest - idEnd);
return line.slice(0, idEnd) + pad + line.slice(idEnd);
});
}
export function renderFabro(draft: WorkflowDraft): string {
const lines: string[] = [];
const digraphName = pascalCase(draft.name) || "Workflow";
lines.push(`digraph ${digraphName} {`);
if (draft.goal.length > 0) {
lines.push(` graph [goal="${escapeDot(draft.goal)}"]`);
}
lines.push(" rankdir=LR");
lines.push("");
const terminalLines = draft.nodes
.filter((n) => n.shape === "mdiamond" || n.shape === "msquare")
.map(renderNode);
for (const line of alignAfterId(terminalLines)) {
lines.push(` ${line}`);
}
const otherLines = draft.nodes
.filter((n) => n.shape !== "mdiamond" && n.shape !== "msquare")
.map(renderNode);
if (otherLines.length > 0) {
lines.push("");
for (const line of alignAfterId(otherLines)) {
lines.push(` ${line}`);
}
}
if (draft.edges.length > 0) {
lines.push("");
for (const edge of draft.edges) {
lines.push(` ${renderEdge(edge)}`);
}
}
lines.push("}");
lines.push("");
return lines.join("\n");
}

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "../state/draft";
import { applyToolCalls } from "../state/reducer";
import { renderReadme } from "./render-readme";
describe("renderReadme", () => {
test("uses fallback name and omits goal block for the welcome state", () => {
const md = renderReadme(createInitialDraft());
expect(md).toContain("# Playground workflow");
expect(md).toContain("fabro run playground-workflow");
expect(md).not.toContain(">"); // no goal line
});
test("renders the goal as a markdown quote when present", () => {
const { draft } = applyToolCalls(createInitialDraft(), [
{
name: "set_workflow_meta",
args: { name: "release_notes", goal: "Generate release notes." },
},
]);
const md = renderReadme(draft);
expect(md).toContain("# Release notes");
expect(md).toContain("> Generate release notes.");
expect(md).toContain("fabro run release_notes");
});
test("includes the Fabro blurb and learn-more link", () => {
const md = renderReadme(createInitialDraft());
expect(md).toContain("Fabro turns a Graphviz file");
expect(md).toContain("https://fabro.sh");
});
});

View file

@ -0,0 +1,53 @@
/**
* Render the README that ships in the downloaded zip.
*
* The downloaded artifact is meant to be dropped straight into any repo, so
* the README explains what the user got, how to run it, and what Fabro is
* (in case they're handing the folder to a teammate who hasn't seen Fabro
* yet). No link back to the playground session, by design.
*/
import {
DEFAULT_NAME,
FALLBACK_DOWNLOAD_NAME,
type WorkflowDraft,
} from "../state/draft";
const FABRO_BLURB = [
"Fabro turns a Graphviz file into a runnable AI workflow. The shape of",
"each node picks the handler (agent / shell / human / branch / sub-",
"workflow). Edit the `.fabro/` directory, commit it to git, re-run forever.",
].join("\n");
export function renderReadme(draft: WorkflowDraft): string {
const runName = draft.name === DEFAULT_NAME ? FALLBACK_DOWNLOAD_NAME : draft.name;
const title = humanTitle(runName);
const goalLine = draft.goal.length > 0 ? `> ${draft.goal}\n` : "";
return [
`# ${title}`,
"",
`${goalLine}Generated with the Fabro playground.`,
"",
"## Run it",
"",
"```bash",
`fabro run ${runName}`,
"```",
"",
"## What this is",
"",
FABRO_BLURB,
"",
"Learn more: https://fabro.sh",
"",
].join("\n");
}
/** `release_notes` -> `Release notes`. Used only for the README heading. */
function humanTitle(slug: string): string {
const words = slug.split(/[_-]+/).filter((p) => p.length > 0);
if (words.length === 0) return "Workflow";
const [first, ...rest] = words;
return [first![0]!.toUpperCase() + first!.slice(1), ...rest].join(" ");
}

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
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", () => {
expect(renderWorkflowToml(createInitialDraft())).toBe(
[
"_version = 1",
"",
"[workflow]",
'graph = "workflow.fabro"',
"",
"[run.sandbox]",
'provider = "local"',
"",
].join("\n"),
);
});
});
describe("renderProjectToml", () => {
test("enables draft PRs by default", () => {
expect(renderProjectToml(createInitialDraft())).toBe(
[
"_version = 1",
"",
"[run.pull_request]",
"enabled = true",
"draft = true",
"",
].join("\n"),
);
});
});

View file

@ -0,0 +1,47 @@
/**
* Render the two TOML companion files that ship alongside the `.fabro`
* graph: `workflow.toml` (per-workflow run config) and `project.toml`
* (project-wide defaults).
*
* Both files are largely static at the MVP stage they reflect the
* playground's defaults rather than draft-derived configuration.
*/
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.
*/
export function renderWorkflowToml(_draft: WorkflowDraft): string {
return [
"_version = 1",
"",
"[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.
*/
export function renderProjectToml(_draft: WorkflowDraft): string {
return [
"_version = 1",
"",
"[run.pull_request]",
"enabled = true",
"draft = true",
"",
].join("\n");
}

View file

@ -0,0 +1,178 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { SparklesIcon } from "@heroicons/react/24/solid";
import PlaygroundCanvas from "./canvas/canvas";
import { useSimulation } from "./canvas/use-simulation";
import PlaygroundChatSidebar, {
SIDEBAR_WIDTH,
} from "./chat/sidebar";
import { usePlaygroundDraft } from "./state/persist";
import type { WorkflowDraft } from "./state/draft";
import type { ToolCall } from "./state/reducer";
import FileTabs from "./ui/file-tabs";
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 RunTrace from "./ui/run-trace";
import SimulationControls from "./ui/simulation-controls";
import WorkflowHeader from "./ui/workflow-header";
export type PlaygroundAuthMode = "required" | "anonymous";
export type PlaygroundProps = {
/**
* URL the chat adapter posts each turn against. Externalised so the same
* component tree can re-embed against a public, rate-limited variant of
* the endpoint later.
*/
chatEndpoint: string;
/**
* `required` assume the parent shell has already enforced authentication
* (current fabro-web routes do this via `AppShell`).
* `anonymous` anonymous embed mode for non-authenticated contexts; not
* used by fabro-web today.
*/
authMode: PlaygroundAuthMode;
/**
* Override the "Run for real" button to redirect somewhere instead of
* opening the in-page modal that POSTs to `/api/v1/runs`. Set this in
* embed contexts where the visitor has no project to run against, to
* send them to a CTA URL such as `/download`. When unset, the button
* uses the default in-page launch flow.
*/
realRunRedirect?: RealRunRedirect;
};
/**
* The playground feature surface, deliberately framed as a standalone
* component tree so it can later be re-embedded as a self-contained React
* subtree in other contexts. It must not depend on `AppShell`, react-router
* context, or any of fabro-web's app-wide stores; any cross-cutting concern
* (chat endpoint, auth mode, theme) flows in through props.
*
* Layout mirrors `/ask-fabro`: workspace on the left, a docked chat
* column on the right that drives the canvas via streamed tool calls.
*/
export default function Playground({
chatEndpoint,
authMode: _authMode,
realRunRedirect,
}: PlaygroundProps) {
const { draft, applyCall, reset } = usePlaygroundDraft();
const [isChatOpen, setChatOpen] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_WIDTH);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const sim = useSimulation(draft);
// The selected node still exists in the draft, right? After a write
// we may have deleted it — keep selection consistent.
const selectedNode = useMemo(
() =>
selectedNodeId
? draft.nodes.find((n) => n.id === selectedNodeId) ?? null
: null,
[draft.nodes, selectedNodeId],
);
const handleReset = useCallback(() => {
setSelectedNodeId(null);
reset();
}, [reset]);
// The chat adapter is memoized on (chatEndpoint, getWorkflow, dispatch,
// onParseFailure); we need every callback to be referentially stable
// across draft mutations so the adapter doesn't get rebuilt mid-turn.
// `draftRef` lets `getWorkflow` read the latest draft without becoming a
// dependency.
const draftRef = useRef<WorkflowDraft>(draft);
draftRef.current = draft;
const getWorkflow = useCallback(() => draftRef.current, []);
const dispatch = useCallback(
(call: ToolCall) => applyCall(call),
[applyCall],
);
const onParseFailure = useCallback(
(info: { message: string; rawContent: string }) => {
// For now: log to the dev console. The chat itself already
// surfaces "1 tool call, 1 with an error" via the tool-call
// summary's `isError` flag, which is the user-visible signal
// that something went wrong. A future iteration could auto-
// submit a follow-up turn that nudges the model to re-emit
// valid DOT.
console.warn("[playground] failed to parse workflow file:", info.message);
},
[],
);
return (
<div className="relative isolate -mx-4 -my-6 flex h-[calc(100%+3rem)] sm:-mx-6 lg:-mx-8">
<main className="flex h-full min-h-0 flex-1 flex-col gap-3 p-3">
<header className="flex items-center gap-3 px-2">
<WorkflowHeader draft={draft} />
<div className="ml-auto flex items-center gap-2">
<ResetButton onReset={handleReset} />
<DownloadButton draft={draft} />
<RunForRealButton draft={draft} redirect={realRunRedirect} />
{!isChatOpen && (
<button
type="button"
onClick={() => setChatOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<SparklesIcon className="size-4 text-teal-300" />
Ask Fabro
</button>
)}
</div>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[3fr_2fr] gap-3">
<div className="grid min-h-0 grid-cols-[1fr_320px] gap-3">
<PlaygroundCanvas
draft={draft}
simulation={sim.state}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
/>
<aside className="flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40">
{selectedNode ? (
<NodeInspector
node={selectedNode}
draft={draft}
onClose={() => setSelectedNodeId(null)}
/>
) : (
<>
<div className="flex shrink-0 items-center justify-between border-b border-line px-3 py-2">
<span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted">
Run trace
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<RunTrace state={sim.state} />
</div>
<div className="shrink-0 border-t border-line p-2">
<SimulationControls sim={sim} />
</div>
</>
)}
</aside>
</div>
<FileTabs draft={draft} />
</div>
</main>
<PlaygroundChatSidebar
isOpen={isChatOpen}
onClose={() => setChatOpen(false)}
chatEndpoint={chatEndpoint}
getWorkflow={getWorkflow}
dispatch={dispatch}
onParseFailure={onParseFailure}
width={sidebarWidth}
onWidthChange={setSidebarWidth}
/>
</div>
);
}

View file

@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test";
import { animateOps } from "./animate";
import type { ToolCall } from "./reducer";
interface ScheduledTimer {
fire: () => void;
ms: number;
}
function makeFakeTimers() {
const queue: ScheduledTimer[] = [];
const setTimeoutImpl = (handler: () => void, ms: number) => {
const timer: ScheduledTimer = { fire: handler, ms };
queue.push(timer);
return timer;
};
const clearTimeoutImpl = (handle: unknown) => {
const idx = queue.indexOf(handle as ScheduledTimer);
if (idx >= 0) queue.splice(idx, 1);
};
const advance = () => {
const next = queue.shift();
next?.fire();
};
return { setTimeoutImpl, clearTimeoutImpl, advance, queue };
}
const sampleOps: ToolCall[] = [
{ name: "set_workflow_meta", args: { name: "demo" } },
{
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
},
{ name: "connect", args: { from: "start", to: "plan" } },
];
describe("animateOps", () => {
test("dispatches first op immediately and queues the rest", () => {
const timers = makeFakeTimers();
const dispatched: ToolCall[] = [];
animateOps(sampleOps, {
dispatch: (c) => dispatched.push(c),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
expect(dispatched).toHaveLength(1);
expect(timers.queue).toHaveLength(1);
timers.advance();
expect(dispatched).toHaveLength(2);
timers.advance();
expect(dispatched).toEqual(sampleOps);
});
test("onComplete fires after the last op", () => {
const timers = makeFakeTimers();
let completed = false;
animateOps(sampleOps, {
dispatch: () => {},
onComplete: () => {
completed = true;
},
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
expect(completed).toBe(false);
timers.advance();
expect(completed).toBe(false);
timers.advance();
expect(completed).toBe(true);
});
test("empty ops list completes synchronously", () => {
const timers = makeFakeTimers();
let completed = false;
animateOps([], {
dispatch: () => {},
onComplete: () => {
completed = true;
},
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
expect(completed).toBe(true);
expect(timers.queue).toHaveLength(0);
});
test("cancel stops further dispatch without applying remaining ops", () => {
const timers = makeFakeTimers();
const dispatched: ToolCall[] = [];
const handle = animateOps(sampleOps, {
dispatch: (c) => dispatched.push(c),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
expect(dispatched).toHaveLength(1);
handle.cancel();
expect(timers.queue).toHaveLength(0);
// Trying to advance a cleared timer is a no-op; queue is empty.
timers.advance();
expect(dispatched).toHaveLength(1);
});
test("finish applies remaining ops immediately and fires onComplete", () => {
const timers = makeFakeTimers();
const dispatched: ToolCall[] = [];
let completed = false;
const handle = animateOps(sampleOps, {
dispatch: (c) => dispatched.push(c),
onComplete: () => {
completed = true;
},
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
expect(dispatched).toHaveLength(1);
handle.finish();
expect(dispatched).toEqual(sampleOps);
expect(completed).toBe(true);
expect(timers.queue).toHaveLength(0);
});
});

View file

@ -0,0 +1,102 @@
/**
* Scheduler that walks a `ToolCall[]` with a delay between each op,
* dispatching to the reducer so the canvas paints in node by node
* instead of replacing the whole graph at once. Used by the chat
* runtime after parsing the model's `write_workflow_file` content and
* diffing it against the current draft.
*
* The animation is purely visual the resulting draft is identical to
* what we'd get by applying the ops in one shot. Skipping or
* cancelling animation is therefore safe: the user just sees the new
* state without the intermediate frames.
*/
import type { ToolCall } from "./reducer";
export interface AnimateOptions {
/** Apply one op. Implementation typically dispatches to the reducer. */
dispatch: (call: ToolCall) => void;
/** Milliseconds between consecutive ops. Default 220ms. */
stepDelayMs?: number;
/** Called once after the last op runs. */
onComplete?: () => void;
/** Test seam: defaults to `globalThis.setTimeout`. */
setTimeoutImpl?: (handler: () => void, ms: number) => unknown;
/** Test seam: defaults to `globalThis.clearTimeout`. */
clearTimeoutImpl?: (handle: unknown) => void;
}
export interface AnimationHandle {
/** Cancel the schedule. Ops already dispatched stay applied. */
cancel: () => void;
/**
* Apply every remaining op immediately and clear the schedule.
* The reducer sees the same final state either way; this just skips
* the visual cadence.
*/
finish: () => void;
}
const DEFAULT_STEP_DELAY_MS = 220;
export function animateOps(ops: ToolCall[], options: AnimateOptions): AnimationHandle {
const stepMs = options.stepDelayMs ?? DEFAULT_STEP_DELAY_MS;
const setT = options.setTimeoutImpl ?? globalThis.setTimeout.bind(globalThis);
const clearT = options.clearTimeoutImpl ?? globalThis.clearTimeout.bind(globalThis);
if (ops.length === 0) {
options.onComplete?.();
return { cancel: noop, finish: noop };
}
let index = 0;
let pendingHandle: unknown = null;
let stopped = false;
const tick = () => {
pendingHandle = null;
if (stopped) return;
const op = ops[index++];
if (!op) {
stopped = true;
options.onComplete?.();
return;
}
options.dispatch(op);
if (index >= ops.length) {
stopped = true;
options.onComplete?.();
return;
}
pendingHandle = setT(tick, stepMs);
};
// First op fires immediately so users get a fast acknowledgement that
// something is happening; subsequent ops are paced.
tick();
return {
cancel: () => {
if (stopped) return;
stopped = true;
if (pendingHandle !== null) {
clearT(pendingHandle);
pendingHandle = null;
}
},
finish: () => {
if (stopped) return;
if (pendingHandle !== null) {
clearT(pendingHandle);
pendingHandle = null;
}
while (index < ops.length) {
const op = ops[index++];
if (op) options.dispatch(op);
}
stopped = true;
options.onComplete?.();
},
};
}
function noop() {}

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft } from "./draft";
import { buildRunManifest, resolveWorkflowName } from "./build-manifest";
describe("resolveWorkflowName", () => {
test("uses the draft name when set and valid", () => {
const draft = { ...createInitialDraft(), name: "release_notes" };
expect(resolveWorkflowName(draft)).toBe("release_notes");
});
test("falls back when the draft is still the default 'untitled'", () => {
expect(resolveWorkflowName(createInitialDraft())).toBe(
"playground_workflow",
);
});
test("falls back for invalid names (snake_case rule)", () => {
const draft = { ...createInitialDraft(), name: "Bad-Name!" };
expect(resolveWorkflowName(draft)).toBe("playground_workflow");
});
});
describe("buildRunManifest", () => {
test("welcome draft → minimal manifest with inline DOT + TOML", () => {
const manifest = buildRunManifest(createInitialDraft());
expect(manifest.version).toBe(1);
expect(manifest.target.identifier).toBe("playground_workflow");
expect(manifest.target.path).toBe(
".fabro/workflows/playground_workflow/workflow.fabro",
);
const workflow =
manifest.workflows[".fabro/workflows/playground_workflow/workflow.fabro"];
expect(workflow).toBeDefined();
expect(workflow!.source).toContain("digraph");
expect(workflow!.source).toContain("start ->");
expect(workflow!.config?.path).toBe("workflow.toml");
expect(workflow!.config?.source).toContain("[run.sandbox]");
});
test("named draft → title and identifier use the snake_case name", () => {
const draft = {
...createInitialDraft(),
name: "release_notes",
goal: "Generate release notes.",
};
const manifest = buildRunManifest(draft);
expect(manifest.target.identifier).toBe("release_notes");
expect(manifest.target.path).toBe(
".fabro/workflows/release_notes/workflow.fabro",
);
expect(manifest.title).toBe("Generate release notes.");
expect(manifest.cwd).toBe("/tmp/fabro-playground");
});
test("title falls back when goal is empty", () => {
const draft = { ...createInitialDraft(), name: "release_notes" };
const manifest = buildRunManifest(draft);
expect(manifest.title).toBe("Playground: release_notes");
});
});

View file

@ -0,0 +1,83 @@
/**
* Build a `RunManifest` from the current `WorkflowDraft`.
*
* Inline-everything style: the manifest carries the full DOT and
* `workflow.toml` source in `workflows[key].{source, config}`, so the
* server doesn't need a temp dir or git commit.
*/
import { renderFabro } from "../files/render-fabro";
import { renderWorkflowToml } from "../files/render-toml";
import {
DEFAULT_NAME,
FALLBACK_DOWNLOAD_NAME,
isValidWorkflowName,
type WorkflowDraft,
} from "./draft";
/**
* `cwd` placeholder. Playground manifests carry no GitHub origin, so the
* sandbox provider creates an empty workspace inside the container and
* never touches this path on the host. We pin it to a fixed string so
* nothing the LLM emits influences a filesystem-looking field.
*/
const PLAYGROUND_CWD = "/tmp/fabro-playground";
/**
* Minimal subset of `RunManifest` the playground needs to send. The
* generated `RunManifest` type from `@qltysh/fabro-api-client` accepts
* the same shape; we keep this lightweight so the playground subtree
* doesn't pick up an extra dep.
*/
export interface PlaygroundRunManifest {
version: 1;
cwd: string;
title?: string;
target: {
identifier: string;
path: string;
};
workflows: {
[path: string]: {
source: string;
config?: {
path: string;
source: string;
};
};
};
}
/**
* Resolve the workflow identifier used in the manifest paths. Mirrors
* the download-zip filename logic so a user who downloaded and a user
* who hit "Run for real" end up with the same artifact name.
*/
export function resolveWorkflowName(draft: WorkflowDraft): string {
if (draft.name && draft.name !== DEFAULT_NAME && isValidWorkflowName(draft.name)) {
return draft.name;
}
return FALLBACK_DOWNLOAD_NAME.replace(/-/g, "_");
}
export function buildRunManifest(draft: WorkflowDraft): PlaygroundRunManifest {
const name = resolveWorkflowName(draft);
const workflowPath = `.fabro/workflows/${name}/workflow.fabro`;
return {
version: 1,
cwd: PLAYGROUND_CWD,
title: draft.goal && draft.goal.length > 0 ? draft.goal : `Playground: ${name}`,
target: {
identifier: name,
path: workflowPath,
},
workflows: {
[workflowPath]: {
source: renderFabro(draft),
config: {
path: "workflow.toml",
source: renderWorkflowToml(draft),
},
},
},
};
}

View file

@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft, type WorkflowDraft } from "./draft";
import { diffDrafts } from "./diff";
import { applyToolCalls } from "./reducer";
function welcome(): WorkflowDraft {
return createInitialDraft();
}
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" },
],
};
}
describe("diffDrafts", () => {
test("identical drafts produce no ops", () => {
expect(diffDrafts(welcome(), welcome())).toEqual([]);
});
test("welcome → with-plan emits meta, disconnect placeholder, add, connects", () => {
const ops = diffDrafts(welcome(), withPlan());
const names = ops.map((o) => o.name);
// meta first, disconnect before delete/add, add before update/connect.
expect(names).toEqual([
"set_workflow_meta",
"disconnect", // start -> exit placeholder is gone in next
"add_node", // plan
"connect", // start -> plan
"connect", // plan -> exit
]);
const meta = ops[0];
expect(meta).toMatchObject({
name: "set_workflow_meta",
args: { name: "release_notes", goal: "Generate release notes." },
});
const addNode = ops[2];
expect(addNode).toMatchObject({
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
});
});
test("only goal changed → single set_workflow_meta with only goal", () => {
const a = withPlan();
const b = { ...withPlan(), goal: "Different goal." };
const ops = diffDrafts(a, b);
expect(ops).toEqual([
{ name: "set_workflow_meta", args: { goal: "Different goal." } },
]);
});
test("node deleted → disconnect any edges first, then delete_node", () => {
const a = withPlan();
const b: WorkflowDraft = {
...welcome(),
name: a.name,
goal: a.goal,
};
const ops = diffDrafts(a, b);
// No meta change. Both edges (start→plan, plan→exit) gone; placeholder
// (start→exit) added; plan deleted.
expect(ops.map((o) => o.name)).toEqual([
"disconnect",
"disconnect",
"delete_node",
"connect",
]);
expect(ops[2]).toMatchObject({ name: "delete_node", args: { id: "plan" } });
});
test("node updated emits update_node with only changed fields", () => {
const a = withPlan();
const b = withPlan();
const planIdx = b.nodes.findIndex((n) => n.id === "plan");
b.nodes[planIdx] = {
...b.nodes[planIdx]!,
label: "Planning",
prompt: "Plan it carefully.",
};
const ops = diffDrafts(a, b);
expect(ops).toEqual([
{
name: "update_node",
args: { id: "plan", label: "Planning", prompt: "Plan it carefully." },
},
]);
});
test("edge attrs change → disconnect + reconnect", () => {
const a = withPlan();
const b = withPlan();
b.edges[0] = { ...b.edges[0]!, condition: "outcome=approved" };
const ops = diffDrafts(a, b);
expect(ops).toEqual([
{ name: "disconnect", args: { from: "start", to: "plan" } },
{
name: "connect",
args: { from: "start", to: "plan", condition: "outcome=approved" },
},
]);
});
test("reserved nodes never get add/delete ops", () => {
const a = welcome();
const b: WorkflowDraft = {
...welcome(),
// Pretend `next` somehow omitted start/exit; we should still not emit
// delete_node for them.
nodes: [],
edges: [],
};
const ops = diffDrafts(a, b);
const names = ops.map((o) => o.name);
expect(names).not.toContain("delete_node");
});
test("diff ops replay cleanly on the reducer", () => {
const a = welcome();
const b = withPlan();
const ops = diffDrafts(a, b);
const replayed = applyToolCalls(a, ops);
expect(replayed.ok).toBe(true);
expect(replayed.draft.name).toBe(b.name);
expect(replayed.draft.goal).toBe(b.goal);
expect(replayed.draft.nodes).toEqual(b.nodes);
expect(replayed.draft.edges).toEqual(b.edges);
});
test("complex pipeline replays cleanly", () => {
const a = welcome();
const b: WorkflowDraft = {
name: "ci",
goal: "Lint, test, then PR.",
nodes: [
{ id: "start", label: "Start", shape: "mdiamond" },
{ id: "exit", label: "Exit", shape: "msquare" },
{ id: "lint", label: "Lint", shape: "parallelogram", prompt: "lint" },
{ id: "test", label: "Test", shape: "parallelogram", prompt: "test" },
{ id: "gate", label: "Passed?", shape: "diamond" },
{ id: "pr", label: "Open PR", shape: "box" },
],
edges: [
{ from: "start", to: "lint" },
{ from: "lint", to: "test" },
{ from: "test", to: "gate" },
{ from: "gate", to: "pr", condition: "outcome=pass" },
{ from: "gate", to: "exit", condition: "outcome=fail" },
{ from: "pr", to: "exit" },
],
};
const ops = diffDrafts(a, b);
const replayed = applyToolCalls(a, ops);
expect(replayed.ok).toBe(true);
expect(replayed.draft.nodes).toEqual(b.nodes);
expect(replayed.draft.edges).toEqual(b.edges);
});
});

View file

@ -0,0 +1,171 @@
/**
* Semantic diff between two `WorkflowDraft`s, producing the same
* `ToolCall` shapes the reducer already takes. The chat endpoint asks
* the model to emit a full new draft each turn; this function turns
* "old state vs new state" back into the granular ops that the
* reducer + canvas already know how to apply.
*
* The op order is chosen so that every intermediate draft state is
* valid for the reducer:
* 1. set_workflow_meta (if name or goal changed)
* 2. disconnect removed or modified edges
* 3. delete_node nodes no longer present (post-disconnect, so no
* dangling edge refs)
* 4. add_node newly introduced nodes (before any new edges touch them)
* 5. update_node nodes whose props changed (label/shape/prompt/attrs)
* 6. connect added or modified edges (now that all endpoints exist)
*
* Reserved ids (`start`, `exit`) are never added/deleted; we trust that
* both drafts agree on them.
*/
import { EXIT_ID, START_ID, type Edge, type Node, type WorkflowDraft } from "./draft";
import type { ToolCall } from "./reducer";
type AddNodeArgs = Extract<ToolCall, { name: "add_node" }>["args"];
type UpdateNodeArgs = Extract<ToolCall, { name: "update_node" }>["args"];
type ConnectArgs = Extract<ToolCall, { name: "connect" }>["args"];
export function diffDrafts(prev: WorkflowDraft, next: WorkflowDraft): ToolCall[] {
const ops: ToolCall[] = [];
// 1. set_workflow_meta — only emit if at least one field changed.
if (prev.name !== next.name || prev.goal !== next.goal) {
const args: { name?: string; goal?: string } = {};
if (prev.name !== next.name) args.name = next.name;
if (prev.goal !== next.goal) args.goal = next.goal;
ops.push({ name: "set_workflow_meta", args });
}
const prevNodesById = new Map(prev.nodes.map((n) => [n.id, n] as const));
const nextNodesById = new Map(next.nodes.map((n) => [n.id, n] as const));
const edgeKey = (e: Edge) => `${e.from}->${e.to}`;
const prevEdgesByKey = new Map(prev.edges.map((e) => [edgeKey(e), e] as const));
const nextEdgesByKey = new Map(next.edges.map((e) => [edgeKey(e), e] as const));
// 2. disconnect — edges that are removed OR whose attributes
// changed. Modified edges get disconnected here and re-emitted in
// step 6 with the new attrs.
for (const [key, edge] of prevEdgesByKey) {
const nextEdge = nextEdgesByKey.get(key);
if (!nextEdge || !edgesEqual(edge, nextEdge)) {
ops.push({
name: "disconnect",
args: { from: edge.from, to: edge.to },
});
}
}
// 3. delete_node — nodes in prev but not in next. Skip reserved ids
// (they're never deleted; if next somehow drops one, the reducer
// would refuse anyway).
for (const [id] of prevNodesById) {
if (id === START_ID || id === EXIT_ID) continue;
if (!nextNodesById.has(id)) {
ops.push({ name: "delete_node", args: { id } });
}
}
// 4. add_node — nodes in next but not in prev. Skip reserved ids.
for (const node of next.nodes) {
if (node.id === START_ID || node.id === EXIT_ID) continue;
if (!prevNodesById.has(node.id)) {
ops.push({
name: "add_node",
args: addNodeArgs(node),
});
}
}
// 5. update_node — same id in both drafts but at least one property
// changed. Reserved ids are skipped to match the reducer's stance.
for (const node of next.nodes) {
if (node.id === START_ID || node.id === EXIT_ID) continue;
const prevNode = prevNodesById.get(node.id);
if (!prevNode) continue; // already handled as add
if (nodesEqual(prevNode, node)) continue;
ops.push({
name: "update_node",
args: updateNodeArgs(prevNode, node),
});
}
// 6. connect — edges in next that are new or have updated attrs.
for (const [key, edge] of nextEdgesByKey) {
const prevEdge = prevEdgesByKey.get(key);
if (!prevEdge || !edgesEqual(prevEdge, edge)) {
ops.push({
name: "connect",
args: connectArgs(edge),
});
}
}
return ops;
}
function addNodeArgs(node: Node): AddNodeArgs {
const args: AddNodeArgs = {
id: node.id,
label: node.label,
shape: node.shape,
};
if (node.prompt !== undefined) args.prompt = node.prompt;
if (node.attrs !== undefined) args.attrs = { ...node.attrs };
return args;
}
function updateNodeArgs(prev: Node, next: Node): UpdateNodeArgs {
const args: UpdateNodeArgs = { id: next.id };
if (prev.label !== next.label) args.label = next.label;
if (prev.shape !== next.shape) args.shape = next.shape;
if (prev.prompt !== next.prompt) args.prompt = next.prompt;
if (!attrsEqual(prev.attrs, next.attrs)) {
args.attrs = next.attrs ? { ...next.attrs } : {};
}
return args;
}
function connectArgs(edge: Edge): ConnectArgs {
const args: ConnectArgs = {
from: edge.from,
to: edge.to,
};
if (edge.condition !== undefined) args.condition = edge.condition;
if (edge.label !== undefined) args.label = edge.label;
if (edge.attrs !== undefined) args.attrs = { ...edge.attrs };
return args;
}
function nodesEqual(a: Node, b: Node): boolean {
return (
a.label === b.label &&
a.shape === b.shape &&
a.prompt === b.prompt &&
attrsEqual(a.attrs, b.attrs)
);
}
function edgesEqual(a: Edge, b: Edge): boolean {
return (
a.from === b.from &&
a.to === b.to &&
a.condition === b.condition &&
a.label === b.label &&
attrsEqual(a.attrs, b.attrs)
);
}
function attrsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === undefined && b === undefined) return true;
const aEntries = a ? Object.entries(a) : [];
const bEntries = b ? Object.entries(b) : [];
if (aEntries.length !== bEntries.length) return false;
for (const [k, v] of aEntries) {
if (b?.[k] !== v) return false;
}
return true;
}

View file

@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import {
ALL_SHAPES,
EXIT_ID,
RESERVED_IDS,
START_ID,
createInitialDraft,
isValidNodeId,
isValidShape,
isValidWorkflowName,
isWelcomeState,
} from "./draft";
describe("createInitialDraft", () => {
test("welcome state has only start and exit", () => {
const draft = createInitialDraft();
expect(draft.nodes).toHaveLength(2);
expect(draft.nodes.map((n) => n.id).sort()).toEqual([EXIT_ID, START_ID].sort());
expect(draft.edges).toEqual([{ from: START_ID, to: EXIT_ID }]);
});
test("uses reserved shapes for terminals", () => {
const draft = createInitialDraft();
const start = draft.nodes.find((n) => n.id === START_ID);
const exit = draft.nodes.find((n) => n.id === EXIT_ID);
expect(start?.shape).toBe("mdiamond");
expect(exit?.shape).toBe("msquare");
});
test("default name is 'untitled' and goal is empty", () => {
const draft = createInitialDraft();
expect(draft.name).toBe("untitled");
expect(draft.goal).toBe("");
});
});
describe("isWelcomeState", () => {
test("true on a fresh draft", () => {
expect(isWelcomeState(createInitialDraft())).toBe(true);
});
test("false once any user node is added", () => {
const draft = createInitialDraft();
draft.nodes.push({ id: "plan", label: "Plan", shape: "box" });
expect(isWelcomeState(draft)).toBe(false);
});
});
describe("isValidNodeId", () => {
test.each([
["plan", true],
["run_tests", true],
["step_42", true],
["a", true],
["Plan", false], // uppercase
["1step", false], // leading digit
["_hidden", false], // leading underscore
["run-tests", false], // hyphen
["", false],
])("`%s` -> %s", (input, expected) => {
expect(isValidNodeId(input)).toBe(expected);
});
});
describe("isValidWorkflowName", () => {
test("snake_case ok", () => {
expect(isValidWorkflowName("release_notes")).toBe(true);
});
test("rejects uppercase", () => {
expect(isValidWorkflowName("ReleaseNotes")).toBe(false);
});
});
describe("isValidShape", () => {
test("accepts every shape in ALL_SHAPES", () => {
for (const shape of ALL_SHAPES) {
expect(isValidShape(shape)).toBe(true);
}
});
test("rejects unknown shapes and non-strings", () => {
expect(isValidShape("circle")).toBe(false);
expect(isValidShape(42)).toBe(false);
expect(isValidShape(null)).toBe(false);
expect(isValidShape(undefined)).toBe(false);
});
});
describe("RESERVED_IDS", () => {
test("covers start and exit", () => {
expect(RESERVED_IDS).toContain(START_ID);
expect(RESERVED_IDS).toContain(EXIT_ID);
});
});

View file

@ -0,0 +1,114 @@
/**
* Playground draft schema the entire workflow the user is composing lives
* in this single object. The reducer in `./reducer` mutates it via tool
* calls; `./persist` writes it to `localStorage` so a refresh doesn't nuke
* the user's work.
*
* Kept deliberately small and self-contained so the playground component
* subtree can be re-embedded elsewhere as a standalone island without
* dragging fabro-web's app shell along.
*/
/** All Graphviz shape names Fabro recognises. Each shape picks a handler. */
export type Shape =
| "box" // default agent (multi-turn LLM with tools)
| "tab" // single LLM call
| "parallelogram" // shell script
| "hexagon" // human gate
| "diamond" // conditional branch
| "component" // fan-out parallel
| "tripleoctagon" // merge parallel
| "house" // sub-workflow
| "mdiamond" // start (terminal)
| "msquare"; // exit (terminal)
export const ALL_SHAPES: readonly Shape[] = [
"box",
"tab",
"parallelogram",
"hexagon",
"diamond",
"component",
"tripleoctagon",
"house",
"mdiamond",
"msquare",
] as const;
/** Reserved node ids. The reducer refuses to add/delete/rename these. */
export const START_ID = "start";
export const EXIT_ID = "exit";
export const RESERVED_IDS: readonly string[] = [START_ID, EXIT_ID];
/** Primitive types we accept inside Node/Edge `attrs` bags. */
export type AttrValue = string | number | boolean;
export type Node = {
/** Unique within the draft; snake_case. */
id: string;
label: string;
shape: Shape;
/** Prose body for agent / tab / parallelogram nodes. */
prompt?: string;
attrs?: Record<string, AttrValue>;
};
export type Edge = {
from: string;
to: string;
/** For diamond branches, e.g. `pass`, `fail`. */
condition?: string;
label?: string;
attrs?: Record<string, AttrValue>;
};
export type WorkflowDraft = {
/** snake_case identifier used in `fabro run <name>` and the zip filename. */
name: string;
goal: string;
nodes: Node[];
edges: Edge[];
};
/** Default workflow name until the model picks one via `set_workflow_meta`. */
export const DEFAULT_NAME = "untitled";
/** Filename stem used when downloading from a draft with the default name. */
export const FALLBACK_DOWNLOAD_NAME = "playground-workflow";
/**
* The welcome canvas a `start → exit` skeleton with no user-added nodes
* between them. A ghost `???` placeholder is rendered in the canvas layer
* whenever a draft `isWelcomeState`.
*/
export function createInitialDraft(): WorkflowDraft {
return {
name: DEFAULT_NAME,
goal: "",
nodes: [
{ id: START_ID, label: "Start", shape: "mdiamond" },
{ id: EXIT_ID, label: "Exit", shape: "msquare" },
],
edges: [{ from: START_ID, to: EXIT_ID }],
};
}
/** A draft is in the welcome state iff there are no nodes other than start/exit. */
export function isWelcomeState(draft: WorkflowDraft): boolean {
return draft.nodes.every((n) => RESERVED_IDS.includes(n.id));
}
/** Whether a node id is allowed for `add_node` / `update_node`. */
export function isValidNodeId(id: string): boolean {
return /^[a-z][a-z0-9_]*$/.test(id);
}
/** Whether a string is a valid workflow name (drives the zip filename). */
export function isValidWorkflowName(name: string): boolean {
return /^[a-z][a-z0-9_]*$/.test(name);
}
/** Whether a value is one of Fabro's recognised shapes. */
export function isValidShape(value: unknown): value is Shape {
return typeof value === "string" && (ALL_SHAPES as readonly string[]).includes(value);
}

View file

@ -0,0 +1,205 @@
import { describe, expect, test } from "bun:test";
import { renderFabro } from "../files/render-fabro";
import { createInitialDraft, type WorkflowDraft } from "./draft";
import { parseFabro } from "./parse-fabro";
function expectOk(result: ReturnType<typeof parseFabro>): WorkflowDraft {
if (!result.ok) throw new Error(`expected ok, got: ${result.error}`);
return result.draft;
}
describe("parseFabro", () => {
test("welcome state", () => {
const draft = expectOk(
parseFabro(`digraph Workflow {
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.name).toBe("workflow");
expect(draft.nodes).toHaveLength(2);
expect(draft.nodes[0]).toMatchObject({ id: "start", shape: "mdiamond" });
expect(draft.nodes[1]).toMatchObject({ id: "exit", shape: "msquare" });
expect(draft.edges).toEqual([{ from: "start", to: "exit" }]);
});
test("captures graph goal attribute", () => {
const draft = expectOk(
parseFabro(`digraph ReleaseNotes {
graph [goal="Generate release notes from git log."]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.name).toBe("release_notes");
expect(draft.goal).toBe("Generate release notes from git log.");
});
test("parses node with prompt and extra attrs", () => {
const draft = expectOk(
parseFabro(`digraph Run {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [shape=parallelogram, label="Run Tests", prompt="Execute the suite.", script="npm test", timeout=60]
start -> run_tests -> exit
}`),
);
const tests = draft.nodes.find((n) => n.id === "run_tests");
expect(tests).toBeDefined();
expect(tests!.shape).toBe("parallelogram");
expect(tests!.label).toBe("Run Tests");
expect(tests!.prompt).toBe("Execute the suite.");
expect(tests!.attrs).toEqual({ script: "npm test", timeout: 60 });
});
test("edge chain `a -> b -> c` produces multiple edges", () => {
const draft = expectOk(
parseFabro(`digraph Linear {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
a [shape=box, label="A"]
b [shape=box, label="B"]
c [shape=box, label="C"]
start -> a -> b -> c -> exit
}`),
);
const edges = draft.edges.map((e) => `${e.from}->${e.to}`);
expect(edges).toEqual(["start->a", "a->b", "b->c", "c->exit"]);
});
test("edge with condition and label attributes", () => {
const draft = expectOk(
parseFabro(`digraph Branch {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
gate [shape=diamond, label="Gate"]
happy [shape=box, label="Happy"]
start -> gate
gate -> happy [condition="outcome=approved", label="approved"]
happy -> exit
}`),
);
const edge = draft.edges.find((e) => e.from === "gate" && e.to === "happy");
expect(edge).toBeDefined();
expect(edge!.condition).toBe("outcome=approved");
expect(edge!.label).toBe("approved");
});
test("escapes inside strings", () => {
const draft = expectOk(
parseFabro(`digraph Esc {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [shape=box, label="Plan", prompt="He said \\"hi\\" and added a \\\\ slash."]
start -> plan -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "plan")?.prompt).toBe(
'He said "hi" and added a \\ slash.',
);
});
test("comments and trailing semicolons are tolerated", () => {
const draft = expectOk(
parseFabro(`// top of file
digraph Cmt {
// inline comment
start [shape=Mdiamond, label="Start"];
exit [shape=Msquare, label="Exit"];
/* block
comment */
start -> exit;
}`),
);
expect(draft.nodes).toHaveLength(2);
});
test("ignores global node/edge defaults and rankdir", () => {
const draft = expectOk(
parseFabro(`digraph G {
rankdir=LR
node [shape=box]
edge [color=gray]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.edges).toEqual([{ from: "start", to: "exit" }]);
});
test("round-trips renderFabro output", () => {
const initial = createInitialDraft();
initial.name = "release_notes";
initial.goal = "Generate release notes.";
initial.nodes.push({
id: "plan",
label: "Plan",
shape: "box",
prompt: "Plan it.",
});
initial.nodes.push({
id: "implement",
label: "Implement",
shape: "box",
});
initial.edges = [
{ from: "start", to: "plan" },
{ from: "plan", to: "implement" },
{ from: "implement", to: "exit" },
];
const dot = renderFabro(initial);
const parsed = expectOk(parseFabro(dot));
expect(parsed.name).toBe(initial.name);
expect(parsed.goal).toBe(initial.goal);
expect(parsed.nodes).toEqual(initial.nodes);
expect(parsed.edges).toEqual(initial.edges);
});
test("missing shape on non-terminal node defaults to box", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plain [label="No Shape"]
start -> plain -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "plain")?.shape).toBe("box");
});
test("unknown shape falls back to box", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
weird [shape=ellipse, label="Weird"]
start -> weird -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "weird")?.shape).toBe("box");
});
test("missing digraph header is a parse error", () => {
const result = parseFabro(`{ start -> exit }`);
expect(result.ok).toBe(false);
});
test("missing closing brace is a parse error", () => {
const result = parseFabro(`digraph G { start -> exit`);
expect(result.ok).toBe(false);
});
test("unterminated string is a parse error", () => {
const result = parseFabro(
`digraph G { plan [label="unterminated...] start -> plan }`,
);
expect(result.ok).toBe(false);
});
});

View file

@ -0,0 +1,405 @@
/**
* Parse a constrained subset of Graphviz DOT into a `WorkflowDraft`.
*
* The playground's chat endpoint asks the model to emit the full
* `workflow.fabro` each turn via the `write_workflow_file` tool. This
* parser turns that DOT string back into the same draft schema the
* reducer operates on, so the new state can be diffed against the
* previous state and animated into the canvas.
*
* The grammar is intentionally limited to what `render-fabro.ts` emits:
* a single top-level `digraph` block, plain identifiers, quoted-string
* attribute values, `graph [goal=...]` for the workflow goal, simple
* `<id> [<attrs>]` node declarations, and `<from> -> <to>` edges with
* optional `[<attrs>]` lists. Edge chains (`a -> b -> c`) are
* supported because the model is encouraged to write them.
*/
import {
ALL_SHAPES,
DEFAULT_NAME,
type AttrValue,
type Edge,
type Node,
type Shape,
type WorkflowDraft,
} from "./draft";
export type ParseResult =
| { ok: true; draft: WorkflowDraft }
| { ok: false; error: string };
interface State {
src: string;
pos: number;
}
const SHAPE_SET = new Set(ALL_SHAPES as readonly string[]);
export function parseFabro(src: string): ParseResult {
const state: State = { src, pos: 0 };
skipWs(state);
if (!tryConsume(state, "digraph")) {
return fail("expected `digraph` keyword at start of file", state);
}
skipWs(state);
// The digraph name is optional but our renderer always writes one.
let digraphName: string | null = null;
if (state.src[state.pos] !== "{") {
digraphName = parseIdent(state) ?? parseString(state);
}
skipWs(state);
if (!consumeChar(state, "{")) {
return fail("expected `{` after digraph header", state);
}
const draft: WorkflowDraft = {
name: digraphName ? toSnakeCase(digraphName) : DEFAULT_NAME,
goal: "",
nodes: [],
edges: [],
};
const seenNodeIds = new Set<string>();
while (true) {
skipWs(state);
if (state.pos >= state.src.length) {
return fail("unexpected end of input before closing `}`", state);
}
if (consumeChar(state, "}")) {
// Trailing junk after `}` is tolerated — model may add prose
// after the closing brace and we don't care for parsing.
return { ok: true, draft };
}
// `graph [goal=...]` carries the workflow goal.
if (tryConsume(state, "graph") && isAttrOrEnd(state)) {
const attrs = parseAttrList(state);
if (attrs && typeof attrs.goal === "string") {
draft.goal = attrs.goal;
}
consumeStatementEnd(state);
continue;
}
// `node [...]` / `edge [...]` set defaults that we ignore.
// `rankdir=LR` and similar bare attribute assignments are layout
// hints — also ignored.
if (
(tryConsume(state, "node") && isAttrOrEnd(state)) ||
(tryConsume(state, "edge") && isAttrOrEnd(state))
) {
parseAttrList(state);
consumeStatementEnd(state);
continue;
}
if (tryConsumeBareAssignment(state)) {
continue;
}
const id = parseIdent(state) ?? parseString(state);
if (!id) {
return fail(`unexpected token`, state);
}
skipWs(state);
// Edge (possibly chained).
if (peek(state, "->")) {
let from = id;
while (peek(state, "->")) {
state.pos += 2;
skipWs(state);
const to = parseIdent(state) ?? parseString(state);
if (!to) {
return fail("expected target node after `->`", state);
}
const edge: Edge = { from, to };
skipWs(state);
// The attribute list (if present) binds to the terminal edge
// in the chain. While there's another `->`, we have more
// edges to emit before any attrs apply.
if (!peek(state, "->") && state.src[state.pos] === "[") {
const attrs = parseAttrList(state) ?? {};
applyEdgeAttrs(edge, attrs);
}
draft.edges.push(edge);
from = to;
skipWs(state);
}
consumeStatementEnd(state);
continue;
}
// Node declaration.
if (seenNodeIds.has(id)) {
// Duplicate node decl — last write wins. Drop the previous.
const idx = draft.nodes.findIndex((n) => n.id === id);
if (idx >= 0) draft.nodes.splice(idx, 1);
}
let attrs: Record<string, AttrValue> = {};
if (state.src[state.pos] === "[") {
attrs = parseAttrList(state) ?? {};
}
const node = buildNode(id, attrs);
draft.nodes.push(node);
seenNodeIds.add(id);
consumeStatementEnd(state);
}
}
function buildNode(id: string, attrs: Record<string, AttrValue>): Node {
const shape = coerceShape(attrs.shape, id);
const label = typeof attrs.label === "string" ? attrs.label : id;
const node: Node = { id, label, shape };
if (typeof attrs.prompt === "string") node.prompt = attrs.prompt;
const rest = { ...attrs };
delete rest.shape;
delete rest.label;
delete rest.prompt;
if (Object.keys(rest).length > 0) node.attrs = rest;
return node;
}
function coerceShape(raw: AttrValue | undefined, nodeId: string): Shape {
if (typeof raw !== "string") {
// Shape omitted: default to start/exit terminals if id matches,
// otherwise `box` (Fabro's agent default).
if (nodeId === "start") return "mdiamond";
if (nodeId === "exit") return "msquare";
return "box";
}
const lower = raw.toLowerCase();
if (SHAPE_SET.has(lower)) return lower as Shape;
return "box";
}
function applyEdgeAttrs(edge: Edge, attrs: Record<string, AttrValue>): void {
if (typeof attrs.condition === "string") edge.condition = attrs.condition;
if (typeof attrs.label === "string") edge.label = attrs.label;
const rest = { ...attrs };
delete rest.condition;
delete rest.label;
if (Object.keys(rest).length > 0) edge.attrs = rest;
}
function skipWs(state: State): void {
while (state.pos < state.src.length) {
const c = state.src[state.pos]!;
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
state.pos++;
continue;
}
if (c === "/" && state.src[state.pos + 1] === "/") {
while (state.pos < state.src.length && state.src[state.pos] !== "\n") {
state.pos++;
}
continue;
}
if (c === "/" && state.src[state.pos + 1] === "*") {
state.pos += 2;
while (
state.pos + 1 < state.src.length &&
!(state.src[state.pos] === "*" && state.src[state.pos + 1] === "/")
) {
state.pos++;
}
state.pos += 2;
continue;
}
if (c === "#") {
// Some DOT writers use `#` for line comments.
while (state.pos < state.src.length && state.src[state.pos] !== "\n") {
state.pos++;
}
continue;
}
break;
}
}
function parseIdent(state: State): string | null {
skipWs(state);
const start = state.pos;
// DOT identifiers: [a-zA-Z_€-￿][\w€-￿]*
const first = state.src[state.pos];
if (!first || !/[a-zA-Z_]/.test(first)) return null;
state.pos++;
while (
state.pos < state.src.length &&
/[a-zA-Z0-9_]/.test(state.src[state.pos]!)
) {
state.pos++;
}
return state.src.slice(start, state.pos);
}
function parseString(state: State): string | null {
skipWs(state);
if (state.src[state.pos] !== '"') return null;
state.pos++;
let result = "";
while (state.pos < state.src.length) {
const c = state.src[state.pos]!;
if (c === "\\") {
const next = state.src[state.pos + 1];
if (next === '"') {
result += '"';
state.pos += 2;
} else if (next === "\\") {
result += "\\";
state.pos += 2;
} else if (next === "n") {
result += "\n";
state.pos += 2;
} else if (next === "t") {
result += "\t";
state.pos += 2;
} else if (next === "r") {
result += "\r";
state.pos += 2;
} else {
// Unknown escape: pass through verbatim.
result += c;
state.pos += 1;
}
} else if (c === '"') {
state.pos++;
// DOT supports string concatenation with `+`. Splice if present.
const save = state.pos;
skipWs(state);
if (state.src[state.pos] === "+") {
state.pos++;
const more = parseString(state);
if (more === null) {
state.pos = save;
return result;
}
return result + more;
}
state.pos = save;
return result;
} else {
result += c;
state.pos++;
}
}
return null;
}
function parseAttrValue(state: State): AttrValue | null {
skipWs(state);
if (state.src[state.pos] === '"') {
return parseString(state);
}
const start = state.pos;
while (state.pos < state.src.length && /[a-zA-Z0-9_\-.]/.test(state.src[state.pos]!)) {
state.pos++;
}
if (state.pos === start) return null;
const raw = state.src.slice(start, state.pos);
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
if (raw === "true") return true;
if (raw === "false") return false;
return raw;
}
function parseAttrList(state: State): Record<string, AttrValue> | null {
skipWs(state);
if (state.src[state.pos] !== "[") return null;
state.pos++;
const out: Record<string, AttrValue> = {};
while (true) {
skipWs(state);
if (state.pos >= state.src.length) return null;
if (state.src[state.pos] === "]") {
state.pos++;
return out;
}
const key = parseIdent(state);
if (!key) return null;
skipWs(state);
if (state.src[state.pos] !== "=") return null;
state.pos++;
const value = parseAttrValue(state);
if (value === null) return null;
out[key] = value;
skipWs(state);
if (state.src[state.pos] === "," || state.src[state.pos] === ";") {
state.pos++;
}
}
}
function tryConsume(state: State, word: string): boolean {
skipWs(state);
if (state.src.slice(state.pos, state.pos + word.length) !== word) return false;
// Word-boundary check so `nodename` doesn't match `node`.
const after = state.src[state.pos + word.length];
if (after && /[a-zA-Z0-9_]/.test(after)) return false;
state.pos += word.length;
return true;
}
function consumeChar(state: State, ch: string): boolean {
skipWs(state);
if (state.src[state.pos] !== ch) return false;
state.pos++;
return true;
}
function consumeStatementEnd(state: State): void {
skipWs(state);
if (state.src[state.pos] === ";") state.pos++;
}
function peek(state: State, str: string): boolean {
skipWs(state);
return state.src.slice(state.pos, state.pos + str.length) === str;
}
function isAttrOrEnd(state: State): boolean {
skipWs(state);
const c = state.src[state.pos];
return c === "[" || c === ";" || c === "}" || c === undefined;
}
/**
* Handle bare `rankdir=LR` style assignments at digraph scope. Returns
* true if one was consumed.
*/
function tryConsumeBareAssignment(state: State): boolean {
const save = state.pos;
skipWs(state);
const ident = parseIdent(state);
if (!ident) {
state.pos = save;
return false;
}
skipWs(state);
if (state.src[state.pos] !== "=") {
state.pos = save;
return false;
}
state.pos++;
parseAttrValue(state);
consumeStatementEnd(state);
return true;
}
function fail(message: string, state: State): ParseResult {
const lines = state.src.slice(0, state.pos).split("\n");
const line = lines.length;
const col = lines[lines.length - 1]!.length + 1;
return { ok: false, error: `${message} (line ${line}, col ${col})` };
}
/**
* Convert PascalCase or camelCase back to snake_case so `digraph
* ReleaseNotes` round-trips with `release_notes`.
*/
function toSnakeCase(name: string): string {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.toLowerCase();
}

View file

@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act } from "react-test-renderer";
import { renderHook, setupReactTestEnv } from "../../../lib/test-utils";
import { STORAGE_KEY, usePlaygroundDraft } from "./persist";
import { createInitialDraft } from "./draft";
type LocalStorageLike = {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
};
function installFakeStorage(): { storage: LocalStorageLike; restore: () => void } {
const map = new Map<string, string>();
const fake: LocalStorageLike = {
getItem: (key) => map.get(key) ?? null,
setItem: (key, value) => {
map.set(key, value);
},
removeItem: (key) => {
map.delete(key);
},
clear: () => {
map.clear();
},
};
const original = (globalThis as { window?: { localStorage?: LocalStorageLike } })
.window;
(globalThis as { window: { localStorage: LocalStorageLike } }).window = {
localStorage: fake,
};
return {
storage: fake,
restore: () => {
if (original === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window: unknown }).window = original;
}
},
};
}
describe("usePlaygroundDraft", () => {
let teardownReact: () => void = () => {};
let restoreStorage: () => void = () => {};
let storage: LocalStorageLike;
beforeEach(() => {
teardownReact = setupReactTestEnv();
const installed = installFakeStorage();
storage = installed.storage;
restoreStorage = installed.restore;
});
afterEach(() => {
restoreStorage();
teardownReact();
});
function wrapper({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
test("starts in welcome state when storage is empty", () => {
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
expect(result.current.draft).toEqual(createInitialDraft());
});
test("applyCall mutates draft and writes to localStorage", () => {
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
act(() => {
result.current.applyCall({
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
});
});
expect(result.current.draft.nodes.some((n) => n.id === "plan")).toBe(true);
const stored = storage.getItem(STORAGE_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.nodes.some((n: { id: string }) => n.id === "plan")).toBe(true);
});
test("invalid tool calls are silently dropped (state stays the same)", () => {
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
const before = result.current.draft;
act(() => {
result.current.applyCall({
name: "add_node",
args: { id: "start", label: "Start again", shape: "box" },
});
});
expect(result.current.draft).toBe(before); // same reference, no mutation
});
test("reset returns to welcome state (and persists it)", () => {
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
act(() => {
result.current.applyCall({
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
});
});
expect(
JSON.parse(storage.getItem(STORAGE_KEY)!).nodes.some(
(n: { id: string }) => n.id === "plan",
),
).toBe(true);
act(() => {
result.current.reset();
});
expect(result.current.draft).toEqual(createInitialDraft());
// The persist effect re-writes the welcome state on the next render —
// semantically equivalent to an empty slot since `loadInitial` would
// produce the same draft for either.
const persisted = JSON.parse(storage.getItem(STORAGE_KEY)!);
expect(persisted).toEqual(createInitialDraft());
});
test("hydrates from existing localStorage on mount", () => {
const stashed = {
name: "release_notes",
goal: "Generate notes",
nodes: [
{ id: "start", label: "Start", shape: "mdiamond" },
{ id: "exit", label: "Exit", shape: "msquare" },
{ id: "plan", label: "Plan", shape: "box" },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "exit" },
],
};
storage.setItem(STORAGE_KEY, JSON.stringify(stashed));
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
expect(result.current.draft.name).toBe("release_notes");
expect(result.current.draft.nodes.some((n) => n.id === "plan")).toBe(true);
});
test("falls back to welcome state on corrupt localStorage", () => {
storage.setItem(STORAGE_KEY, "{not valid json");
const { result } = renderHook(() => usePlaygroundDraft(), { wrapper });
expect(result.current.draft).toEqual(createInitialDraft());
});
});

View file

@ -0,0 +1,112 @@
/**
* React hook layer over the playground reducer plus localStorage persistence.
*
* The playground deliberately keeps state in the browser: the server is
* stateless across chat turns (each turn POSTs the full draft and gets back
* text + tool calls). That makes the same component tree trivially
* re-embeddable in other contexts later, and means a refresh just re-
* hydrates from `localStorage` rather than hitting any API.
*/
import { useCallback, useEffect, useReducer } from "react";
import { applyToolCall, type ToolCall } from "./reducer";
import { createInitialDraft, type WorkflowDraft } from "./draft";
/** `localStorage` key. Versioned so we can bump on a breaking schema change. */
export const STORAGE_KEY = "fabro:playground:draft:v1";
type Action =
| { type: "tool_call"; call: ToolCall }
| { type: "reset" }
| { type: "hydrate"; draft: WorkflowDraft };
export type PlaygroundDraftHandle = {
draft: WorkflowDraft;
/** Apply a single tool call. Invalid calls are silently dropped here; the
* caller already had a chance to surface the error from `applyToolCall`. */
applyCall: (call: ToolCall) => void;
/** Wipe the draft back to the welcome state and clear localStorage. */
reset: () => void;
};
function reducer(state: WorkflowDraft, action: Action): WorkflowDraft {
switch (action.type) {
case "tool_call": {
const result = applyToolCall(state, action.call);
// Silent on failure: validation errors are surfaced via the chat ack
// pane upstream, not the reducer.
return result.ok ? result.draft : state;
}
case "reset":
return createInitialDraft();
case "hydrate":
return action.draft;
}
}
/**
* Lazy initial state: read once from localStorage on mount, fall through to
* a fresh welcome draft if storage is empty or corrupt.
*/
function loadInitial(): WorkflowDraft {
if (typeof window === "undefined") return createInitialDraft();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return createInitialDraft();
const parsed = JSON.parse(raw) as WorkflowDraft;
if (
typeof parsed === "object" &&
parsed !== null &&
Array.isArray(parsed.nodes) &&
Array.isArray(parsed.edges) &&
typeof parsed.name === "string" &&
typeof parsed.goal === "string"
) {
return parsed;
}
return createInitialDraft();
} catch {
// Corrupt JSON, blocked storage, anything else — fall back to fresh.
return createInitialDraft();
}
}
/**
* Drives the playground draft. State is owned by a `useReducer` so the chat
* adapter can `applyCall(...)` for each tool call streamed in over SSE, and
* the canvas just re-renders.
*
* Returns a stable handle whose methods are referentially stable across
* renders.
*/
export function usePlaygroundDraft(): PlaygroundDraftHandle {
const [draft, dispatch] = useReducer(reducer, undefined, loadInitial);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
} catch {
// Storage quota / privacy mode — non-fatal; the user just loses
// refresh persistence for this session.
}
}, [draft]);
const applyCall = useCallback((call: ToolCall) => {
dispatch({ type: "tool_call", call });
}, []);
const reset = useCallback(() => {
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// see above
}
}
dispatch({ type: "reset" });
}, []);
return { draft, applyCall, reset };
}

View file

@ -0,0 +1,323 @@
import { describe, expect, test } from "bun:test";
import { createInitialDraft, type WorkflowDraft } from "./draft";
import { applyToolCall, applyToolCalls, type ToolCall } from "./reducer";
function withPlanAndExit(): WorkflowDraft {
return applyToolCalls(createInitialDraft(), [
{
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it." },
},
{ name: "connect", args: { from: "start", to: "plan" } },
{ name: "connect", args: { from: "plan", to: "exit" } },
{ name: "disconnect", args: { from: "start", to: "exit" } },
]).draft;
}
describe("set_workflow_meta", () => {
test("sets name and goal", () => {
const result = applyToolCall(createInitialDraft(), {
name: "set_workflow_meta",
args: { name: "release_notes", goal: "Generate release notes." },
});
expect(result.ok).toBe(true);
expect(result.draft.name).toBe("release_notes");
expect(result.draft.goal).toBe("Generate release notes.");
});
test("setting only goal leaves name alone", () => {
const result = applyToolCall(createInitialDraft(), {
name: "set_workflow_meta",
args: { goal: "Do the thing." },
});
expect(result.ok).toBe(true);
expect(result.draft.name).toBe("untitled");
expect(result.draft.goal).toBe("Do the thing.");
});
test("rejects bad workflow name", () => {
const result = applyToolCall(createInitialDraft(), {
name: "set_workflow_meta",
args: { name: "Release Notes" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("snake_case");
expect(result.draft.name).toBe("untitled"); // unchanged
});
});
describe("add_node", () => {
test("adds a node with all optional fields", () => {
const result = applyToolCall(createInitialDraft(), {
name: "add_node",
args: {
id: "plan",
label: "Plan",
shape: "box",
prompt: "Plan the work.",
attrs: { max_visits: 3 },
},
});
expect(result.ok).toBe(true);
const plan = result.draft.nodes.find((n) => n.id === "plan");
expect(plan).toEqual({
id: "plan",
label: "Plan",
shape: "box",
prompt: "Plan the work.",
attrs: { max_visits: 3 },
});
});
test("rejects reserved id", () => {
const result = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "start", label: "Start again", shape: "box" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("reserved");
});
test("rejects duplicate id", () => {
const seeded = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
}).draft;
const result = applyToolCall(seeded, {
name: "add_node",
args: { id: "plan", label: "Plan again", shape: "tab" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("already exists");
});
test("rejects invalid id format", () => {
const result = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "PlanIt", label: "Plan", shape: "box" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("snake_case");
});
test("rejects unknown shape", () => {
const result = applyToolCall(createInitialDraft(), {
name: "add_node",
// @ts-expect-error — testing runtime validation of a bad shape
args: { id: "plan", label: "Plan", shape: "circle" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("shape");
});
test("rejects terminal-only shapes (mdiamond, msquare)", () => {
const a = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "alt_start", label: "Alt", shape: "mdiamond" },
});
expect(a.ok).toBe(false);
const b = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "alt_exit", label: "Alt", shape: "msquare" },
});
expect(b.ok).toBe(false);
});
});
describe("update_node", () => {
test("updates label, shape, prompt", () => {
const seeded = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
}).draft;
const result = applyToolCall(seeded, {
name: "update_node",
args: { id: "plan", label: "Plan v2", shape: "tab", prompt: "New prompt" },
});
expect(result.ok).toBe(true);
const plan = result.draft.nodes.find((n) => n.id === "plan");
expect(plan?.label).toBe("Plan v2");
expect(plan?.shape).toBe("tab");
expect(plan?.prompt).toBe("New prompt");
});
test("rejects nonexistent id", () => {
const result = applyToolCall(createInitialDraft(), {
name: "update_node",
args: { id: "ghost", label: "x" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("does not exist");
});
test("rejects modifying reserved nodes", () => {
const result = applyToolCall(createInitialDraft(), {
name: "update_node",
args: { id: "start", label: "Not start" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("reserved");
});
});
describe("delete_node", () => {
test("removes the node and any incident edges", () => {
const seeded = withPlanAndExit();
const result = applyToolCall(seeded, {
name: "delete_node",
args: { id: "plan" },
});
expect(result.ok).toBe(true);
expect(result.draft.nodes.find((n) => n.id === "plan")).toBeUndefined();
expect(
result.draft.edges.some((e) => e.from === "plan" || e.to === "plan"),
).toBe(false);
});
test("rejects reserved id", () => {
const result = applyToolCall(createInitialDraft(), {
name: "delete_node",
args: { id: "exit" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("reserved");
});
test("rejects nonexistent id", () => {
const result = applyToolCall(createInitialDraft(), {
name: "delete_node",
args: { id: "ghost" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("does not exist");
});
});
describe("connect", () => {
test("adds an edge between two existing nodes", () => {
const seeded = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
}).draft;
const result = applyToolCall(seeded, {
name: "connect",
args: { from: "plan", to: "exit", condition: "ok", label: "done" },
});
expect(result.ok).toBe(true);
const edge = result.draft.edges.find(
(e) => e.from === "plan" && e.to === "exit",
);
expect(edge).toEqual({
from: "plan",
to: "exit",
condition: "ok",
label: "done",
});
});
test("rejects when either endpoint is missing", () => {
const a = applyToolCall(createInitialDraft(), {
name: "connect",
args: { from: "ghost", to: "exit" },
});
expect(a.ok).toBe(false);
const b = applyToolCall(createInitialDraft(), {
name: "connect",
args: { from: "start", to: "ghost" },
});
expect(b.ok).toBe(false);
});
test("rejects self-loop", () => {
const result = applyToolCall(createInitialDraft(), {
name: "connect",
args: { from: "start", to: "start" },
});
expect(result.ok).toBe(false);
});
test("rejects outgoing edges from exit", () => {
const result = applyToolCall(createInitialDraft(), {
name: "connect",
args: { from: "exit", to: "start" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("exit");
});
test("rejects incoming edges to start", () => {
const seeded = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
}).draft;
const result = applyToolCall(seeded, {
name: "connect",
args: { from: "plan", to: "start" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("start");
});
test("rejects duplicate edges", () => {
const seeded = applyToolCall(createInitialDraft(), {
name: "add_node",
args: { id: "plan", label: "Plan", shape: "box" },
}).draft;
const seeded2 = applyToolCall(seeded, {
name: "connect",
args: { from: "plan", to: "exit" },
}).draft;
const result = applyToolCall(seeded2, {
name: "connect",
args: { from: "plan", to: "exit" },
});
expect(result.ok).toBe(false);
expect(result.error).toContain("already exists");
});
});
describe("disconnect", () => {
test("removes the edge", () => {
const result = applyToolCall(createInitialDraft(), {
name: "disconnect",
args: { from: "start", to: "exit" },
});
expect(result.ok).toBe(true);
expect(result.draft.edges).toHaveLength(0);
});
test("rejects nonexistent edges", () => {
const result = applyToolCall(createInitialDraft(), {
name: "disconnect",
args: { from: "start", to: "ghost" },
});
expect(result.ok).toBe(false);
});
});
describe("applyToolCalls (batch)", () => {
test("applies a sequence end to end", () => {
const draft = withPlanAndExit();
expect(draft.nodes.map((n) => n.id)).toEqual(["start", "exit", "plan"]);
expect(draft.edges).toEqual([
{ from: "start", to: "plan" },
{ from: "plan", to: "exit" },
]);
});
test("short-circuits and reports the first failing call", () => {
const calls: ToolCall[] = [
{ name: "add_node", args: { id: "plan", label: "Plan", shape: "box" } },
{ name: "connect", args: { from: "plan", to: "ghost" } }, // fails
{ name: "add_node", args: { id: "after", label: "After", shape: "tab" } }, // skipped
];
const result = applyToolCalls(createInitialDraft(), calls);
expect(result.ok).toBe(false);
expect(result.error).toContain("ghost");
// The "plan" mutation from the first successful call is preserved in
// the returned draft; the caller can decide whether to commit it.
expect(result.draft.nodes.some((n) => n.id === "after")).toBe(false);
});
});

View file

@ -0,0 +1,301 @@
/**
* Pure reducer for playground tool calls.
*
* The model emits a stream of tool calls (see the OpenAPI-defined playground
* chat endpoint); each call is applied to the current `WorkflowDraft` via
* `applyToolCall`. The reducer never throws and never partially mutates: on
* validation failure it returns the original draft unchanged along with a
* single-line error string that the chat surface can render as a soft
* apology before the model retries.
*
* The tool-call shapes here are the wire format: the same JSON the server
* streams over SSE, the same JSON the model's tool definitions describe.
*/
import {
EXIT_ID,
RESERVED_IDS,
START_ID,
isValidNodeId,
isValidShape,
isValidWorkflowName,
type AttrValue,
type Edge,
type Node,
type Shape,
type WorkflowDraft,
} from "./draft";
export type ToolCall =
| {
name: "set_workflow_meta";
args: { name?: string; goal?: string };
}
| {
name: "add_node";
args: {
id: string;
label: string;
shape: Shape;
prompt?: string;
attrs?: Record<string, AttrValue>;
};
}
| {
name: "update_node";
args: {
id: string;
label?: string;
shape?: Shape;
prompt?: string;
attrs?: Record<string, AttrValue>;
};
}
| {
name: "delete_node";
args: { id: string };
}
| {
name: "connect";
args: {
from: string;
to: string;
condition?: string;
label?: string;
attrs?: Record<string, AttrValue>;
};
}
| {
name: "disconnect";
args: { from: string; to: string };
};
export type ToolCallName = ToolCall["name"];
export type ApplyResult = {
/** The draft after applying the call. Same reference as input on failure. */
draft: WorkflowDraft;
ok: boolean;
/** Single-line, human-readable on validation failure. Omitted on success. */
error?: string;
};
/** Apply a single tool call to a draft. Pure; never throws. */
export function applyToolCall(draft: WorkflowDraft, call: ToolCall): ApplyResult {
switch (call.name) {
case "set_workflow_meta":
return applySetMeta(draft, call.args);
case "add_node":
return applyAddNode(draft, call.args);
case "update_node":
return applyUpdateNode(draft, call.args);
case "delete_node":
return applyDeleteNode(draft, call.args);
case "connect":
return applyConnect(draft, call.args);
case "disconnect":
return applyDisconnect(draft, call.args);
}
}
/** Apply a batch of calls in order, short-circuiting on the first failure. */
export function applyToolCalls(
draft: WorkflowDraft,
calls: ToolCall[],
): ApplyResult {
let current = draft;
for (const call of calls) {
const result = applyToolCall(current, call);
if (!result.ok) return result;
current = result.draft;
}
return { draft: current, ok: true };
}
function ok(draft: WorkflowDraft): ApplyResult {
return { draft, ok: true };
}
function fail(draft: WorkflowDraft, error: string): ApplyResult {
return { draft, ok: false, error };
}
function findNode(draft: WorkflowDraft, id: string): Node | undefined {
return draft.nodes.find((n) => n.id === id);
}
function applySetMeta(
draft: WorkflowDraft,
args: { name?: string; goal?: string },
): ApplyResult {
let next = draft;
if (args.name !== undefined) {
if (!isValidWorkflowName(args.name)) {
return fail(
draft,
`Workflow name "${args.name}" must be snake_case (lowercase + underscores).`,
);
}
next = { ...next, name: args.name };
}
if (args.goal !== undefined) {
next = { ...next, goal: args.goal };
}
return ok(next);
}
function applyAddNode(
draft: WorkflowDraft,
args: {
id: string;
label: string;
shape: Shape;
prompt?: string;
attrs?: Record<string, AttrValue>;
},
): ApplyResult {
if (RESERVED_IDS.includes(args.id)) {
return fail(draft, `Node id "${args.id}" is reserved (start/exit).`);
}
if (!isValidNodeId(args.id)) {
return fail(
draft,
`Node id "${args.id}" must be snake_case (lowercase + underscores).`,
);
}
if (findNode(draft, args.id)) {
return fail(draft, `Node "${args.id}" already exists.`);
}
if (!isValidShape(args.shape)) {
return fail(draft, `Unknown shape "${String(args.shape)}".`);
}
if (args.shape === "mdiamond" || args.shape === "msquare") {
return fail(
draft,
`Shape "${args.shape}" is reserved for start/exit nodes.`,
);
}
const node: Node = {
id: args.id,
label: args.label,
shape: args.shape,
};
if (args.prompt !== undefined) node.prompt = args.prompt;
if (args.attrs !== undefined) node.attrs = { ...args.attrs };
return ok({ ...draft, nodes: [...draft.nodes, node] });
}
function applyUpdateNode(
draft: WorkflowDraft,
args: {
id: string;
label?: string;
shape?: Shape;
prompt?: string;
attrs?: Record<string, AttrValue>;
},
): ApplyResult {
const existing = findNode(draft, args.id);
if (!existing) {
return fail(draft, `Node "${args.id}" does not exist.`);
}
if (RESERVED_IDS.includes(args.id)) {
return fail(draft, `Node "${args.id}" cannot be modified (reserved).`);
}
if (args.shape !== undefined) {
if (!isValidShape(args.shape)) {
return fail(draft, `Unknown shape "${String(args.shape)}".`);
}
if (args.shape === "mdiamond" || args.shape === "msquare") {
return fail(
draft,
`Shape "${args.shape}" is reserved for start/exit nodes.`,
);
}
}
const updated: Node = { ...existing };
if (args.label !== undefined) updated.label = args.label;
if (args.shape !== undefined) updated.shape = args.shape;
if (args.prompt !== undefined) updated.prompt = args.prompt;
if (args.attrs !== undefined) updated.attrs = { ...args.attrs };
return ok({
...draft,
nodes: draft.nodes.map((n) => (n.id === args.id ? updated : n)),
});
}
function applyDeleteNode(
draft: WorkflowDraft,
args: { id: string },
): ApplyResult {
if (RESERVED_IDS.includes(args.id)) {
return fail(draft, `Node "${args.id}" cannot be deleted (reserved).`);
}
if (!findNode(draft, args.id)) {
return fail(draft, `Node "${args.id}" does not exist.`);
}
return ok({
...draft,
nodes: draft.nodes.filter((n) => n.id !== args.id),
edges: draft.edges.filter((e) => e.from !== args.id && e.to !== args.id),
});
}
function applyConnect(
draft: WorkflowDraft,
args: {
from: string;
to: string;
condition?: string;
label?: string;
attrs?: Record<string, AttrValue>;
},
): ApplyResult {
if (!findNode(draft, args.from)) {
return fail(draft, `Cannot connect: node "${args.from}" does not exist.`);
}
if (!findNode(draft, args.to)) {
return fail(draft, `Cannot connect: node "${args.to}" does not exist.`);
}
if (args.from === args.to) {
return fail(draft, `Cannot connect node "${args.from}" to itself.`);
}
if (args.from === EXIT_ID) {
return fail(draft, `"exit" cannot have outgoing edges.`);
}
if (args.to === START_ID) {
return fail(draft, `"start" cannot have incoming edges.`);
}
if (draft.edges.some((e) => e.from === args.from && e.to === args.to)) {
return fail(
draft,
`Edge "${args.from}" → "${args.to}" already exists.`,
);
}
const edge: Edge = { from: args.from, to: args.to };
if (args.condition !== undefined) edge.condition = args.condition;
if (args.label !== undefined) edge.label = args.label;
if (args.attrs !== undefined) edge.attrs = { ...args.attrs };
return ok({ ...draft, edges: [...draft.edges, edge] });
}
function applyDisconnect(
draft: WorkflowDraft,
args: { from: string; to: string },
): ApplyResult {
const exists = draft.edges.some(
(e) => e.from === args.from && e.to === args.to,
);
if (!exists) {
return fail(
draft,
`Edge "${args.from}" → "${args.to}" does not exist.`,
);
}
return ok({
...draft,
edges: draft.edges.filter(
(e) => !(e.from === args.from && e.to === args.to),
),
});
}

View file

@ -0,0 +1,28 @@
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import type { WorkflowDraft } from "../state/draft";
import {
buildDownloadBundle,
triggerDownload,
} from "../files/download";
/**
* Bundles the draft into the `.fabro.zip` layout and kicks a browser
* download. The pure parts of that flow live in `files/download.ts`; this
* component only owns the click handler and the visual treatment.
*
* Enabled even in the welcome state the artifact is still a runnable,
* minimal workflow worth taking away.
*/
export default function DownloadButton({ draft }: { draft: WorkflowDraft }) {
return (
<button
type="button"
onClick={() => triggerDownload(buildDownloadBundle(draft))}
className="inline-flex items-center gap-1.5 rounded-md bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-200 ring-1 ring-teal-500/30 transition-colors hover:bg-teal-500/20 hover:text-teal-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-4" />
Download .fabro.zip
</button>
);
}

View file

@ -0,0 +1,96 @@
import { useMemo, useState } from "react";
import type { WorkflowDraft } from "../state/draft";
import { renderFabro } from "../files/render-fabro";
import { renderProjectToml, renderWorkflowToml } from "../files/render-toml";
import { renderReadme } from "../files/render-readme";
/** A single file the user can preview before downloading the zip. */
type FileTab = {
id: string;
label: string;
language: string;
render: (draft: WorkflowDraft) => string;
};
const TABS: FileTab[] = [
{
id: "workflow.fabro",
label: "workflow.fabro",
language: "dot",
render: (draft) => renderFabro(draft),
},
{
id: "workflow.toml",
label: "workflow.toml",
language: "toml",
render: (draft) => renderWorkflowToml(draft),
},
{
id: "project.toml",
label: "project.toml",
language: "toml",
render: (draft) => renderProjectToml(draft),
},
{
id: "README.md",
label: "README.md",
language: "markdown",
render: (draft) => renderReadme(draft),
},
];
/**
* Tabbed preview of the four files that ship in the downloaded zip.
*
* All four are live-rendered from the draft (no debounce file generation
* is microseconds at the sizes a playground workflow reaches). The active
* tab persists in local component state, not the draft, so switching tabs
* never alters what gets downloaded.
*/
export default function FileTabs({ draft }: { draft: WorkflowDraft }) {
const [activeId, setActiveId] = useState(TABS[0]!.id);
const active = TABS.find((t) => t.id === activeId) ?? TABS[0]!;
const body = useMemo(() => active.render(draft), [active, draft]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40">
<div
role="tablist"
aria-label="Generated files"
className="flex shrink-0 items-center gap-0.5 border-b border-line px-1.5 py-1.5"
>
{TABS.map((tab) => {
const isActive = tab.id === activeId;
return (
<button
type="button"
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveId(tab.id)}
className={[
"rounded px-2.5 py-1 font-mono text-[11.5px] transition-colors",
isActive
? "bg-teal-500/10 text-teal-200 ring-1 ring-teal-500/30"
: "text-fg-muted hover:bg-overlay hover:text-fg-3",
].join(" ")}
>
{tab.label}
</button>
);
})}
</div>
<pre
role="tabpanel"
aria-label={active.label}
data-language={active.language}
className="m-0 min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg-2"
>
{body}
</pre>
</div>
);
}

View file

@ -0,0 +1,146 @@
import { XMarkIcon } from "@heroicons/react/24/outline";
import type { Edge, Node, Shape, WorkflowDraft } from "../state/draft";
const SHAPE_KIND_LABELS: Record<Shape, string> = {
box: "agent",
tab: "single LLM call",
parallelogram: "shell script",
hexagon: "human gate",
diamond: "conditional branch",
component: "fan-out parallel",
tripleoctagon: "merge parallel",
house: "sub-workflow",
mdiamond: "start (terminal)",
msquare: "exit (terminal)",
};
/**
* Read-only node inspector. Lives in the right pane while a node is
* selected on the canvas; replaces the RUN TRACE log when active.
* Mirrors the explainer's node-detail panel, scaled to fit the
* narrow column.
*/
export default function NodeInspector({
node,
draft,
onClose,
}: {
node: Node;
draft: WorkflowDraft;
onClose: () => void;
}) {
const incoming = draft.edges.filter((e) => e.to === node.id);
const outgoing = draft.edges.filter((e) => e.from === node.id);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-start justify-between gap-2 border-b border-line px-3 py-2">
<div className="min-w-0">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted">
Inspector
</div>
<div className="mt-0.5 truncate text-sm font-semibold text-fg">
{node.label}
</div>
</div>
<button
type="button"
aria-label="Close inspector"
onClick={onClose}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<XMarkIcon className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-auto p-3 text-xs">
<Field label="id">
<span className="font-mono text-fg-2">{node.id}</span>
</Field>
<Field label="shape">
<span className="font-mono text-fg-2">{node.shape}</span>
<span className="ml-1.5 text-fg-muted">
· {SHAPE_KIND_LABELS[node.shape]}
</span>
</Field>
{node.prompt && (
<Field label="prompt">
<p className="whitespace-pre-wrap text-fg-2">{node.prompt}</p>
</Field>
)}
{node.attrs && Object.keys(node.attrs).length > 0 && (
<Field label="attrs">
<dl className="space-y-1">
{Object.entries(node.attrs).map(([k, v]) => (
<div key={k} className="grid grid-cols-[auto_1fr] gap-2">
<dt className="font-mono text-fg-muted">{k}</dt>
<dd className="break-words font-mono text-fg-2">
{formatAttrValue(v)}
</dd>
</div>
))}
</dl>
</Field>
)}
<Field label="edges in">
<EdgeList edges={incoming} idKey="from" emptyMsg="(none)" />
</Field>
<Field label="edges out">
<EdgeList edges={outgoing} idKey="to" emptyMsg="(none)" />
</Field>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-wider text-fg-muted">
{label}
</div>
<div>{children}</div>
</div>
);
}
function EdgeList({
edges,
idKey,
emptyMsg,
}: {
edges: Edge[];
idKey: "from" | "to";
emptyMsg: string;
}) {
if (edges.length === 0) {
return <span className="italic text-fg-muted">{emptyMsg}</span>;
}
return (
<ul className="space-y-1">
{edges.map((edge, i) => {
const cond = edge.condition ? ` (condition: ${edge.condition})` : "";
const label = edge.label ? ` "${edge.label}"` : "";
return (
<li key={`${edge.from}-${edge.to}-${i}`} className="font-mono text-fg-2">
{edge[idKey]}
<span className="text-fg-muted">
{label}
{cond}
</span>
</li>
);
})}
</ul>
);
}
function formatAttrValue(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}

View file

@ -0,0 +1,49 @@
import { useState } from "react";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
/**
* "Start over" wipes the localStorage draft and resets the canvas
* back to the welcome state. Confirms inline before firing so a
* misclick on an actively-built graph doesn't silently torch the
* user's work.
*/
export default function ResetButton({ onReset }: { onReset: () => void }) {
const [confirming, setConfirming] = useState(false);
if (confirming) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-coral/10 px-2 py-1 text-sm text-coral ring-1 ring-coral/30">
<span>Start over?</span>
<button
type="button"
onClick={() => {
onReset();
setConfirming(false);
}}
className="rounded px-1.5 py-0.5 font-medium ring-1 ring-coral/40 hover:bg-coral/20"
>
Yes
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded px-1.5 py-0.5 text-fg-muted hover:bg-overlay hover:text-fg-2"
>
Cancel
</button>
</span>
);
}
return (
<button
type="button"
onClick={() => setConfirming(true)}
title="Wipe the canvas and start a new workflow"
className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-3 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<ArrowPathIcon className="size-4" />
Start over
</button>
);
}

View file

@ -0,0 +1,68 @@
import { useState } from "react";
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
import type { WorkflowDraft } from "../state/draft";
import { isWelcomeState } from "../state/draft";
import RunForRealModal from "./run-for-real-modal";
export interface RealRunRedirect {
href: string;
/** Button label override. Defaults to "Run for real". */
label?: string;
}
/**
* "Run for real" toolbar button. Two modes:
*
* - **Default** (fabro-web): the button opens a confirmation modal that
* POSTs the workflow to `/api/v1/runs` and redirects to the resulting
* run page.
* - **Redirect** (custom embed): when `redirect` is supplied, the button
* renders as a plain anchor pointing at the configured href. Use this
* in embed contexts that have no backend to launch against, to send
* visitors to a CTA URL (e.g. `/download`) instead.
*
* Disabled in the welcome state for the default mode running an empty
* workflow is pointless. The redirect mode stays enabled because the
* destination is informational, not a real run.
*/
export default function RunForRealButton({
draft,
redirect,
}: {
draft: WorkflowDraft;
redirect?: RealRunRedirect;
}) {
const [isOpen, setIsOpen] = useState(false);
const classes =
"inline-flex items-center gap-1.5 rounded-md bg-fuchsia-500/10 px-3 py-1.5 text-sm font-medium text-fuchsia-200 ring-1 ring-fuchsia-500/30 transition-colors hover:bg-fuchsia-500/20 hover:text-fuchsia-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-fuchsia-500/10 disabled:hover:text-fuchsia-200";
if (redirect) {
return (
<a href={redirect.href} className={classes}>
<RocketLaunchIcon className="size-4" />
{redirect.label ?? "Run for real"}
</a>
);
}
const disabled = isWelcomeState(draft);
return (
<>
<button
type="button"
disabled={disabled}
title={disabled ? "Add at least one node first" : undefined}
onClick={() => setIsOpen(true)}
className={classes}
>
<RocketLaunchIcon className="size-4" />
Run for real
</button>
{isOpen && (
<RunForRealModal draft={draft} onClose={() => setIsOpen(false)} />
)}
</>
);
}

View file

@ -0,0 +1,178 @@
import { useState } from "react";
import {
CommandLineIcon,
ExclamationTriangleIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { useDocumentEvent } from "../../../hooks/effects";
import { buildRunManifest } from "../state/build-manifest";
import type { WorkflowDraft } from "../state/draft";
/**
* "Run for real" confirmation modal. Placeholder project/repo/folder
* selection the run is hard-wired to a fresh local sandbox for the
* initial release. When the automation branch lands the established
* project-picker pattern, the disabled inputs below become the live
* surface.
*/
export default function RunForRealModal({
draft,
onClose,
}: {
draft: WorkflowDraft;
onClose: () => void;
}) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useDocumentEvent("keydown", (event) => {
if (event.key === "Escape" && !submitting) onClose();
});
const launch = async () => {
setSubmitting(true);
setError(null);
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.");
}
window.location.assign(`/runs/${body.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setSubmitting(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="run-for-real-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-bg/80 backdrop-blur-sm"
onClick={(event) => {
if (event.target === event.currentTarget && !submitting) onClose();
}}
>
<div className="w-full max-w-md rounded-lg border border-line bg-panel p-5 shadow-2xl">
<header className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<CommandLineIcon className="size-5 text-teal-300" aria-hidden="true" />
<h2
id="run-for-real-title"
className="text-base font-semibold text-fg"
>
Run for real
</h2>
</div>
<button
type="button"
aria-label="Close"
onClick={onClose}
disabled={submitting}
className="inline-flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg disabled:opacity-50"
>
<XMarkIcon className="size-4" />
</button>
</header>
<p className="mb-4 text-sm text-fg-2">
This launches your workflow as a real Fabro run, redirecting you to
its run page when it starts. The run executes in a fresh local
sandbox for now project, repo, and folder selection are coming
soon.
</p>
<fieldset
disabled
aria-disabled="true"
className="mb-4 space-y-3 rounded-md border border-line bg-overlay/30 p-3 opacity-60"
>
<legend className="px-1 font-mono text-[10px] uppercase tracking-wider text-fg-muted">
Where to run (coming soon)
</legend>
<label className="block text-xs">
<span className="mb-1 block text-fg-muted">Project</span>
<input
type="text"
placeholder="No connected projects yet"
className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted"
/>
</label>
<label className="block text-xs">
<span className="mb-1 block text-fg-muted">GitHub repo</span>
<input
type="text"
placeholder="github.com/owner/repo"
className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted"
/>
</label>
<label className="block text-xs">
<span className="mb-1 block text-fg-muted">Local folder</span>
<input
type="text"
placeholder="/path/to/repo"
className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted"
/>
</label>
</fieldset>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-200">
<ExclamationTriangleIcon
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<div className="mb-0.5 font-semibold">Couldn't launch the run</div>
<div className="break-words">{error}</div>
</div>
</div>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-md px-3 py-1.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={launch}
disabled={submitting}
className="inline-flex items-center gap-1.5 rounded-md bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-200 ring-1 ring-teal-500/30 transition-colors hover:bg-teal-500/20 hover:text-teal-100 disabled:opacity-50"
>
{submitting ? "Launching…" : "Run in sandbox"}
</button>
</div>
</div>
</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;
}
}

View file

@ -0,0 +1,54 @@
import type { SimulationState } from "../canvas/simulation";
/**
* Tiny live log next to the canvas during a simulated run. Mirrors the
* cadence of the explainer's "RUN TRACE" pane: one mono line per step,
* timestamped, with the active one highlighted.
*/
export default function RunTrace({ state }: { state: SimulationState }) {
if (state.trace.length === 0) {
return (
<p className="px-3 py-2 font-mono text-[11px] text-fg-muted">
Press <span className="text-fg-2">Simulate</span> to walk this graph.
</p>
);
}
return (
<ul className="flex flex-col gap-0.5 px-2 py-2 font-mono text-[11px]">
{state.trace.map((step) => {
const isActive = state.active === step.nodeId && !state.finished;
const isDone = !isActive;
return (
<li
key={step.index}
className={[
"flex items-baseline gap-2 rounded px-2 py-1 transition-colors",
isActive && "bg-teal-500/10 text-teal-200 ring-1 ring-teal-500/30",
isDone && "text-fg-3",
]
.filter(Boolean)
.join(" ")}
>
<span className="text-fg-muted tabular-nums">
{formatElapsed(step.elapsedMs)}
</span>
<span className="truncate">
<span className="text-fg-muted">{step.nodeId}</span>
{step.label !== step.nodeId && (
<span className="ml-1.5 text-fg-2">{step.label}</span>
)}
</span>
</li>
);
})}
{state.finished && (
<li className="px-2 py-1 text-mint"> done </li>
)}
</ul>
);
}
function formatElapsed(ms: number): string {
const s = (ms / 1000).toFixed(1);
return `${s.padStart(5, " ")}s`;
}

View file

@ -0,0 +1,69 @@
import {
ArrowPathIcon,
PlayIcon,
} from "@heroicons/react/24/solid";
import {
MAX_STEP_MS,
MIN_STEP_MS,
type PlaygroundSimulation,
} from "../canvas/use-simulation";
/**
* Play / Reset buttons + speed slider. The Play button is disabled when
* the draft is the welcome state (start ??? exit) there's nothing
* to walk yet. The slider is the explainer's "speed" control: 500ms
* (snappy) to 3000ms (slow tour).
*/
export default function SimulationControls({
sim,
}: {
sim: PlaygroundSimulation;
}) {
return (
<div className="flex items-center gap-3">
<button
type="button"
onClick={sim.play}
disabled={!sim.isPlayable || sim.isRunning}
title={
sim.isPlayable
? "Simulate a walk through the workflow"
: "Add a node first"
}
className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<PlayIcon className="size-3.5 text-teal-300" />
{sim.isRunning ? "Running…" : "Simulate"}
</button>
<button
type="button"
onClick={sim.reset}
disabled={sim.state.trace.length === 0}
title="Reset simulation"
className="inline-flex size-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-40"
>
<ArrowPathIcon className="size-3.5" />
</button>
<div className="flex items-center gap-2 text-[11px] text-fg-muted">
<span className="font-mono">speed</span>
<input
type="range"
min={MIN_STEP_MS}
max={MAX_STEP_MS}
step={250}
// Slider feels natural when "right = fast"; invert via max+min-value.
value={MAX_STEP_MS + MIN_STEP_MS - sim.stepMs}
onChange={(e) =>
sim.setStepMs(
MAX_STEP_MS + MIN_STEP_MS - Number.parseInt(e.currentTarget.value, 10),
)
}
className="h-1 w-24 cursor-pointer accent-teal-500"
aria-label="Simulation speed"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { WorkflowDraft } from "../state/draft";
/**
* Read-only display of the workflow's auto-generated name and goal.
*
* Per the spec, neither field is user-editable in v1 the model sets
* both via `set_workflow_meta`. Until it does, we show muted placeholder
* copy so the canvas header has a stable shape rather than collapsing.
*/
export default function WorkflowHeader({ draft }: { draft: WorkflowDraft }) {
const named = draft.name !== "untitled" && draft.name.length > 0;
const hasGoal = draft.goal.length > 0;
return (
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-baseline gap-2">
<h1 className="truncate text-sm font-semibold text-fg">
{named ? draft.name : "untitled workflow"}
</h1>
{!named && (
<span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted">
(Ask Fabro will name it)
</span>
)}
</div>
<p
className={`truncate text-xs ${hasGoal ? "text-fg-3" : "text-fg-muted italic"}`}
>
{hasGoal ? draft.goal : "Describe a workflow in the chat to get going."}
</p>
</div>
);
}

View file

@ -10,6 +10,7 @@ import * as ChatsLayout from "./routes/chats-layout";
import * as ChatsNew from "./routes/chats-new";
import * as ChatsDetail from "./routes/chats-detail";
import * as AskFabro from "./routes/ask-fabro";
import * as Playground from "./routes/playground";
import * as Automations from "./routes/automations";
import * as AutomationsNew from "./routes/automations-new";
import * as AutomationsEdit from "./routes/automations-edit";
@ -110,6 +111,7 @@ export const routes: RouteObject[] = [
],
}),
route("ask-fabro", AskFabro),
route("playground", Playground),
route("automations", Automations),
route("automations/new", AutomationsNew),
route("automations/:id/edit", AutomationsEdit),

View file

@ -0,0 +1,21 @@
import Playground from "../components/playground/playground";
// hideHeader/fullHeight/wide mirror the chats / ask-fabro routes so the
// workspace and the docked playground sidebar bleed edge-to-edge below the
// top nav. AppShell already auth-gates this tree, so `authMode="required"`
// here is descriptive (it tells the Playground component what guarantee
// its parent provides), not enforced inside the playground itself.
export const handle = { hideHeader: true, fullHeight: true, wide: true };
export function meta() {
return [{ title: "Playground — Fabro" }];
}
export default function PlaygroundRoute() {
return (
<Playground
chatEndpoint="/api/v1/playground/chat"
authMode="required"
/>
);
}

View file

@ -28,6 +28,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.0",
"fflate": "^0.8.3",
"marked": "^18.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View file

@ -25,6 +25,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.0",
"fflate": "^0.8.3",
"marked": "^18.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View file

@ -5317,6 +5317,44 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Playground ────────────────────────────────────────────────────────
/api/v1/playground/chat:
post:
operationId: createPlaygroundChat
tags: [Playground]
summary: Chat with the playground assistant
description: |
Drives a single turn of the playground chat that builds a workflow
graph incrementally. The server is stateless: each request includes
the full current draft, and the response streams text deltas plus a
single `write_workflow_file` tool call carrying the full new contents
of `workflow.fabro` for the client to parse, diff against its local
draft, and animate into the canvas.
Responses are always SSE. Frames use `event: stream_event` with a
JSON-serialized StreamEvent payload — see /api/v1/completions for the
StreamEvent shape. The tool call arrives on a `tool_call_end` event
with the tool name and parsed JSON arguments.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreatePlaygroundChatRequest"
responses:
"200":
description: SSE stream of text deltas and tool calls.
"400":
description: Invalid request
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Settings ──────────────────────────────────────────────────────────
/api/v1/settings:
@ -7858,6 +7896,36 @@ components:
output:
description: Parsed structured output when schema was provided.
# ── Playground ────────────────────────────────────────────────────────
CreatePlaygroundChatRequest:
description: >
Body of POST /api/v1/playground/chat. The server is stateless across
turns: the browser owns the draft and submits it as the literal
`workflow.fabro` contents with every turn. The server embeds the
file in the model's system prompt and exposes a single
`write_workflow_file` tool that emits the full new contents of
`workflow.fabro`; the browser parses, diffs, and animates the
result.
type: object
required: [messages, workflow_fabro]
properties:
messages:
type: array
description: assistant-ui-style message history for the turn.
items:
$ref: "#/components/schemas/CompletionMessage"
workflow_fabro:
type: string
description: >
Full current `workflow.fabro` (Graphviz DOT) contents as
rendered by the client — a complete `digraph <name> { ... }`
block including the `start` / `exit` terminals. This is the
same format the model writes back via `write_workflow_file`.
model:
type: string
description: Model id or alias. Server picks the default if omitted.
PaginatedSavedQueryList:
description: Paginated list of saved queries.
type: object

View file

@ -644,6 +644,9 @@ fn main() {
("SessionRecord", "fabro_types::SessionRecord", &[]),
("SessionSummary", "fabro_types::SessionSummary", &[]),
("SessionDetail", "fabro_types::SessionDetail", &[]),
("CompletionMessage", "fabro_types::Message", &[]),
("CompletionMessageRole", "fabro_types::Role", &[]),
("CompletionContentPart", "fabro_types::ContentPart", &[]),
];
for (name, path, impls) in replacements {
settings.with_replacement(*name, *path, impls.iter().copied());

View file

@ -40,17 +40,17 @@ pub mod types {
pub use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro,
AuthMethod, AutomationRef, BilledTokenCounts, CommandTermination, Conclusion,
AuthMethod, AutomationRef, BilledTokenCounts, CommandTermination, Conclusion, ContentPart,
CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail,
FailureCategory, FailureDetail, FailureSignature, GitContext, IdpIdentity,
IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus,
IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord,
McpServerProjection, McpServerStatus, PairId, PairMessageId, PairMessageRecord,
McpServerProjection, McpServerStatus, Message, PairId, PairMessageId, PairMessageRecord,
PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget,
PairTranscriptEntry, PairTranscriptResponse, PendingInterviewRecord, PermissionLevel,
PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance,
QuestionType, RepositoryRef, Role, Run, RunApproval, RunApprovalState, RunClientProvenance,
RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,
RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime,

View file

@ -0,0 +1,153 @@
//! Proves the `CompletionMessage` / `CompletionMessageRole` /
//! `CompletionContentPart` OpenAPI schemas are served by the canonical
//! `fabro_types::{Message, Role, ContentPart}` via build.rs
//! `with_replacement`, and that the canonical serde output matches the
//! wire shape the spec describes.
use std::any::{TypeId, type_name};
use fabro_api::types::{ContentPart as ApiContentPart, Message as ApiMessage, Role as ApiRole};
use fabro_types::{ContentPart, Message, Role, ToolCall, ToolResult};
use serde_json::json;
#[test]
fn completion_message_reuses_domain_types() {
assert_same_type::<ApiMessage, Message>();
assert_same_type::<ApiRole, Role>();
assert_same_type::<ApiContentPart, ContentPart>();
}
#[test]
fn role_json_matches_openapi_enum() {
for (role, wire) in [
(Role::System, "system"),
(Role::User, "user"),
(Role::Assistant, "assistant"),
(Role::Tool, "tool"),
(Role::Developer, "developer"),
] {
assert_eq!(serde_json::to_value(role).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<Role>(json!(wire)).unwrap(),
role,
"round trip for {wire}"
);
}
}
#[test]
fn message_json_matches_openapi_shape() {
// Optional fields are omitted, not serialized as null.
assert_eq!(
serde_json::to_value(Message::user("hello")).unwrap(),
json!({
"role": "user",
"content": [{"kind": "text", "data": "hello"}]
})
);
// Populated optionals appear under the spec's property names.
let mut message = Message::tool_result("call_1", json!("ok"), false);
message.name = Some("checker".to_string());
assert_eq!(
serde_json::to_value(message).unwrap(),
json!({
"role": "tool",
"content": [{
"kind": "tool_result",
"data": {
"tool_call_id": "call_1",
"content": "ok",
"is_error": false
}
}],
"name": "checker",
"tool_call_id": "call_1"
})
);
}
#[test]
fn message_accepts_explicit_nulls_for_optionals() {
// The previously generated API type serialized absent optionals as
// explicit nulls; inbound payloads in that older shape must keep
// parsing.
let message: Message = serde_json::from_value(json!({
"role": "assistant",
"content": [{"kind": "text", "data": "hi"}],
"name": null,
"tool_call_id": null
}))
.unwrap();
assert_eq!(message.role, Role::Assistant);
assert_eq!(message.name, None);
assert_eq!(message.tool_call_id, None);
}
#[test]
fn content_part_json_matches_openapi_envelope() {
// The spec describes a `{kind, data}` envelope; every variant must
// serialize into it.
assert_eq!(
serde_json::to_value(ContentPart::text("hi")).unwrap(),
json!({"kind": "text", "data": "hi"})
);
assert_eq!(
serde_json::to_value(ContentPart::ToolCall(ToolCall::new(
"call_1",
"write_workflow_file",
json!({"file_name": "workflow.fabro"}),
)))
.unwrap(),
json!({
"kind": "tool_call",
"data": {
"id": "call_1",
"name": "write_workflow_file",
"type": "function",
"arguments": {"file_name": "workflow.fabro"},
"raw_arguments": null
}
})
);
assert_eq!(
serde_json::to_value(ContentPart::ToolResult(ToolResult::success(
"call_1",
json!("done"),
)))
.unwrap(),
json!({
"kind": "tool_result",
"data": {
"tool_call_id": "call_1",
"content": "done",
"is_error": false
}
})
);
}
#[test]
fn content_part_preserves_unknown_kinds() {
// The spec leaves `kind` open-ended; unknown kinds must round-trip
// (previously the handler conversion silently dropped them).
let wire = json!({"kind": "mystery", "data": {"x": 1}});
let part: ContentPart = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(part, ContentPart::Other {
kind: "mystery".to_string(),
data: json!({"x": 1}),
});
assert_eq!(serde_json::to_value(part).unwrap(), wire);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -1,105 +1,23 @@
use std::collections::HashMap;
use std::sync::Arc;
// --- 3.1 / 3.2 / 3.5 Canonical chat + content data structures ---
//
// `Message`, `Role`, `ContentPart`, `ImageData`, `AudioData`,
// `DocumentData`, `ThinkingData`, `ToolCall`, and `ToolResult` are the
// canonical provider-neutral replay primitives. They live in `fabro-types`
// so the event stream, API responses, and runtime history can share one
// model. They are re-exported here so existing `fabro_llm::types::*`
// imports keep working.
pub use fabro_types::{
AudioData, ContentPart, DocumentData, ImageData, Message, Role, ThinkingData, ToolCall,
ToolResult,
};
use fabro_util::backoff::BackoffPolicy;
use serde::{Deserialize, Serialize};
use crate::error::Error;
// --- 3.2 Role ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
User,
Assistant,
Tool,
Developer,
}
// --- 3.5 Content Data Structures ---
//
// `ContentPart`, `ImageData`, `AudioData`, `DocumentData`, `ThinkingData`,
// `ToolCall`, and `ToolResult` are the canonical provider-neutral replay
// primitives. They live in `fabro-types` so the event stream, API responses,
// and runtime history can share one model. They are re-exported here so
// existing `fabro_llm::types::*` imports keep working.
pub use fabro_types::{
AudioData, ContentPart, DocumentData, ImageData, ThinkingData, ToolCall, ToolResult,
};
// --- 3.1 Message ---
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentPart>,
pub name: Option<String>,
pub tool_call_id: Option<String>,
}
impl Message {
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn tool_result(
tool_call_id: impl Into<String>,
content: serde_json::Value,
is_error: bool,
) -> Self {
let id = tool_call_id.into();
Self {
role: Role::Tool,
content: vec![ContentPart::ToolResult(ToolResult {
tool_call_id: id.clone(),
content,
is_error,
image_data: None,
image_media_type: None,
})],
name: None,
tool_call_id: Some(id),
}
}
/// Concatenates text from all text content parts.
#[must_use]
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(|part| match part {
ContentPart::Text(text) => Some(text.as_str()),
_ => None,
})
.collect()
}
}
// --- 3.8 FinishReason ---
#[derive(Debug, Clone, PartialEq, Eq)]

View file

@ -28,21 +28,20 @@ pub use fabro_api::types::{
BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary, BatchRunLifecycleRequest,
BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome,
BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,
CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse,
CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse,
DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow,
DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider,
ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection,
RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run, RunArtifactEntry,
RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest,
RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,
StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreatePlaygroundChatRequest, CreateRunPullRequestRequest, CreateSecretRequest,
CreateVariableRequest, DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest,
DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry,
ForkRequest, ForkResponse, IntegrationConnectionKind, IntegrationConnectionState,
IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, LinkRunPullRequestRequest,
MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList,
PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse,
Provider, ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run,
RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse,
SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler,
StageState, StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources,
SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemIntegrationStatus,
SystemIntegrationsResponse, SystemMemoryResourceScope, SystemMemoryResources,
SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts,
@ -61,8 +60,7 @@ use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_llm::model_test::run_model_test;
use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Role, ToolChoice,
ToolDefinition,
FinishReason, Message as LlmMessage, Request as LlmRequest, ToolChoice, ToolDefinition,
};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId};

View file

@ -1,12 +1,12 @@
use std::sync::Arc;
use super::super::{
ApiError, AppState, CompletionContentPart, CompletionMessage, CompletionMessageRole,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, ContentPart,
CreateCompletionRequest, Duration, Event, FinishReason, GenerateParams, IntoResponse, Json,
KeepAlive, LlmMessage, LlmRequest, RequiredUser, Response, Role, Router, Sse, State,
StatusCode, ToolChoice, ToolDefinition, Ulid, error, generate_object, info, post, warn,
ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
CreateCompletionRequest, FinishReason, GenerateParams, IntoResponse, Json, LlmMessage,
LlmRequest, RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition,
Ulid, error, generate_object, info, post, warn,
};
use super::llm_sse;
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route("/completions", post(create_completion))
@ -23,54 +23,6 @@ fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
}
}
fn convert_api_message(msg: &CompletionMessage) -> LlmMessage {
let role = match msg.role {
CompletionMessageRole::System => Role::System,
CompletionMessageRole::User => Role::User,
CompletionMessageRole::Assistant => Role::Assistant,
CompletionMessageRole::Tool => Role::Tool,
CompletionMessageRole::Developer => Role::Developer,
};
let content: Vec<ContentPart> = msg
.content
.iter()
.filter_map(|part| {
let json = serde_json::to_value(part).ok()?;
serde_json::from_value(json).ok()
})
.collect();
LlmMessage {
role,
content,
name: msg.name.clone(),
tool_call_id: msg.tool_call_id.clone(),
}
}
fn convert_llm_message(msg: &LlmMessage) -> CompletionMessage {
let role = match msg.role {
Role::System => CompletionMessageRole::System,
Role::User => CompletionMessageRole::User,
Role::Assistant => CompletionMessageRole::Assistant,
Role::Tool => CompletionMessageRole::Tool,
Role::Developer => CompletionMessageRole::Developer,
};
let content: Vec<CompletionContentPart> = msg
.content
.iter()
.filter_map(|part| {
let json = serde_json::to_value(part).ok()?;
serde_json::from_value(json).ok()
})
.collect();
CompletionMessage {
role,
content,
name: msg.name.clone(),
tool_call_id: msg.tool_call_id.clone(),
}
}
async fn create_completion(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -92,14 +44,14 @@ async fn create_completion(
info!(model = %model_id, provider = ?provider_name, "Completion request received");
// Build messages list
// Build messages list. Request messages are already the canonical
// `fabro_types::Message` — the API schema reuses it via build.rs
// `with_replacement`, so no conversion is needed.
let mut messages: Vec<LlmMessage> = Vec::new();
if let Some(system) = req.system {
messages.push(LlmMessage::system(system));
}
for msg in &req.messages {
messages.push(convert_api_message(msg));
}
messages.extend(req.messages);
// Convert tools
let tools: Option<Vec<ToolDefinition>> = if req.tools.is_empty() {
@ -185,43 +137,7 @@ async fn create_completion(
}
};
let sse_stream = tokio_stream::StreamExt::filter_map(stream_result, |event| match event {
Ok(ref evt) => match serde_json::to_string(evt) {
Ok(json) => Some(Ok::<_, std::convert::Infallible>(
Event::default().event("stream_event").data(json),
)),
Err(e) => Some(Ok(Event::default().event("stream_event").data(
serde_json::json!({
"type": "error",
"error": {"Stream": {"message": format!("failed to serialize event: {e}")}},
"raw": null
})
.to_string(),
))),
},
Err(e) => Some(Ok(Event::default().event("stream_event").data(
serde_json::json!({
"type": "error",
"error": {"Stream": {"message": e.to_string()}},
"raw": null
})
.to_string(),
))),
});
let sse_stream = futures_util::StreamExt::take_until(
sse_stream,
state.shutdown_token().cancelled_owned(),
);
Sse::new(sse_stream)
.keep_alive(
KeepAlive::new().interval(Duration::from_secs(15)).event(
Event::default()
.event("ping")
.data(serde_json::json!({"type": "ping"}).to_string()),
),
)
.into_response()
llm_sse::stream_response(stream_result, state.shutdown_token())
} else {
// Non-streaming path
let msg_id = Ulid::new().to_string();
@ -244,35 +160,46 @@ async fn create_completion(
params = params.top_p(top_p);
}
match generate_object(params, schema).await {
Ok(result) => Json(CompletionResponse {
id: msg_id,
model: model_id,
message: convert_llm_message(&result.response.message),
stop_reason: finish_reason_to_api_stop_reason(&result.finish_reason),
usage: CompletionUsage {
input_tokens: result.usage.input_tokens,
output_tokens: result.usage.output_tokens,
},
output: result.output,
})
.into_response(),
Ok(result) => {
// `result.finish_reason` / `result.usage` resolve through
// GenerateResult's Deref to the inner Response; move the
// Response out once so `message` can be taken by value.
let output = result.output;
let response = result.response;
let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason);
Json(CompletionResponse {
id: msg_id,
model: model_id,
message: response.message,
stop_reason,
usage: CompletionUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
output,
})
.into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}
} else {
match client.complete(&request).await {
Ok(response) => Json(CompletionResponse {
id: response.id,
model: response.model,
message: convert_llm_message(&response.message),
stop_reason: finish_reason_to_api_stop_reason(&response.finish_reason),
usage: CompletionUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
output: None,
})
.into_response(),
Ok(response) => {
let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason);
Json(CompletionResponse {
id: response.id,
model: response.model,
message: response.message,
stop_reason,
usage: CompletionUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
output: None,
})
.into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}

View file

@ -0,0 +1,118 @@
//! Shared SSE plumbing for endpoints that proxy LLM `StreamEvent`s.
//!
//! `POST /api/v1/completions` and `POST /api/v1/playground/chat` both run an
//! LLM stream and forward every `StreamEvent` to the browser as a
//! `stream_event` SSE frame. This module owns that framing so the two
//! endpoints cannot drift: serialization failures and stream errors are
//! shaped into the same `{"type": "error", ...}` frame vocabulary, the
//! stream ends when the LLM stream ends or the server shuts down, and a
//! `ping` keep-alive frame goes out every 15 seconds.
use std::convert::Infallible;
use std::time::Duration;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use fabro_llm::types::StreamEvent;
use futures_util::{Stream, StreamExt};
use serde_json::json;
use tokio_util::sync::CancellationToken;
use tracing::error;
/// Forward LLM `StreamEvent`s as `stream_event` SSE frames until the LLM
/// stream ends or `shutdown` fires.
pub(super) fn stream_response(
stream: impl Stream<Item = Result<StreamEvent, fabro_llm::Error>> + Send + 'static,
shutdown: CancellationToken,
) -> Response {
let sse_stream = stream.map(|event| match event {
Ok(ref evt) => match serde_json::to_string(evt) {
Ok(json) => Ok::<_, Infallible>(Event::default().event("stream_event").data(json)),
Err(e) => Ok(Event::default().event("stream_event").data(
json!({
"type": "error",
"error": {"Stream": {"message": format!("failed to serialize event: {e}")}},
"raw": null
})
.to_string(),
)),
},
Err(e) => {
error!(error = %e, "LLM stream event error");
Ok(Event::default().event("stream_event").data(
json!({
"type": "error",
"error": {"Stream": {"message": e.to_string()}},
"raw": null
})
.to_string(),
))
}
});
let sse_stream = sse_stream.take_until(shutdown.cancelled_owned());
Sse::new(sse_stream)
.keep_alive(
KeepAlive::new().interval(Duration::from_secs(15)).event(
Event::default()
.event("ping")
.data(json!({"type": "ping"}).to_string()),
),
)
.into_response()
}
#[cfg(test)]
mod tests {
use axum::body::to_bytes;
use super::*;
async fn body_text(response: Response) -> String {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
String::from_utf8(bytes.to_vec()).expect("SSE body is UTF-8")
}
#[tokio::test]
async fn forwards_events_as_stream_event_frames() {
let stream = futures_util::stream::iter(vec![
Ok(StreamEvent::StreamStart),
Ok(StreamEvent::TextDelta {
delta: "hi".to_string(),
text_id: None,
}),
]);
let body = body_text(stream_response(stream, CancellationToken::new())).await;
assert!(body.contains("event: stream_event"), "body: {body}");
assert!(body.contains(r#""type":"stream_start""#), "body: {body}");
assert!(body.contains(r#""delta":"hi""#), "body: {body}");
}
#[tokio::test]
async fn shapes_stream_errors_into_error_frames() {
let stream = futures_util::stream::iter(vec![Err(fabro_llm::Error::Interrupt {
message: "boom".to_string(),
})]);
let body = body_text(stream_response(stream, CancellationToken::new())).await;
assert!(body.contains("event: stream_event"), "body: {body}");
assert!(body.contains(r#""type":"error""#), "body: {body}");
assert!(body.contains("boom"), "body: {body}");
}
#[tokio::test]
async fn shutdown_token_ends_the_stream() {
let shutdown = CancellationToken::new();
shutdown.cancel();
let stream = futures_util::stream::pending::<Result<StreamEvent, fabro_llm::Error>>();
let body = body_text(stream_response(stream, shutdown)).await;
assert!(
!body.contains("stream_event"),
"cancelled stream should emit no frames, body: {body}"
);
}
}

View file

@ -15,8 +15,10 @@ mod environments;
pub(in crate::server) mod events;
pub(in crate::server) mod graph;
pub(in crate::server) mod lifecycle;
mod llm_sse;
mod models;
mod pair;
mod playground;
mod pull_requests;
pub(in crate::server) mod runs;
mod sandbox;
@ -182,6 +184,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
.merge(graph::manifest_routes())
.merge(models::routes())
.merge(completions::routes())
.merge(playground::routes())
}
pub(super) fn real_routes() -> Router<Arc<AppState>> {
@ -223,4 +226,5 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(sessions::routes())
.merge(system::routes())
.merge(completions::routes())
.merge(playground::routes())
}

View file

@ -0,0 +1,279 @@
//! Playground chat endpoint.
//!
//! POST /api/v1/playground/chat drives a single turn of the chat-driven
//! workflow builder at /playground in fabro-web. The server is stateless
//! across turns: the browser owns the workflow draft and submits it as
//! the literal `workflow.fabro` contents with every request; the server
//! embeds the file in the system prompt, runs the LLM with a single
//! file-write tool surface, and streams the result back over SSE. The
//! browser parses the emitted `workflow.fabro` content, diffs it against
//! its current draft, and animates the resulting changes into the canvas.
use std::sync::Arc;
use serde_json::json;
use super::super::{
ApiError, AppState, CreatePlaygroundChatRequest, IntoResponse, Json, LlmMessage, LlmRequest,
RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, error, info,
post, warn,
};
use super::llm_sse;
/// Sanity caps on a playground chat request. Axum's default 2 MB body
/// limit already catches gigabyte payloads at the framework layer; these
/// add cheap, descriptive 400s before we touch the LLM so a misbehaving
/// or malicious client can't drag a multi-megabyte transcript through
/// streaming + token-billing.
const MAX_MESSAGES_PER_TURN: usize = 50;
/// Generous ceiling for the submitted `workflow.fabro` text: the canvas
/// caps out around a hundred nodes, which renders to roughly 12 KB of
/// DOT.
const MAX_WORKFLOW_FABRO_BYTES: usize = 32 * 1024;
fn validate_request(req: &CreatePlaygroundChatRequest) -> Result<(), ApiError> {
if req.messages.len() > MAX_MESSAGES_PER_TURN {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
format!(
"Conversation too long: {} messages (limit {MAX_MESSAGES_PER_TURN}). \
Start a new playground session.",
req.messages.len(),
),
));
}
if req.workflow_fabro.len() > MAX_WORKFLOW_FABRO_BYTES {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
format!(
"Workflow file too large: {} bytes (limit {MAX_WORKFLOW_FABRO_BYTES}).",
req.workflow_fabro.len(),
),
));
}
Ok(())
}
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route("/playground/chat", post(create_playground_chat))
}
/// System prompt template. The `{workflow_fabro}` placeholder receives
/// the literal `workflow.fabro` contents submitted with the request.
const SYSTEM_PROMPT_TEMPLATE: &str = include_str!("prompts/playground_system.md");
const WORKFLOW_FABRO_PLACEHOLDER: &str = "{workflow_fabro}";
fn build_system_prompt(workflow_fabro: &str) -> String {
SYSTEM_PROMPT_TEMPLATE.replace(WORKFLOW_FABRO_PLACEHOLDER, workflow_fabro)
}
/// The single file-write tool the model uses to update the workflow.
/// Each turn the model emits one call with the full new contents of
/// `workflow.fabro`. The browser parses the content, diffs it against
/// its current draft, and animates the resulting changes into the
/// canvas.
fn playground_tools() -> Vec<ToolDefinition> {
vec![ToolDefinition {
name: "write_workflow_file".into(),
description: "Write the full new contents of a workflow file. For the playground, only \
`workflow.fabro` is meaningful the model emits the complete DOT for the \
current desired state of the workflow. The previous file is replaced \
atomically; always include every node and edge, not just changes."
.into(),
parameters: json!({
"type": "object",
"required": ["file_name", "content"],
"properties": {
"file_name": {
"type": "string",
"enum": ["workflow.fabro"],
"description": "Target file name. Currently only `workflow.fabro` is supported."
},
"content": {
"type": "string",
"description": "Full DOT contents of the workflow file. Must be a complete `digraph <name> { ... }` block including `start` and `exit` terminals and every desired node and edge."
}
}
}),
}]
}
async fn create_playground_chat(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(req): Json<CreatePlaygroundChatRequest>,
) -> Response {
if let Err(e) = validate_request(&req) {
return e.into_response();
}
let catalog = state.catalog();
let model_id = req
.model
.unwrap_or_else(|| catalog.default_model().id.clone());
info!(model = %model_id, "Playground chat turn");
// Request messages are already the canonical `fabro_types::Message` —
// the API schema reuses it via build.rs `with_replacement`.
let mut messages: Vec<LlmMessage> = Vec::new();
messages.push(LlmMessage::system(build_system_prompt(&req.workflow_fabro)));
messages.extend(req.messages);
let request = LlmRequest {
model: model_id,
messages,
provider: None,
tools: Some(playground_tools()),
tool_choice: Some(ToolChoice::Auto),
response_format: None,
temperature: None,
top_p: None,
max_tokens: None,
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
};
let llm_result = match state.resolve_llm_client().await {
Ok(r) => r,
Err(err) => {
error!(error = ?err, "playground: failed to create LLM client");
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create LLM client: {err}"),
)
.into_response();
}
};
for (provider, issue) in &llm_result.auth_issues {
warn!(provider = %provider, error = %issue, "playground: provider auth issue");
}
let client = llm_result.client;
let stream_result = match client.stream(&request).await {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "playground: LLM stream call failed");
return ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response();
}
};
// Forward StreamEvents as `stream_event` SSE frames. The browser-side
// adapter listens for the `tool_call_end` event carrying the
// `write_workflow_file` arguments, parses the DOT, diffs it against
// its current draft, and animates the diff into the canvas.
llm_sse::stream_response(stream_result, state.shutdown_token())
}
#[cfg(test)]
mod tests {
use super::*;
/// Welcome-state DOT, matching the client renderer's canonical style.
const WELCOME_DOT: &str = r#"digraph untitled {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}
"#;
#[test]
fn system_prompt_embeds_workflow_file_verbatim() {
let dot = r#"digraph release_notes {
graph [goal="Generate release notes"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [shape=box, label="Plan", prompt="Plan it"]
start -> plan
plan -> exit
}
"#;
let prompt = build_system_prompt(dot);
assert!(prompt.contains(dot), "prompt should embed the DOT verbatim");
assert!(prompt.contains("digraph release_notes"));
assert!(prompt.contains("write_workflow_file"));
}
#[test]
fn system_prompt_explains_empty_canvas_convention() {
let prompt = build_system_prompt(WELCOME_DOT);
assert!(prompt.contains("the canvas is empty"));
assert!(prompt.contains("digraph snake_case_name"));
assert!(prompt.contains(WELCOME_DOT));
}
#[test]
fn system_prompt_template_substitutes_its_placeholder() {
assert_eq!(
SYSTEM_PROMPT_TEMPLATE
.matches(WORKFLOW_FABRO_PLACEHOLDER)
.count(),
1,
"template must contain the placeholder exactly once"
);
let prompt = build_system_prompt(WELCOME_DOT);
assert!(
!prompt.contains(WORKFLOW_FABRO_PLACEHOLDER),
"placeholder must be substituted away"
);
}
fn make_request(messages_len: usize, workflow_fabro: String) -> CreatePlaygroundChatRequest {
let messages = (0..messages_len)
.map(|_| fabro_types::Message {
role: fabro_types::Role::User,
content: Vec::new(),
name: None,
tool_call_id: None,
})
.collect();
CreatePlaygroundChatRequest {
messages,
workflow_fabro,
model: None,
}
}
#[test]
fn validate_rejects_oversize_message_history() {
let req = make_request(MAX_MESSAGES_PER_TURN + 1, WELCOME_DOT.to_string());
let err = validate_request(&req).expect_err("expected too-many-messages error");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn validate_rejects_oversize_workflow_file() {
let req = make_request(1, "x".repeat(MAX_WORKFLOW_FABRO_BYTES + 1));
let err = validate_request(&req).expect_err("expected too-large-file error");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn validate_accepts_normal_sized_requests() {
let req = make_request(10, WELCOME_DOT.to_string());
assert!(validate_request(&req).is_ok());
}
#[test]
fn tool_surface_is_single_file_write_tool() {
let tools = playground_tools();
assert_eq!(tools.len(), 1, "expected exactly one tool");
let tool = &tools[0];
assert_eq!(tool.name, "write_workflow_file");
let params = serde_json::to_value(&tool.parameters).expect("serialize params");
let required = params
.get("required")
.and_then(|r| r.as_array())
.expect("required array");
let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(required_names.contains(&"file_name"));
assert!(required_names.contains(&"content"));
}
}

View file

@ -0,0 +1,49 @@
You are Ask Fabro, helping the user build a Fabro workflow inside the /playground builder. Fabro workflows are Graphviz digraphs where each node's `shape` picks the handler:
- box: agent (multi-turn LLM with tools — the default)
- tab: a single LLM call
- parallelogram: a shell script (use a `script` attribute)
- hexagon: a human gate (pause for review)
- diamond: a conditional branch (multiple outgoing edges with a `condition`)
- component: fan-out parallel
- tripleoctagon: merge parallel
- house: a sub-workflow
To update the workflow, call the `write_workflow_file` tool exactly once per turn with the full new contents of `workflow.fabro`. The file you write REPLACES the previous one — always emit the complete workflow, even nodes and edges that didn't change.
Always include a brief one-line acknowledgement before the tool call so the chat doesn't feel silent — something like "Built the lint/test/PR pipeline." or "Added the fix-and-retry loop.". Keep it to one sentence; the canvas shows the details.
DOT template:
```
digraph snake_case_name {
graph [goal="One-sentence goal."]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [shape=box, label="Plan", prompt="Plan the work."]
implement [shape=box, label="Implement", prompt="..."]
start -> plan
plan -> implement
implement -> exit
}
```
Rules:
- snake_case node ids (e.g. `run_tests`, `open_pr`).
- `start` (shape=Mdiamond) and `exit` (shape=Msquare) are reserved terminals — always present, never renamed, never have prompts.
- Pick a clear snake_case name for the digraph (the `digraph <name>` token) as soon as the user's intent is obvious.
- Preserve existing node ids across turns. Only invent a new id for a genuinely new node — don't rename `lint` to `lint_step` just because you're regenerating the file.
- Every user-added node must be on a path from `start` to `exit`.
- For `diamond` branches, give each outgoing edge a `condition` attribute (e.g. `gate -> happy_path [condition="outcome=approved"]`).
- Escape `\` and `"` inside attribute strings.
Current `workflow.fabro` (exactly the file you are rewriting; if it only contains the `start` and `exit` terminals, the canvas is empty and you are building the user's first nodes):
```
{workflow_fabro}
```

View file

@ -158,8 +158,8 @@ pub use system_integrations::{
pub use timing::{RunTiming, StageTiming};
pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus};
pub use transcript::{
AudioData, ContentPart, DocumentData, ImageData, MessageId, MessageKind, MessageSource,
PairMessageRef, ThinkingData, ToolCall, ToolResult, TranscriptMessage,
AudioData, ContentPart, DocumentData, ImageData, Message, MessageId, MessageKind,
MessageSource, PairMessageRef, Role, ThinkingData, ToolCall, ToolResult, TranscriptMessage,
};
pub use variable::{
CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name,

View file

@ -253,6 +253,98 @@ impl ContentPart {
}
}
// --- Role / Message
// -----------------------------------------------------------
/// Author role of a chat [`Message`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
User,
Assistant,
Tool,
Developer,
}
/// Provider-neutral chat message exchanged with an LLM.
///
/// This is the request/response message shape shared by `fabro-llm`
/// requests and the completions API wire contract. The durable
/// session-transcript record is [`TranscriptMessage`], which carries
/// identity, provenance, and usage on top of the same [`ContentPart`]
/// vocabulary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: vec![ContentPart::text(text)],
name: None,
tool_call_id: None,
}
}
pub fn tool_result(
tool_call_id: impl Into<String>,
content: serde_json::Value,
is_error: bool,
) -> Self {
let id = tool_call_id.into();
Self {
role: Role::Tool,
content: vec![ContentPart::ToolResult(ToolResult {
tool_call_id: id.clone(),
content,
is_error,
image_data: None,
image_media_type: None,
})],
name: None,
tool_call_id: Some(id),
}
}
/// Concatenates text from all text content parts.
#[must_use]
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(|part| match part {
ContentPart::Text(text) => Some(text.as_str()),
_ => None,
})
.collect()
}
}
// --- TranscriptMessage ------------------------------------------------------
/// Provider/model-role semantics for a committed transcript message.

View file

@ -10,6 +10,7 @@ api/insights-api.ts
api/install-api.ts
api/integrations-api.ts
api/models-api.ts
api/playground-api.ts
api/repos-api.ts
api/run-internals-api.ts
api/run-outputs-api.ts
@ -96,6 +97,7 @@ models/conclusion.ts
models/create-automation-request.ts
models/create-completion-request.ts
models/create-environment-request.ts
models/create-playground-chat-request.ts
models/create-run-pull-request-request.ts
models/create-run-session-request.ts
models/create-secret-request.ts

View file

@ -25,6 +25,7 @@ export * from './api/insights-api';
export * from './api/install-api';
export * from './api/integrations-api';
export * from './api/models-api';
export * from './api/playground-api';
export * from './api/repos-api';
export * from './api/run-internals-api';
export * from './api/run-outputs-api';

View file

@ -0,0 +1,132 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreatePlaygroundChatRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
/**
* PlaygroundApi - axios parameter creator
*/
export const PlaygroundApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments.
* @summary Chat with the playground assistant
* @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPlaygroundChat: async (createPlaygroundChatRequest: CreatePlaygroundChatRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createPlaygroundChatRequest' is not null or undefined
assertParamExists('createPlaygroundChat', 'createPlaygroundChatRequest', createPlaygroundChatRequest)
const localVarPath = `/api/v1/playground/chat`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createPlaygroundChatRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* PlaygroundApi - functional programming interface
*/
export const PlaygroundApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = PlaygroundApiAxiosParamCreator(configuration)
return {
/**
* Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments.
* @summary Chat with the playground assistant
* @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createPlaygroundChat(createPlaygroundChatRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['PlaygroundApi.createPlaygroundChat']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* PlaygroundApi - factory interface
*/
export const PlaygroundApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = PlaygroundApiFp(configuration)
return {
/**
* Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments.
* @summary Chat with the playground assistant
* @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.createPlaygroundChat(createPlaygroundChatRequest, options).then((request) => request(axios, basePath));
},
};
};
/**
* PlaygroundApi - object-oriented interface
*/
export class PlaygroundApi extends BaseAPI {
/**
* Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments.
* @summary Chat with the playground assistant
* @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig) {
return PlaygroundApiFp(this.configuration).createPlaygroundChat(createPlaygroundChatRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -0,0 +1,36 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { CompletionMessage } from './completion-message';
/**
* Body of POST /api/v1/playground/chat. The server is stateless across turns: the browser owns the draft and submits it as the literal `workflow.fabro` contents with every turn. The server embeds the file in the model\'s system prompt and exposes a single `write_workflow_file` tool that emits the full new contents of `workflow.fabro`; the browser parses, diffs, and animates the result.
*/
export interface CreatePlaygroundChatRequest {
/**
* assistant-ui-style message history for the turn.
*/
'messages': Array<CompletionMessage>;
/**
* Full current `workflow.fabro` (Graphviz DOT) contents as rendered by the client a complete `digraph <name> { ... }` block including the `start` / `exit` terminals. This is the same format the model writes back via `write_workflow_file`.
*/
'workflow_fabro': string;
/**
* Model id or alias. Server picks the default if omitted.
*/
'model'?: string;
}

View file

@ -69,6 +69,7 @@ export * from './conclusion';
export * from './create-automation-request';
export * from './create-completion-request';
export * from './create-environment-request';
export * from './create-playground-chat-request';
export * from './create-run-pull-request-request';
export * from './create-run-session-request';
export * from './create-secret-request';