Skip to content

Commit 49331fb

Browse files
committed
fix(files): fix new-file collab streaming latch and agent-edit duplication
- Latch collab readiness so a new file's post-seed `synced` flap can no longer re-gate agent streaming (the stream previously showed only the seed and the rest appeared only on reload) - Relay defers the durable edit_content merge to an actively-streaming client: the client shadow stream and the server merge were both writing the same content into the live doc, duplicating it when the server ran ahead - Render the collaborator caret bar out of flow so a peer caret never nudges the surrounding text by ~1px - Remove dead code: unused FileDocMessageType alias, unnecessary LiveFileDocMergeOrder export Covered by tests: readiness latch (flap/offline/latch cases), relay merge deferral (single- and multi-replica), plus verified-failing guards.
1 parent c5a56e5 commit 49331fb

11 files changed

Lines changed: 289 additions & 20 deletions

File tree

apps/realtime/src/handlers/file-doc-store.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ const SEED_LOCK_PREFIX = 'filedoc:seedlock:'
107107
const COMPACT_LOCK_PREFIX = 'filedoc:compactlock:'
108108
const PERSIST_LOCK_PREFIX = 'filedoc:persistlock:'
109109
const MERGE_LOCK_PREFIX = 'filedoc:mergelock:'
110+
/** Cluster-wide "a client is actively streaming an agent edit into this live doc" flag — set (refreshed)
111+
* on every agent frame so a durable {@link applyMarkdownToLiveFileDoc} merge defers to that client
112+
* (which is applying the same content) instead of double-writing it. Short-TTL'd so it self-clears the
113+
* moment streaming stops, after which the final durable merge lands as a near-noop. */
114+
const AGENT_STREAM_PREFIX = 'filedoc:agentstream:'
110115

111116
/** The field each stream entry carries — a base64 Yjs update. */
112117
const UPDATE_FIELD = 'u'
@@ -555,6 +560,34 @@ export class FileDocStore {
555560
}
556561
}
557562

563+
/** Mark (or refresh) that a client is actively streaming an agent edit into this live doc — a plain
564+
* `SET key "1" PX ttl`, so it self-clears when streaming stops. Best-effort; no-op when disabled. */
565+
async markAgentStreaming(name: string, ttlMs: number): Promise<void> {
566+
if (!this.enabled || !this.write) return
567+
try {
568+
await this.write.set(`${AGENT_STREAM_PREFIX}${name}`, '1', { PX: ttlMs })
569+
} catch (error) {
570+
logger.warn(`FileDocStore markAgentStreaming failed for ${name}`, {
571+
error: getErrorMessage(error),
572+
})
573+
}
574+
}
575+
576+
/** Whether a client is currently streaming an agent edit into this live doc (see
577+
* {@link markAgentStreaming}). Best-effort; treats an error/disabled store as "not streaming" so a
578+
* merge never blocks on this check. */
579+
async isAgentStreaming(name: string): Promise<boolean> {
580+
if (!this.enabled || !this.write) return false
581+
try {
582+
return (await this.write.exists(`${AGENT_STREAM_PREFIX}${name}`)) === 1
583+
} catch (error) {
584+
logger.warn(`FileDocStore isAgentStreaming failed for ${name}`, {
585+
error: getErrorMessage(error),
586+
})
587+
return false
588+
}
589+
}
590+
558591
async releaseMergeSlot(name: string, token: string): Promise<void> {
559592
await this.releaseLock(`${MERGE_LOCK_PREFIX}${name}`, token)
560593
}

apps/realtime/src/handlers/file-doc.multireplica.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const fakeStore = {
3131
setSyncedVersion: vi.fn(async (name: string, version: number) => {
3232
fakeStore.versions.set(name, Math.max(fakeStore.versions.get(name) ?? 0, version))
3333
}),
34+
markAgentStreaming: vi.fn(async () => {}),
35+
isAgentStreaming: vi.fn(async () => false),
3436
}
3537

