diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 8dd71093673..cda7c032d62 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -1,4 +1,4 @@ -import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { setupConnectionHandlers } from '@/handlers/connection' import { setupWorkspaceFileDocHandlers } from '@/handlers/file-doc' import { setupOperationsHandlers } from '@/handlers/operations' @@ -18,8 +18,9 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) // Presence-free, workspace-scoped live-list rooms (share one implementation). - setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_FILES) - setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_TABLES) + for (const roomType of WORKSPACE_LIST_ROOM_TYPES) { + setupWorkspaceInvalidationRoom(socket, roomManager, roomType) + } setupWorkspaceFileDocHandlers(socket, roomManager) setupTablesHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts index e487edd0a8e..46c5b210efb 100644 --- a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts +++ b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IRoomManager } from '@/rooms' @@ -73,229 +73,226 @@ function createRoomManager(overrides?: Partial): IRoomManager { } as unknown as IRoomManager } -// The two presence-free live-list rooms share one implementation; run the whole suite against both -// so files and tables can never drift. Event names and room names derive from the room type. -describe.each([ROOM_TYPES.WORKSPACE_FILES, ROOM_TYPES.WORKSPACE_TABLES] as const)( - 'setupWorkspaceInvalidationRoom(%s)', - (roomType) => { - const joinEvent = `join-${roomType}` - const successEvent = `${joinEvent}-success` - const errorEvent = `${joinEvent}-error` - const leaveEvent = `leave-${roomType}` - const roomOf = (workspaceId: string) => `${roomType}:${workspaceId}` - - const setup = (socket: ReturnType['socket'], roomManager: IRoomManager) => - setupWorkspaceInvalidationRoom( - socket as unknown as Parameters[0], - roomManager, - roomType - ) - - beforeEach(() => { - vi.clearAllMocks() - mockAuthorizeRoom.mockResolvedValue({ - allowed: true, - status: 200, - workspaceId: 'ws-1', - workspacePermission: 'admin', - }) - }) - - it('rejects join when the socket is not authenticated', async () => { - const { socket, handlers } = createSocket({ userId: undefined, userName: undefined }) - setup(socket, createRoomManager()) - - await handlers[joinEvent]({ workspaceId: 'ws-1' }) - - expect(socket.emit).toHaveBeenCalledWith(errorEvent, { - workspaceId: 'ws-1', - error: 'Authentication required', - code: 'AUTHENTICATION_REQUIRED', - retryable: false, - }) +// The presence-free live-list rooms share one implementation; run the whole suite against each +// so they can never drift. Event names and room names derive from the room type. +describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', (roomType) => { + const joinEvent = `join-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const leaveEvent = `leave-${roomType}` + const roomOf = (workspaceId: string) => `${roomType}:${workspaceId}` + + const setup = (socket: ReturnType['socket'], roomManager: IRoomManager) => + setupWorkspaceInvalidationRoom( + socket as unknown as Parameters[0], + roomManager, + roomType + ) + + beforeEach(() => { + vi.clearAllMocks() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'admin', }) + }) - it('rejects join with a retryable error when realtime is unavailable', async () => { - const { socket, handlers } = createSocket() - setup(socket, createRoomManager({ isReady: vi.fn().mockReturnValue(false) })) + it('rejects join when the socket is not authenticated', async () => { + const { socket, handlers } = createSocket({ userId: undefined, userName: undefined }) + setup(socket, createRoomManager()) - await handlers[joinEvent]({ workspaceId: 'ws-1' }) + await handlers[joinEvent]({ workspaceId: 'ws-1' }) - expect(socket.emit).toHaveBeenCalledWith( - errorEvent, - expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) - ) + expect(socket.emit).toHaveBeenCalledWith(errorEvent, { + workspaceId: 'ws-1', + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, }) - - it('rejects join when workspace access is denied', async () => { - mockAuthorizeRoom.mockResolvedValue({ - allowed: false, - status: 403, - workspaceId: 'ws-1', - workspacePermission: null, - }) - const { socket, handlers } = createSocket() - setup(socket, createRoomManager()) - - await handlers[joinEvent]({ workspaceId: 'ws-1' }) - - expect(socket.emit).toHaveBeenCalledWith( - errorEvent, - expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) - ) + }) + + it('rejects join with a retryable error when realtime is unavailable', async () => { + const { socket, handlers } = createSocket() + setup(socket, createRoomManager({ isReady: vi.fn().mockReturnValue(false) })) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) + ) + }) + + it('rejects join when workspace access is denied', async () => { + mockAuthorizeRoom.mockResolvedValue({ + allowed: false, + status: 403, + workspaceId: 'ws-1', + workspacePermission: null, }) - - it('joins the room on success without any presence bookkeeping', async () => { - const { socket, handlers } = createSocket() - const roomManager = createRoomManager() - setup(socket, roomManager) - - await handlers[joinEvent]({ workspaceId: 'ws-1' }) - - expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) - expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) - // The room is live-list-only: no room-manager presence is tracked or broadcast. - expect(roomManager.addUserToRoom).not.toHaveBeenCalled() - expect(roomManager.broadcastPresenceUpdate).not.toHaveBeenCalled() - }) - - it('aborts a join superseded during the access re-check await', async () => { - // The access re-resolve is an await like any other: a leave landing during it must - // still cancel this join, or the stale join would leave the room the client - // switched to and commit the abandoned one. Forced down the re-resolve's DB path - // by expiring the cached decision mid-join, so the interleaving is deterministic - // rather than dependent on microtask ordering. - vi.useFakeTimers() - try { - const { handlers, socket } = createSocket({ id: 'socket-sup', userId: 'user-sup' }) - setupWorkspaceInvalidationRoom( - socket as unknown as Parameters[0], - createRoomManager(), - roomType - ) - - let call = 0 - mockAuthorizeRoom.mockImplementation(async () => { - call += 1 - if (call === 1) { - // A later-started read commits, so this join's own decision is dropped; then - // the join stalls past the TTL so that decision is expired by re-check time. - commitRoomPermission( - 'user-sup', - { type: roomType, id: 'ws-sup' }, - 'admin', - beginRoomPermissionRead() - ) - await sleep(31_000) - } else { - // Second call is the re-check's re-resolve: the client leaves during it. - handlers[leaveEvent]({ workspaceId: 'ws-sup' }) - } - return { allowed: true, status: 200, workspaceId: 'ws-sup', workspacePermission: 'admin' } - }) - - const joining = handlers[joinEvent]({ workspaceId: 'ws-sup' }) - await vi.advanceTimersByTimeAsync(31_000) - await joining - - expect(call).toBe(2) - expect(socket.join).not.toHaveBeenCalled() - expect(socket.emit).not.toHaveBeenCalledWith(successEvent, expect.anything()) - } finally { - vi.useRealTimers() - } - }) - - it('does not join when access was revoked while the join was in flight', async () => { - // The sweep records a revocation before it evicts, so a join whose authorize - // completed just before that must not put the socket back in the room. - const { handlers, socket } = createSocket({ id: 'socket-race', userId: 'user-race' }) + const { socket, handlers } = createSocket() + setup(socket, createRoomManager()) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + }) + + it('joins the room on success without any presence bookkeeping', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + setup(socket, roomManager) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) + expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) + // The room is live-list-only: no room-manager presence is tracked or broadcast. + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + expect(roomManager.broadcastPresenceUpdate).not.toHaveBeenCalled() + }) + + it('aborts a join superseded during the access re-check await', async () => { + // The access re-resolve is an await like any other: a leave landing during it must + // still cancel this join, or the stale join would leave the room the client + // switched to and commit the abandoned one. Forced down the re-resolve's DB path + // by expiring the cached decision mid-join, so the interleaving is deterministic + // rather than dependent on microtask ordering. + vi.useFakeTimers() + try { + const { handlers, socket } = createSocket({ id: 'socket-sup', userId: 'user-sup' }) setupWorkspaceInvalidationRoom( socket as unknown as Parameters[0], createRoomManager(), roomType ) + let call = 0 mockAuthorizeRoom.mockImplementation(async () => { - commitRoomPermission( - 'user-race', - { type: roomType, id: 'ws-race' }, - null, - beginRoomPermissionRead() - ) - return { allowed: true, status: 200, workspaceId: 'ws-race', workspacePermission: 'admin' } + call += 1 + if (call === 1) { + // A later-started read commits, so this join's own decision is dropped; then + // the join stalls past the TTL so that decision is expired by re-check time. + commitRoomPermission( + 'user-sup', + { type: roomType, id: 'ws-sup' }, + 'admin', + beginRoomPermissionRead() + ) + await sleep(31_000) + } else { + // Second call is the re-check's re-resolve: the client leaves during it. + handlers[leaveEvent]({ workspaceId: 'ws-sup' }) + } + return { allowed: true, status: 200, workspaceId: 'ws-sup', workspacePermission: 'admin' } }) - await handlers[joinEvent]({ workspaceId: 'ws-race' }) + const joining = handlers[joinEvent]({ workspaceId: 'ws-sup' }) + await vi.advanceTimersByTimeAsync(31_000) + await joining - expect(socket.emit).toHaveBeenCalledWith( - errorEvent, - expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) - ) + expect(call).toBe(2) expect(socket.join).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalledWith(successEvent, expect.anything()) + } finally { + vi.useRealTimers() + } + }) + + it('does not join when access was revoked while the join was in flight', async () => { + // The sweep records a revocation before it evicts, so a join whose authorize + // completed just before that must not put the socket back in the room. + const { handlers, socket } = createSocket({ id: 'socket-race', userId: 'user-race' }) + setupWorkspaceInvalidationRoom( + socket as unknown as Parameters[0], + createRoomManager(), + roomType + ) + + mockAuthorizeRoom.mockImplementation(async () => { + commitRoomPermission( + 'user-race', + { type: roomType, id: 'ws-race' }, + null, + beginRoomPermissionRead() + ) + return { allowed: true, status: 200, workspaceId: 'ws-race', workspacePermission: 'admin' } }) - it('leaves a previously-joined room when switching workspaces', async () => { - const { socket, handlers, rooms } = createSocket() - rooms.add(roomOf('ws-old')) - setup(socket, createRoomManager()) + await handlers[joinEvent]({ workspaceId: 'ws-race' }) - await handlers[joinEvent]({ workspaceId: 'ws-1' }) + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + expect(socket.join).not.toHaveBeenCalled() + }) - expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-old')) - expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) - }) + it('leaves a previously-joined room when switching workspaces', async () => { + const { socket, handlers, rooms } = createSocket() + rooms.add(roomOf('ws-old')) + setup(socket, createRoomManager()) - it('leaves the scoped room on leave', () => { - const { socket, handlers, rooms } = createSocket() - rooms.add(roomOf('ws-1')) - setup(socket, createRoomManager()) + await handlers[joinEvent]({ workspaceId: 'ws-1' }) - handlers[leaveEvent]({ workspaceId: 'ws-1' }) + expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-old')) + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) + }) - expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-1')) - }) + it('leaves the scoped room on leave', () => { + const { socket, handlers, rooms } = createSocket() + rooms.add(roomOf('ws-1')) + setup(socket, createRoomManager()) - it('cancels an in-flight join when the user leaves that workspace mid-authorize', async () => { - const { socket, handlers } = createSocket() - let resolveAuth: (value: unknown) => void = () => {} - mockAuthorizeRoom.mockReturnValue( - new Promise((resolve) => { - resolveAuth = resolve - }) - ) - setup(socket, createRoomManager()) + handlers[leaveEvent]({ workspaceId: 'ws-1' }) - // Join ws-1 is awaiting authorization when the view unmounts and leaves ws-1. - const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-1' }) - handlers[leaveEvent]({ workspaceId: 'ws-1' }) - resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) - await joinPromise + expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-1')) + }) - // The stale join must NOT join the room the client has since left (no stranded membership). - expect(socket.join).not.toHaveBeenCalled() - expect(socket.emit).not.toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) - }) - - it('does not cancel an in-flight join when a deferred leave targets a different workspace', async () => { - const { socket, handlers } = createSocket() - let resolveAuth: (value: unknown) => void = () => {} - mockAuthorizeRoom.mockReturnValue( - new Promise((resolve) => { - resolveAuth = resolve - }) - ) - setup(socket, createRoomManager()) - - // The client has switched to ws-2 (join in-flight) when a stale leave for the prior ws-1 lands. - const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-2' }) - handlers[leaveEvent]({ workspaceId: 'ws-1' }) - resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-2', workspacePermission: 'admin' }) - await joinPromise - - // The deferred leave for ws-1 must not abort the join the client actually wants (ws-2). - expect(socket.join).toHaveBeenCalledWith(roomOf('ws-2')) - expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-2' }) - }) - } -) + it('cancels an in-flight join when the user leaves that workspace mid-authorize', async () => { + const { socket, handlers } = createSocket() + let resolveAuth: (value: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValue( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + setup(socket, createRoomManager()) + + // Join ws-1 is awaiting authorization when the view unmounts and leaves ws-1. + const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-1' }) + handlers[leaveEvent]({ workspaceId: 'ws-1' }) + resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) + await joinPromise + + // The stale join must NOT join the room the client has since left (no stranded membership). + expect(socket.join).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) + }) + + it('does not cancel an in-flight join when a deferred leave targets a different workspace', async () => { + const { socket, handlers } = createSocket() + let resolveAuth: (value: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValue( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + setup(socket, createRoomManager()) + + // The client has switched to ws-2 (join in-flight) when a stale leave for the prior ws-1 lands. + const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-2' }) + handlers[leaveEvent]({ workspaceId: 'ws-1' }) + resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-2', workspacePermission: 'admin' }) + await joinPromise + + // The deferred leave for ws-1 must not abort the join the client actually wants (ws-2). + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-2')) + expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-2' }) + }) +}) diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index aed7d1a58a9..19d2401e37e 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'http' -import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc' @@ -164,43 +164,27 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } - // Fan out a file-tree change to everyone viewing a workspace's files, so their - // browser refetches. File mutations happen over the HTTP API (not the socket); - // this is the lossy liveness signal — a missed one only means stale-until-refetch. - if (req.method === 'POST' && req.url === '/api/workspace-files-changed') { + // Fan out a workspace list change (files tree, tables list, workflow registry) to everyone in + // that workspace's live-list room, so their browser refetches. These mutations happen over the + // HTTP API (not the socket); this is the lossy liveness signal — a missed one only means + // stale-until-refetch. Endpoint and event names derive from the room type, mirroring the socket + // handler and the client hook. + const listRoomType = WORKSPACE_LIST_ROOM_TYPES.find( + (type) => req.url === `/api/${type}-changed` + ) + if (req.method === 'POST' && listRoomType) { try { const body = await readRequestBody(req) const { workspaceId } = JSON.parse(body) if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) - roomManager.emitToRoom( - { type: ROOM_TYPES.WORKSPACE_FILES, id: workspaceId }, - 'workspace-files-changed', - { workspaceId, timestamp: Date.now() } - ) - sendSuccess(res) - } catch (error) { - logger.error('Error handling workspace files changed notification:', error) - sendError(res, 'Failed to process files change notification') - } - return - } - - // Fan out a table-list change to everyone viewing a workspace's tables, so their browser - // refetches. The list-level counterpart to workspace-files-changed; same lossy-signal contract. - if (req.method === 'POST' && req.url === '/api/workspace-tables-changed') { - try { - const body = await readRequestBody(req) - const { workspaceId } = JSON.parse(body) - if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) - roomManager.emitToRoom( - { type: ROOM_TYPES.WORKSPACE_TABLES, id: workspaceId }, - 'workspace-tables-changed', - { workspaceId, timestamp: Date.now() } - ) + roomManager.emitToRoom({ type: listRoomType, id: workspaceId }, `${listRoomType}-changed`, { + workspaceId, + timestamp: Date.now(), + }) sendSuccess(res) } catch (error) { - logger.error('Error handling workspace tables changed notification:', error) - sendError(res, 'Failed to process tables change notification') + logger.error(`Error handling ${listRoomType} changed notification:`, error) + sendError(res, 'Failed to process list change notification') } return } diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index ab3492b0fc0..8d0a8815d7b 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -24,6 +24,7 @@ const { mockDbDelete, mockDbUpdate, mockWorkspaceRows, + mockNotifyWorkspaceWorkflowsChanged, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockValidateWorkspaceAccess: vi.fn(), @@ -37,6 +38,7 @@ const { mockDbDelete: vi.fn(), mockDbUpdate: vi.fn(), mockWorkspaceRows: { value: [{ id: 'ws-1' }] as Array<{ id: string }> }, + mockNotifyWorkspaceWorkflowsChanged: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -53,6 +55,10 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performCreateWorkflow: mockPerformCreateWorkflow, })) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceWorkflowsChanged: mockNotifyWorkspaceWorkflowsChanged, +})) + vi.mock('@/lib/workflows/persistence/utils', () => ({ saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) @@ -270,6 +276,7 @@ describe('POST /api/v1/workflows/import', () => { expect.anything(), expect.anything() ) + expect(mockNotifyWorkspaceWorkflowsChanged).toHaveBeenCalledWith(WORKSPACE_ID) }) it('derives the name from the export envelope and deduplicates it', async () => { diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 762be48852c..fb5d3affcff 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/api/contracts/v1/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { importWorkflowIntoWorkspace, MAX_IMPORT_BODY_BYTES, @@ -82,6 +83,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + await notifyWorkspaceWorkflowsChanged(result.workflow.workspaceId) + const data: V1ImportWorkflowData = { id: result.workflow.id, name: result.workflow.name, diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index adb1b5416e5..45ab9f9addd 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -16,6 +16,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowReorderAPI') @@ -83,6 +84,8 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { `[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}` ) + await notifyWorkspaceWorkflowsChanged(workspaceId) + return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { if ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts index cee9841d8e6..53cfc2026d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts @@ -18,4 +18,5 @@ export { useWorkflowOperations } from './use-workflow-operations' export { useWorkflowSelection } from './use-workflow-selection' export { useWorkspaceLogoUpload } from './use-workspace-logo-upload' export { useWorkspaceManagement } from './use-workspace-management' +export { useWorkspaceWorkflowsRoom } from './use-workspace-workflows-room' export { WORKSPACE_LOGO_ACCEPT_ATTRIBUTE } from './workspace-logo-file' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-workflows-room.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-workflows-room.ts new file mode 100644 index 00000000000..60c679120bf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-workflows-room.ts @@ -0,0 +1,29 @@ +'use client' + +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { useQueryClient } from '@tanstack/react-query' +import { useWorkspaceInvalidationRoom } from '@/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' + +/** + * Keeps the sidebar's workflow registry live: joins the workspace-workflows room so a + * `workspace-workflows-changed` broadcast (fanned out by the workflow application use cases and the + * folder mutation services on every surface — UI, CLI, copilot, API) invalidates this workspace's + * workflow lists AND the workflow folders so every viewer refetches without waiting for staleness. + * A created/renamed/moved/deleted/duplicated/imported/restored/reordered workflow changes the list + * result; a folder create/rename/delete/restore changes the folder tree — the sidebar renders both, + * so both are invalidated — each scoped to this workspace, in both scopes, so one workspace's + * broadcast never touches another workspace's cache. Lists go through + * {@link invalidateWorkflowLists} (which also covers the workflow selectors) so a remote change + * refreshes exactly what a local mutation would. Thin binding over + * {@link useWorkspaceInvalidationRoom}, mirroring `useWorkspaceTablesRoom`. + */ +export function useWorkspaceWorkflowsRoom(workspaceId: string): void { + const queryClient = useQueryClient() + useWorkspaceInvalidationRoom(workspaceId, ROOM_TYPES.WORKSPACE_WORKFLOWS, () => { + invalidateWorkflowLists(queryClient, workspaceId, ['active', 'archived']) + queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId, 'active') }) + queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId, 'archived') }) + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index efe8b0c769e..da5bdaeed8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -93,6 +93,7 @@ import { useWorkflowOperations, useWorkspaceLogoUpload, useWorkspaceManagement, + useWorkspaceWorkflowsRoom, WORKSPACE_LOGO_ACCEPT_ATTRIBUTE, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { @@ -574,6 +575,7 @@ export const Sidebar = memo(function Sidebar({ }) useFolders(workspaceId) + useWorkspaceWorkflowsRoom(workspaceId) const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId) const updateWorkflowMutation = useUpdateWorkflow() diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index f1d9846919b..2374a8d5580 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -18,33 +18,30 @@ const NOTIFY_TIMEOUT_MS = 2000 const APPLY_EDIT_TIMEOUT_MS = FILE_DOC_TIMEOUTS.applyEditMs /** - * Best-effort fan-out to the realtime server that a workspace's file tree changed, - * so every browser currently viewing that workspace's files refetches. File - * mutations happen over the HTTP API (not the socket); this is a lossy liveness - * signal — a dropped notification only degrades to stale-until-refetch. - * - * Never throws. Callers `await` it (rather than fire-and-forget) so the fetch is - * guaranteed to dispatch before a Node route handler returns — a floating promise - * can be dropped after the response is sent. It is a normally-sub-millisecond - * local call and is hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that + * POST one workspace list-changed signal (`/api/workspace--changed`) to the realtime server, + * which fans it out to every socket in that workspace's live-list room so their browser refetches. + * Lossy — a dropped notification only degrades to stale-until-refetch. Never throws. Callers + * `await` it (rather than fire-and-forget) so the fetch is guaranteed to dispatch before a Node + * route handler returns — a floating promise can be dropped after the response is sent. It is a + * normally-sub-millisecond local call, hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that * latency only when the socket pod is unreachable. */ -export async function notifyWorkspaceFilesChanged(workspaceId: string): Promise { +async function postWorkspaceListChanged(endpoint: string, workspaceId: string): Promise { try { - const response = await fetch(`${getSocketServerUrl()}/api/workspace-files-changed`, { + const response = await fetch(`${getSocketServerUrl()}/api/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, body: JSON.stringify({ workspaceId }), signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), }) if (!response.ok) { - logger.warn('workspace-files-changed notify failed', { + logger.warn(`${endpoint} notify failed`, { workspaceId, status: response.status, }) } } catch (error) { - logger.warn('workspace-files-changed notify error', { + logger.warn(`${endpoint} notify error`, { workspaceId, error: getErrorMessage(error), }) @@ -52,36 +49,34 @@ export async function notifyWorkspaceFilesChanged(workspaceId: string): Promise< } /** - * Best-effort fan-out to the realtime server that a workspace's table list changed (a table was - * created, renamed, moved, deleted, or restored), so every browser currently viewing that - * workspace's tables refetches. The list-level counterpart to {@link notifyWorkspaceFilesChanged}; - * table mutations happen server-side (HTTP routes AND copilot), so this fires from the shared table - * service, not a socket. Lossy — a dropped notification only degrades to stale-until-refetch. - * - * Never throws. Callers `await` it so the fetch is guaranteed to dispatch before the mutation - * returns; hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that latency only when the socket - * pod is unreachable. + * Best-effort fan-out that a workspace's file tree changed, so every viewer of that workspace's + * files refetches. See {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. */ -export async function notifyWorkspaceTablesChanged(workspaceId: string): Promise { - try { - const response = await fetch(`${getSocketServerUrl()}/api/workspace-tables-changed`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ workspaceId }), - signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), - }) - if (!response.ok) { - logger.warn('workspace-tables-changed notify failed', { - workspaceId, - status: response.status, - }) - } - } catch (error) { - logger.warn('workspace-tables-changed notify error', { - workspaceId, - error: getErrorMessage(error), - }) - } +export function notifyWorkspaceFilesChanged(workspaceId: string): Promise { + return postWorkspaceListChanged('workspace-files-changed', workspaceId) +} + +/** + * Best-effort fan-out that a workspace's table list changed (a table was created, renamed, moved, + * deleted, or restored), so every viewer of that workspace's tables refetches. Fires from the + * shared table service, so it covers every surface (HTTP routes AND copilot). See + * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + */ +export function notifyWorkspaceTablesChanged(workspaceId: string): Promise { + return postWorkspaceListChanged('workspace-tables-changed', workspaceId) +} + +/** + * Best-effort fan-out that a workspace's workflow registry changed (a workflow was created, + * renamed, moved, deleted, duplicated, imported, restored, or reordered, or a workflow folder + * changed), so every viewer's sidebar workflow list refetches. The list-level counterpart to the + * per-workflow editor notifications ({@link notifyWorkflowUpdated}): those only reach sockets with + * that workflow's canvas open, while this reaches everyone in the workspace. Fires from the + * workflow application use cases, so it covers every surface (UI, CLI, copilot, API). See + * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + */ +export function notifyWorkspaceWorkflowsChanged(workspaceId: string): Promise { + return postWorkspaceListChanged('workspace-workflows-changed', workspaceId) } /** Best-effort fan-out that invalidates open editors for one durably changed workflow. */ @@ -149,12 +144,13 @@ export async function notifyWorkflowReverted(workflowId: string, timestamp: numb * (create/rename/move/delete/restore) for one of these must fan out the same list-changed signal as a * direct resource mutation, because a new/renamed/removed folder changes what that resource's browser * shows. Extend this map as more resource lists adopt an invalidation room — `file` and - * `knowledge_base` currently refetch through their own paths, and `workflow` has no such list room. + * `knowledge_base` currently refetch through their own paths. */ const FOLDER_RESOURCE_NOTIFIERS: Partial< Record Promise> > = { table: notifyWorkspaceTablesChanged, + workflow: notifyWorkspaceWorkflowsChanged, } /** diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts index 53580cd0e21..295edac51c3 100644 --- a/apps/sim/lib/workflows/application/create-workflow.ts +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -6,7 +6,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { notifyWorkflowUpdated, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' @@ -98,7 +98,10 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ }, }), async afterSuccess({ result }) { - await notifyWorkflowUpdated(result.workflow.id) + await Promise.all([ + notifyWorkflowUpdated(result.workflow.id), + notifyWorkspaceWorkflowsChanged(result.workflow.workspaceId), + ]) try { PlatformEvents.workflowCreated({ workflowId: result.workflow.id, diff --git a/apps/sim/lib/workflows/application/delete-workflow.ts b/apps/sim/lib/workflows/application/delete-workflow.ts index cfa01b44119..4a845fc9fcd 100644 --- a/apps/sim/lib/workflows/application/delete-workflow.ts +++ b/apps/sim/lib/workflows/application/delete-workflow.ts @@ -3,7 +3,7 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { notifyWorkflowDeleted } from '@/lib/realtime/notify' +import { notifyWorkflowDeleted, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -69,6 +69,11 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ metadata: { archived: true }, } : [], - afterSuccess: ({ context, result }) => - result.archived ? notifyWorkflowDeleted(context.workflowId) : undefined, + async afterSuccess({ context, result }) { + if (!result.archived) return + await Promise.all([ + notifyWorkflowDeleted(context.workflowId), + notifyWorkspaceWorkflowsChanged(context.workspaceId), + ]) + }, }) diff --git a/apps/sim/lib/workflows/application/duplicate-workflow.ts b/apps/sim/lib/workflows/application/duplicate-workflow.ts index a1a3abe0902..024b5fb6066 100644 --- a/apps/sim/lib/workflows/application/duplicate-workflow.ts +++ b/apps/sim/lib/workflows/application/duplicate-workflow.ts @@ -7,7 +7,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { notifyWorkflowUpdated, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -100,5 +100,10 @@ export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ source: principalAuditSource(principal), }, }), - afterSuccess: ({ result }) => notifyWorkflowUpdated(result.id), + async afterSuccess({ context, result }) { + await Promise.all([ + notifyWorkflowUpdated(result.id), + notifyWorkspaceWorkflowsChanged(context.workspaceId), + ]) + }, }) diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index c205d225361..3b36db1a7c5 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ folderLock: vi.fn(), loadIndex: vi.fn(), recordAudit: vi.fn(), + notifyWorkspace: vi.fn(), })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ @@ -41,6 +42,10 @@ vi.mock('@/lib/folders/queries', () => ({ resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => path === '/' ? null : index.idByPath.get(path), })) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceWorkflowsChanged: mocks.notifyWorkspace, +})) + vi.mock('@/lib/workflows/operations/import-workflow', () => ({ importWorkflowIntoWorkspaceTransition: mocks.importTransition, })) @@ -153,6 +158,7 @@ describe('workflow import and export application operations', () => { }), }) ) + expect(mocks.notifyWorkspace).toHaveBeenCalledWith('ws-1') }) it('preserves classified import details and does not audit a failure', async () => { diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index ecad2d84428..5c43112a095 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -5,6 +5,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -96,6 +97,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ }, } }, + afterSuccess: ({ result }) => notifyWorkspaceWorkflowsChanged(result.workflow.workspaceId), }) export const exportWorkflow = defineAuthorizedWorkflowUseCase({ diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts index 20260e59dfd..b50532261be 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -15,6 +15,7 @@ const { FolderLockedError, WorkflowLockedError, mocks } = vi.hoisted(() => { assertWorkflowMutable: vi.fn(), audit: vi.fn(), notify: vi.fn(), + notifyWorkspace: vi.fn(), permission: vi.fn(), resolveContext: vi.fn(), updateWorkflow: vi.fn(), @@ -48,7 +49,10 @@ vi.mock('@/lib/workflows/orchestration', () => ({ updateWorkflowRecord: mocks.updateWorkflow, })) -vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowUpdated: mocks.notify, + notifyWorkspaceWorkflowsChanged: mocks.notifyWorkspace, +})) import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' @@ -118,6 +122,7 @@ describe('moveWorkflowsBulk', () => { ) expect(mocks.notify).toHaveBeenCalledWith('workflow-1') expect(mocks.notify).not.toHaveBeenCalledWith('workflow-2') + expect(mocks.notifyWorkspace).toHaveBeenCalledWith('workspace-1') }) it('conceals cross-workspace workflow IDs as failed items', async () => { diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.ts index 2232a78150a..ec102f57692 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.ts @@ -11,7 +11,7 @@ import { import { and, eq, inArray, isNull } from 'drizzle-orm' import { principalAuditSource } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' -import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { notifyWorkflowUpdated, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' @@ -171,9 +171,11 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ source: principalAuditSource(principal), }, })), - afterSuccess: async ({ result }) => { - for (const workflowId of result.moved) { - await notifyWorkflowUpdated(workflowId) - } + afterSuccess: async ({ context, result }) => { + if (result.moved.length === 0) return + await Promise.all([ + ...result.moved.map((workflowId) => notifyWorkflowUpdated(workflowId)), + notifyWorkspaceWorkflowsChanged(context.workspaceId), + ]) }, }) diff --git a/apps/sim/lib/workflows/application/restore-workflow.test.ts b/apps/sim/lib/workflows/application/restore-workflow.test.ts index def7c6931a9..bba5720353d 100644 --- a/apps/sim/lib/workflows/application/restore-workflow.test.ts +++ b/apps/sim/lib/workflows/application/restore-workflow.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ resolveContext: vi.fn(), resolvePermission: vi.fn(), notify: vi.fn(), + notifyWorkspace: vi.fn(), restoreRecord: vi.fn(), folderIndex: vi.fn(), })) @@ -33,7 +34,10 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/workflows/application/context', () => ({ resolveArchivedWorkflowApplicationContext: mocks.resolveContext, })) -vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowUpdated: mocks.notify, + notifyWorkspaceWorkflowsChanged: mocks.notifyWorkspace, +})) vi.mock('@/lib/workflows/lifecycle', () => ({ restoreWorkflow: mocks.restoreRecord })) vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.folderIndex })) @@ -87,6 +91,8 @@ describe('restoreWorkflow', () => { }) ) expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + expect(mocks.notifyWorkspace).toHaveBeenCalledWith('workspace-1') }) it('refuses a workflow that is not archived as a conflict', async () => { diff --git a/apps/sim/lib/workflows/application/restore-workflow.ts b/apps/sim/lib/workflows/application/restore-workflow.ts index 44573566e88..3d8149aaf40 100644 --- a/apps/sim/lib/workflows/application/restore-workflow.ts +++ b/apps/sim/lib/workflows/application/restore-workflow.ts @@ -11,7 +11,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { notifyWorkflowUpdated, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveArchivedWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -94,5 +94,10 @@ export const restoreWorkflow = defineAuthorizedWorkflowUseCase({ source: principalAuditSource(principal), }, }), - afterSuccess: ({ context }) => notifyWorkflowUpdated(context.workflowId), + async afterSuccess({ context }) { + await Promise.all([ + notifyWorkflowUpdated(context.workflowId), + notifyWorkspaceWorkflowsChanged(context.workspaceId), + ]) + }, }) diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts index 9002d9839df..d56cd95445a 100644 --- a/apps/sim/lib/workflows/application/update-workflow.ts +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -11,7 +11,7 @@ import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { notifyWorkflowUpdated, notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { type ActiveWorkflowApplicationContext, @@ -249,11 +249,15 @@ function projectWorkflowUpdateAudit(args: { return entries } -function notifyAfterWorkflowUpdate(args: { +async function notifyAfterWorkflowUpdate(args: { context: ActiveWorkflowApplicationContext result: WorkflowUpdateResult }) { - return args.result.changes.length > 0 ? notifyWorkflowUpdated(args.context.workflowId) : undefined + if (args.result.changes.length === 0) return + await Promise.all([ + notifyWorkflowUpdated(args.context.workflowId), + notifyWorkspaceWorkflowsChanged(args.context.workspaceId), + ]) } export const updateWorkflow = defineAuthorizedWorkflowUseCase({ diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index 8c7168eab0d..b2004215b6e 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ readVersion: vi.fn(), loadNormalized: vi.fn(), notifyWorkflowUpdated: vi.fn(), + notifyWorkspaceWorkflowsChanged: vi.fn(), workflowCreated: vi.fn(), })) @@ -91,6 +92,7 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notifyWorkflowUpdated, + notifyWorkspaceWorkflowsChanged: mocks.notifyWorkspaceWorkflowsChanged, })) vi.mock('@/lib/core/telemetry', () => ({ @@ -237,6 +239,7 @@ describe('authorized workflow CRUD and version reads', () => { }) ) expect(mocks.notifyWorkflowUpdated).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mocks.notifyWorkspaceWorkflowsChanged).toHaveBeenCalledWith(WORKSPACE_ID) expect(mocks.workflowCreated).toHaveBeenCalledWith( expect.objectContaining({ workflowId: WORKFLOW_ID, workspaceId: WORKSPACE_ID }) ) diff --git a/packages/platform-authz/src/room-policy.ts b/packages/platform-authz/src/room-policy.ts index 0d22ded1a3c..73ea28105ed 100644 --- a/packages/platform-authz/src/room-policy.ts +++ b/packages/platform-authz/src/room-policy.ts @@ -22,6 +22,7 @@ export const ROOM_MEMBERSHIP_ACTIONS = { [ROOM_TYPES.WORKFLOW]: 'read', [ROOM_TYPES.WORKSPACE_FILES]: 'read', [ROOM_TYPES.WORKSPACE_TABLES]: 'read', + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'read', [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'write', [ROOM_TYPES.TABLE]: 'read', } as const satisfies Record diff --git a/packages/platform-authz/src/rooms.ts b/packages/platform-authz/src/rooms.ts index 6d76fdceb4f..f2e86ff3bbd 100644 --- a/packages/platform-authz/src/rooms.ts +++ b/packages/platform-authz/src/rooms.ts @@ -87,6 +87,8 @@ const ROOM_WORKSPACE_RESOLVERS: Partial> [ROOM_TYPES.WORKSPACE_FILES]: resolveWorkspaceRoomWorkspace, // A workspace-tables room is addressed directly by its workspace id. [ROOM_TYPES.WORKSPACE_TABLES]: resolveWorkspaceRoomWorkspace, + // A workspace-workflows room is addressed directly by its workspace id. + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: resolveWorkspaceRoomWorkspace, // A file-doc room is addressed by file id; resolve it to its workspace. [ROOM_TYPES.WORKSPACE_FILE_DOC]: resolveFileDocWorkspace, // A table room is addressed by table id; resolve it to its workspace. diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts index afc3edde5a8..c1751ba1a04 100644 --- a/packages/realtime-protocol/src/rooms.ts +++ b/packages/realtime-protocol/src/rooms.ts @@ -43,6 +43,16 @@ export const ROOM_TYPES = { * space is the workspace id, mirroring {@link ROOM_TYPES.WORKSPACE_FILES}. */ WORKSPACE_TABLES: 'workspace-tables', + /** + * The workspace workflow registry (one room per workspace). The list-level + * counterpart to {@link ROOM_TYPES.WORKFLOW}: it carries NO presence, only a + * lossy `workspace-workflows-changed` invalidation signal so every viewer's + * sidebar workflow list (and workflow folder tree) refetches when a workflow + * or workflow folder is created/renamed/moved/deleted/restored — including + * mutations from other surfaces (CLI, copilot, API). Its id space is the + * workspace id, mirroring {@link ROOM_TYPES.WORKSPACE_TABLES}. + */ + WORKSPACE_WORKFLOWS: 'workspace-workflows', } as const export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] @@ -50,6 +60,18 @@ export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] /** Every known room type, for exhaustive iteration/validation. */ export const ALL_ROOM_TYPES = Object.values(ROOM_TYPES) as readonly RoomType[] +/** + * The presence-free, workspace-scoped live-list rooms. They share one contract derived entirely + * from the room-type token: clients join via `join-${type}`, the app server fans a mutation out via + * `POST /api/${type}-changed`, and members receive a lossy `${type}-changed` invalidation signal. + * Adding a room type here wires it into the shared socket handler and HTTP relay branch. + */ +export const WORKSPACE_LIST_ROOM_TYPES = [ + ROOM_TYPES.WORKSPACE_FILES, + ROOM_TYPES.WORKSPACE_TABLES, + ROOM_TYPES.WORKSPACE_WORKFLOWS, +] as const + /** Universal address of a realtime room. */ export interface RoomRef { type: RoomType