mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Merge pull request #839 from fabro-sh/codex/remove-playground
Remove unsupported workflow playground
This commit is contained in:
commit
ab7cbeb4ae
62 changed files with 45 additions and 6429 deletions
|
|
@ -1,253 +0,0 @@
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import type { WorkflowDraft } from "../state/draft";
|
||||
import { renderCanvasDot } from "./render-canvas";
|
||||
import type { SimulationState } from "./simulation";
|
||||
import { useCanvasRender } from "./use-canvas-render";
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 3; // 100%
|
||||
|
||||
/**
|
||||
* Read a node's id from its SVG `<title>` element. Graphviz writes the
|
||||
* node's DOT identifier into a child `<title>` inside each `<g
|
||||
* class="node">`, which we keep around precisely so click handlers can
|
||||
* recover the id from a hit-test target.
|
||||
*/
|
||||
function nodeIdFromGroup(group: Element): string | null {
|
||||
const title = group.querySelector(":scope > title");
|
||||
return title?.textContent?.trim() || null;
|
||||
}
|
||||
|
||||
/** Inline-style selection highlight. We do this via inline styles
|
||||
* (rather than a CSS class) because Graphviz writes `stroke=...`
|
||||
* attributes directly on each shape element and CSS classes alone
|
||||
* can't override them without `!important`. */
|
||||
const SELECT_STROKE = "rgb(45 212 191)"; // teal-400, matches fabro-web accent
|
||||
const SELECT_STROKE_WIDTH = "2";
|
||||
|
||||
function applySelectionHighlight(svg: SVGSVGElement, selectedId: string | null) {
|
||||
for (const group of svg.querySelectorAll<SVGGElement>("g.node")) {
|
||||
const id = nodeIdFromGroup(group);
|
||||
const isSelected = id !== null && id === selectedId;
|
||||
group.classList.toggle("is-selected", isSelected);
|
||||
group.style.cursor = "pointer";
|
||||
const shapes = group.querySelectorAll<SVGElement>("polygon, ellipse, path");
|
||||
for (const shape of shapes) {
|
||||
if (isSelected) {
|
||||
shape.style.stroke = SELECT_STROKE;
|
||||
shape.style.strokeWidth = SELECT_STROKE_WIDTH;
|
||||
shape.style.filter = "drop-shadow(0 0 6px rgb(20 184 166 / 0.45))";
|
||||
} else {
|
||||
shape.style.stroke = "";
|
||||
shape.style.strokeWidth = "";
|
||||
shape.style.filter = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas for the playground. Re-renders whenever the draft changes by piping
|
||||
* a themed DOT (see `render-canvas`) through `@viz-js/viz` — the same
|
||||
* Graphviz layout engine Fabro uses, so what the user sees here is exactly
|
||||
* what their downloaded `.fabro` graph will lay out as.
|
||||
*/
|
||||
export default function PlaygroundCanvas({
|
||||
draft,
|
||||
simulation,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: {
|
||||
draft: WorkflowDraft;
|
||||
simulation?: SimulationState;
|
||||
/** Currently-inspected node id, or null. Drives the SVG highlight. */
|
||||
selectedNodeId?: string | null;
|
||||
/** Click-to-inspect callback. Null means the user clicked empty canvas (deselect). */
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
}) {
|
||||
const dot = useMemo(
|
||||
() => renderCanvasDot(draft, simulation),
|
||||
[draft, simulation],
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
startPanX: number;
|
||||
startPanY: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex]!;
|
||||
|
||||
const { svgRef, error } = useCanvasRender(
|
||||
innerRef,
|
||||
dot,
|
||||
selectedNodeId ?? null,
|
||||
applySelectionHighlight,
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragState.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startPanX: pan.x,
|
||||
startPanY: pan.y,
|
||||
moved: false,
|
||||
};
|
||||
},
|
||||
[pan],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((event: React.PointerEvent) => {
|
||||
const drag = dragState.current;
|
||||
if (!drag) return;
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 3) {
|
||||
drag.moved = true;
|
||||
}
|
||||
setPan({
|
||||
x: drag.startPanX + dx,
|
||||
y: drag.startPanY + dy,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
const drag = dragState.current;
|
||||
dragState.current = null;
|
||||
if (!onSelectNode || !drag || drag.moved) return;
|
||||
// `event.target` is the pointer-capture target (the container div),
|
||||
// not the element under the cursor. `elementFromPoint` does a fresh
|
||||
// hit-test that ignores capture.
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const group = hit?.closest("g.node");
|
||||
if (group) {
|
||||
const id = nodeIdFromGroup(group);
|
||||
if (id) onSelectNode(id);
|
||||
return;
|
||||
}
|
||||
// Clicked empty canvas — deselect.
|
||||
onSelectNode(null);
|
||||
},
|
||||
[onSelectNode],
|
||||
);
|
||||
|
||||
const fitToWindow = useCallback(() => {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container) return;
|
||||
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
|
||||
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
|
||||
const padPx = 48;
|
||||
const containerW = container.clientWidth - padPx;
|
||||
const containerH = container.clientHeight - padPx;
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i]! <= fitPct) {
|
||||
best = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
aria-label="Fit diagram to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1.5"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<span className="px-1 font-mono text-[11px] tabular-nums text-fg-muted">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
onClick={() =>
|
||||
setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))
|
||||
}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="m-6 text-sm text-coral">{error}</p>
|
||||
) : (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-1 overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="m-auto"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">Loading canvas…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(", ")}]`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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\"]");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
docs/public/changelog/2026-09-03.mdx
Normal file
14
docs/public/changelog/2026-09-03.mdx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: "Workflow Playground removed"
|
||||
date: "2026-09-03"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
The unsupported Workflow Playground and `POST /api/v1/playground/chat` have
|
||||
been removed. Requests to that endpoint now return 404, and generated clients
|
||||
no longer expose it. There is no direct replacement.
|
||||
</Warning>
|
||||
|
||||
Workflows can still be defined in normal workflow files and executed with the
|
||||
Fabro CLI. Existing chat, completion, and workflow graph features are
|
||||
unchanged.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1
lib/packages/fabro-api-client/src/api.ts
generated
1
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -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';
|
||||
|
|
|
|||
132
lib/packages/fabro-api-client/src/api/playground-api.ts
generated
132
lib/packages/fabro-api-client/src/api/playground-api.ts
generated
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue