Skip to content

Commit 86e6e1d

Browse files
feat(forking): excluded workflows (#5727)
* feat(forking): excluded workflows * improve sync preview
1 parent 442eaba commit 86e6e1d

33 files changed

Lines changed: 18802 additions & 87 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/forks.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from "
117117

118118
---
119119

120+
## Excluded workflows
121+
122+
The **Excluded workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure. Check a workflow — or a whole folder at once — to keep it out of forking entirely. Think of it as a `.gitignore` for syncs:
123+
124+
- **Never sent** — pushes from this workspace do not carry it, the other side pulling from this workspace does not receive it, and creating a new fork does not copy it
125+
- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side
126+
127+
The setting belongs to **this workspace's copy** only. Excluding a workflow here does not exclude its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so un-excluding later resumes updating the same counterpart instead of creating a duplicate.
128+
129+
**Example:** a staging fork excludes `Scratch experiment` so it can never reach production, and production excludes `Billing hotfix` so no push from staging can ever overwrite it.
130+
131+
---
132+
120133
## Activity
121134

122135
**See activity** (or the Activity view from the Forks header) lists forks, pushes, pulls, and rollbacks that involve this workspace — including events recorded on the other side of the edge.
@@ -156,8 +169,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
156169

157170
| Resource | Fork | Sync |
158171
|----------|------|------|
159-
| Deployed workflows | Always copied as drafts | Updated / created / archived (force overwrite) |
172+
| Deployed workflows | Copied as drafts (unless excluded) | Updated / created / archived (force overwrite) |
160173
| Undeployed workflows | Not copied | Not synced |
174+
| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived |
161175
| Files | Optional copy (default on) | Map or copy |
162176
| Tables | Optional copy (default on) | Map or copy |
163177
| Knowledge bases (+ documents) | Optional copy; referenced docs come with the KB | Map or copy; documents follow the KB |
@@ -176,7 +190,7 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
176190

177191
### Workflows
178192

179-
Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits.
193+
Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. Workflows marked [excluded](#excluded-workflows) never move in either direction.
180194

181195
| | Behavior |
182196
|---|----------|

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ describe('Workflow By ID API Route', () => {
9797
folderId: params.folderId ?? params.currentFolderId ?? null,
9898
sortOrder: params.sortOrder ?? null,
9999
locked: params.locked ?? null,
100+
forkSyncExcluded: params.forkSyncExcluded ?? null,
100101
createdAt: new Date(),
101102
updatedAt: new Date(),
102103
archivedAt: null,
@@ -917,6 +918,107 @@ describe('Workflow By ID API Route', () => {
917918
// db.select should NOT have been called since no name/folder change
918919
expect(mockDbSelect).not.toHaveBeenCalled()
919920
})
921+
922+
it('should deny forkSyncExcluded update for non-admin users', async () => {
923+
const mockWorkflow = {
924+
id: 'workflow-123',
925+
userId: 'user-123',
926+
name: 'Test Workflow',
927+
workspaceId: 'workspace-456',
928+
forkSyncExcluded: false,
929+
}
930+
931+
mockGetSession({ user: { id: 'user-123' } })
932+
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
933+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
934+
allowed: true,
935+
status: 200,
936+
workflow: mockWorkflow,
937+
workspacePermission: 'write',
938+
})
939+
940+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
941+
method: 'PUT',
942+
body: JSON.stringify({ forkSyncExcluded: true }),
943+
})
944+
const params = Promise.resolve({ id: 'workflow-123' })
945+
946+
const response = await PUT(req, { params })
947+
948+
expect(response.status).toBe(403)
949+
const data = await response.json()
950+
expect(data.error).toBe('Admin access required to exclude workflows from sync')
951+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
952+
})
953+
954+
it('should allow admin to toggle forkSyncExcluded and carry it on the response', async () => {
955+
const mockWorkflow = {
956+
id: 'workflow-123',
957+
userId: 'user-123',
958+
name: 'Test Workflow',
959+
workspaceId: 'workspace-456',
960+
forkSyncExcluded: false,
961+
}
962+
963+
mockGetSession({ user: { id: 'user-123' } })
964+
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
965+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
966+
allowed: true,
967+
status: 200,
968+
workflow: mockWorkflow,
969+
workspacePermission: 'admin',
970+
})
971+
972+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
973+
method: 'PUT',
974+
body: JSON.stringify({ forkSyncExcluded: true }),
975+
})
976+
const params = Promise.resolve({ id: 'workflow-123' })
977+
978+
const response = await PUT(req, { params })
979+
980+
expect(response.status).toBe(200)
981+
const data = await response.json()
982+
expect(data.workflow.forkSyncExcluded).toBe(true)
983+
expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith(
984+
expect.objectContaining({
985+
workflowId: 'workflow-123',
986+
forkSyncExcluded: true,
987+
currentForkSyncExcluded: false,
988+
})
989+
)
990+
})
991+
992+
it('should skip the mutability check for an exclusion-only update (locked workflow stays togglable)', async () => {
993+
const mockWorkflow = {
994+
id: 'workflow-123',
995+
userId: 'user-123',
996+
name: 'Test Workflow',
997+
workspaceId: 'workspace-456',
998+
locked: true,
999+
forkSyncExcluded: false,
1000+
}
1001+
1002+
mockGetSession({ user: { id: 'user-123' } })
1003+
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
1004+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
1005+
allowed: true,
1006+
status: 200,
1007+
workflow: mockWorkflow,
1008+
workspacePermission: 'admin',
1009+
})
1010+
1011+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
1012+
method: 'PUT',
1013+
body: JSON.stringify({ forkSyncExcluded: true }),
1014+
})
1015+
const params = Promise.resolve({ id: 'workflow-123' })
1016+
1017+
const response = await PUT(req, { params })
1018+
1019+
expect(response.status).toBe(200)
1020+
expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled()
1021+
})
9201022
})
9211023

