Repository navigation
fix(files): cancel resumed runs across replicas, unify anchored file edits, and harden workspace, integration, and presence paths #9035
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
57 commits
Select commit
Hold shift + click to select a range
0be5733
fix(projects): share the Project lock during fork creation, archive e…
waleedlatif1 5cdb836
fix(files): resolve the end anchor after the start, keep line endings…
waleedlatif1 951e6e2
fix(files): resolve a named file in a folder scope by exact match, no…
waleedlatif1 74ca9e3
fix(files): treat the root with its subfolders as unscoped instead of…
waleedlatif1 14f8a5e
chore(files): drop history narration and overstated folder-scope clai…
waleedlatif1 a5b5c16
refactor(files): move the anchored-edit mapper out of the intent stor…
waleedlatif1 f79bdc8
fix(files-ui): stop folder-scope pruning writes, memoize resource fol…
waleedlatif1 3200623
fix(resume): abort an active resume attempt when Stop is recorded on …
waleedlatif1 c3a1d7f
fix(agent): reject evaluation models as fallbacks at authoring time a…
waleedlatif1 f5e3bb7
fix(agent): skip a cross-provider fallback that needs its own family …
waleedlatif1 5be903a
refactor(agent): drop the fallback normalization from the Agent block…
waleedlatif1 3aed748
improvement(deployments): skip the defensive clone for an uncached co…
waleedlatif1 48b19d1
fix(zoho-desk): fail permanent webhook registration errors terminally
waleedlatif1 a9bba8f
fix(sailpoint): cache tokens with lru-cache, guard path ids, reject u…
waleedlatif1 39f7e24
fix(outlook): bind calendar page tokens to event collections and guar…
waleedlatif1 2b6fd80
improvement(ashby): share the error extractor and sync cursor descrip…
waleedlatif1 c6416f0
refactor(webhooks): classify every trigger configuration failure thro…
waleedlatif1 0f5c0cb
fix(tables): list built-in agent tools by base type and teach boolean…
waleedlatif1 a1b3b63
fix(logs): treat archived workflows as unopenable in the logs list an…
waleedlatif1 d17aa42
fix(embeddings): admit tool calls on hosted keys through the shared h…
waleedlatif1 521db8c
fix(knowledge): keep removing disabled images past a page that remove…
waleedlatif1 73fedc0
chore(sim-cli): drop unused Node proxy-support constants from the upd…
waleedlatif1 dc7937a
fix(realtime): keep file-editor presence fresh when adapter reads tim…
waleedlatif1 d19e493
perf(db): index chat and legacy workspace_file owners for member handoff
waleedlatif1 83007f5
perf(files): load creator-handoff version heads in one query
waleedlatif1 dbf56dc
refactor(workspaces): drop unreachable revoke refusal and name unreso…
waleedlatif1 1146cdb
docs(forking): note policy readers wait on the sync-default flip
waleedlatif1 fb5d621
refactor(workflows): share the tool param visibility map between fork…
waleedlatif1 69c8fd8
perf(files): fetch only head rows when batching version heads
waleedlatif1 cd9a929
fix(workspaces): disclose inbound source-org custom blocks on admin m…
waleedlatif1 df5b5a4
test(billing): drop organization-index removal case covered by the ha…
waleedlatif1 c0c2748
revert(files): keep the file picker's out-of-scope prune
waleedlatif1 9d98d82
fix(resume): keep the pause retryable when the cancellation subscript…
waleedlatif1 adf7302
test(outlook): cover calendar scoping, the blank-calendar default, an…
waleedlatif1 65149ca
chore: regenerate Ashby docs and complete the removal test's sync result
waleedlatif1 df21176
fix: cap in-flight SailPoint token exchanges, bound anchored edits, a…
waleedlatif1 7dd777a
revert: keep hosted-key Embeddings tool calls on their own admission …
waleedlatif1 4a6aa9b
revert(ashby): restore per-tool nextSyncCursor descriptions
waleedlatif1 b8e3f23
revert(sailpoint): fall back to search for an unrecognized block oper…
waleedlatif1 671fc1c
docs(files): state that occurrence selects the start anchor and the e…
waleedlatif1 0488176
fix(workspaces): keep the admin-api actor email fallback when replayi…
waleedlatif1 1acefd5
test(knowledge): complete the disabled-image removal test's sync resu…
waleedlatif1 2f1d8ed
docs(knowledge): say each member-accounts continuation run can read u…
waleedlatif1 c80eb57
fix(realtime): restart presence retries after a membership change and…
waleedlatif1 1f20ee2
test(resume): keep the pause retryable when the cancellation subscrip…
waleedlatif1 a22f285
fix(db): log each archive repair after its retry loop so a logging fa…
waleedlatif1 5cb2265
fix(files): name an untitled file from its heading only after a real …
waleedlatif1 a1490b2
fix(logs): keep an archived workflow's name on its logs
waleedlatif1 9983391
fix(logs): accept archivedAt on the workflow label input
waleedlatif1 97f0c4d
fix(files): lock the workspace row before the directory lock and the …
waleedlatif1 17af461
fix(retention): isolate per-owner failures in unbudgeted runs and loc…
waleedlatif1 b4d0a1c
fix(files): log a deferred inline live-doc reconcile as a warning and…
waleedlatif1 71b2734
fix(outlook): encode a slash in Graph event and calendar ids instead …
waleedlatif1 18ce833
refactor(tools): encode opaque Graph ids through safeUrlPathSegment w…
waleedlatif1 6e20f0d
test(files): scope the bulk-archive lock barrier to waiters queued be…
waleedlatif1 b99acdd
refactor(outlook): mark the Graph id segment options as const
waleedlatif1 f786543
fix(retention): charge committed Project version batches to the share…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
198 changes: 198 additions & 0 deletions
198
apps/realtime/src/handlers/file-doc-presence.integration.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| import { mkdir, writeFile } from 'node:fs/promises' | ||
| import { createServer, type Server as HttpServer } from 'node:http' | ||
| import { dirname, resolve } from 'node:path' | ||
| import { | ||
| FILE_DOC_EVENTS, | ||
| FILE_DOC_SCHEMA_VERSION, | ||
| FILE_DOC_SEED, | ||
| type FileDocPresence, | ||
| type JoinFileDocSuccess, | ||
| } from '@sim/realtime-protocol/file-doc' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { generateId } from '@sim/utils/id' | ||
| import { createAdapter } from '@socket.io/redis-adapter' | ||
| import { createClient, type RedisClientType } from 'redis' | ||
| import { Server } from 'socket.io' | ||
| import { io as connect, type Socket } from 'socket.io-client' | ||
| import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' | ||
| import * as Y from 'yjs' | ||
| import type { AuthenticatedSocket } from '@/middleware/auth' | ||
|
|
||
| const redisUrl = process.env.REDIS_URL | ||
| if (!redisUrl) throw new Error('Integration setup must provide disposable Redis') | ||
| const internalSecret = process.env.INTERNAL_API_SECRET | ||
|
|
||
| const projectId = generateId() | ||
| const fileId = generateId() | ||
| const roomName = `project-file-doc:${projectId}/${fileId}` | ||
| const docId = generateId() | ||
| const seedDoc = new Y.Doc() | ||
| seedDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) | ||
| seedDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, docId) | ||
| const seed = Buffer.from(Y.encodeStateAsUpdate(seedDoc)).toString('base64') | ||
| seedDoc.destroy() | ||
|
|
||
| interface Replica { | ||
| io: Server | ||
| url: string | ||
| pub: RedisClientType | ||
| sub: RedisClientType | ||
| shutdownStore: () => Promise<void> | ||
| } | ||
|
|
||
| const checks: { name: string; status: string; durationMs: number; error?: string }[] = [] | ||
| const clients: Socket[] = [] | ||
| const replicas: Replica[] = [] | ||
| let app: HttpServer | ||
|
|
||
| async function listen(server: HttpServer) { | ||
| await new Promise<void>((done) => server.listen(0, '127.0.0.1', done)) | ||
| const address = server.address() | ||
| if (!address || typeof address === 'string') throw new Error('Missing fixture port') | ||
| return `http://127.0.0.1:${address.port}` | ||
| } | ||
|
|
||
| /** | ||
| * One realtime replica: a fresh module graph (its own rooms, owners and document store, as in a | ||
| * separate process) behind its own Socket.IO server on the shared Redis adapter. | ||
| */ | ||
| async function startReplica(): Promise<Replica> { | ||
| vi.resetModules() | ||
| const fileDoc = await import('@/handlers/file-doc') | ||
| const fileDocStore = await import('@/handlers/file-doc-store') | ||
| const { MemoryRoomManager } = await import('@/rooms') | ||
| await fileDocStore.initFileDocStore(redisUrl) | ||
| const pub: RedisClientType = createClient({ url: redisUrl }) | ||
| const sub: RedisClientType = createClient({ url: redisUrl }) | ||
| await Promise.all([pub.connect(), sub.connect()]) | ||
| const http = createServer() | ||
| const io = new Server(http) | ||
| io.adapter(createAdapter(pub, sub)) | ||
| fileDoc.registerFileDocPresenceRelay(io) | ||
| const manager = new MemoryRoomManager(io) | ||
| io.on('connection', (socket) => { | ||
| const authed = socket as AuthenticatedSocket | ||
| authed.userId = socket.handshake.auth.actor | ||
| authed.userName = `Fixture ${socket.handshake.auth.actor}` | ||
| authed.userImage = 'https://fixture.invalid/avatar.png' | ||
| fileDoc.setupWorkspaceFileDocHandlers(authed, manager) | ||
| socket.on('disconnect', () => fileDoc.cleanupFileDocForSocket(socket.id, io, true)) | ||
| }) | ||
| const replica = { | ||
| io, | ||
| url: await listen(http), | ||
| pub, | ||
| sub, | ||
| shutdownStore: () => fileDocStore.getFileDocStore().shutdown(), | ||
| } | ||
| replicas.push(replica) | ||
| return replica | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| app = createServer((request, response) => { | ||
| response.setHeader('Content-Type', 'application/json') | ||
| const prefix = `/api/internal/project-file-doc/${projectId}/${fileId}/` | ||
| if (request.headers['x-api-key'] !== internalSecret || !request.url?.startsWith(prefix)) { | ||
| response.writeHead(401).end('{}') | ||
| return | ||
| } | ||
| const action = request.url.slice(prefix.length) | ||
| if (action === 'access') { | ||
| response.end(JSON.stringify({ projectId, fileId, canRead: true, canWrite: true, docId })) | ||
| } else if (action === 'seed') { | ||
| response.end(JSON.stringify({ update: seed, version: 1 })) | ||
| } else if (action === 'persist') { | ||
| response.end(JSON.stringify({ status: 'persisted', version: 2 })) | ||
| } else { | ||
| response.writeHead(404).end('{}') | ||
| } | ||
| }) | ||
| process.env.NEXT_PUBLIC_APP_URL = await listen(app) | ||
| }) | ||
|
|
||
| /** Joins the shared file through `replica`, recording every roster the client receives. */ | ||
| async function joinThrough(replica: Replica, actor: string) { | ||
| const socket = connect(replica.url, { transports: ['websocket'], auth: { actor } }) | ||
| clients.push(socket) | ||
| const rosters: string[][] = [] | ||
| socket.on(FILE_DOC_EVENTS.PRESENCE, (payload: FileDocPresence) => { | ||
| if (payload.fileId === fileId) rosters.push(payload.users.map((user) => user.socketId).sort()) | ||
| }) | ||
| await new Promise<void>((done) => socket.once('connect', done)) | ||
| const joined = new Promise<JoinFileDocSuccess>((done, fail) => { | ||
| socket.once(FILE_DOC_EVENTS.JOIN_SUCCESS, done) | ||
| socket.once(FILE_DOC_EVENTS.JOIN_ERROR, (error) => fail(new Error(error.code))) | ||
| }) | ||
| const clientDoc = new Y.Doc() | ||
| const clientId = clientDoc.clientID | ||
| clientDoc.destroy() | ||
| socket.emit(FILE_DOC_EVENTS.JOIN, { | ||
| fileId, | ||
| projectId, | ||
| clientId, | ||
| schemaVersion: FILE_DOC_SCHEMA_VERSION, | ||
| }) | ||
| await joined | ||
| const id = socket.id | ||
| if (!id) throw new Error('Connected socket has no id') | ||
| return { socket, id, latestRoster: () => rosters.at(-1) } | ||
| } | ||
|
|
||
| function check(name: string, run: () => Promise<void>) { | ||
| it(name, async () => { | ||
| const start = performance.now() | ||
| try { | ||
| await run() | ||
| checks.push({ name, status: 'passed', durationMs: performance.now() - start }) | ||
| } catch (error) { | ||
| checks.push({ | ||
| name, | ||
| status: 'failed', | ||
| durationMs: performance.now() - start, | ||
| error: getErrorMessage(error), | ||
| }) | ||
| throw error | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| describe('File editor presence across replicas sharing the Redis adapter', () => { | ||
| check( | ||
| 'each replica lists collaborators joined through the other, and drops a departed one', | ||
| async () => { | ||
| const first = await startReplica() | ||
| const second = await startReplica() | ||
| const alice = await joinThrough(first, 'fixture-alice') | ||
| const bob = await joinThrough(second, 'fixture-bob') | ||
| const both = [alice.id, bob.id].sort() | ||
|
|
||
| await expect.poll(alice.latestRoster, { timeout: 5_000 }).toEqual(both) | ||
| await expect.poll(bob.latestRoster, { timeout: 5_000 }).toEqual(both) | ||
|
|
||
| bob.socket.disconnect() | ||
| await expect.poll(alice.latestRoster, { timeout: 5_000 }).toEqual([alice.id]) | ||
| } | ||
| ) | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| for (const client of clients) client.disconnect() | ||
| for (const replica of replicas) { | ||
| await replica.io.close() | ||
| await replica.shutdownStore() | ||
| } | ||
| const cleanup = replicas[0]?.pub | ||
| if (cleanup?.isOpen) { | ||
| const keys = await cleanup.keys(`filedoc:*:${roomName}`) | ||
| if (keys.length) await cleanup.del(keys) | ||
| } | ||
| for (const replica of replicas) { | ||
| for (const client of [replica.pub, replica.sub]) if (client.isOpen) await client.quit() | ||
| } | ||
| await new Promise<void>((done) => app.close(() => done())) | ||
| const reportPath = | ||
| process.env.FILE_DOC_PRESENCE_REPORT_PATH ?? resolve('test-results/file-doc-presence.json') | ||
| await mkdir(dirname(reportPath), { recursive: true }) | ||
| await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.