Skip to content

Editing together

The shared flow draft: realtime co-editing with presence, how changes merge, deploy with co-authors, discard, offline behaviour and the WebSocket protocol.

Updated View as Markdown

Overview

Everyone who opens the editor works on one shared draft of the flow, kept on the connector in <data_dir>/flow.draft.json. Changes by anyone (adding, moving, connecting and deleting nodes, typing in a field, adding or renaming a flow) reach every open editor within about a tenth of a second and survive a page reload and a connector restart. The running flow and the version history change only at Deploy.

  • Avatars in the top bar list who is in the editor (the name comes from the OIDC sign-in or the API token). The list shows what each person is doing and offers Follow, which mirrors that person’s flow and viewport until you pan or zoom.
  • Cursors of the others are drawn on the canvas. A node somebody else has selected or has open gets a ring in their colour with their name, and the side panel says Ann is editing this node while she works in its form.
  • The Draft chip next to Deploy counts the unsaved changes against the running flow and names who wrote them.
  • Viewers and operators watch live but cannot edit. A protected environment is read-only for everyone (presence still works); its draft follows the live flow after every promotion or rollback.

Deploy and discard

Action What happens
Deploy (POST /api/collab/deploy {message?, if_version?}, editor, audited flow.deploy) Takes the shared draft as it is now, creates the version with the caller as actor and everyone else who changed the draft since the previous version as co-authors (co_authors in GET /api/versions, shown in History), then announces deployed vN by X to every open editor. 409 no_changes when the draft equals the live flow, 409 for a stale if_version. Document-level fields the canvas does not edit (for example mocks) are taken from the live flow.
Discard draft changes (POST /api/collab/discard, editor, audited flow.discard) Resets the draft to the running flow for everyone. The editor asks for confirmation first. Changes still queued on offline machines are void (those clients are told).
A deploy by another route (PUT /api/flow, MCP, rollback, promotion, import) Announced to everyone as well; the draft is kept and the unsaved count is taken against the new live flow. The authors list restarts, because a new version exists.

GET /api/collab/draft[?flow=true] returns {epoch, seq, base_version, live_version, unsaved:{count, added, removed, changed}, authors[], peers[], protected} (plus the draft document with flow=true).

How changes merge

The server holds the draft as nodes, edges and flows with plain JSON fields and sequences every change; clients apply the same sequence, so they converge without version vectors. The rules, per field, are deterministic:

  • Different fields of one node (x, label, script, …) both survive, whoever sent first.
  • The same field: the change the server saw last wins (last-writer-wins per field).
  • A delete wins over a concurrent move or edit of the same node: those find no target and are dropped. Deleting a node removes its edges; deleting a flow removes its nodes. Adding the same id again brings it back.
  • A connection to a node that was just deleted is dropped. A connection that would make a cycle, duplicate an existing one or cross flows, and deleting the last flow, are refused: the client that sent them is told and resynchronised.
  • Undo takes back your own edits; other people’s changes made since are kept.

This was chosen over Yjs / yrs because the draft is a small graph of JSON fields that one server already orders: the merge needs no tombstones or binary document format and adds no dependency. The price is that text is merged per field, not per character: two people typing into the same script at the same moment is last-writer-wins.

Offline and reconnect

The editor reconnects with backoff. While offline the top bar shows Offline · N queued; your edits stay on the canvas, are merged (consecutive edits of one node become one change) and are sent after the editor caught up with what happened meanwhile. A reconnecting client sends the last epoch and seq it applied and gets either the ops it missed or, when too much happened (more than 4096 changes, a connector restart or a new epoch), the whole draft; its queued changes are replayed on top, and the server ignores ones it already applied (each change carries a unique id). If the draft was discarded while a client was offline (new epoch), its queued changes are dropped and the user is told. Closing the tab with unconfirmed queued changes asks for confirmation; everything the connector confirmed is safe.

Wire protocol

GET /api/ws/collab (any role; same bearer token / ?token= / session cookie as the other sockets). JSON text frames.

Client → server
{type:"hello", epoch?, seq?} First frame. The last epoch and seq this client applied.
{type:"ops", ops:[{cid, op}]} Edit the draft (editor role, not in a protected environment; at most 500 ops per frame). Anyone else gets {type:"error", code:"read_only", reason:"role"|"protected", cids}.
{type:"presence", cursor, selection, editing, flow, view} Cursor and viewport in flow coordinates, selected node/edge ids, the node being edited. Every role; rate-limited.
{type:"resync"}, {type:"ping"} Full state / keep-alive (a silent client is dropped after 90 s).

Ops (op.t): node_add {node}, node_set {id, set, unset}, node_del {id}, edge_add {edge}, edge_del {id}, flow_add {flow}, flow_set {id, set, unset}, flow_del {id}.

Server → client
welcome client, you, readonly + reason, epoch, seq, mode full (+state) or delta (+ops), peers, authors, base_version
ops {client, user, ops:[{seq, cid, op}]} to everyone including the sender (the sender’s echo is its acknowledgement)
ack, refused, error To the sender: nothing left to do for these cids / these cids were refused (code invalid or conflict) / read-only and others
presence, joined, left, authors Who is where; who changed the draft
deployed {version, actor, message, co_authors, via:"collab"|"external"}
reset The draft was discarded: new epoch and the full state
state Full state after a resync or when a client fell behind

The draft file is written 150 ms after the last change and again at shutdown; an unreadable file is kept as flow.draft.json.corrupt and the draft starts from the live flow. Back it up with the rest of the data directory.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close