Implementation Plan: Real-Time Collaborative Workflow Editing (“Multiplayer”)
Author: Matti Georgi Status: Draft — for review Last updated: 2026-07-10 Design doc: multiplayer-design.md
This plan turns the design into ordered, reviewable PRs with file-level targets, exit criteria, and a rollout checklist. Estimates assume one engineer full-time; workstreams marked ∥ can run in parallel with a second engineer.
0. Ground rules
- Every PR passes
npm run check(typecheck + lint + test) and follows repo conventions: strict TS, noany, ui_primitives only (no raw MUI), design tokens, Zustand selectors, tests in__tests__/. - New backend code lands in a new workspace package
packages/collabplus surgical changes topackages/websocket,packages/models, andweb/. No changes topackages/kernel,packages/runtime, or any node package — if a PR touches those, it violates design goal G4 and needs a design amendment first. - All schema changes are additive, mirrored in
packages/models/src/schema/andschema-pg/, registered inpackages/models/src/migrations/versions.ts, and covered inmigrations.test.ts(the suite assertstableExists). - Feature flags via the existing settings registry (
packages/websocket/src/settings-registry.ts→registerSetting):collab_occ(Phase 0 save safety),collab_presence(Phase 0 UI),collab(Phase 1 CRDT). Everything defaults off until the rollout checklist says otherwise.
Phase 0 — Save safety + presence (no CRDT) · ~2 eng-weeks
Ships standalone value: kills silent last-write-wins data loss and adds presence. Nothing in Phase 0 depends on Yjs.
PR-1: Optimistic concurrency on workflow saves (~3 days)
Server — packages/websocket/src/trpc/routers/workflows.ts:
- Extend
updateInput(theupdatemutation, ~line 489) andautosaveInput(~line 706) with optionalbase_updated_at: string. - When present and ≠ the row’s current
updated_at, throw a tRPCCONFLICT(the error formatter already mapsALREADY_EXISTS/CONFLICT; add a distinctSTALE_WRITEcode inpackages/websocket/src/error-codes.tsso clients can discriminate). Return the server’s currentupdated_atand graph in the error payload. - Omitted token ⇒ today’s behavior (older clients, CLI keep working).
Client — web/src/stores/WorkflowManagerStore.ts (saveWorkflow, ~line 314) and the autosave path:
- Track
updated_atfrom the last successful load/save per workflow; send asbase_updated_at; update it from every save response. - On
STALE_WRITE: conflict dialog (ui_primitives) with three actions — Reload theirs (refetch, discard local), Overwrite (resend without token), Keep both (save local graph as a manual version via the existingversions.createmutation, then reload). Autosave never overwrites: on conflict it silently snapshots local state as an autosave version and backs off until the user decides.
Interplay fix (required, not optional): web/src/stores/resourceChangeHandler.ts currently invalidates ["workflows"] on every workflow change, which refetches and clobbers a dirty open editor. Gate that invalidation: if the changed workflow is open and dirty, surface the conflict banner instead of refetching.
Tests: router unit tests (token match / mismatch / omitted); WorkflowManagerStore tests for token threading and each conflict action; regression test for the dirty-editor refetch clobber.
Exit: two clients racing on one workflow can no longer silently lose work, with collab_occ on.
PR-2: Awareness channel, server side (~2 days)
- New module
packages/websocket/src/collab/presence-registry.ts: in-memoryMap<workflow_id, Map<socket_id, AwarenessState>>, join/leave/update, 30 s stale eviction, broadcast to co-members. - Wire three message types into the dispatch switch in
packages/websocket/src/unified-websocket-runner.ts(thecaseblock starting ~line 5849):collab_join,collab_leave,collab_awareness. MsgPack frames like every other message; no new socket, no new port. - Authz on
collab_join: reuse the access check pattern from the workflows router (access !== "public" && user_id !== ctx.userId⇒ deny, plus collaborators table once PR-9 lands). - Message schemas in
packages/protocol/src/(newcollab.ts), since protocol is the shared-types package. Note: protocol changes requirecd packages/protocol && npm run buildbefore mobile typecheck — CI already handles this. - OTel:
collab.presence.join/leaveevents; gauge for members per workflow.
Tests: registry unit tests (join/leave/evict/broadcast); an integration test in packages/websocket/tests/ driving two fake sockets through join → awareness → leave.
PR-3: Presence UI (~4 days) ∥ after PR-2’s protocol types merge
web/src/lib/collab/PresenceChannel.ts: thin client overglobalWebSocketManager(web/src/lib/websocket/GlobalWebSocketManager.ts— singleton, per CLAUDE.md never a new socket): join on workflow open (hook intoWorkflowManagerStoreopen/close lifecycle), throttle outgoing cursor/selection to ~15 Hz, resend state on reconnect (subscribe to the manager’s reconnect event).web/src/stores/CollabPresenceStore.ts: Zustand store keyed by workflow id → member list with cursor/selection; selectors per member to keep re-renders scoped.- UI (all ui_primitives + design tokens):
- Roster: avatar row in the editor header; stable per-user color derived from user id hash.
- Remote cursors: absolutely-positioned overlay inside the ReactFlow viewport (transform flow-coords → screen via ReactFlow’s viewport state; rAF-batched).
- Selection highlights: colored outline on nodes present in a remote member’s selection.
- Banner: “N others viewing/editing” using the existing notification/banner primitives.
- Own selection/cursor published from existing ReactFlow callbacks (
onSelectionChange, paneonMouseMovethrottled).
Tests: store tests; RTL component tests for roster/banner; one Playwright two-context smoke (second browser’s cursor appears) — this bootstraps the multi-client e2e harness Phase 1 needs.
Phase 0 exit / dogfood gate: flags on for the team workspace ≥1 week; zero clobbered-save reports; presence latency < 2 s observed.
Phase 1 — CRDT co-editing · ~7 eng-weeks
PR-4: packages/collab — document model (~1 week) ∥ with PR-2/3
New workspace package (depends only on @nodetool-ai/protocol; register in root workspaces and build:packages order — it has no decorators, so no dist/-loading caveats):
src/doc-model.ts: shared-type layout (nodes/edges/metamaps per design §5.1); typed accessors.src/convert.ts:docFromGraph(graph): Y.Doc,graphFromDoc(doc): Graph— pure, lossless, the contract everything else leans on.src/invariants.ts:enforceGraphInvariants(doc, transaction)— edge GC for dangling endpoints after delete-vs-edit races, duplicate-edge collapse; runs inside the same Yjs transaction.src/protocol.ts: sync/awareness frame encode/decode wrappingy-protocols(opaque bytes in MsgPack fields).
Tests (the heart of this PR): round-trip property tests (fast-check) over generated graphs: graphFromDoc(docFromGraph(g)) ≡ g; convergence property tests — N docs, random interleaved concurrent ops, sync pairwise ⇒ identical graphFromDoc output and invariants hold; targeted delete-vs-edit fuzz for edge GC. Budget half the PR’s time for these tests; they are the cheapest place this project can fail.
Deps: yjs, y-protocols, fast-check (dev). Pin exact versions; both are pure-JS ESM (no native modules, no sandbox install pain).
PR-5: Persistence — schema + migrations (~2 days)
packages/models/src/schema/workflow-crdt.ts(+schema-pg/mirror):workflow_crdt_updates(id, workflow_id, seq, update blob, user_id, created_at; index workflow_id+seq) andworkflow_crdt_snapshots(id, workflow_id, seq_through, snapshot blob, created_at; index workflow_id+seq_through desc). Export from bothschema/index.tsfiles.workflow_collaborators(workflow_id, user_id, role, invited_by, created_at; pk workflow_id+user_id).- Migrations
create_workflow_crdt_tables,create_workflow_collaboratorsappended inpackages/models/src/migrations/versions.tswith down-migrations, covered inmigrations.test.ts. - DAO
packages/models/src/workflow-crdt.ts:appendUpdate,loadSince(seq),latestSnapshot,compact(seqThrough, snapshot)(transactional),collaboratorsFor(workflowId).
PR-6: Server — CollabRoomManager (~2 weeks)
packages/websocket/src/collab/room-manager.ts + room.ts, replacing PR-2’s presence registry internals (presence becomes room-scoped; wire protocol unchanged so PR-3’s client needs no changes):
- Lifecycle: first join loads snapshot + update tail via the DAO, else seeds from
workflow.graphwithdocFromGraph; last-leave flush + 60 s grace eviction. - Update path: authz (editor role) → apply to room doc → rebroadcast to other members → batched append to
workflow_crdt_updates(single writer loop per room; batch inserts for SQLite friendliness). - Sync:
collab_joinreply carries state vector + snapshot; handle sync1/sync2 frames viapackages/collabprotocol helpers. - Compaction: threshold 500 updates or on eviction; snapshot + delete-covered-updates in one transaction.
- Materialized flush: debounced 2 s idle / 15 s max; write
graphFromDoc(doc)through the same internal save routine theupdatemutation uses (soupdated_atadvances, the ModelObserver resource-change event fires, and autosave versions accrue). Tag the write with a room origin so the OCC check (PR-1) knows it’s the authority. - External-writer rebase: when a non-collab write lands on a workflow with a live room (detected via the OCC token path), diff incoming JSON vs
graphFromDoc(doc)and apply the delta as a Yjs transaction with arebaseorigin. - Divergence guard: on a client-reported state-vector-hash mismatch, send authoritative re-sync; count it; log both encoded states at debug level.
- Run-submit hook: the
run_jobpath forces a flush for workflows with a live room before job creation. - OTel spans
collab.room.load,collab.update.apply,collab.flush,collab.compact; metrics per design §6.
Tests: room unit tests (lifecycle, authz, batching, compaction transactionality); integration tests with two fake sockets (join → concurrent updates → convergence → flush lands in workflows table → resource event observed); rebase fuzz tests; restart simulation (kill room, rejoin, sync from persistence).
PR-7: Client — YjsGraphBinding (~2 weeks) ∥ with PR-6 against a stub room
web/src/lib/collab/YjsGraphBinding.ts, owned per-workflow by WorkflowManagerStore:
- Store → doc: wrap the
NodeStoremutation surface —onNodesChange,onEdgesChange,onConnect,addNode,updateNode,updateNodeData,deleteNode(s),setNodes,setEdges(all inweb/src/stores/NodeStore.ts) — mirroring each mutation into a Y transaction taggedlocalOrigin. Position drags coalesced to ~30 Hz, one transaction per frame. - Doc → store: deep-observe handler translating remote transactions into minimal store mutations, entering the store through a
remote: truepath so wrapped actions don’t echo back into the doc, and marked to not dirty the workflow (the store already has a shouldn’t-dirty pattern, NodeStore.ts ~line 808) — collab edits must not trigger the legacy autosave. - Save suppression: in collab mode,
saveWorkflow/autosave become no-ops for the graph (server room flush is the writer); manual “save version” calls the versions mutation directly. - Undo/redo:
Y.UndoManagerovernodes/edges/meta,trackedOrigins: {localOrigin}; the editor’s undo/redo entry points dispatch to it in collab mode,zundotemporal path untouched otherwise. Keyboard shortcuts unchanged. - Sync client: join/sync/update/reconnect state machine over
globalWebSocketManager; buffered local edits merge on reconnect; periodic state-vector hash for the divergence guard; on server-forced re-sync, rebuild store from doc. - Mode selection: binding created only when
collabflag is on and the workflow is server-backed and shared/multi-member-capable; pure-local desktop workflows never enter collab mode.
Tests: binding unit tests against an in-memory Y.Doc pair (each store action round-trips; remote ops produce exact store mutations; echo prevention; undo isolation); store integration tests for save suppression and no-dirty remote edits; jsdom perf smoke for the 500-node update-storm budget.
PR-8: Collaborative signals in the editor (~3 days)
- Run-status mirroring: initiating client publishes
{running_nodes}into its awareness state; collaborators render the existing node-running affordance in the runner’s color. - Cache interplay e2e: collaborator edits an upstream node ⇒ my “run from here” signature dirties (should be automatic via
computeRunSignatures; this PR proves it with a test, changes nothing). - Version restore while a room is live: route the
versions.restoremutation through the room rebase path (PR-6 mechanism) instead of a raw graph write.
PR-9: Permissions + sharing surface (~3 days)
- Enforce
workflow_collaboratorsroles atcollab_join(editor/viewer/deny) and percollab_update(viewer updates dropped + logged). - tRPC mutations:
collaborators.list/add/removeon the workflows router; minimal share dialog in the editor header (ui_primitives) — add by email/user id, role toggle. - Public-workflow live observation stays behind its own flag, default off.
PR-10: Multi-client e2e + chaos suite (~1 week, overlaps 6–9)
- Extend the Playwright harness from PR-3: two browser contexts, one workflow — edit propagation, both-edit-same-node property LWW, delete-vs-edit edge GC, undo isolation, reconnect-and-merge, presence rendering, conflict dialog (Phase 0 path with flag off).
- Chaos at the transport seam: inject a frame-mangling proxy (drop/reorder/duplicate
collab_update) and assert convergence via state-vector hash. - Perf budget test: 500-node graph, 4 simulated editors at full rate, assert frame time and store-update latency budgets.
Sequencing
Phase 0: PR-1 ──► dogfood ─────────────────────────────┐
PR-2 ──► PR-3 ─────────────► dogfood │
▼
Phase 1: PR-4 ─┬─► PR-6 ─┬─► PR-8 ─► PR-9 ─► PR-10 ─► dogfood ─► GA
│ │
PR-5 ─┘ PR-7 ─┘ (PR-6 ∥ PR-7 via stubbed counterpart)
Total: ~9 eng-weeks serial; ~6–7 calendar weeks with two engineers (PR-3∥PR-4, PR-6∥PR-7).
Rollout checklist
- Phase 0 dogfood:
collab_occ+collab_presenceon for the team workspace ≥1 week. Gate: zero lost-work reports, zero false-positive conflict dialogs. - Phase 0 GA:
collab_occdefault-on everywhere (it is pure safety); presence default-on for hosted. - Phase 1 dogfood:
collabon for team workspace ≥2 weeks. Gates: zero divergence-counter increments; p95 remote-edit latency < 500 ms; flush lag p99 < 20 s; compaction keeping logs < 2× threshold; both editor code paths (collab/solo) green in CI. - Phase 1 GA (hosted): default-on; self-hosters receive via normal upgrade (migrations are additive; flag still available as kill switch).
- Kill switch drill (before GA): disable
collabmid-session in staging; verify clients fall back to solo mode, materialized JSON is current, no data loss.
Monitoring to have in place before step 3: divergence counter (pages), room count / members gauge, update rate, broadcast latency histogram, flush lag, compaction duration, workflow_crdt_updates row-count alert at 10× threshold.
Risk register (implementation-level)
| Risk | Mitigation | Where |
|---|---|---|
NodeStore has mutation paths not on the wrapped action list (bulk ops, timeline/sketch side stores writing nodes) |
Audit every set() touching nodes/edges in NodeStore.ts during PR-7; add a dev-mode assertion that the doc and store hash-match after each transaction |
PR-7 |
| Legacy autosave fires in collab mode and double-writes | Save suppression tested explicitly; server rejects graph writes from collab-mode clients (origin check) | PR-6/7 |
resourceChangeHandler refetch fights the binding |
Collab-mode workflows opt out of workflow query invalidation (binding is the sync channel) | PR-7 |
| Yjs update floods from position drags degrade ReactFlow | 30 Hz coalescing + rAF apply batching; perf budget test | PR-7/10 |
| SQLite write stalls under append load on self-hosted boxes | Batched appends in room writer loop; measured in dogfood before GA | PR-6 |
| Protocol drift between web and mobile | Frames defined once in packages/protocol; mobile ignores unknown message types today (verify in PR-2) |
PR-2 |
Explicit non-goals of this plan (deferred with the design doc)
Y.Text prompt merging, comments/annotations, follow-mode, offline-first merge, horizontal room scale-out, anonymous public observers, org/team permissions.