9221024
describe('Error handling', () => {

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,22 @@ export const PUT = withRouteHandler(
300300
)
301301
}
302302

303-
const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
304-
if (hasNonLockUpdate) {
303+
if (updates.forkSyncExcluded !== undefined && authorization.workspacePermission !== 'admin') {
304+
logger.warn(
305+
`[${requestId}] User ${userId} denied permission to change sync exclusion for workflow ${workflowId}`
306+
)
307+
return NextResponse.json(
308+
{ error: 'Admin access required to exclude workflows from sync' },
309+
{ status: 403 }
310+
)
311+
}
312+
313+
// Policy flags (lock, sync exclusion) don't modify content, so a locked workflow
314+
// may still have them toggled; everything else requires mutability.
315+
const hasNonPolicyUpdate = Object.keys(updates).some(
316+
(key) => key !== 'locked' && key !== 'forkSyncExcluded'
317+
)
318+
if (hasNonPolicyUpdate) {
305319
await assertWorkflowMutable(workflowId)
306320
}
307321
if (updates.folderId !== undefined) {
@@ -320,6 +334,7 @@ export const PUT = withRouteHandler(
320334
currentName: workflowData.name,
321335
currentFolderId: workflowData.folderId,
322336
currentLocked: workflowData.locked,
337+
currentForkSyncExcluded: workflowData.forkSyncExcluded,
323338
...updates,
324339
requestId,
325340
})

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { parseRequest } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
10-
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
10+
import {
11+
listForkExcludedDeployedWorkflows,
12+
loadSourceDeployedStates,
13+
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
1114
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
1215
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
1316
import {
@@ -73,17 +76,24 @@ export const GET = withRouteHandler(
7376
.filter((item) => item.mode === 'replace')
7477
.map((item) => item.targetWorkflowId)
7578
const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
76-
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
77-
await Promise.all([
78-
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
79-
loadTargetDraftSubBlocks(db, replaceTargetIds),
80-
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
81-
listForkResourceCandidates(db, auth.sourceWorkspaceId),
82-
db
83-
.select({ id: workflow.id, name: workflow.name })
84-
.from(workflow)
85-
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
86-
])
79+
const [
80+
storedValues,
81+
targetDraftByWorkflow,
82+
sourceCandidates,
83+
sourceWorkflowRows,
84+
excludedSourceWorkflows,
85+
] = await Promise.all([
86+
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
87+
loadTargetDraftSubBlocks(db, replaceTargetIds),
88+
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
89+
listForkResourceCandidates(db, auth.sourceWorkspaceId),
90+
db
91+
.select({ id: workflow.id, name: workflow.name })
92+
.from(workflow)
93+
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
94+
// Deployed-but-excluded source workflows, so the preview can show what a sync skips.
95+
listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId),
96+
])
8797
const storedByKey = new Map(
8898
storedValues.map((entry) => [
8999
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
@@ -204,6 +214,8 @@ export const GET = withRouteHandler(
204214
willCreate: plan.willCreate,
205215
willArchive: plan.willArchive,
206216
workflows,
217+
excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name),
218+
excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name),
207219
unmappedRequired: plan.unmappedRequired.map(toRef),
208220
unmappedOptional: plan.unmappedOptional.map(toRef),
209221
mcpReauthServerIds: plan.mcpReauthServerIds,
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { auditMock, auditMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockAssertWorkspaceAdminAccess, mockDbUpdate, mockCaptureServerEvent } =
8+
vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockAssertWorkspaceAdminAccess: vi.fn(),
11+
mockDbUpdate: vi.fn(),
12+
mockCaptureServerEvent: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/auth', () => ({
16+
auth: { api: { getSession: vi.fn() } },
17+
getSession: mockGetSession,
18+
}))
19+
20+
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
21+
assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess,
22+
}))
23+
24+
vi.mock('@sim/audit', () => auditMock)
25+
26+
vi.mock('@/lib/posthog/server', () => ({
27+
captureServerEvent: mockCaptureServerEvent,
28+
}))
29+
30+
vi.mock('@sim/db', () => ({
31+
db: { update: () => mockDbUpdate() },
32+
}))
33+
34+
import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route'
35+
36+
const WORKSPACE_ID = 'workspace-1'
37+
const ADMIN_ID = 'user-1'
38+
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
39+
40+
function mockUpdateReturning(rows: Array<{ id: string; name: string }>) {
41+
mockDbUpdate.mockReturnValue({
42+
set: vi.fn().mockReturnValue({
43+
where: vi.fn().mockReturnValue({
44+
returning: vi.fn().mockResolvedValue(rows),
45+
}),
46+
}),
47+
})
48+
}
49+
50+
describe('fork excluded-workflows route', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } })
54+
mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' })
55+
mockUpdateReturning([])
56+
})
57+
58+
it('returns 401 when there is no session', async () => {
59+
mockGetSession.mockResolvedValue(null)
60+
61+
const res = await PUT(
62+
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }),
63+
routeContext
64+
)
65+
66+
expect(res.status).toBe(401)
67+
expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled()
68+
})
69+
70+
it('rejects an empty workflowIds batch', async () => {
71+
const res = await PUT(
72+
createMockRequest('PUT', { workflowIds: [], forkSyncExcluded: true }),
73+
routeContext
74+
)
75+
76+
expect(res.status).toBe(400)
77+
expect(mockDbUpdate).not.toHaveBeenCalled()
78+
})
79+
80+
it('requires workspace admin (and the fork entitlement gate) before writing', async () => {
81+
mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }])
82+
83+
await PUT(
84+
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }),
85+
routeContext
86+
)
87+
88+
expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, ADMIN_ID)
89+
})
90+
91+
it('updates the batch, reports the transition count, and records one audit entry', async () => {
92+
mockUpdateReturning([
93+
{ id: 'wf-1', name: 'Alpha' },
94+
{ id: 'wf-2', name: 'Beta' },
95+
])
96+
97+
const res = await PUT(
98+
createMockRequest('PUT', { workflowIds: ['wf-1', 'wf-2'], forkSyncExcluded: true }),
99+
routeContext
100+
)
101+
102+
expect(res.status).toBe(200)
103+
expect(await res.json()).toEqual({ updated: 2 })
104+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1)
105+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
106+
expect.objectContaining({
107+
workspaceId: WORKSPACE_ID,
108+
actorId: ADMIN_ID,
109+
action: 'workflow.fork_sync_excluded',
110+
resourceId: WORKSPACE_ID,
111+
metadata: expect.objectContaining({
112+
forkSyncExcluded: true,
113+
workflowCount: 2,
114+
workflowNames: ['Alpha', 'Beta'],
115+
}),
116+
})
117+
)
118+
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
119+
ADMIN_ID,
120+
'fork_excluded_workflows_updated',
121+
expect.objectContaining({ workflow_count: 2, fork_sync_excluded: true }),
122+
{ groups: { workspace: WORKSPACE_ID } }
123+
)
124+
})
125+
126+
it('records the inclusion action when unmarking workflows', async () => {
127+
mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }])
128+
129+
const res = await PUT(
130+
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: false }),
131+
routeContext
132+
)
133+
134+
expect(res.status).toBe(200)
135+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
136+
expect.objectContaining({ action: 'workflow.fork_sync_included' })
137+
)
138+
})
139+
140+
it('skips audit and analytics when nothing transitioned', async () => {
141+
mockUpdateReturning([])
142+
143+
const res = await PUT(
144+
createMockRequest('PUT', { workflowIds: ['wf-unknown'], forkSyncExcluded: true }),
145+
routeContext
146+
)
147+
148+
expect(res.status).toBe(200)
149+
expect(await res.json()).toEqual({ updated: 0 })
150+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
151+
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
152+
})
153+
})

0 commit comments

Comments
 (0)