Skip to content

Commit 895c86e

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(workflows): sync sidebar after external mutations
1 parent 358af42 commit 895c86e

53 files changed

Lines changed: 467 additions & 52 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/realtime/src/access-revalidation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ function fallbackRoleFor(type: RoomType): string {
4949
/**
5050
* Room types whose membership is mirrored in the room manager's (Redis) presence
5151
* state, and therefore need a presence removal + rebroadcast after an eviction.
52-
* The workspace-files / workspace-tables invalidation rooms carry no presence at
53-
* all, and a file-doc room's roster is pod-local in-memory state reconciled by its
52+
* The workspace-files / workspace-tables / workspace-workflows invalidation rooms carry no
53+
* presence at all, and a file-doc room's roster is pod-local in-memory state reconciled by its
5454
* registered eviction handler — neither has anything for the cleanup lane to do.
5555
*/
5656
const PRESENCE_ROOM_TYPES: ReadonlySet<RoomType> = new Set<RoomType>([

apps/realtime/src/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom
2020
// Presence-free, workspace-scoped live-list rooms (share one implementation).
2121
setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_FILES)
2222
setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_TABLES)
23+
setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_WORKFLOWS)
2324
setupWorkspaceFileDocHandlers(socket, roomManager)
2425
setupTablesHandlers(socket, roomManager)
2526
setupConnectionHandlers(socket, roomManager)

apps/realtime/src/handlers/workspace-invalidation-room.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,15 @@ function createRoomManager(overrides?: Partial<IRoomManager>): IRoomManager {
7373
} as unknown as IRoomManager
7474
}
7575

76-
// The two presence-free live-list rooms share one implementation; run the whole suite against both
77-
// so files and tables can never drift. Event names and room names derive from the room type.
78-
describe.each([ROOM_TYPES.WORKSPACE_FILES, ROOM_TYPES.WORKSPACE_TABLES] as const)(
76+
// The presence-free live-list rooms share one implementation; run the whole suite against all of
77+
// them so their authorization and lifecycle behavior cannot drift.
78+
const workspaceInvalidationRoomTypesCoveredBySharedLifecycle = [
79+
ROOM_TYPES.WORKSPACE_FILES,
80+
ROOM_TYPES.WORKSPACE_TABLES,
81+
ROOM_TYPES.WORKSPACE_WORKFLOWS,
82+
] as const
83+
84+
describe.each(workspaceInvalidationRoomTypesCoveredBySharedLifecycle)(
7985
'setupWorkspaceInvalidationRoom(%s)',
8086
(roomType) => {
8187
const joinEvent = `join-${roomType}`

apps/realtime/src/routes/http.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IncomingMessage, ServerResponse } from 'http'
22
import { describe, expect, it, vi } from 'vitest'
3+
import { env } from '@/env'
34
import type { IRoomManager } from '@/rooms'
45
import { createHttpHandler } from '@/routes/http'
56

@@ -11,6 +12,7 @@ function createMocks(req: Partial<IncomingMessage>) {
1112
const roomManager = {
1213
getTotalActiveConnections: vi.fn().mockResolvedValue(0),
1314
isReady: vi.fn().mockReturnValue(true),
15+
emitToRoom: vi.fn(),
1416
} as unknown as IRoomManager
1517

1618
return {
@@ -20,6 +22,7 @@ function createMocks(req: Partial<IncomingMessage>) {
2022
setHeader,
2123
writeHead,
2224
end,
25+
roomManager,
2326
}
2427
}
2528

@@ -58,4 +61,29 @@ describe('createHttpHandler', () => {
5861

5962
expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' })
6063
})
64+
65+
it('fans workflow-tree changes out to the workspace workflows room', async () => {
66+
const workspaceId = 'workspace-1'
67+
const body = JSON.stringify({ workspaceId })
68+
const request = {
69+
method: 'POST',
70+
url: '/api/workspace-workflows-changed',
71+
headers: { 'x-api-key': env.INTERNAL_API_SECRET },
72+
on(event: string, callback: (chunk?: Buffer) => void) {
73+
if (event === 'data') callback(Buffer.from(body))
74+
if (event === 'end') callback()
75+
return this
76+
},
77+
} as unknown as IncomingMessage
78+
const { handler, res, roomManager, writeHead } = createMocks(request)
79+
80+
await handler(request, res)
81+
82+
expect(roomManager.emitToRoom).toHaveBeenCalledWith(
83+
{ type: 'workspace-workflows', id: workspaceId },
84+
'workspace-workflows-changed',
85+
{ workspaceId, timestamp: expect.any(Number) }
86+
)
87+
expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' })
88+
})
6189
})

apps/realtime/src/routes/http.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,26 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
205205
return
206206
}
207207

208+
// Fan out a workflow-tree change to everyone viewing a workspace's persistent sidebar.
209+
// One signal invalidates both workflow and workflow-folder lists.
210+
if (req.method === 'POST' && req.url === '/api/workspace-workflows-changed') {
211+
try {
212+
const body = await readRequestBody(req)
213+
const { workspaceId } = JSON.parse(body)
214+
if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400)
215+
roomManager.emitToRoom(
216+
{ type: ROOM_TYPES.WORKSPACE_WORKFLOWS, id: workspaceId },
217+
'workspace-workflows-changed',
218+
{ workspaceId, timestamp: Date.now() }
219+
)
220+
sendSuccess(res)
221+
} catch (error) {
222+
logger.error('Error handling workspace workflows changed notification:', error)
223+
sendError(res, 'Failed to process workflows change notification')
224+
}
225+
return
226+
}
227+
208228
// Merge a durable file write into a file's LIVE collaborative document so open editors reconcile to
209229
// it (Stage C) — this is the stream-end/durable reconcile, not token-by-token streaming (that is now
210230
// applied client-side by the open editor). Returns `{ applied }`: when false, no seeded live room

