fabro/apps/fabro-web/app/components/playground/state/parse-fabro.ts
Scott Werner d590122531
feat: chat-driven workflow builder at /playground (#450)
## Summary

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

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

## What changed

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

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

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

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

**Spec / wire contract**

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

## Key design decisions

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

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

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

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

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

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

## Still outstanding (planned follow-ups)

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

## Test plan

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 11:24:56 -04:00

405 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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