3638
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() }))
@@ -83,4 +85,26 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering'
8385
// setSyncedVersion fired only for the two applied durable writes, never for the stale one.
8486
expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2)
8587
})
88+
89+
it('defers the content merge cluster-wide when a client is streaming (records version, no diff)', async () => {
90+
// The cluster-wide counterpart of the single-replica deferral: while the shared `isAgentStreaming`
91+
// flag is set (a client on ANY replica is streaming this agent edit), the durable merge must record
92+
// the version but skip the content diff — the streaming client owns the bytes, so a whole-document
93+
// merge here would double-write them.
94+
fakeStore.isAgentStreaming.mockResolvedValue(true)
95+
96+
expect(
97+
await applyMarkdownToLiveFileDoc('file-1', '# streamed by a client', { version: 100 })
98+
).toBe('applied')
99+
expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client
100+
expect(fakeStore.publishAndWait).not.toHaveBeenCalled()
101+
expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100) // version still recorded
102+
103+
// Once streaming stops the flag clears and the (now near-noop) durable merge resumes normally.
104+
fakeStore.isAgentStreaming.mockResolvedValue(false)
105+
expect(await applyMarkdownToLiveFileDoc('file-1', '# final durable', { version: 150 })).toBe(
106+
'applied'
107+
)
108+
expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1)
109+
})
86110
})

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,39 @@ describe('setupWorkspaceFileDocHandlers', () => {
568568
)
569569
})
570570

