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
2 changes: 1 addition & 1 deletion apps/sim/app/api/webhooks/outbox/process/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'

const logger = createLogger('OutboxProcessorAPI')

Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/api/workspaces/[id]/background-work/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspac
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
import { listSurfacedBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'

export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
Expand All @@ -17,12 +17,13 @@ export const GET = withRouteHandler(
const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { cursor, limit } = parsed.data.query

// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
// workspace-admin check the other fork routes use, instead of a bare access check.
await assertWorkspaceAdminAccess(id, session.user.id)

const rows = await listSurfacedBackgroundWork(db, id)
const { rows, nextCursor } = await listSurfacedBackgroundWork(db, id, { cursor, limit })
return NextResponse.json({
items: rows.map((row) => ({
id: row.id,
Expand All @@ -36,6 +37,7 @@ export const GET = withRouteHandler(
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
})),
nextCursor,
})
}
)
37 changes: 37 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/availability/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'

/**
* Whether forking is available for this workspace: the server-evaluated verdict of the
* same gate every fork route enforces (env/plan + the `workspace-forking` AppConfig
* rollout flag). Member-readable — it only reveals feature on/off, and the client uses
* it to show/hide the Forks settings tab and context-menu entries.
*/
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(getForkAvailabilityContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params

const access = await checkWorkspaceAccess(id, session.user.id)
if (!access.exists || !access.workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

const available = await isForkingAvailableForWorkspace(
access.workspace.organizationId,
session.user.id
)
return NextResponse.json({ available })
}
)
83 changes: 50 additions & 33 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,26 @@ import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { 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 {
collectForkDependentReconfigs,
collectForkResourceUsages,
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources'
import {
annotateForkClearedRefSourceLiveness,
collectForkClearedRefCandidates,
} from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'

export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
Expand Down Expand Up @@ -62,17 +62,20 @@ export const GET = withRouteHandler(
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)

// Stored dependent values are the source of truth for what each selector is set to. Overlay
// them as each field's currentValue so the modal pre-fills what the user actually saved. For
// an edge that predates the store the fallback is the TARGET's own configured value (loaded
// from its draft) - never the source's, which would overwrite the target's selection on the
// first sync. Both the stored read and the draft read are scoped to the plan's replace
// targets, the only workflows with dependents to reconfigure.
// them as each field's currentValue so the modal pre-fills what the user actually saved.
// Before the FIRST sync populates the store (fork-create seeds mappings but no dependent
// values), the fallback is the TARGET's own configured value (loaded from its draft) - never
// the source's, which would overwrite the target's selection. The stored read spans EVERY
// plan target: a create-mode (never-synced) workflow's deterministic target id is what the
// first sync will use, so values pre-configured for it in the mapping editor pre-fill here
// too. The draft read stays replace-scoped (creates have no target draft to fall back to).
const replaceTargetIds = plan.items
.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, replaceTargetIds),
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),
Expand Down Expand Up @@ -105,22 +108,36 @@ export const GET = withRouteHandler(
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
}

const dependentReconfigs = collectForkDependentReconfigs(
plan.items,
sourceStates,
resolveBlockId
).map((field) => ({
...field,
currentValue:
storedByKey.get(
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
) ??
readTargetDraftDependentValue(
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
field.subBlockKey
),
}))
// Replace-target fields pre-fill from the store, falling back to the TARGET's own draft
// value before the first sync populates the store (never the source's, which would
// overwrite the target's selection). Create-target fields (never-synced workflows)
// pre-fill from the store, falling back to the SOURCE value the collector emitted -
// that's exactly what the first sync copies verbatim, so the pre-fill is honest and
// configuring it ahead of the first sync is possible (the deterministic target ids
// already exist).
const dependentReconfigs = [
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({
...field,
currentValue:
storedByKey.get(
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
) ??
readTargetDraftDependentValue(
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
field.subBlockKey
),
})),
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map(
(field) => ({
...field,
currentValue:
storedByKey.get(
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
) ?? field.currentValue,
})
),
]

// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
// list. Labels resolve from the source candidate lists + workflow names loaded above.
Expand Down
149 changes: 149 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
mockAssertWorkspaceAdminAccess,
mockGetForkParent,
mockGetForkChildren,
mockGetUndoableRunForTarget,
mockGetEffectiveWorkspacePermission,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockAssertWorkspaceAdminAccess: vi.fn(),
mockGetForkParent: vi.fn(),
mockGetForkChildren: vi.fn(),
mockGetUndoableRunForTarget: vi.fn(),
mockGetEffectiveWorkspacePermission: 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('@/ee/workspace-forking/lib/lineage/lineage', () => ({
getForkParent: mockGetForkParent,
getForkChildren: mockGetForkChildren,
}))

vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
getUndoableRunForTarget: mockGetUndoableRunForTarget,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission,
}))

import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route'

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

const parentNode = { id: 'parent-1', name: 'Parent', organizationId: 'org-1' }
const childCreatedAt = new Date('2026-01-02T03:04:05.000Z')
const childNode = (id: string, name: string) => ({
id,
name,
organizationId: 'org-1',
createdAt: childCreatedAt,
})

describe('fork lineage route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } })
mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID })
mockGetForkParent.mockResolvedValue(null)
mockGetForkChildren.mockResolvedValue([])
mockGetUndoableRunForTarget.mockResolvedValue(null)
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
})

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

const res = await GET(createMockRequest('GET'), routeContext)

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

it('requires admin on the current workspace before loading lineage', async () => {
await GET(createMockRequest('GET'), routeContext)

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

it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => {
mockGetForkParent.mockResolvedValue(parentNode)
mockGetForkChildren.mockResolvedValue([
childNode('fork-accessible', 'Accessible fork'),
childNode('fork-hidden', 'Hidden fork'),
])
mockGetEffectiveWorkspacePermission.mockImplementation(
async (_userId: string, ws: { id: string }) => {
if (ws.id === parentNode.id) return 'read'
if (ws.id === 'fork-accessible') return 'admin'
return null
}
)

const res = await GET(createMockRequest('GET'), routeContext)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: true })
expect(body.children).toEqual([
{
id: 'fork-accessible',
name: 'Accessible fork',
organizationId: 'org-1',
createdAt: childCreatedAt.toISOString(),
viewerAccessible: true,
},
{
id: 'fork-hidden',
name: 'Hidden fork',
organizationId: 'org-1',
createdAt: childCreatedAt.toISOString(),
viewerAccessible: false,
},
])
expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledWith(
VIEWER_ID,
expect.objectContaining({ id: parentNode.id, organizationId: 'org-1' })
)
})

it('marks the parent inaccessible when the viewer holds no permission on it', async () => {
mockGetForkParent.mockResolvedValue(parentNode)
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)

const res = await GET(createMockRequest('GET'), routeContext)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: false })
expect(body.children).toEqual([])
})

it('keeps a null parent null without resolving permissions', async () => {
const res = await GET(createMockRequest('GET'), routeContext)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.parent).toBeNull()
expect(body.children).toEqual([])
expect(body.undoableRun).toBeNull()
expect(mockGetEffectiveWorkspacePermission).not.toHaveBeenCalled()
})
})
Loading
Loading