apps/sim/app/api/folders/[id]/duplicate/route.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const {
2929
mockDuplicateWorkflow,
3030
mockAcquireFolderMutationLock,
3131
mockWithFolderTreeLock,
32+
mockNotifyWorkspace,
3233
} = vi.hoisted(() => ({
3334
mockLogger: {
3435
info: vi.fn(),
@@ -44,6 +45,7 @@ const {
4445
mockDuplicateWorkflow: vi.fn(),
4546
mockAcquireFolderMutationLock: vi.fn(),
4647
mockWithFolderTreeLock: vi.fn(),
48+
mockNotifyWorkspace: vi.fn(),
4749
}))
4850

4951
vi.mock('@sim/audit', () => auditMock)
@@ -62,6 +64,9 @@ vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateF
6264
vi.mock('@/lib/workflows/persistence/duplicate', () => ({
6365
duplicateWorkflow: mockDuplicateWorkflow,
6466
}))
67+
vi.mock('@/lib/realtime/notify', () => ({
68+
notifyWorkspaceWorkflowsChanged: mockNotifyWorkspace,
69+
}))
6570

6671
import { POST } from '@/app/api/folders/[id]/duplicate/route'
6772

@@ -141,6 +146,8 @@ describe('POST /api/folders/[id]/duplicate', () => {
141146

142147
expect(response.status).toBe(201)
143148
await expect(response.json()).resolves.toMatchObject({ folder: { name: 'Copy' } })
149+
expect(mockNotifyWorkspace).toHaveBeenCalledOnce()
150+
expect(mockNotifyWorkspace).toHaveBeenCalledWith(WORKSPACE_ID)
144151
})
145152

146153
it('refuses a single-folder duplicate once the workspace is at the ceiling', async () => {

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { nextFolderSortOrder } from '@/lib/folders/orchestration'
1919
import { assertFolderCollectionHasRoom, toFolderApi } from '@/lib/folders/queries'
2020
import { folderMutationStatus } from '@/lib/folders/status'
2121
import { collectDescendantFolderIds } from '@/lib/folders/subtree'
22+
import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify'
2223
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
2324
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2425

@@ -208,6 +209,7 @@ export const POST = withRouteHandler(
208209

209210
return { newFolderId, folderMapping, workflowStats }
210211
})
212+
await notifyWorkspaceWorkflowsChanged(targetWorkspaceId)
211213

212214
const elapsed = Date.now() - startTime
213215
logger.info(

apps/sim/app/api/folders/reorder/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { mockLogger } = vi.hoisted(() => ({
9+
const { mockLogger, mockNotifyFolder } = vi.hoisted(() => ({
1010
mockLogger: {
1111
info: vi.fn(),
1212
warn: vi.fn(),
@@ -16,11 +16,13 @@ const { mockLogger } = vi.hoisted(() => ({
1616
fatal: vi.fn(),
1717
child: vi.fn(),
1818
},
19+
mockNotifyFolder: vi.fn(),
1920
}))
2021

2122
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
2223

2324
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
25+
vi.mock('@/lib/realtime/notify', () => ({ notifyFolderResourceChanged: mockNotifyFolder }))
2426

2527
import { db } from '@sim/db'
2628
import { PUT } from '@/app/api/folders/reorder/route'
@@ -73,6 +75,7 @@ describe('PUT /api/folders/reorder', () => {
7375
expect(response.status).toBe(200)
7476
const data = await response.json()
7577
expect(data).toMatchObject({ success: true, updated: 1 })
78+
expect(mockNotifyFolder).toHaveBeenCalledWith('workflow', 'workspace-123')
7679
})
7780

7881
it('maps a sibling-name collision from a reparent to a 409', async () => {

apps/sim/app/api/folders/reorder/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { withTransactionRetry } from '@/lib/db/transaction'
1313
import { acquireFolderMutationLock } from '@/lib/folders/locks'
1414
import { folderResourceSupportsLocking } from '@/lib/folders/resource-traits'
15+
import { notifyFolderResourceChanged } from '@/lib/realtime/notify'
1516
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1617

1718
const logger = createLogger('FolderReorderAPI')
@@ -38,7 +39,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
3839
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
3940
}
4041

41-
return await withTransactionRetry(
42+
const response = await withTransactionRetry(
4243
async (tx) => {
4344
await acquireFolderMutationLock(tx, workspaceId, resourceType)
4445
const folderIds = updates.map((u) => u.id)
@@ -178,6 +179,8 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
178179
},
179180
{ label: 'reorder-folders' }
180181
)
182+
if (response.ok) await notifyFolderResourceChanged(resourceType, workspaceId)
183+
return response
181184
} catch (error) {
182185
if (error instanceof FolderLockedError) {
183186
return NextResponse.json({ error: error.message }, { status: error.status })

apps/sim/app/api/superuser/import-workflow/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
1111
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
14+
import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify'
1415
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
1516
import {
1617
loadWorkflowFromNormalizedTables,
@@ -163,6 +164,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
163164
)
164165
}
165166

167+
await notifyWorkspaceWorkflowsChanged(targetWorkspaceId)
168+
166169
// Copy copilot chats associated with the source workflow
167170
const sourceCopilotChats = await db
168171
.select({

0 commit comments

Comments
 (0)