571+
it('defers the content merge to an actively-streaming client (records version, no double-write)', async () => {
572+
// Two-writer duplication guard: while a client is streaming an agent edit into the shared doc
573+
// (agent frames flowing), a durable apply-edit merge must NOT also publish the same content — the
574+
// client's private shadow never observes this merge and would re-insert it. The merge still records
575+
// the durable version; once streaming stops it resumes and lands as a near-noop.
576+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original'))
577+
const { io } = createIo()
578+
const { handlers } = setup('socket-1', io)
579+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
580+
await flushMicrotasks()
581+
582+
// A client streams an agent frame → the room is now "actively streaming".
583+
const edit = new Y.Doc()
584+
edit.getText(FILE_DOC_FIELD).insert(0, 'agent streaming this live')
585+
handlers[FILE_DOC_EVENTS.MESSAGE](
586+
frame(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST, (e) =>
587+
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
588+
)
589+
)
590+
await flushMicrotasks()
591+
592+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
593+
// The durable merge lands mid-stream: it must defer (record version) and skip the content diff.
594+
const result = await applyMarkdownToLiveFileDoc('file-1', '# Rewritten by copilot', {
595+
version: 100,
596+
})
597+
expect(result).toBe('applied')
598+
expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client
599+
600+
// The recorded version is honored: a later stale merge is still rejected on it.
601+
expect(await applyMarkdownToLiveFileDoc('file-1', '# older', { version: 50 })).toBe('stale')
602+
})
603+
571604
it('reports no-live-room (and does not call the app) when the file has no seeded room', async () => {
572605
const result = await applyMarkdownToLiveFileDoc('file-1', '# anything')
573606
expect(result).toBe('no-live-room')

apps/realtime/src/handlers/file-doc.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ const MERGE_LOCK_RETRIES = Math.ceil(
9090
(MERGE_LOCK_TTL_MS + FILE_DOC_TIMEOUTS.mergeRequestMs) / MERGE_LOCK_RETRY_MS
9191
)
9292

93+
/**
94+
* How long after the last agent frame a client is still treated as "actively streaming" (so a durable
95+
* merge defers to it). Comfortably longer than the gap between agent frames (per-rAF to the client's
96+
* reparse throttle) so brief throttle pauses don't flip it off mid-stream, yet short enough that the
97+
* final durable merge — which lands as a near-noop once the client has streamed everything — isn't
98+
* deferred for long after the stream ends.
99+
*/
100+
const AGENT_STREAM_FLAG_TTL_MS = 10_000
101+
93102
/** One presence ownership within a room: a (socket, clientID) pair. */
94103
interface FileDocOwner {
95104
/**
@@ -149,6 +158,15 @@ interface FileDocRoom {
149158
* edit the live doc hasn't incorporated. `null` before the first seed (persist writes unconditionally).
150159
*/
151160
syncedVersion: number | null
161+
/**
162+
* Epoch ms until which a client is treated as actively streaming an agent edit into this doc —
163+
* refreshed on every agent frame ({@link AGENT_SYNC_ORIGIN}/{@link REDIS_AGENT_ORIGIN}). While it is in
164+
* the future, a durable {@link applyMarkdownToLiveFileDoc} merge DEFERS its content diff to that client
165+
* (which applies the same content into the shared doc), preventing the two-writer duplication; the merge
166+
* still records the durable version. The single-replica counterpart of the cluster-wide
167+
* {@link FileDocStore.isAgentStreaming} flag. `0` when no agent stream is active.
168+
*/
169+
agentStreamingUntil: number
152170
}
153171

154172
/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */
@@ -645,6 +663,15 @@ async function mergeMarkdownIntoRoom(
645663
const shared = await store.getSyncedVersion(name)
646664
const current = Math.max(shared ?? 0, fileDocRooms.get(name)?.syncedVersion ?? 0)
647665
if (isStale(current)) return 'stale'
666+
// Defer to an actively-streaming client: it is applying this SAME agent edit into the shared doc
667+
// frame-by-frame, so also publishing a whole-document merge here would double-write the content (the
668+
// client's private shadow never observes this merge, so it re-inserts what we added → duplication).
669+
// Still record the durable version so the persist If-Match stays correct; the client owns the bytes,
670+
// and once streaming stops the flag clears and the final durable merge lands as a near-noop.
671+
if (await store.isAgentStreaming(name)) {
672+
await recordVersion()
673+
return 'applied'
674+
}
648675
// Compute the diff against the committed SHARED state and PUBLISH it — every task with the doc
649676
// live (including this one, via its own tailer) applies it and fans it out to its clients, so the
650677
// merge reaches the live doc no matter which task the apply-edit call landed on. An empty stream
@@ -665,6 +692,12 @@ async function mergeMarkdownIntoRoom(
665692
const room = fileDocRooms.get(name)
666693
if (!room || room.owners.size === 0 || !isDocSeeded(room.doc)) return 'no-live-room'
667694
if (isStale(room.syncedVersion ?? 0)) return 'stale'
695+
// Defer to an actively-streaming client (see the multi-replica branch above) — it applies this agent
696+
// edit itself, so merging it here too would double-write. Record the version; skip the content merge.
697+
if (room.agentStreamingUntil > Date.now()) {
698+
await recordVersion()
699+
return 'applied'
700+
}
668701
const update = await fetchFileDocMerge(fileId, Y.encodeStateAsUpdate(room.doc), markdown)
669702
// The room may have been dropped while the diff was being built; never touch a destroyed doc.
670703
if (fileDocRooms.get(name) !== room) return 'no-live-room'
@@ -702,6 +735,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
702735
persistTimer: null,
703736
persistDeadline: null,
704737
syncedVersion: null,
738+
agentStreamingUntil: 0,
705739
}
706740
// Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
707741
fileDocRooms.set(name, room)
@@ -735,6 +769,17 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
735769
origin !== SEED_ORIGIN
736770
)
737771
getFileDocStore().publish(name, update, origin === AGENT_SYNC_ORIGIN)
772+
// A locally-originated agent frame (this task's stream leader) means a client is applying this agent
773+
// edit itself. Refresh the "actively streaming" markers so a durable `applyMarkdownToLiveFileDoc`
774+
// merge defers to that client instead of double-writing the same content (the two-writer
775+
// duplication). Both are TTL'd, so they self-clear once frames stop and the final durable merge then
776+
// lands as a near-noop. Only AGENT_SYNC_ORIGIN is handled: the in-memory marker is read solely by the
777+
// single-replica merge branch (where every agent frame is AGENT_SYNC_ORIGIN), and the cluster flag is
778+
// read cluster-wide, so a peer task tailing REDIS_AGENT_ORIGIN never needs to set either.
779+
if (origin === AGENT_SYNC_ORIGIN) {
780+
room.agentStreamingUntil = Date.now() + AGENT_STREAM_FLAG_TTL_MS
781+
void getFileDocStore().markAgentStreaming(name, AGENT_STREAM_FLAG_TTL_MS)
782+
}
738783
// Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a
739784
// local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so
740785
// whichever task is last to leave persists real edits, even one that only tailed them. A compaction

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ export function renderCaret(user: Record<string, unknown>): HTMLElement {
8484
// One inline var drives the caret bar, the dormant cap, and the name tag (all in CSS).
8585
caret.style.setProperty('--caret-color', color)
8686
if (clientId !== undefined) caret.dataset.caretClientId = String(clientId)
87+
// The visible caret bar is a SEPARATE, absolutely-positioned child — never an inline border on the
88+
// caret span. The caret is a ProseMirror inline widget inserted between characters; an in-flow bar
89+
// (border + width) reflows the surrounding text by ~1px each time a peer's caret appears or moves.
90+
// An out-of-flow bar has zero layout footprint, so peer carets never nudge the document.
91+
const bar = document.createElement('span')
92+
bar.className = 'collaboration-carets__bar'
93+
caret.appendChild(bar)
8794
const label = document.createElement('div')
8895
label.className = 'collaboration-carets__label'
8996
label.textContent = name
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { type CollabReadinessInputs, nextCollabReadiness } from './readiness'
6+
7+
/** Drive a sequence of observations through the latch, returning the readiness at each step. */
8+
function run(steps: CollabReadinessInputs[]): boolean[] {
9+
let syncedOnce = false
10+
return steps.map((input) => {
11+
const next = nextCollabReadiness(syncedOnce, input)
12+
syncedOnce = next.syncedOnce
13+
return next.ready
14+
})
15+
}
16+
17+
describe('nextCollabReadiness', () => {
18+
it('is not ready before syncing or seeding', () => {
19+
const { syncedOnce, ready } = nextCollabReadiness(false, {
20+
synced: false,
21+
seeded: false,
22+
offlineSeed: false,
23+
})
24+
expect(syncedOnce).toBe(false)
25+
expect(ready).toBe(false)
26+
})
27+
28+
it('is not ready when synced but not yet seeded', () => {
29+
const { syncedOnce, ready } = nextCollabReadiness(false, {
30+
synced: true,
31+
seeded: false,
32+
offlineSeed: false,
33+
})
34+
expect(syncedOnce).toBe(true) // latched
35+
expect(ready).toBe(false) // waits for the seed
36+
})
37+
38+
it('opens on the new-file flap sequence: synced true, then seed lands while synced flapped false', () => {
39+
// The exact bug: `synced` and `seeded` are never true in the same observation. The latch must still
40+
// open once BOTH have been seen across observations.
41+
const readiness = run([
42+
{ synced: false, seeded: false, offlineSeed: false }, // joining
43+
{ synced: true, seeded: false, offlineSeed: false }, // initial (empty) sync
44+
{ synced: false, seeded: false, offlineSeed: false }, // synced flaps false on re-sync
45+
{ synced: false, seeded: true, offlineSeed: false }, // server seed lands (synced still false)
46+
])
47+
expect(readiness).toEqual([false, false, false, true])
48+
})
49+
50+
it('opens even if the seed lands before we ever observed synced (server seed proves a sync)', () => {
51+
// If the flap beat our first observation, the seed flag alone (not the offline fallback) proves a
52+
// completed sync happened.
53+
const { syncedOnce, ready } = nextCollabReadiness(false, {
54+
synced: false,
55+
seeded: true,
56+
offlineSeed: false,
57+
})
58+
expect(syncedOnce).toBe(true)
59+
expect(ready).toBe(true)
60+
})
61+
62+
it('stays read-only for an offline (local) seed that never reached the server', () => {
63+
const readiness = run([
64+
{ synced: false, seeded: false, offlineSeed: false },
65+
{ synced: false, seeded: true, offlineSeed: true }, // offline fallback seeded locally
66+
])
67+
expect(readiness).toEqual([false, false])
68+
})
69+
70+
it('never reverts once ready, even if synced later flaps false', () => {
71+
const readiness = run([
72+
{ synced: true, seeded: true, offlineSeed: false }, // ready
73+
{ synced: false, seeded: true, offlineSeed: false }, // synced flaps — must stay ready
74+
])
75+
expect(readiness).toEqual([true, true])
76+
})
77+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Collaborative-readiness latch for the file editor.
3+
*
4+
* A file becomes "ready" (editable + agent streaming enabled) once its shared doc has both SYNCED and
5+
* SEEDED. The subtlety this latch solves: a brand-new file's provider reports `synced: true` on the
6+
* initial (empty) sync, then the SERVER pushes the seed and receiving that update flips `synced` back to
7+
* `false` — so `synced` and `seeded` are never `true` in the same observation. An un-latched
8+
* `synced && seeded` gate would therefore never open, and agent streaming would be dropped for the whole
9+
* run (the file only fills in on reload).
10+
*
11+
* The latch: `syncedOnce` is sticky — set the first time a completed sync is observed, and it never
12+
* reverts. A completed sync is proven by EITHER a live `synced`, OR the seed flag being present without
13+
* the offline fallback having set it (`offlineSeed`) — because a SERVER seed can only arrive after a
14+
* sync, whereas the offline fallback seeds locally without ever reaching the server and must stay
15+
* read-only. Once `syncedOnce` is set, a later `synced` flap can no longer re-gate the doc.
16+
*/
17+
18+
export interface CollabReadinessInputs {
19+
/** The provider's current `synced` flag (may flap false after the seed update). */
20+
synced: boolean
21+
/** Whether the shared doc carries the seed flag. */
22+
seeded: boolean
23+
/** Whether the seed flag was set by the offline fallback (no server sync) rather than the server. */
24+
offlineSeed: boolean
25+
}
26+
27+
/**
28+
* Pure transition for the readiness latch. `syncedOnce` is the sticky prior state — pass the returned
29+
* `syncedOnce` back in on the next call. `ready` is whether the doc is synced-and-seeded.
30+
*/
31+
export function nextCollabReadiness(
32+
syncedOnce: boolean,
33+
input: CollabReadinessInputs
34+
): { syncedOnce: boolean; ready: boolean } {
35+
const next = syncedOnce || input.synced || (input.seeded && !input.offlineSeed)
36+
return { syncedOnce: next, ready: next && input.seeded }
37+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -472,19 +472,26 @@
472472
* cell is not hidden behind adjacent cells.
473473
*/
474474
.rich-markdown-prose .collaboration-carets__caret {
475+
/* Zero inline footprint: a positioned anchor with NO width/border/margin, so inserting or moving this
476+
* inline widget never reflows the surrounding text. The visible bar, dormant cap (::before), name label,
477+
* and hover slop (::after) are all positioned relative to this anchor and are out of the inline flow. */
475478
position: relative;
476-
margin-left: -1px;
477-
margin-right: -1px;
478-
border-left-width: 1px;
479-
border-left-style: solid;
480-
border-left-color: var(--caret-color);
481-
border-right-width: 1px;
482-
border-right-style: solid;
483-
border-right-color: var(--caret-color);
484479
word-break: normal;
485480
z-index: 20;
486481
}
487482

483+
/* The visible caret bar — absolutely positioned so it draws over the text without occupying inline
484+
* width. `left: -1px` centers the 2px bar on the cursor position; top/bottom span the line box height. */
485+
.rich-markdown-prose .collaboration-carets__bar {
486+
position: absolute;
487+
top: -0.1em;
488+
bottom: -0.1em;
489+
left: -1px;
490+
width: 2px;
491+
background-color: var(--caret-color);
492+
pointer-events: none;
493+
}
494+
488495
/* Dormant affordance: a small cap at the top of the caret in the collaborator's color,
489496
* shaped like a collapsed presence name tag (same `rounded-xs` + notch corner as the
490497
* tables/canvas tags) so the whole presence system reads as one language. It signals
@@ -493,10 +500,9 @@
493500
.rich-markdown-prose .collaboration-carets__caret::before {
494501
content: "";
495502
position: absolute;
496-
/* Seat the cap's square bottom-left corner on the pole's top-left, flush like a flag on its
497-
* pole. `left: -1px` backs out the caret's 1px left border (the abs-positioning origin is the
498-
* padding box, inside that border) so the cap's left edge lines up with the pole's left edge;
499-
* `top` overlaps the pole's top a hair so the notch reads as continuous, no gap. */
503+
/* Seat the cap's square bottom-left corner on the pole's top-left, flush like a flag on its pole.
504+
* `left: -1px` matches the bar's `left: -1px` so the cap's left edge lines up with the bar's left edge;
505+
* `top` overlaps the bar's top a hair so the notch reads as continuous, no gap. */
500506
top: -2px;
501507
left: -1px;
502508
width: 8px;

0 commit comments

Comments
 (0)