Skip to content

feat: realtime collaborative editing — live stream, presence, browser editor, CRDT co-editing, auto-merge - #207

Merged
ssowonny merged 18 commits into
mainfrom
worktree-realtime-live-sync
Sep 6, 2026
Merged

feat: realtime collaborative editing — live stream, presence, browser editor, CRDT co-editing, auto-merge#207
ssowonny merged 18 commits into
mainfrom
worktree-realtime-live-sync

Conversation

@ssowonny

@ssowonny ssowonny commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Two people can now type in the same document at the same time, in the web app — with each other's cursors.
  • A teammate's change reaches you in about a second instead of ten, and an open file updates itself instead of going stale forever.
  • The top bar shows who else is in the project, ringed when they're on your file.
  • Concurrent edits to different parts of a file now merge instead of forking a .bdrive-conflict-* copy.
  • Works in the Mac app too — a desktop window and a browser can co-edit the same document.
  • Known gap: a live editing room ignores writes to its file from outside (CLI, another device) and overwrites them on its next snapshot.

What this is

Four things, each usable on its own, built in that order.

A live change stream. The hub streams "these paths changed" over SSE. The sync daemon holds it open and syncs on the news rather than waiting out its 10s remote interval; the browser listens on the same stream. This also fixes a bug that was never latent: file content has no refetch interval, so a teammate's edit never refreshed an open file — not for fifteen seconds, indefinitely.

It is strictly an accelerator. Every interval still runs underneath, so an older hub, a hub that is down, a buffering proxy, or an object-store remote with no hub at all falls back to exactly today's behaviour.

Presence. A 10s heartbeat naming the file you're on, held for 15s, never written to disk. Presence is true for fifteen seconds and then it's a lie, so storing it would only create something to serve staler than the thing it describes.

An editor. This reverses a stated product decision — the web app was a read/share/history surface and content entered only through local sync. The server needed nothing new: upload/content has existed since browser uploads were built, PermWrite and quota-checked, with no caller at all. That is also why the Mac app gets editing for free — its sidecar already proxies that route to the hub.

Co-editing. Yjs between browsers, relayed by the hub. The split that keeps it safe: the document is a CRDT, the file is not. The hub relays opaque updates and links no CRDT library, which keeps the build pure Go — the same reason modernc sqlite was chosen over cgo. Nothing touches the journal: the document is snapshotted to an ordinary blob by an ordinary upload/content call, so journal.Less and Replay are untouched and every desktop device, agent and older client converges exactly as before.

Auto-merge. Three-way merge against the common ancestor the journal already holds. It declines far more than it resolves — no ancestor, non-UTF-8, over 4 MiB, or edits touching the same lines all fall through to the conflict copy that existed before. A machine guessing between two rewrites of one sentence is how people lose work quietly.

The Mac app

