Merge pull request #839 from fabro-sh/codex/remove-playground

Remove unsupported workflow playground
This commit is contained in:
Scott Werner 2026-09-04 11:18:00 -04:00 committed by GitHub
commit ab7cbeb4ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 45 additions and 6429 deletions

View file

@ -1,253 +0,0 @@
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

@ -1,97 +0,0 @@
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

@ -1,170 +0,0 @@
/**
* 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

@ -1,115 +0,0 @@
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

@ -1,163 +0,0 @@
/**
* 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

@ -1,73 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { importChunk } from "../../../lib/import-chunk";
/**
* 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 importChunk(() => 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

@ -1,79 +0,0 @@
/**
* 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

@ -1,68 +0,0 @@
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

@ -1,361 +0,0 @@
/**
* 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

@ -1,201 +0,0 @@
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

@ -1,119 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -1,76 +0,0 @@
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

@ -1,102 +0,0 @@
/**
* 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

@ -1,164 +0,0 @@
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

@ -1,142 +0,0 @@
/**
* 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

@ -1,33 +0,0 @@
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

@ -1,53 +0,0 @@
/**
* 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

@ -1,36 +0,0 @@
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

@ -1,47 +0,0 @@
/**
* 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

@ -1,178 +0,0 @@
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

@ -1,123 +0,0 @@
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

@ -1,102 +0,0 @@
/**
* 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

@ -1,59 +0,0 @@
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).toEqual({
path: ".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 target path use the snake_case name", () => {
const draft = {
...createInitialDraft(),
name: "release_notes",
goal: "Generate release notes.",
};
const manifest = buildRunManifest(draft);
expect(manifest.target).toEqual({
path: ".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

@ -1,81 +0,0 @@
/**
* 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: {
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: {
path: workflowPath,
},
workflows: {
[workflowPath]: {
source: renderFabro(draft),
config: {
path: "workflow.toml",
source: renderWorkflowToml(draft),
},
},
},
};
}

View file

@ -1,169 +0,0 @@
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

@ -1,171 +0,0 @@
/**
* 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

@ -1,96 +0,0 @@
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

@ -1,114 +0,0 @@
/**
* 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

@ -1,235 +0,0 @@
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("shapeless script node keeps command behavior after round trip", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
build [script="cargo build"]
start -> build -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
const reparsed = expectOk(parseFabro(renderFabro(draft)));
expect(reparsed.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
});
test("explicit type disables command shape inference", () => {
const draft = expectOk(
parseFabro(`digraph G {
work [type=agent, script="cargo build"]
}`),
);
expect(draft.nodes.find((n) => n.id === "work")?.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

@ -1,413 +0,0 @@
/**
* 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, 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(
attrs: Record<string, AttrValue>,
nodeId: string,
): Shape {
const raw = attrs.shape;
if (typeof raw !== "string") {
// Without an explicit shape or type, `script` selects the command
// handler. Keep the inferred shape when the draft is rendered again.
if (typeof attrs.type !== "string" && attrs.script !== undefined) {
return "parallelogram";
}
// The playground also infers terminal shapes from reserved ids.
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

@ -1,151 +0,0 @@
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

@ -1,112 +0,0 @@
/**
* 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

@ -1,323 +0,0 @@
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

@ -1,301 +0,0 @@
/**
* 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

@ -1,28 +0,0 @@
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

@ -1,96 +0,0 @@
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

@ -1,146 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,68 +0,0 @@
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

@ -1,178 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,10 +1,6 @@
// Pan/zoom viewport math for the run graph. There's no React in here, so it can be
// unit-tested on its own. Zoom is a continuous percentage rather than a discrete step
// index, which is what keeps cursor-anchored ⌘-scroll zoom smooth.
//
// The playground canvas (components/playground/canvas) has the same hand-rolled
// pan/zoom but still uses a discrete step index. If it ever wants cursor-anchored
// zoom, it can import this module.
export const GRAPH_MIN_ZOOM = 25; // percent; the clamp bounds. Widen if you want more range.
export const GRAPH_MAX_ZOOM_TB = 200; // top-down orientation

View file

@ -10,7 +10,6 @@ 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";
@ -117,7 +116,6 @@ 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

@ -1,21 +0,0 @@
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,7 +28,6 @@
"@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,7 +25,6 @@
"@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

@ -5840,44 +5840,6 @@ 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:
@ -8930,39 +8892,6 @@ components:
or catalog price estimation (estimated).
enum: [authoritative, estimated]
# ── 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 a ready-provider default if omitted.
provider:
$ref: "#/components/schemas/ProviderId"
description: Optional provider pin.
PaginatedSavedQueryList:
description: Paginated list of saved queries.
type: object

View file

@ -0,0 +1,14 @@
---
title: "Workflow Playground removed"
date: "2026-09-03"
---
<Warning>
The unsupported Workflow Playground and `POST /api/v1/playground/chat` have
been removed. Requests to that endpoint now return 404, and generated clients
no longer expose it. There is no direct replacement.
</Warning>
Workflows can still be defined in normal workflow files and executed with the
Fabro CLI. Existing chat, completion, and workflow graph features are
unchanged.

View file

@ -305,6 +305,13 @@
"tab": "Changelog",
"icon": "clock-rotate-left",
"groups": [
{
"group": "September 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-09-03"
]
},
{
"group": "August 2026",
"icon": "clock-rotate-left",

View file

@ -29,25 +29,24 @@ pub use fabro_api::types::{
BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome,
BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,
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, ProviderCredentialTestRequest, ProviderCredentialTestResponse, 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,
TimelineEntryResponse, UpdateVariableRequest, VariableListResponse, VncPreviewResponse,
WriteBlobResponse,
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,
ProviderCredentialTestRequest, ProviderCredentialTestResponse, 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, TimelineEntryResponse,
UpdateVariableRequest, VariableListResponse, VncPreviewResponse, WriteBlobResponse,
};
use fabro_auth::{CredentialSource, SqlVaultCredentialSource, auth_issue_message};
use fabro_automation::{self, AutomationStore};

View file

@ -1,12 +1,10 @@
//! Shared SSE plumbing for endpoints that proxy LLM `StreamEvent`s.
//! SSE plumbing for the completion endpoint's 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.
//! `POST /api/v1/completions` forwards every `StreamEvent` to the browser as a
//! `stream_event` SSE frame. 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;

View file

@ -19,7 +19,6 @@ mod llm_sse;
mod mcp_servers;
mod models;
mod pair;
mod playground;
pub(in crate::server) mod pull_requests;
pub(in crate::server) mod runs;
mod sandbox;
@ -187,7 +186,6 @@ 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>> {
@ -231,5 +229,4 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(sessions::routes())
.merge(system::routes())
.merge(completions::routes())
.merge(playground::routes())
}

View file

@ -1,290 +0,0 @@
//! 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 llm_result = match state.resolve_llm_client().await {
Ok(result) => result,
Err(err) => {
error!(error = ?err, "playground: failed to create LLM client");
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to resolve LLM providers: {err}"),
)
.into_response();
}
};
for (provider, issue) in &llm_result.auth_issues {
warn!(provider = %provider, error = %issue, "playground: provider auth issue");
}
for issue in &llm_result.registration_issues {
warn!(provider = %issue.provider, error = %issue.error, "playground: provider registration issue");
}
let client = llm_result.client;
let (model_id, selected_provider) = match super::completions::resolve_request_model(
catalog.as_ref(),
&client.provider_ids(),
req.model.as_deref(),
req.provider.map(|provider| provider.to_string()),
) {
Ok(selection) => selection,
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
// 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: Some(selected_provider.into_inner()),
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,
};
info!(
model = %request.model,
provider = request.provider.as_deref().unwrap_or(""),
"Playground chat turn"
);
let stream_result = match client.stream(&request).await {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "playground: LLM stream call failed");
return ApiError::from(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,
provider: 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

@ -1,49 +0,0 @@
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

@ -11,7 +11,6 @@ api/install-api.ts
api/integrations-api.ts
api/mcpservers-api.ts
api/models-api.ts
api/playground-api.ts
api/repos-api.ts
api/run-internals-api.ts
api/run-outputs-api.ts
@ -101,7 +100,6 @@ models/create-automation-request.ts
models/create-completion-request.ts
models/create-environment-request.ts
models/create-mcp-server-request.ts
models/create-playground-chat-request.ts
models/create-run-pull-request-request.ts
models/create-run-request.ts
models/create-run-session-request.ts

View file

@ -26,7 +26,6 @@ export * from './api/install-api';
export * from './api/integrations-api';
export * from './api/mcpservers-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

@ -1,132 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.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

@ -1,40 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.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 a ready-provider default if omitted.
*/
'model'?: string;
/**
* Optional provider pin.
*/
'provider'?: string;
}

View file

@ -71,7 +71,6 @@ export * from './create-automation-request';
export * from './create-completion-request';
export * from './create-environment-request';
export * from './create-mcp-server-request';
export * from './create-playground-chat-request';
export * from './create-run-pull-request-request';
export * from './create-run-request';
export * from './create-run-session-request';