fabro/apps/fabro-web/app/components/playground/canvas/simulation.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

163 lines
5.3 KiB
TypeScript

/**
* 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;
}