mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
## 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>
163 lines
5.3 KiB
TypeScript
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;
|
|
}
|