Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/docs/content/docs/en/platform/enterprise/forks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from "

---

## Excluded workflows

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:

- **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
- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side

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.

**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.

---

## Activity

**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.
Expand Down Expand Up @@ -156,8 +169,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a

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

### Workflows

Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits.
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.

| | Behavior |
|---|----------|
Expand Down
102 changes: 102 additions & 0 deletions apps/sim/app/api/workflows/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('Workflow By ID API Route', () => {
folderId: params.folderId ?? params.currentFolderId ?? null,
sortOrder: params.sortOrder ?? null,
locked: params.locked ?? null,
forkSyncExcluded: params.forkSyncExcluded ?? null,
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
Expand Down Expand Up @@ -917,6 +918,107 @@ describe('Workflow By ID API Route', () => {
// db.select should NOT have been called since no name/folder change
expect(mockDbSelect).not.toHaveBeenCalled()
})

it('should deny forkSyncExcluded update for non-admin users', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
forkSyncExcluded: false,
}

mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})

const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ forkSyncExcluded: true }),
})
const params = Promise.resolve({ id: 'workflow-123' })

const response = await PUT(req, { params })

expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Admin access required to exclude workflows from sync')
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
})

it('should allow admin to toggle forkSyncExcluded and carry it on the response', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
forkSyncExcluded: false,
}

mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})

const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ forkSyncExcluded: true }),
})
const params = Promise.resolve({ id: 'workflow-123' })

const response = await PUT(req, { params })

expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.forkSyncExcluded).toBe(true)
expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
forkSyncExcluded: true,
currentForkSyncExcluded: false,
})
)
})

it('should skip the mutability check for an exclusion-only update (locked workflow stays togglable)', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
locked: true,
forkSyncExcluded: false,
}

mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})

const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ forkSyncExcluded: true }),
})
const params = Promise.resolve({ id: 'workflow-123' })

const response = await PUT(req, { params })

expect(response.status).toBe(200)
expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled()
})
})

describe('Error handling', () => {
Expand Down
19 changes: 17 additions & 2 deletions apps/sim/app/api/workflows/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,22 @@ export const PUT = withRouteHandler(
)
}

const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
if (hasNonLockUpdate) {
if (updates.forkSyncExcluded !== undefined && authorization.workspacePermission !== 'admin') {
logger.warn(
`[${requestId}] User ${userId} denied permission to change sync exclusion for workflow ${workflowId}`
)
return NextResponse.json(
{ error: 'Admin access required to exclude workflows from sync' },
{ status: 403 }
)
}

// Policy flags (lock, sync exclusion) don't modify content, so a locked workflow
// may still have them toggled; everything else requires mutability.
const hasNonPolicyUpdate = Object.keys(updates).some(
(key) => key !== 'locked' && key !== 'forkSyncExcluded'
)
if (hasNonPolicyUpdate) {
await assertWorkflowMutable(workflowId)
}
if (updates.folderId !== undefined) {
Expand All @@ -320,6 +334,7 @@ export const PUT = withRouteHandler(
currentName: workflowData.name,
currentFolderId: workflowData.folderId,
currentLocked: workflowData.locked,
currentForkSyncExcluded: workflowData.forkSyncExcluded,
...updates,
requestId,
})
Expand Down
36 changes: 24 additions & 12 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import {
listForkExcludedDeployedWorkflows,
loadSourceDeployedStates,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
import {
Expand Down Expand Up @@ -73,17 +76,24 @@ export const GET = withRouteHandler(
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
listForkResourceCandidates(db, auth.sourceWorkspaceId),
db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
])
const [
storedValues,
targetDraftByWorkflow,
sourceCandidates,
sourceWorkflowRows,
excludedSourceWorkflows,
] = await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
listForkResourceCandidates(db, auth.sourceWorkspaceId),
db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
// Deployed-but-excluded source workflows, so the preview can show what a sync skips.
listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId),
])
const storedByKey = new Map(
storedValues.map((entry) => [
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
Expand Down Expand Up @@ -204,6 +214,8 @@ export const GET = withRouteHandler(
willCreate: plan.willCreate,
willArchive: plan.willArchive,
workflows,
excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name),
excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name),
unmappedRequired: plan.unmappedRequired.map(toRef),
unmappedOptional: plan.unmappedOptional.map(toRef),
mcpReauthServerIds: plan.mcpReauthServerIds,
Expand Down
153 changes: 153 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* @vitest-environment node
*/
import { auditMock, auditMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockAssertWorkspaceAdminAccess, mockDbUpdate, mockCaptureServerEvent } =
vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockAssertWorkspaceAdminAccess: vi.fn(),
mockDbUpdate: vi.fn(),
mockCaptureServerEvent: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))

vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess,
}))

vi.mock('@sim/audit', () => auditMock)

vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))

vi.mock('@sim/db', () => ({
db: { update: () => mockDbUpdate() },
}))

import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route'

const WORKSPACE_ID = 'workspace-1'
const ADMIN_ID = 'user-1'
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }

function mockUpdateReturning(rows: Array<{ id: string; name: string }>) {
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue(rows),
}),
}),
})
}

describe('fork excluded-workflows route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } })
mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' })
mockUpdateReturning([])
})

it('returns 401 when there is no session', async () => {
mockGetSession.mockResolvedValue(null)

const res = await PUT(
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }),
routeContext
)

expect(res.status).toBe(401)
expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled()
})

it('rejects an empty workflowIds batch', async () => {
const res = await PUT(
createMockRequest('PUT', { workflowIds: [], forkSyncExcluded: true }),
routeContext
)

expect(res.status).toBe(400)
expect(mockDbUpdate).not.toHaveBeenCalled()
})

it('requires workspace admin (and the fork entitlement gate) before writing', async () => {
mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }])

await PUT(
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }),
routeContext
)

expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, ADMIN_ID)
})

it('updates the batch, reports the transition count, and records one audit entry', async () => {
mockUpdateReturning([
{ id: 'wf-1', name: 'Alpha' },
{ id: 'wf-2', name: 'Beta' },
])

const res = await PUT(
createMockRequest('PUT', { workflowIds: ['wf-1', 'wf-2'], forkSyncExcluded: true }),
routeContext
)

expect(res.status).toBe(200)
expect(await res.json()).toEqual({ updated: 2 })
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1)
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: WORKSPACE_ID,
actorId: ADMIN_ID,
action: 'workflow.fork_sync_excluded',
resourceId: WORKSPACE_ID,
metadata: expect.objectContaining({
forkSyncExcluded: true,
workflowCount: 2,
workflowNames: ['Alpha', 'Beta'],
}),
})
)
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
ADMIN_ID,
'fork_excluded_workflows_updated',
expect.objectContaining({ workflow_count: 2, fork_sync_excluded: true }),
{ groups: { workspace: WORKSPACE_ID } }
)
})

it('records the inclusion action when unmarking workflows', async () => {
mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }])

const res = await PUT(
createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: false }),
routeContext
)

expect(res.status).toBe(200)
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'workflow.fork_sync_included' })
)
})

it('skips audit and analytics when nothing transitioned', async () => {
mockUpdateReturning([])

const res = await PUT(
createMockRequest('PUT', { workflowIds: ['wf-unknown'], forkSyncExcluded: true }),
routeContext
)

expect(res.status).toBe(200)
expect(await res.json()).toEqual({ updated: 0 })
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
})
})
Loading
Loading