The desktop sidecar serves the same /api/* contract over local state, and its rule is that it never journals locally — every write proxies to the hub. So editing needed nothing: upload/content was already proxied, and HubApp already resolves the account's real hub permission into project.perm, which is what the Edit button reads.

The live surfaces did need wiring: /events, /presence and /collab are hub state, and two things had to change for a stream to survive the proxy — initClient's 10s whole-request timeout would sever it, and io.Copy buffers 4 KiB so ~100-byte SSE frames would never arrive.

Adding co-editing initially broke desktop editing: the editor mounts on the relay's first frame, and with /collab unproxied that request fell through to the local PermRead handler, 403'd, and the pane never appeared. Fixed two ways — the route is proxied, and the editor now falls back to single-writer editing when the relay is unreachable, so an older hub gives a working editor rather than a blank one.

Verified with a browser on the desktop sidecar and a browser on the hub typing into one document: both keep their text, both converge, the journaled file has both.

Backward compatibility

Verified in both directions with real binaries built from the merge base, not by reasoning:

Old client → new hub works — pulled and pushed normally
New client → old hub works — /events 404s and the daemon degrades to polling, with no log spam
Old client's push → new hub's live stream works — so an un-upgraded device's edit still updates a teammate's open browser

internal/journal, internal/store and internal/syncer's wire format have zero diff. /events, /presence and /collab are purely additive routes; remote.Watcher is an optional capability in the existing PutSigner mold.

Three bugs the tests caught, each of which would have shipped silently

  1. The editor rebuilt on every keystroke. Inline callbacks in the effect deps meant CodeMirror was destroyed per character, resetting the cursor to 0 and writing text backwards — "rieso ehtn T".
  2. 32 of 32 joiners were told to seed the CRDT document. "First" was inferred from an empty update log, but the log only fills after the seeder posts, so everyone arriving in that window seeded too — and two clients seeding the same text build two different Yjs documents whose merge duplicates every character. The claim is now taken at join under the room lock.
  3. …and releasing that claim. If the claimer closes the tab without typing, the room stays claimed but empty, and the next joiner would open a blank document and snapshot that emptiness over a real file.

A fourth was caught by recording the demo: during ordinary co-editing both panes showed "Someone else changed this file while you were editing", because each client's snapshot fires a change event the other read as an outside write.

Known gaps

  • A live room ignores outside writes. A CLI push or another device editing the same file while a room is open is overwritten by the room's next snapshot. The banner warns a lone editor; with two people in the room it happens silently.
  • Co-editing is browser-to-browser. A desktop editor writing the same file is still a whole-file write — now auto-merged when disjoint, conflict-copied when not.
  • The relay's update log is deliberately not durable. If every browser closes, the document is whatever was last snapshotted.
  • A long editing session produces many journal ops for one file. Correct, but a compaction question.

Verification

  • Two browsers typing simultaneously: both edits survive, both converge, the journaled file has both, no conflict copy
  • Two devices editing different parts of a file: merged, Conflicts = 0; overlapping edits and binary files still fork
  • Old/new binaries against each other's hubs, both directions
  • Full internal/syncer, internal/webapp, cmd/bdrive (incl. every sec_* suite), store, journal, daemon
  • 126 frontend tests, tsc, gofmt clean, all four mermaid diagrams parse-checked

Not run: the full Playwright suite, which is contended on this machine — several other worktrees are using the reserved e2e ports. The four live.spec.ts specs pass 20/20 in isolation.

Architecture changes

architecture/webapp-server.md

Watcher joins PutSigner as an optional Backend capability. Server gained three in-memory services: eventHub (the change stream and its subscriber/changeEvent), presenceHub (+person), and collabHub (+collabRoom). presenceHub publishes on the event stream rather than owning one, and collabRoom reuses the same subscriber fan-out.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Server["<div style='text-align:left'><b>Server</b><br/>+Root remote.Backend<br/>+Auth AuthProvider<br/>+Quota QuotaProvider<br/>-vols per-project volume cache<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -ev *eventHub</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -pres *presenceHub</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -col *collabHub</span></div>"]
    Backend["<div style='text-align:left'><b>Backend</b><br/>&lt;&lt;interface&gt;&gt;<br/>+Put +Get +List +Exists +Close</div>"]
    PutSigner["<div style='text-align:left'><b>PutSigner</b><br/>&lt;&lt;interface&gt;&gt;<br/>+SignPut(ctx, key, size, ttl)</div>"]
    Watcher["<div style='text-align:left'><b>Watcher</b><br/>&lt;&lt;interface&gt;&gt;<br/>+Watch(ctx) signal channel</div>"]
    eventHub["<div style='text-align:left'><b>eventHub</b><br/>-subs per project id<br/>+subscribe(project) sub, ok<br/>+publish(project, changeEvent)</div>"]
    subscriber["<div style='text-align:left'><b>subscriber</b><br/>-ch chan of frames<br/>-lost atomic.Bool</div>"]
    changeEvent["<div style='text-align:left'><b>changeEvent</b><br/>+Type change or resync<br/>+Paths list<br/>+People roster, presence only</div>"]
    presenceHub["<div style='text-align:left'><b>presenceHub</b><br/>-at per project, per actor<br/>+mark(...) roster, changed<br/>+drop(...) roster, changed</div>"]
    person["<div style='text-align:left'><b>person</b><br/>+Name string<br/>+Path string</div>"]
    collabHub["<div style='text-align:left'><b>collabHub</b><br/>-rooms per project and path<br/>+room(key) collabRoom</div>"]
    collabRoom["<div style='text-align:left'><b>collabRoom</b><br/>-updates opaque Yjs updates<br/>-seeded claimed at join<br/>+join(sub) log, first<br/>+post(update, from) ok<br/>+relay(frame) not logged</div>"]
    SeedNote["seeded is CLAIMED at join, never inferred<br/>from an empty log: the log only fills after<br/>the seeder POSTs, so joiners inside that<br/>window were told to seed too (32 of 32).<br/>Two seeds of the same text are two different<br/>Yjs docs, and a merge doubles every character."]
    JournalNote["Nothing here touches the journal.<br/>The DOCUMENT is a CRDT between browsers.<br/>The FILE is an ordinary blob via upload/content.<br/>journal.Less and Replay are untouched."]
    Backend --> PutSigner
    Backend -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ optional capability</span>" --> Watcher
    Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ live change fan-out</span>" --> eventHub
    Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ who is looking at what</span>" --> presenceHub
    Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ per-document editing relay</span>" --> collabHub
    eventHub --> subscriber
    eventHub -.- changeEvent
    presenceHub -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ publishes roster on the SAME stream</span>" --> eventHub
    presenceHub -.-> person
    collabHub --> collabRoom
    collabRoom -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ reuses the event fan-out</span>" --> subscriber
    collabRoom -.- SeedNote
    collabHub -.- JournalNote
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Watcher,eventHub,subscriber,changeEvent,presenceHub,person,collabHub,collabRoom added
    class SeedNote,JournalNote noteBox
    linkStyle 1 stroke:#22c55e,stroke-width:2px
    linkStyle 2 stroke:#22c55e,stroke-width:2px
    linkStyle 3 stroke:#22c55e,stroke-width:2px
    linkStyle 4 stroke:#22c55e,stroke-width:2px
    linkStyle 5 stroke:#22c55e,stroke-width:2px
    linkStyle 7 stroke:#22c55e,stroke-width:2px
    linkStyle 8 stroke:#22c55e,stroke-width:2px
    linkStyle 9 stroke:#22c55e,stroke-width:2px
    linkStyle 10 stroke:#22c55e,stroke-width:2px
Loading

architecture/webapp-frontend.md

hooks gained useProjectEvents and usePresence; lib gained collab.ts; components gained Editor and PresenceBar. The Browser → hooks edge now names the event hook.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Browser["<div style='text-align:left'><b>Browser</b><br/>folder listing, file view<br/>per-view routes</div>"]
    hooks["<div style='text-align:left'><b>hooks</b><br/>+useConfig<br/>+useHub<br/>+useBrowse<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +useProjectEvents (SSE → invalidate)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +usePresence (10s heartbeat)</span><br/>+useTextAt → useBlobText</div>"]
    lib["<div style='text-align:left'><b>lib</b><br/>+diff.ts splitLines lcsDiff<br/>+heat.ts heatFor heatTotal<br/>+conflict.ts parseConflict<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +collab.ts CollabDoc peerCount</span></div>"]
    components["<div style='text-align:left'><b>components</b><br/>FileView FolderListing FileTree<br/>HistoryView DiffView ConflictBanner<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ Editor</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ PresenceBar</span></div>"]
    CollabNote["collab.ts is the Yjs provider:<br/>SSE down, POST up — the same pair<br/>/events already uses. No websocket,<br/>no upgrade handshake.<br/>Awareness is posted separately<br/>and never logged."]
    BufferNote["The editor never re-seeds from the server.<br/>`initial` changes when the seed query<br/>refetches, and a peer's write invalidates<br/>exactly that query — so it would reset<br/>the buffer under the typist's cursor."]
    Browser -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ + useProjectEvents</span>" --> hooks
    Browser --> components
    components --> lib
    hooks --> lib
    lib -.- CollabNote
    components -.- BufferNote
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class CollabNote,BufferNote noteBox
    linkStyle 0 stroke:#22c55e,stroke-width:2px
Loading

architecture/cli-sync.md

A new merge unit (internal/syncer/merge.go) that Session consults before making a conflict copy. daemon gained a third select arm on remote.Watcher.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Session["<div style='text-align:left'><b>Session</b><br/>+Cycle(ctx) Result<br/>-scan, pull, materialize, push<br/>-conflictCopies(...)</div>"]
    merge["<div style='text-align:left'><b>merge</b><br/>+tryMerge(path, mine, theirs, all) blob, ok<br/>+mergeText(base, a, b) out, ok<br/>+commonAncestor(path, mine, theirs, all)<br/>-maxMergeBytes 4 MiB, UTF-8 only</div>"]
    Result["<div style='text-align:left'><b>Result</b><br/>+LocalOps +PulledOps<br/>+Conflicts int<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Merged int</span></div>"]
    daemon["<div style='text-align:left'><b>daemon</b><br/>+Run(folder, scan, remote)<br/>+Start / Stop / Running<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ selects on remote.Watcher</span></div>"]
    Store["<div style='text-align:left'><b>Store</b><br/>blobs + journals + state</div>"]
    OrderNote["Only NON-overlapping regions are taken,<br/>so merge(base,a,b) == merge(base,b,a).<br/>That is what lets two devices observe the<br/>same concurrency and merge independently<br/>without pushing different blobs."]
    DeclineNote["Declines far more than it resolves:<br/>no ancestor, non-UTF-8 or NUL, over 4 MiB,<br/>an unreadable blob, or edits touching the<br/>same lines all fall through to the conflict<br/>copy that existed before."]
    Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ before a conflict copy</span>" --> merge
    Session --> Store
    Session --> Result
    merge -.- OrderNote
    merge -.- DeclineNote
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class merge added
    class OrderNote,DeclineNote noteBox
    linkStyle 0 stroke:#22c55e,stroke-width:2px
Loading

ssowonny and others added 18 commits September 4, 2026 18:08
The hub streams "these paths changed" over SSE, so both the browser and the
sync daemon learn about a peer's write as it happens instead of on their
next poll.

- GET {prefix}events, proj(PermRead) — a per-project in-memory fan-out
  (events.go), published from beside captureChange at the five write
  handlers. Not inside it: that one is the PostHog path, whose contract is
  that "nothing here carries a path or a file name", and whose signature is
  counts-only.
- remote.Watcher, a new optional backend capability in the PutSigner mold.
  Only httpBackend implements it; the daemon selects on its channel and
  clears the remote gate, turning ~10s into ~1s. Object-store remotes never
  had a hub to tell them and keep polling unchanged.
- useProjectEvents invalidates exactly what a write touched. This is what
  makes an OPEN file update at all — useTextAt has no refetchInterval, so a
  body fetched once previously stayed on screen until the reader navigated
  away.

Strictly an accelerator: every interval still runs underneath, a slow
listener is told to resync rather than waited for, and a hub that is down,
too old to serve the route, or behind a buffering proxy degrades to exactly
today's behaviour.

Also fixes handleUndoRun, which wrote journal ops while calling neither
captureChange nor (now) publishChange — the gap captureChange's own comment
warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SSE frames held by a proxy until close turn live updates off with no error
anywhere — sync keeps working at its old pace, which is the hard kind of
problem to notice. nginx recipe, plus the note that Caddy and Cloudflare
need nothing and that the fallback is simply today's behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
handleEvents only returned when the client disconnected, so anything that
never disconnects held a subscriber forever — a half-open connection in
production, and in tests a ResponseRecorder, which would have hung the whole
package the first time someone poked the route with the ordinary do()
helper. maxSubsTotal bounds concurrent streams, not abandoned ones.

Streams now age out after an hour. Both clients already re-dial, but the
daemon has to tell a clean expiry from a fast death or it would go blind for
watchRetry once an hour on purpose: a stream that lived longer than the
backoff re-dials at once, one that died quickly still backs off.

Also: the resync now rides the keepalive, not only the next frame. A client
that overflowed and then saw the project go quiet would otherwise stay stale
until its stream aged out, because the traffic that would tell it to refetch
is exactly the traffic that stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Go tests cover both ends of the wire — the hub publishes, the sync client
wakes up. What only a browser can check is that a frame actually reaches
TanStack Query and invalidates the right thing.

The first spec is the one that pins the bug: file content has no
refetchInterval, so before the change stream an open body stayed on screen
until the reader navigated away — not for 15 seconds, indefinitely. It marks
the document and re-checks the mark afterwards, so a passing run also proves
no reload happened.

Every budget is well under the 15s tree poll; a spec that waited 20s would
pass on the poll alone and prove nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A heartbeat every 10s says which path you are on; the hub holds it for 15s and
fans the roster out on the change stream that already exists. The top bar
shows initials, ringed when someone is on the same file as you — which is the
one question it exists to answer: am I about to collide with a teammate.

Nothing is persisted and there is no MetaStore repo. Presence is true for
fifteen seconds and then it is a lie, so writing it down would only create
something to serve staler than the thing it describes; a hub restart empties
it and the next heartbeat rebuilds it.

The roster is keyed by account email and goes to every member of the project,
so the key never appears in what they receive — display names and paths only,
the same pair History already shows them. A claimed path is untrusted text
echoed to teammates, so it goes through journal.SafePath rather than a fourth
private copy of "looks fine to me".

Expiry is lazy rather than a sweeper goroutine: the only people who need to
know a roster shrank are the ones still in it, and they are exactly the ones
still heartbeating.

PermRead, not PermWrite — a read-only member is precisely who a teammate most
wants to see on a file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
presenceHub, person, changeEvent.People on the server diagram; usePresence
and PresenceBar on the frontend one. Both notes carry the two decisions that
are not obvious from the types: the roster's actor key is an email that is
never serialized, and expiry is lazy because the only people who need to know
a roster shrank are the ones still heartbeating.

All blocks parse-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README gets a "Live" bullet; concepts/how-it-works gets a section next to the
daemon's intervals, since that is where a reader forms their mental model of
how long a change takes to arrive.

Both say the same thing the code does: it is an accelerator, not a mechanism.
An older hub, a buffering proxy, or an object-store remote falls back to the
intervals and nothing else changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version wrote to the seeded index.md, which browse.spec and
admin.spec both assert is "Wiki" — so depending on file order it would have
broken two other specs, and its own second run: the "before" it asserted was
gone the moment it had run once.

The harness is one hub with mutable state shared by every spec (workers: 1),
so a spec has to establish its own precondition. These now write their own
fixture file, and the "a new file appears" test takes a fresh path per run,
since a file that already exists proves nothing about appearing.

20/20 on --repeat-each=5, sub-500ms each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-sync

# Conflicts:
#	internal/webapp/static/assets/index-Bf0LFaYC.css
#	internal/webapp/static/index.html
Reverses a stated product decision: the web app was a read/share/history
surface and content entered only through local sync. Owner call, 2026-09-06.

The server needed nothing new. upload/content has existed since browser
uploads were built — PermWrite, quota-checked, blobs-before-journal — with no
caller at all. This gives it one. That is also why the Mac app gets editing
for free: its sidecar already proxies that route to the hub, so a desktop
edit is journaled by the hub under the account exactly like any other write.

CodeMirror 6 rather than a textarea, because the CRDT layer attaches to a real
editor and doing this surface twice is not cheaper.

Saving is debounced autosave, not a Save button — that is what makes the
change stream worth having: a teammate watching the file sees it fill in
while you type instead of in one lump.

Two things the editor must never do, both found by driving a real browser:

- Rebuild on a re-render. The callbacks are inline arrows and reporting a save
  state re-renders, so with them in the effect deps CodeMirror was torn down
  per keystroke, resetting the cursor to 0 and writing the text back to front
  ("rieso ehtn T"). Deps are the document alone now; callbacks live in a ref.
- Re-seed from the server. `initial` changes when the seed query refetches,
  and a peer's write invalidates exactly that query — so a teammate could
  reset your buffer under your cursor. Seeded once from a ref; a peer write
  raises a banner instead.

Still last-writer-wins: two people typing into one file collide exactly as two
laptops do. That is what the CRDT phase is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidecar never journals locally — every write proxies to the hub — so a
change stream served from local state would be a connection that stays open
and silent forever. /events and /presence join heat and shares as hub
proxies, and the app sees a teammate's edit the moment the hub does.

Two things had to change for a stream to survive the proxy, both of which
would have failed silently rather than loudly:

- initClient carries a 10s whole-request timeout, correct for every JSON call
  here and fatal for a connection whose job is to stay open with nothing to
  say. Streams get a client without one, same redirect policy so a stream
  cannot carry the device token off-origin either.
- io.Copy buffers. An SSE frame is ~100 bytes and the response writer holds
  4 KiB, so events would have sat there until something else filled the
  buffer — on an idle project, never. Streamed responses flush per read.

Editing needed nothing: upload/content was already proxied, and HubApp
already resolves the account's real hub permission into project.perm on
desktop, which is what the Edit button reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yjs between browsers, relayed by the hub. Two editors in the same paragraph
now merge character by character instead of one clobbering the other and
collecting a conflict copy.

The split that keeps this safe: the DOCUMENT is a CRDT, the FILE is not. The
hub relays opaque updates and never links a CRDT library — which keeps the
build pure Go, the same reason modernc sqlite was chosen over cgo. Nothing
here touches the journal: the document is snapshotted to an ordinary blob by
an ordinary upload/content call from whichever client stopped typing last, so
journal.Less and Replay are untouched and every desktop device, agent and
older client converges exactly as before.

The relay's log is deliberately not durable. The file is.

The one piece of coordination a pure relay cannot avoid is who seeds the
document from the file, because two clients seeding the same text produce two
DIFFERENT Yjs documents and merging them duplicates every character. The first
version inferred it from an empty update log, which a concurrency test
immediately falsified: the log only fills after the seeder posts, so every
joiner arriving inside that window was told to seed as well (32 of 32). The
claim is now taken at join under the room lock — and released again if the
claimer leaves without typing, or a client that opened a file and closed it
would leave the room claimed but empty, and the next joiner would snapshot
that emptiness over a real file.

Co-editing is browser-to-browser. A desktop editor writing the same file is
still a whole-file write, resolved the way it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two people editing one text file used to fork it: last-writer-wins at the
path, loser preserved as a .bdrive-conflict-* copy, and a hand-merge for
somebody. When the edits are in different parts of the file — the common
case — the machine can just do it.

Three-way merge against the common ancestor, which the journal already has:
the greatest op for that path ordered before BOTH sides is the state each
side started from. Hand-written, no dependency, the same call lib/diff.ts
made and documented; this one runs inside conflictCopies, which has a
security history, so the code deciding what a merged file contains should be
readable in one sitting.

It declines far more than it resolves, on purpose. No common ancestor,
non-UTF-8 or NUL bytes, over 4 MiB, a blob that will not read, or edits that
touch the same lines — every one of those falls through to exactly the
conflict copy that existed before. Two rewrites of one sentence is how people
lose work quietly, and a machine guessing between them is worse than a file
they have to look at.

Order independence is the property that makes this safe on more than one
device: two peers can each observe the same concurrency and merge
independently, so merge(base,a,b) must equal merge(base,b,a) or they push
different blobs and one side's work vanishes under last-writer-wins. Only
non-overlapping regions are ever taken, which makes the result the union
either way, and a test pins it.

Result.Merged is separate from Result.Conflicts because they mean opposite
things to the person reading the line: a merge is work the machine finished,
a conflict is work it handed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Awareness was never relayed, so two things silently did not work: a
co-editor's caret never appeared, and peerCount stayed at zero forever.

Awareness is broadcast but NEVER recorded in the room's log. It says where
somebody's caret is this second — replaying it to a joiner would paint
cursors for people who have gone home, and storing it would grow the room
without bound for something that has no history worth keeping.

That fixes a real bug the demo recording exposed: during ordinary co-editing
BOTH panes showed "Someone else changed this file while you were editing".
Each client's snapshot save fires a change event the other reads as an
outside write — but a co-editor's text is already in your buffer by
construction. The banner now fires only when nobody else is in the room,
which is exactly the case it exists for: a CLI or another device editing the
file from outside, where the room's document really will win.

Caret colour is derived from the account rather than assigned, so the same
person is the same colour on everyone's screen and across sessions; an index
would re-colour the room whenever somebody left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collabHub/collabRoom on the server diagram, collab.ts and Editor on the
frontend one, and the merge on the sync one. Each note carries the decision
that is not visible in the types: the seed claim is taken at join rather than
inferred from an empty log, awareness is relayed but never recorded, and the
merge only ever takes non-overlapping regions so two devices merging the same
concurrency independently cannot disagree.

All blocks parse-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding co-editing broke editing on the Mac app, two commits after making it
work. The editor mounts CodeMirror on the relay's first frame, and the
desktop never proxied /collab — so the request fell through to the local
handler, which resolves every project to PermRead, answered 403, and the
pane never appeared at all.

Two fixes, because either one alone leaves a hole:

- /collab joins /events and /presence as a hub proxy. The document lives in
  the hub's relay and its snapshot is a hub write, so there is nothing local
  to serve. Its GET is marked streaming, or the 10s client timeout severs it.
- The editor no longer depends on the relay existing. A CollabDoc that has
  never once connected reports itself unavailable and the editor opens
  single-writer — the same editing that worked before co-editing existed.
  Without this, ANY hub too old to serve /collab gives a new frontend a blank
  editor, which is a worse failure than not having the feature.

Verified with a browser on the desktop sidecar and a browser on the hub
typing into one document: both keep their text, both converge, and the
journaled file has both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five conflicts, four in real source. Two resolutions worth naming:

- desktop.go: main replaced hand-registered proxies with a desktopRoutes
  table and a test that fails when the hub grows a per-project route nobody
  classified. That test is exactly the safeguard the /collab omission on this
  branch needed, so my four live routes went INTO the table rather than
  beside it.
- Browser.tsx: most of that conflict was mine to answer for. I had run
  `npx prettier` without the repo's width, which reflowed lines I never
  touched. Took main's file and re-applied my changes onto its formatting.

Two real interactions between the two features, both fixed rather than
discovered later:

- upload/content now runs writablePath, so a project-level canEdit would put
  an Edit button on a read-only FOLDER that 403s on the first save — the
  "button that 403s" Share deliberately avoids. canEdit reads FolderRule.me,
  the hub's own answer, instead of recomputing one.
- /collab was project-level PermWrite only. A member with folder read could
  have joined the room and typed: keystrokes relayed to every co-editor and
  then failed to persist, because the snapshot goes through upload/content,
  which does check. Both halves of collab now run the same folder gate —
  better to refuse the session than to show people text that cannot survive.

Verified on the merged tree: web co-editing, Mac-app-to-web co-editing, and a
new check that a read-only folder hides the Edit button and refuses collab
while a writable path in the same project still offers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The change stream made this test's own premise obsolete. When the missing
file arrives, the stream invalidates the tree, the path stops being missing,
and the not-found view unmounts — so the click landed on a button that had
already gone, and Playwright timed out waiting for an element it had just
resolved.

The button still matters: a hub with no stream (older version, a proxy that
buffers SSE) recovers only when someone asks it to. So the spec clicks it if
it is still there and asserts what actually matters either way — the reader
ends up looking at the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny merged commit 1367132 into main Sep 6, 2026
4 of 5 checks passed
@ssowonny
ssowonny deleted the worktree-realtime-live-sync branch September 6, 2026 23:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant