Skip to content

Commit 5558be8

Browse files
authored
Merge pull request #6021 from simstudioai/collab-doc-stage-c
feat(collab-doc): stream copilot edits into the live document (Stage C)
2 parents 499b6c0 + 0fd0e89 commit 5558be8

23 files changed

Lines changed: 649 additions & 65 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
2+
import { env, getBaseUrl } from '@/env'
3+
4+
/**
5+
* The relay's client for the app's internal file-doc endpoints. The app owns the markdown↔Yjs
6+
* conversion engine (TipTap + jsdom) and blob/DB access; the relay owns the live document. So for any
7+
* operation that needs conversion, the relay delegates here over the shared `x-api-key` channel. The
8+
* timeouts (and their ordering vs. the app-side bounds) live in the shared `FILE_DOC_TIMEOUTS`.
9+
*/
10+
11+
function postToApp(path: string, payload: unknown, timeoutMs: number): Promise<Response> {
12+
return fetch(`${getBaseUrl()}${path}`, {
13+
method: 'POST',
14+
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
15+
body: JSON.stringify(payload),
16+
signal: AbortSignal.timeout(timeoutMs),
17+
})
18+
}
19+
20+
/**
21+
* Ask the app to build a server-authoritative seed (markdown → Yjs) for a file's collaborative
22+
* document. Returns the Yjs update to apply, or `null` for a genuinely empty/missing file (an empty
23+
* document is correct). THROWS on a transport failure (non-2xx / network / timeout / malformed body)
24+
* so the caller can tell a real empty from a failure it should be allowed to retry.
25+
*/
26+
export async function fetchFileDocSeed(
27+
workspaceId: string,
28+
fileId: string
29+
): Promise<Uint8Array | null> {
30+
const response = await postToApp(
31+
'/api/internal/file-doc/seed',
32+
{ workspaceId, fileId },
33+
FILE_DOC_TIMEOUTS.seedRequestMs
34+
)
35+
if (!response.ok) {
36+
throw new Error(`Seed fetch failed for file ${fileId}: ${response.status}`)
37+
}
38+
const body = (await response.json()) as { update?: unknown }
39+
const update = body?.update
40+
// A well-formed response is `{ update: base64-string | null }`. Anything else is a contract
41+
// violation, not a "genuinely empty file" — throw so the caller retries rather than silently
42+
// treating a malformed body as empty and stranding the room unseeded.
43+
if (update === null) return null
44+
if (typeof update !== 'string') {
45+
throw new Error(`Seed fetch for file ${fileId} returned a malformed body`)
46+
}
47+
return new Uint8Array(Buffer.from(update, 'base64'))
48+
}
49+
50+
/**
51+
* Ask the app to merge new markdown into the live document as a minimal Yjs diff — Stage C, so a
52+
* copilot edit streams into open editors instead of the file changing underneath them. The relay
53+
* ships the document's current state and applies the returned diff (which Yjs reconciles with any
54+
* concurrent user edits). THROWS on a transport failure or malformed body.
55+
*/
56+
export async function fetchFileDocMerge(
57+
fileId: string,
58+
docState: Uint8Array,
59+
markdown: string
60+
): Promise<Uint8Array> {
61+
const response = await postToApp(
62+
'/api/internal/file-doc/merge',
63+
{ fileId, docState: Buffer.from(docState).toString('base64'), markdown },
64+
FILE_DOC_TIMEOUTS.mergeRequestMs
65+
)
66+
if (!response.ok) {
67+
throw new Error(`Merge fetch failed for file ${fileId}: ${response.status}`)
68+
}
69+
const body = (await response.json()) as { update?: unknown }
70+
if (typeof body?.update !== 'string') {
71+
throw new Error(`Merge fetch for file ${fileId} returned a malformed body`)
72+
}
73+
return new Uint8Array(Buffer.from(body.update, 'base64'))
74+
}

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

Lines changed: 0 additions & 45 deletions
This file was deleted.

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

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@ import * as syncProtocol from 'y-protocols/sync'
1414
import * as Y from 'yjs'
1515
import type { IRoomManager } from '@/rooms'
1616

17-
const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi.hoisted(() => ({
17+
const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocMerge } = vi.hoisted(() => ({
1818
mockAuthorizeRoom: vi.fn(),
1919
mockFetchFileDocSeed: vi.fn(),
20+
mockFetchFileDocMerge: vi.fn(),
2021
}))
2122

2223
vi.mock('@sim/platform-authz/rooms', () => ({
2324
authorizeRoom: mockAuthorizeRoom,
2425
}))
2526

26-
vi.mock('@/handlers/file-doc-seed', () => ({
27+
vi.mock('@/handlers/file-doc-app', () => ({
2728
fetchFileDocSeed: mockFetchFileDocSeed,
29+
fetchFileDocMerge: mockFetchFileDocMerge,
2830
}))
2931

30-
import { cleanupFileDocForSocket, setupWorkspaceFileDocHandlers } from '@/handlers/file-doc'
32+
import {
33+
applyMarkdownToLiveFileDoc,
34+
cleanupFileDocForSocket,
35+
setupWorkspaceFileDocHandlers,
36+
} from '@/handlers/file-doc'
3137

3238
type Handler = (payload?: unknown) => Promise<void> | void
3339

@@ -173,6 +179,9 @@ describe('setupWorkspaceFileDocHandlers', () => {
173179
// Default: the server seed builder returns no content (empty file). Tests that
174180
// exercise seeding override this per-case with an encoded Yjs update.
175181
mockFetchFileDocSeed.mockResolvedValue(null)
182+
// Default: the merge builder returns a valid no-op (empty-doc) update. Tests exercising copilot
183+
// merges override it.
184+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
176185
})
177186

178187
afterEach(() => {
@@ -399,6 +408,65 @@ describe('setupWorkspaceFileDocHandlers', () => {
399408
expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toContain('# Seeded')
400409
})
401410

411+
it('merges a copilot edit into a seeded live room and relays it to editors', async () => {
412+
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# Original'))
413+
const { io, sent } = createIo()
414+
const { handlers } = setup('socket-1', io)
415+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
416+
await flushMicrotasks() // seed lands → room is seeded/live
417+
418+
// The app returns a diff (here, an update introducing text) for the relay to apply.
419+
const diff = new Y.Doc()
420+
diff.getText(FILE_DOC_FIELD).insert(0, 'copilot content')
421+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(diff))
422+
sent.length = 0
423+
424+
const result = await applyMarkdownToLiveFileDoc('file-1', '# Rewritten by copilot')
425+
426+
expect(result).toBe('applied')
427+
expect(mockFetchFileDocMerge).toHaveBeenCalledWith(
428+
'file-1',
429+
expect.any(Uint8Array),
430+
'# Rewritten by copilot'
431+
)
432+
// Applying the diff fires doc.on('update') → the merge is broadcast to the whole room.
433+
expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.target === ROOM_NAME)).toBe(
434+
true
435+
)
436+
})
437+
438+
it('reports no-live-room (and does not call the app) when the file has no seeded room', async () => {
439+
const result = await applyMarkdownToLiveFileDoc('file-1', '# anything')
440+
expect(result).toBe('no-live-room')
441+
expect(mockFetchFileDocMerge).not.toHaveBeenCalled()
442+
})
443+
444+
it('serializes concurrent merges for the same file (second waits for the first)', async () => {
445+
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# Original'))
446+
const { io } = createIo()
447+
const { handlers } = setup('socket-1', io)
448+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
449+
await flushMicrotasks()
450+
451+
// First merge is left in flight; the second must not start its own fetch until the first finishes.
452+
const noOpUpdate = Y.encodeStateAsUpdate(new Y.Doc())
453+
let resolveFirst: (v: Uint8Array) => void = () => {}
454+
mockFetchFileDocMerge
455+
.mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
456+
.mockResolvedValueOnce(noOpUpdate)
457+
458+
const first = applyMarkdownToLiveFileDoc('file-1', '# One')
459+
const second = applyMarkdownToLiveFileDoc('file-1', '# Two')
460+
await flushMicrotasks()
461+
expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) // second is queued behind the first
462+
463+
resolveFirst(noOpUpdate)
464+
await first
465+
await second
466+
// Only after the first resolved did the second run — and it snapshotted the post-first state.
467+
expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(2)
468+
})
469+
402470
it('relays a document update to the rest of the room, excluding the sender', async () => {
403471
const { io, sent } = createIo()
404472
const a = setup('socket-a', io)

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import * as awarenessProtocol from 'y-protocols/awareness'
3636
import * as syncProtocol from 'y-protocols/sync'
3737
import * as Y from 'yjs'
3838
import { resolveAvatarUrl } from '@/handlers/avatar'
39-
import { fetchFileDocSeed } from '@/handlers/file-doc-seed'
39+
import { fetchFileDocMerge, fetchFileDocSeed } from '@/handlers/file-doc-app'
4040
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
4141
import type { AuthenticatedSocket } from '@/middleware/auth'
4242
import type { IRoomManager } from '@/rooms'
@@ -205,6 +205,58 @@ async function ensureServerSeed(
205205
}
206206
}
207207

208+
/** Serializes live merges per file so overlapping calls never race the same doc (see below). */
209+
const fileDocMergeChains = new Map<string, Promise<unknown>>()
210+
211+
/**
212+
* Apply new markdown into a file's LIVE collaborative document (Stage C — copilot writing into an open
213+
* doc). Ships the document's current state to the app to build a minimal Yjs diff, applies it — which
214+
* fires `doc.on('update')` and relays the merge to every connected editor, reconciled with any
215+
* concurrent user edits — and reports whether it landed.
216+
*
217+
* Merges for the same file are SERIALIZED: each waits for any in-flight merge on that doc, so it
218+
* snapshots the already-updated state and its diff is computed against current content — two
219+
* overlapping full-document rewrites can never each diff the same stale snapshot and apply out of
220+
* order. (The relay is single-writer per file — Helm pins one replica — so an in-process chain
221+
* suffices.)
222+
*
223+
* Returns `'no-live-room'` when the file has no seeded, occupied room: an unseeded/empty doc has no
224+
* authoritative content to merge against, so the caller (copilot) writes the file directly instead and
225+
* the seed picks up the new content on the next open.
226+
*/
227+
export function applyMarkdownToLiveFileDoc(
228+
fileId: string,
229+
markdown: string
230+
): Promise<'applied' | 'no-live-room'> {
231+
const name = roomName(fileDocRoom(fileId))
232+
const prior = fileDocMergeChains.get(name) ?? Promise.resolve()
233+
// `.catch` so a failed prior merge doesn't reject this one — each merge is independent.
234+
const run = prior.catch(() => {}).then(() => mergeMarkdownIntoRoom(name, fileId, markdown))
235+
fileDocMergeChains.set(
236+
name,
237+
run.finally(() => {
238+
if (fileDocMergeChains.get(name) === run) fileDocMergeChains.delete(name)
239+
})
240+
)
241+
return run
242+
}
243+
244+
async function mergeMarkdownIntoRoom(
245+
name: string,
246+
fileId: string,
247+
markdown: string
248+
): Promise<'applied' | 'no-live-room'> {
249+
const room = fileDocRooms.get(name)
250+
if (!room || room.owners.size === 0 || !isDocSeeded(room.doc)) return 'no-live-room'
251+
252+
const update = await fetchFileDocMerge(fileId, Y.encodeStateAsUpdate(room.doc), markdown)
253+
// The room may have been dropped while the diff was being built; never touch a destroyed doc.
254+
if (fileDocRooms.get(name) !== room) return 'no-live-room'
255+
// No transaction origin → `doc.on('update')` relays to the WHOLE room (every editor sees copilot).
256+
Y.applyUpdate(room.doc, update)
257+
return 'applied'
258+
}
259+
208260
/**
209261
* Get (or lazily create) the authoritative document for a room, wiring the two
210262
* relay handlers exactly once: document updates and awareness changes are

apps/realtime/src/routes/http.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'http'
22
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
33
import { safeCompare } from '@sim/security/compare'
44
import { env } from '@/env'
5+
import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc'
56
import { type IRoomManager, WorkflowRoomService } from '@/rooms'
67

78
interface Logger {
@@ -183,6 +184,26 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
183184
return
184185
}
185186

187+
// Merge a copilot edit into a file's LIVE collaborative document so it streams into open editors
188+
// (Stage C). Returns `{ applied }`: when false, no seeded live room exists and the caller writes
189+
// the file directly instead. Any live user edits are preserved — the app builds a minimal CRDT diff.
190+
if (req.method === 'POST' && req.url === '/api/file-doc/apply-edit') {
191+
try {
192+
const body = await readRequestBody(req)
193+
const { fileId, markdown } = JSON.parse(body)
194+
if (!isNonEmptyString(fileId) || typeof markdown !== 'string') {
195+
return sendError(res, 'Invalid fileId or markdown', 400)
196+
}
197+
const result = await applyMarkdownToLiveFileDoc(fileId, markdown)
198+
res.writeHead(200, { 'Content-Type': 'application/json' })
199+
res.end(JSON.stringify({ applied: result === 'applied' }))
200+
} catch (error) {
201+
logger.error('Error applying copilot edit to live file-doc:', error)
202+
sendError(res, 'Failed to apply edit to live document')
203+
}
204+
return
205+
}
206+
186207
res.writeHead(404, { 'Content-Type': 'application/json' })
187208
res.end(JSON.stringify({ error: 'Not found' }))
188209
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { mergeFileDocContract } from '@/lib/api/contracts/file-doc'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { buildFileDocMergeUpdate } from '@/lib/collab-doc/merge'
8+
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const logger = createLogger('FileDocMergeAPI')
12+
13+
/**
14+
* POST /api/internal/file-doc/merge — merge new markdown into a live collaborative document as a
15+
* minimal Yjs diff (Stage C — copilot writing into an open doc). The realtime relay ships the current
16+
* doc state; the app returns the diff to apply + relay. Internal only: gated on the shared
17+
* `x-api-key: INTERNAL_API_SECRET` secret, matching the seed endpoint and the realtime relay.
18+
*/
19+
export const POST = withRouteHandler(async (request: NextRequest) => {
20+
const auth = checkInternalApiKey(request)
21+
if (!auth.success) return createUnauthorizedResponse()
22+
23+
const parsed = await parseRequest(mergeFileDocContract, request, {})
24+
if (!parsed.success) return parsed.response
25+
const { fileId, docState, markdown } = parsed.data.body
26+
27+
try {
28+
const update = buildFileDocMergeUpdate(Buffer.from(docState, 'base64'), markdown)
29+
return NextResponse.json({ update: Buffer.from(update).toString('base64') })
30+
} catch (error) {
31+
logger.error('Failed to merge markdown into file-doc', { fileId, error })
32+
return NextResponse.json(
33+
{ error: getErrorMessage(error, 'Failed to merge document') },
34+
{ status: 500 }
35+
)
36+
}
37+
})

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
FILE_DOC_EVENTS,
33
FILE_DOC_MESSAGE_TYPE,
44
FILE_DOC_SEED,
5+
FILE_DOC_TIMEOUTS,
56
type JoinFileDocError,
67
type JoinFileDocSuccess,
78
toFileDocBytes,
@@ -34,9 +35,10 @@ interface FileDocProviderEvents {
3435
* On the deadline the provider latches fatal and surfaces a non-retryable `join-error` — the exact
3536
* path a fatal rejection uses — so the editor falls back to showing the file's stored content
3637
* read-only instead of a permanently blank pane. Generous enough to clear a slow connect + seed
37-
* round-trip; a healthy cold open reaches readiness well within it.
38+
* round-trip; a healthy cold open reaches readiness well within it. Shared with (and must exceed) the
39+
* relay's seed-fetch timeout — see `FILE_DOC_TIMEOUTS` and its ordering test.
3840
*/
39-
const READINESS_DEADLINE_MS = 12_000
41+
const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs
4042

4143
/**
4244
* The client half of the collaborative file-document protocol: a Yjs provider

0 commit comments

Comments
 (0)