diff --git a/apps/fabro-web/app/components/playground/canvas/canvas.tsx b/apps/fabro-web/app/components/playground/canvas/canvas.tsx deleted file mode 100644 index 54347c190..000000000 --- a/apps/fabro-web/app/components/playground/canvas/canvas.tsx +++ /dev/null @@ -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 `` 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…</p> - </div> - </div> - )} - </div> - ); -} diff --git a/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts b/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts deleted file mode 100644 index da6f172fd..000000000 --- a/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts +++ /dev/null @@ -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"'); - }); -}); diff --git a/apps/fabro-web/app/components/playground/canvas/render-canvas.ts b/apps/fabro-web/app/components/playground/canvas/render-canvas.ts deleted file mode 100644 index 2bba07943..000000000 --- a/apps/fabro-web/app/components/playground/canvas/render-canvas.ts +++ /dev/null @@ -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(", ")}]`; -} diff --git a/apps/fabro-web/app/components/playground/canvas/simulation.test.ts b/apps/fabro-web/app/components/playground/canvas/simulation.test.ts deleted file mode 100644 index 1930e2a58..000000000 --- a/apps/fabro-web/app/components/playground/canvas/simulation.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/canvas/simulation.ts b/apps/fabro-web/app/components/playground/canvas/simulation.ts deleted file mode 100644 index b786fe31d..000000000 --- a/apps/fabro-web/app/components/playground/canvas/simulation.ts +++ /dev/null @@ -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; -} diff --git a/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts b/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts deleted file mode 100644 index d11b7bb67..000000000 --- a/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts +++ /dev/null @@ -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(); -} diff --git a/apps/fabro-web/app/components/playground/canvas/use-simulation.ts b/apps/fabro-web/app/components/playground/canvas/use-simulation.ts deleted file mode 100644 index b5c0f8612..000000000 --- a/apps/fabro-web/app/components/playground/canvas/use-simulation.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/fabro-web/app/components/playground/chat/runtime.test.ts b/apps/fabro-web/app/components/playground/chat/runtime.test.ts deleted file mode 100644 index ea299e392..000000000 --- a/apps/fabro-web/app/components/playground/chat/runtime.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/chat/runtime.ts b/apps/fabro-web/app/components/playground/chat/runtime.ts deleted file mode 100644 index 4517e2560..000000000 --- a/apps/fabro-web/app/components/playground/chat/runtime.ts +++ /dev/null @@ -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"); -} diff --git a/apps/fabro-web/app/components/playground/chat/sidebar.tsx b/apps/fabro-web/app/components/playground/chat/sidebar.tsx deleted file mode 100644 index 1e094c4b7..000000000 --- a/apps/fabro-web/app/components/playground/chat/sidebar.tsx +++ /dev/null @@ -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 }; diff --git a/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx b/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx deleted file mode 100644 index 7806203e3..000000000 --- a/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx +++ /dev/null @@ -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 }; -} diff --git a/apps/fabro-web/app/components/playground/chat/welcome.tsx b/apps/fabro-web/app/components/playground/chat/welcome.tsx deleted file mode 100644 index 6a0cdd352..000000000 --- a/apps/fabro-web/app/components/playground/chat/welcome.tsx +++ /dev/null @@ -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'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> - ); -} diff --git a/apps/fabro-web/app/components/playground/files/download.test.ts b/apps/fabro-web/app/components/playground/files/download.test.ts deleted file mode 100644 index 062c23350..000000000 --- a/apps/fabro-web/app/components/playground/files/download.test.ts +++ /dev/null @@ -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\"]"); - }); -}); diff --git a/apps/fabro-web/app/components/playground/files/download.ts b/apps/fabro-web/app/components/playground/files/download.ts deleted file mode 100644 index e7b4f40d3..000000000 --- a/apps/fabro-web/app/components/playground/files/download.ts +++ /dev/null @@ -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); -} diff --git a/apps/fabro-web/app/components/playground/files/render-fabro.test.ts b/apps/fabro-web/app/components/playground/files/render-fabro.test.ts deleted file mode 100644 index ec8a4efd5..000000000 --- a/apps/fabro-web/app/components/playground/files/render-fabro.test.ts +++ /dev/null @@ -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"'); - }); -}); diff --git a/apps/fabro-web/app/components/playground/files/render-fabro.ts b/apps/fabro-web/app/components/playground/files/render-fabro.ts deleted file mode 100644 index ae3db7495..000000000 --- a/apps/fabro-web/app/components/playground/files/render-fabro.ts +++ /dev/null @@ -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"); -} diff --git a/apps/fabro-web/app/components/playground/files/render-readme.test.ts b/apps/fabro-web/app/components/playground/files/render-readme.test.ts deleted file mode 100644 index 4f3731184..000000000 --- a/apps/fabro-web/app/components/playground/files/render-readme.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/fabro-web/app/components/playground/files/render-readme.ts b/apps/fabro-web/app/components/playground/files/render-readme.ts deleted file mode 100644 index 2a864edbf..000000000 --- a/apps/fabro-web/app/components/playground/files/render-readme.ts +++ /dev/null @@ -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(" "); -} diff --git a/apps/fabro-web/app/components/playground/files/render-toml.test.ts b/apps/fabro-web/app/components/playground/files/render-toml.test.ts deleted file mode 100644 index 22b143711..000000000 --- a/apps/fabro-web/app/components/playground/files/render-toml.test.ts +++ /dev/null @@ -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"), - ); - }); -}); diff --git a/apps/fabro-web/app/components/playground/files/render-toml.ts b/apps/fabro-web/app/components/playground/files/render-toml.ts deleted file mode 100644 index 7218f82e4..000000000 --- a/apps/fabro-web/app/components/playground/files/render-toml.ts +++ /dev/null @@ -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"); -} diff --git a/apps/fabro-web/app/components/playground/playground.tsx b/apps/fabro-web/app/components/playground/playground.tsx deleted file mode 100644 index 98a6808c1..000000000 --- a/apps/fabro-web/app/components/playground/playground.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/components/playground/state/animate.test.ts b/apps/fabro-web/app/components/playground/state/animate.test.ts deleted file mode 100644 index 47fb6ef3d..000000000 --- a/apps/fabro-web/app/components/playground/state/animate.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/animate.ts b/apps/fabro-web/app/components/playground/state/animate.ts deleted file mode 100644 index a97d7b9f8..000000000 --- a/apps/fabro-web/app/components/playground/state/animate.ts +++ /dev/null @@ -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() {} diff --git a/apps/fabro-web/app/components/playground/state/build-manifest.test.ts b/apps/fabro-web/app/components/playground/state/build-manifest.test.ts deleted file mode 100644 index 8f9dda83c..000000000 --- a/apps/fabro-web/app/components/playground/state/build-manifest.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/build-manifest.ts b/apps/fabro-web/app/components/playground/state/build-manifest.ts deleted file mode 100644 index 252a33c8d..000000000 --- a/apps/fabro-web/app/components/playground/state/build-manifest.ts +++ /dev/null @@ -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), - }, - }, - }, - }; -} diff --git a/apps/fabro-web/app/components/playground/state/diff.test.ts b/apps/fabro-web/app/components/playground/state/diff.test.ts deleted file mode 100644 index 0ec63da80..000000000 --- a/apps/fabro-web/app/components/playground/state/diff.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/diff.ts b/apps/fabro-web/app/components/playground/state/diff.ts deleted file mode 100644 index d17553648..000000000 --- a/apps/fabro-web/app/components/playground/state/diff.ts +++ /dev/null @@ -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; -} diff --git a/apps/fabro-web/app/components/playground/state/draft.test.ts b/apps/fabro-web/app/components/playground/state/draft.test.ts deleted file mode 100644 index 48e30300f..000000000 --- a/apps/fabro-web/app/components/playground/state/draft.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/draft.ts b/apps/fabro-web/app/components/playground/state/draft.ts deleted file mode 100644 index 07c21592a..000000000 --- a/apps/fabro-web/app/components/playground/state/draft.ts +++ /dev/null @@ -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); -} diff --git a/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts b/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts deleted file mode 100644 index dc3340d2c..000000000 --- a/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/parse-fabro.ts b/apps/fabro-web/app/components/playground/state/parse-fabro.ts deleted file mode 100644 index 632e4f392..000000000 --- a/apps/fabro-web/app/components/playground/state/parse-fabro.ts +++ /dev/null @@ -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(); -} diff --git a/apps/fabro-web/app/components/playground/state/persist.test.tsx b/apps/fabro-web/app/components/playground/state/persist.test.tsx deleted file mode 100644 index eae5f5c37..000000000 --- a/apps/fabro-web/app/components/playground/state/persist.test.tsx +++ /dev/null @@ -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()); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/persist.ts b/apps/fabro-web/app/components/playground/state/persist.ts deleted file mode 100644 index e36810d95..000000000 --- a/apps/fabro-web/app/components/playground/state/persist.ts +++ /dev/null @@ -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 }; -} diff --git a/apps/fabro-web/app/components/playground/state/reducer.test.ts b/apps/fabro-web/app/components/playground/state/reducer.test.ts deleted file mode 100644 index 236236db6..000000000 --- a/apps/fabro-web/app/components/playground/state/reducer.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/fabro-web/app/components/playground/state/reducer.ts b/apps/fabro-web/app/components/playground/state/reducer.ts deleted file mode 100644 index 79c3a59c3..000000000 --- a/apps/fabro-web/app/components/playground/state/reducer.ts +++ /dev/null @@ -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), - ), - }); -} diff --git a/apps/fabro-web/app/components/playground/ui/download-button.tsx b/apps/fabro-web/app/components/playground/ui/download-button.tsx deleted file mode 100644 index 604faccd8..000000000 --- a/apps/fabro-web/app/components/playground/ui/download-button.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/components/playground/ui/file-tabs.tsx b/apps/fabro-web/app/components/playground/ui/file-tabs.tsx deleted file mode 100644 index c940e683f..000000000 --- a/apps/fabro-web/app/components/playground/ui/file-tabs.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/components/playground/ui/node-inspector.tsx b/apps/fabro-web/app/components/playground/ui/node-inspector.tsx deleted file mode 100644 index 57fcf5adb..000000000 --- a/apps/fabro-web/app/components/playground/ui/node-inspector.tsx +++ /dev/null @@ -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); -} diff --git a/apps/fabro-web/app/components/playground/ui/reset-button.tsx b/apps/fabro-web/app/components/playground/ui/reset-button.tsx deleted file mode 100644 index e37edc8da..000000000 --- a/apps/fabro-web/app/components/playground/ui/reset-button.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx deleted file mode 100644 index 47805d424..000000000 --- a/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx +++ /dev/null @@ -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)} /> - )} - </> - ); -} diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx deleted file mode 100644 index d31c57cae..000000000 --- a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx +++ /dev/null @@ -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; - } -} diff --git a/apps/fabro-web/app/components/playground/ui/run-trace.tsx b/apps/fabro-web/app/components/playground/ui/run-trace.tsx deleted file mode 100644 index e094249c8..000000000 --- a/apps/fabro-web/app/components/playground/ui/run-trace.tsx +++ /dev/null @@ -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`; -} diff --git a/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx b/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx deleted file mode 100644 index 324d3f36d..000000000 --- a/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/components/playground/ui/workflow-header.tsx b/apps/fabro-web/app/components/playground/ui/workflow-header.tsx deleted file mode 100644 index 12d3bd855..000000000 --- a/apps/fabro-web/app/components/playground/ui/workflow-header.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/fabro-web/app/lib/graph-viewport.ts b/apps/fabro-web/app/lib/graph-viewport.ts index 6e08658bb..6c27b7836 100644 --- a/apps/fabro-web/app/lib/graph-viewport.ts +++ b/apps/fabro-web/app/lib/graph-viewport.ts @@ -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 diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index ea2a19f6c..21d30a2a4 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -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), diff --git a/apps/fabro-web/app/routes/playground.tsx b/apps/fabro-web/app/routes/playground.tsx deleted file mode 100644 index 46217ad81..000000000 --- a/apps/fabro-web/app/routes/playground.tsx +++ /dev/null @@ -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" - /> - ); -} diff --git a/apps/fabro-web/package.json b/apps/fabro-web/package.json index ff51ce088..01876a406 100644 --- a/apps/fabro-web/package.json +++ b/apps/fabro-web/package.json @@ -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", diff --git a/bun.lock b/bun.lock index bd69d07d6..d8f3dd721 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index aa5938af4..041c28dbb 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 diff --git a/docs/public/changelog/2026-09-03.mdx b/docs/public/changelog/2026-09-03.mdx new file mode 100644 index 000000000..8da88b0ce --- /dev/null +++ b/docs/public/changelog/2026-09-03.mdx @@ -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. diff --git a/docs/public/docs.json b/docs/public/docs.json index f76a3ec34..5655ea2a0 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -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", diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 11f02947d..05545693f 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -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}; diff --git a/lib/apps/fabro-server/src/server/handler/llm_sse.rs b/lib/apps/fabro-server/src/server/handler/llm_sse.rs index 01c6fbdd6..36b826607 100644 --- a/lib/apps/fabro-server/src/server/handler/llm_sse.rs +++ b/lib/apps/fabro-server/src/server/handler/llm_sse.rs @@ -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; diff --git a/lib/apps/fabro-server/src/server/handler/mod.rs b/lib/apps/fabro-server/src/server/handler/mod.rs index d3a22f9f1..643db594c 100644 --- a/lib/apps/fabro-server/src/server/handler/mod.rs +++ b/lib/apps/fabro-server/src/server/handler/mod.rs @@ -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()) } diff --git a/lib/apps/fabro-server/src/server/handler/playground.rs b/lib/apps/fabro-server/src/server/handler/playground.rs deleted file mode 100644 index ee52c56e0..000000000 --- a/lib/apps/fabro-server/src/server/handler/playground.rs +++ /dev/null @@ -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")); - } -} diff --git a/lib/apps/fabro-server/src/server/handler/prompts/playground_system.md b/lib/apps/fabro-server/src/server/handler/prompts/playground_system.md deleted file mode 100644 index 7ccf5b4c1..000000000 --- a/lib/apps/fabro-server/src/server/handler/prompts/playground_system.md +++ /dev/null @@ -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} -``` diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 105e4c5fe..3940c2505 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index b7f58f486..27e40a09c 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/api/playground-api.ts b/lib/packages/fabro-api-client/src/api/playground-api.ts deleted file mode 100644 index acd6dfb6b..000000000 --- a/lib/packages/fabro-api-client/src/api/playground-api.ts +++ /dev/null @@ -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)); - } -} diff --git a/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts b/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts deleted file mode 100644 index 7c17866ec..000000000 --- a/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts +++ /dev/null @@ -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; -} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index a22871b82..f70535003 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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';