The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.
`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.
The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.
Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.
The canvas placed nodes with dagre, which orders nodes but knows nothing
about ports: an edge leaves a particular handle and lands on a particular
one, so two producers feeding a consumer in the other order crossed just
before they landed however well the nodes were arranged. The graph was not
a multigraph either, so two messages between one pair of nodes collapsed
into one before crossings were counted, and the endpoint labels were laid
out as 220-wide cards with every handle stacked on a single pixel.
ELK's layered algorithm replaces it. Every declared port is handed to it as
a fixed point on the node's rim, at the fraction `portFraction` puts the
handle at — which the node components now render from the same function —
so what the crossing count is minimised over is what ends up on the screen.
Measured on this installation's flows, with the labels included: `home`
goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to
5 and 59 to 32, and every graph is the same size or smaller.
The engine answers asynchronously and is a chunk of its own, so positions
became state: the canvas draws nothing until the first layout lands, and an
edit keeps the arrangement it had rather than flashing through the corner.
The entry chunk is untouched and the flow route's own chunk came down 167
to 121 kB, since dagre used to be in it.
Two things the crossings made worse come with it. A hovered edge resolves
to full strength so one line can be followed through a busy rank, and a
feedback edge — one whose target the layout put behind its source — swings
out into a lane beside the graph instead of being drawn through everything
between its ends, which is what "House history" was reported for.
A port with `stream` set publishes repeatedly during one execution, and
until now nothing on the canvas said so. `deriveEdges` carries the flag
from the producing `MessageSpec` onto the edge, `LiveEdge` turns it into
an `edge-stream` class, and the class draws a dashed stroke whose offset
marches toward the target. The pulse still lands on top: both animations
share one declaration when a value arrives on a streaming edge.
Moving a dashboard slider lit up an edge between two nodes that had done
nothing. The canvas pulsed on the message's timestamp alone, and a message
has no idea who published it — so it credited whichever node happened to
be drawn as a producer.
That was never only about dashboards. Two nodes producing one message
pulsed both their edges whichever fired, and a message produced in another
flow changed with nothing on screen to account for it at all.
Values now carry their cause: a node, a dashboard widget, another flow, an
agent or an API caller. An edge pulses only for the producer that actually
published, and the edge inspector says where a value came from when it did
not come from a node.
What is not a node in this flow is now drawn as one — a label rather than
a card, because a dashboard with twenty tiles would otherwise bury the
logic the canvas exists to show. That covers cross-flow wiring too, which
is the link in/out affordance that has been missing.
They are never part of the document. They join at render, after everything
that reads or writes the canvas nodes, so an autosave, an undo or a delete
cannot reach them — with a Playwright test that drags a node and asserts
the stored flow still holds exactly what it did.
Deleting a node cost its source with no way back. Every mutation already
funnels through one commit, so undo/redo is a bounded stack of node
snapshots replayed through the same debounced save. Deleting a node only
drops it from flow.json — the source file survives — so restoring the id
restores the code. Renaming a message now offers to follow the rename
across every node still bound to the old name, as one undoable step.
Autosave never fired: flush depended on the whole mutation object, which
react-query rebuilds every render, so the effect re-ran and its cleanup
cancelled the pending timer. Unmounting now flushes rather than drops.
The canvas looked blurry zoomed out because Background scales the dot
radius by zoom, leaving quarter-pixel dots on a drifting tile; radius
and spacing now divide the zoom back out, spacing in octaves so the grid
halves. Edge value labels are opaque, the edge popover fits its summary
on one row with a trash icon and scrolls names that overflow, and flow
settings moved to the flowbar. The flowbar was sized against the
viewport rather than the canvas, so its buttons left the screen once
enough flows were open.
The shared duration tokens had been stretched to slow the pulses down, which
also slowed every panel and dock animation and left `motion.ts` out of step
with the CSS. Both are back to 200/300 ms, and the pulses use a new
`--duration-pulse` (500 ms): a message arriving is a signal, not a state
change, and it is the only thing here that should linger. The edge pulse read
its own 300 ms constant, so it now takes the token through `motion.ts` and the
two sides cannot drift again.
The code editor also expands: the same node panel fills the content region
beside the sidebar, the canvas chrome steps aside rather than floating on top,
and the settings above the editor keep a readable width.
The browser half of M3. Flows open on a full-bleed canvas with their chrome
floating over it: flow tabs top, dock bottom, node settings in a panel on the
right that leaves the graph visible and running behind it.
- Connections are derived, not stored. A node declares the messages it reads
and publishes; every matching pair draws an edge, so two producers of one
message converge on their consumer. Dragging output to input is shorthand
for pointing that input at the producer's message, and asks before it
replaces an existing one.
- Values land on the edges as they flow, over a websocket that feeds a store
outside React, so a value arriving re-renders its own chip and nothing else.
Clicking an edge shows the last payload and when it arrived.
- Node source is edited in Monaco, loaded only when a panel opens and themed
from the design tokens.
- Edits autosave; identical documents are skipped server-side, so a quiet
canvas writes nothing.
- Validation from the API shows on the node it belongs to and is summarised in
the dock, where each entry pans to its node.
- Works on a phone: touch-connect, 44px dock targets, and the node panel
becomes a full-screen sheet.
Two new tokens (--status-success, --font-mono) are mirrored in the website repo
and recorded in DESIGN-GUIDELINES.md.