Skip to content

Commit b02ec19

Browse files
committed
feat(forks): preview one workflow's block-level changes before a sync
The synced deployed workflows list gets a "View changes" action per row. A new internal route and fork use case return the target as its editor holds it and the source deployment re-keyed into the target's block ids through the fork block map (or the derived id the sync would assign), with condition and route ids and variable ids aligned, so the two sides diff block for block like two versions of one workflow.
1 parent 609c81b commit b02ec19

13 files changed

Lines changed: 968 additions & 2 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { createRouteContext } from '@sim/testing/helpers/http'
6+
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
7+
import {
8+
workspaceAuthorizationMock,
9+
workspaceAuthorizationMockFns,
10+
} from '@sim/testing/mocks/workspace-authorization.mock'
11+
import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock'
12+
import {
13+
workspaceForkingLineageMock,
14+
workspaceForkingLineageMockFns,
15+
} from '@sim/testing/mocks/workspace-forking-lineage.mock'
16+
import { beforeEach, describe, expect, it, vi } from 'vitest'
17+
import { OrchestrationError } from '@/lib/core/orchestration/types'
18+
19+
const mocks = vi.hoisted(() => ({
20+
loadSourceDeployedStates: vi.fn(),
21+
loadTargetDraftState: vi.fn(),
22+
readDeployedState: vi.fn(),
23+
loadForkBlockMap: vi.fn(),
24+
computeForkPromotePlan: vi.fn(),
25+
}))
26+
27+
vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock)
28+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
29+
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock)
30+
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock)
31+
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
32+
loadSourceDeployedStates: mocks.loadSourceDeployedStates,
33+
loadTargetDraftState: mocks.loadTargetDraftState,
34+
readDeployedState: mocks.readDeployedState,
35+
}))
36+
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
37+
loadForkBlockMap: mocks.loadForkBlockMap,
38+
}))
39+
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
40+
computeForkPromotePlan: mocks.computeForkPromotePlan,
41+
}))
42+
43+
import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route'
44+
45+
const { mockAuthorizeWorkspaceOperation } = workspaceAuthorizationMockFns
46+
const mockGetSession = authMockFns.mockGetSession
47+
permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({
48+
id,
49+
name: id,
50+
organizationId: null,
51+
allowPersonalApiKeys: true,
52+
}))
53+
54+
const WORKSPACE_ID = 'child'
55+
const routeContext = createRouteContext({ id: WORKSPACE_ID })
56+
const BASE_URL = `http://localhost/api/workspaces/${WORKSPACE_ID}/fork/workflow-diff`
57+
58+
const emptyState = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }
59+
60+
function request(query: Record<string, string>) {
61+
const url = `${BASE_URL}?${new URLSearchParams(query).toString()}`
62+
return createMockRequest('GET', undefined, undefined, url)
63+
}
64+
65+
describe('fork workflow-diff route', () => {
66+
beforeEach(() => {
67+
vi.clearAllMocks()
68+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
69+
mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
70+
workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue({
71+
parentWorkspaceId: 'parent',
72+
childWorkspaceId: 'child',
73+
})
74+
mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() })
75+
mocks.loadSourceDeployedStates.mockResolvedValue({
76+
deployedWorkflows: [{ id: 'wf-src' }],
77+
sourceStates: new Map([['wf-src', emptyState]]),
78+
})
79+
mocks.computeForkPromotePlan.mockResolvedValue({
80+
items: [
81+
{
82+
sourceWorkflowId: 'wf-src',
83+
targetWorkflowId: 'wf-tgt',
84+
mode: 'create',
85+
sourceMeta: { name: 'Ask Biz' },
86+
},
87+
],
88+
archivedTargets: [],
89+
})
90+
})
91+
92+
it('does not read any state when workspace authorization is refused', async () => {
93+
mockAuthorizeWorkspaceOperation.mockRejectedValue(
94+
new OrchestrationError('forbidden', 'Admin access required')
95+
)
96+
97+
const response = await GET(
98+
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
99+
routeContext
100+
)
101+
102+
expect(response.status).toBe(403)
103+
expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled()
104+
})
105+
106+
it('rejects a request without the source workflow id', async () => {
107+
const response = await GET(
108+
request({ otherWorkspaceId: 'parent', direction: 'push' }),
109+
routeContext
110+
)
111+
112+
expect(response.status).toBe(400)
113+
expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled()
114+
})
115+
116+
it('maps a workflow outside the sync plan to 404', async () => {
117+
const response = await GET(
118+
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }),
119+
routeContext
120+
)
121+
122+
expect(response.status).toBe(404)
123+
})
124+
125+
it('returns the before and after states with their labels', async () => {
126+
const response = await GET(
127+
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
128+
routeContext
129+
)
130+
131+
expect(response.status).toBe(200)
132+
await expect(response.json()).resolves.toEqual({
133+
targetWorkflowId: 'wf-tgt',
134+
before: null,
135+
after: emptyState,
136+
beforeLabel: 'Ask Biz (current)',
137+
afterLabel: 'Ask Biz (deployed)',
138+
})
139+
})
140+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
8+
import { forkOperations } from '@/ee/workspace-forking/application/operations'
9+
import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getForkWorkflowDiffContract,
13+
auth: internalSessionAuth,
14+
operation: forkOperations.syncPreview,
15+
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }),
16+
errorPolicy: internalForkErrorPolicy,
17+
mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }),
18+
useCase: getWorkspaceSyncWorkflowDiff,
19+
present: (result) => result,
20+
})

‎apps/sim/ee/workspace-forking/application/sync-details.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,19 +253,24 @@ export const getWorkspaceSyncDetails = defineForkUseCase({
253253
action: 'create' as const,
254254
currentName: item.sourceMeta.name,
255255
otherName: item.sourceMeta.name,
256+
sourceWorkflowId: item.sourceWorkflowId,
257+
targetWorkflowId: item.targetWorkflowId,
256258
}
257259
}
258260
const targetName = item.targetName ?? item.sourceMeta.name
259261
return {
260262
action: 'update' as const,
261263
currentName: currentIsSource ? item.sourceMeta.name : targetName,
262264
otherName: currentIsSource ? targetName : item.sourceMeta.name,
265+
sourceWorkflowId: item.sourceWorkflowId,
266+
targetWorkflowId: item.targetWorkflowId,
263267
}
264268
}),
265269
...plan.archivedTargets.map((target) => ({
266270
action: 'archive' as const,
267271
currentName: target.name,
268272
otherName: target.name,
273+
targetWorkflowId: target.id,
269274
})),
270275
]
271276

0 commit comments

Comments
 (0)