ADR 0034 — Interactive workflow canvas
This ADR closes the "partial" row in the sync/ee.md feature map for
graph/diagram editors: EE's ee-flow is a React-Flow–parity canvas+DOM
hybrid (drag, zoom, undo/redo, minimap, auto-layout, context menus, collab
transport), and Mosaic's U4 answer was a read-only workflow DAG — static
longest-path layering at codegen rendered as SVG. This ADR makes that graph
an interactive canvas and, where Mosaic's compile-time model lets it do more
for free, does: a live run tracer bound to the L7 streamed run endpoint and
the L5 run history.
Context
EE workflows are runtime artifacts: the WorkflowGraphEditor island
(blocks/workflow/ee-workflow-ui) wraps ee_flow::FlowCanvas to author
workflows visually — node palette, connections, a property panel, and
save/load against the Workflow aggregate. The canvas edits the model.
Mosaic workflows are declared in the tessera (tessera.yaml, the single
source of truth, ADR 0008) and compiled into the app at build time. A visual
DSL editor would be a parallel authoring path — a second source of truth for
workflows — against this repo's core doctrine. What the declared model does
have that EE's authoring canvas does not bind to: real runtime data.
Executions stream node_start / node_output / done over
POST /api/workflows/{slug}/run?stream=true (L7) and every top-level run is
kept in the run history (GET /api/workflows/{slug}/runs[/{id}], L5) with a
full per-node trace.
So the Mosaic canvas is the interactive viewer/tracer for the declared
model: every interactive affordance of ee-flow that makes sense for a
fixed graph (pan/zoom, node drag, undo/redo, auto-layout, minimap,
inspection) plus live execution binding on top.
Decision
Every workflow page (/workflows/{slug}) now emits its DAG as before —
the codegen-time layout is still the static SVG, so the page is fully
readable with no JS — but wrapped in a canvas container:
.wf-canvascontainer — carries the declared graph as adata-wfpayload (nodes with their full detail for the inspector — title, about, code, input/output fields, params,execblock, free-form props — edges with conditions, and the codegen layout as initial positions). The container is enhanced in place byweb/static/wf_canvas.js, a single static script (served exactly likedispatch.js/layout.js) that upgrades every.wf-canvasin the document and aMutationObserverthat catches ones rendered later (CSR route navigation). The page loads the script idempotently through a generatedmount_wf_canvas_js()(wasm-only, no-op in SSR).- Pan / zoom — drag the background to pan, wheel to zoom at the cursor
(clamped 0.2×–4×), toolbar +/− and fit-to-view (
0), double-click to fit. A dot-grid background pans and zooms with the world (SVG pattern — one DOM tree, no separate canvas layer). - Node drag — nodes reposition freely; positions persist per workflow
in
localStorage(mosaic-wf-<slug>) and survive reloads. Edges re-route live (same bezier + arrow as the static render). - Undo / redo — an operation stack over layout actions (drag,
auto-layout, reset), with
Ctrl+Z/Ctrl+Shift+Z/Ctrl+Yand toolbar buttons. - Auto-layout — the same longest-path layering the codegen computes (layers as columns, declaration order within a layer, cycle guard), ported to the client as a one-click re-arrange; "reset" returns to the build-time layout.
- Minimap — corner overview with a viewport rectangle; click/drag to recenter.
- Inspector — click a node for its id, type, about, run state, last
output, code, inputs, outputs, params, props and
execconfig; click an edge for its endpoints and condition;Escor a background click closes it. - Live run tracing (the "more and better" part) — a run panel with one
input per start-node variable (typed: numeric/bool coercion, defaults,
required) or a raw JSON body editor when the workflow takes no declared
inputs. "run" POSTs the streamed run endpoint and parses the SSE frames
from the
fetchbody reader (POST + SSE;EventSourcecannot POST). While running, each node gets a state ring — amber pulse while running, blue when done, red on failure (the last started-but-unresolved node) — and per-node outputs land in the inspector. The panel also lists the ten most recent runs from the run history (✓/✗, age, duration); clicking one replays its trace onto the canvas. - Keyboard —
+/-/0zoom/fit, undo/redo,Esc; ignored while an input has focus.
No DSL key: like the static DAG before it, the canvas is part of the built-in workflow surface and is emitted whenever the app declares workflows. The tessera stays the only workflow definition; nothing the canvas does writes back to the model. The WSS voice and run APIs are unchanged — the canvas is a client over existing endpoints.
Files
crates/mosaic-render/src/wf_canvas.rs(new) —wf_canvas_js()(the static enhancer) andcanvas_payload()(the per-workflowdata-wfJSON fromWfPlan+ the codegen layout).crates/mosaic-render/src/web.rs— workflow-page codegen (canvas container, toolbar, inspector, minimap, run panel, thedata-wfpayload const,mount_wf_canvas_js), the canvas CSS block, and theworkflow_page_is_an_interactive_canvasrender test.- Re-pinned goldens: full-stack
web/src/pages.rs+ newweb/static/ wf_canvas.js;styles.cssin the four web lenses.
Consequences
- The workflow page goes from a static diagram to an interactive canvas with zero backend changes: every feature (pan/zoom/drag/undo/minimap/inspector) works offline against the declared model, and the run panel works against the existing L5/L7 endpoints.
- The no-JS path is preserved: the SSR HTML is still the complete read-only SVG (longest-path layout, tooltips, conditions on hover).
- Layout edits are per-browser (localStorage) and session-visual — they are never a definition change, so no second source of truth for workflows is introduced. A future "persist layout app-wide" would be a one-endpoint extension, not a model change.
- The generated page grows a ~15 KB static JS file (one per app, emitted only when workflows exist) plus per-workflow payload consts.
- The canvas deliberately does not port
ee-flow's authoring features (node/edge creation, property editing, save-to-aggregate) or its collab transport: authoring the tessera is the tessera's job, and Mosaic's run data replaces the value a second authoring surface would add.