chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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 })
}
)
@@ -0,0 +1,217 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
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 '@/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 '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources'
import {
annotateForkClearedRefSourceLiveness,
collectForkClearedRefCandidates,
} 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 }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getForkDiffContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction } = parsed.data.query
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(
auth.sourceWorkspaceId
)
const plan = await computeForkPromotePlan({
executor: db,
edge: auth.edge,
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
direction,
deployedSourceWorkflows: deployedWorkflows,
sourceStates,
})
// Resolve dependent-reconfig target block ids through the SAME persisted block map the
// sync will use, so a re-pick the modal keys by target block id lands on the block the
// promote actually writes (on push that's the parent's original id, not a derived one).
const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
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.
// 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, 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 storedByKey = new Map(
storedValues.map((entry) => [
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
entry.value,
])
)
// Source block subBlocks keyed by their resolved target identity, so the first-sync draft
// fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
// an index alone may point at a different tool in the target draft, whose value isn't the
// dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
// blocks pass without a cast.
const sourceBlocksByTarget = new Map<string, Map<string, Record<string, { value?: unknown }>>>()
for (const item of plan.items) {
if (item.mode !== 'replace') continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
const byBlock = new Map<string, Record<string, { value?: unknown }>>()
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
}
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
}
// 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.
const sourceLabels = new Map<string, string>()
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
for (const candidate of candidates)
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
}
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
// Annotate each reference-cause entry's source liveness so the client can phrase the blocker
// reason (a deleted source can't be copied - it must be mapped to a live target resource).
const clearedRefs = await annotateForkClearedRefSourceLiveness(
db,
auth.sourceWorkspaceId,
collectForkClearedRefCandidates({
items: plan.items,
sourceStates,
resolver: plan.resolver,
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
sourceLabels,
sourceWorkflowNames,
})
)
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
required: reference.required,
blockName: reference.blockName,
})
// Orient the mapping around the workspace the modal is open in (`id`): show the
// caller's workflow name first, the sync partner's second, so renames are legible.
const currentIsSource = auth.sourceWorkspaceId === id
const workflows = [
...plan.items.map((item) => {
if (item.mode === 'create') {
// The target inherits the source's name, so both sides read the same.
return {
action: 'create' as const,
currentName: item.sourceMeta.name,
otherName: item.sourceMeta.name,
}
}
const targetName = item.targetName ?? item.sourceMeta.name
return {
action: 'update' as const,
currentName: currentIsSource ? item.sourceMeta.name : targetName,
otherName: currentIsSource ? targetName : item.sourceMeta.name,
}
}),
...plan.archivedTargets.map((target) => ({
action: 'archive' as const,
currentName: target.name,
otherName: target.name,
})),
]
return NextResponse.json({
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
willUpdate: plan.willUpdate,
willCreate: plan.willCreate,
willArchive: plan.willArchive,
workflows,
unmappedRequired: plan.unmappedRequired.map(toRef),
unmappedOptional: plan.unmappedOptional.map(toRef),
mcpReauthServerIds: plan.mcpReauthServerIds,
inlineSecretSources: plan.inlineSecretSources,
dependentReconfigs,
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
})
}
)
@@ -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()
})
})
@@ -0,0 +1,82 @@
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getForkLineageContract } 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 { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
/**
* Annotates a lineage node with whether the viewer holds any access to it (explicit
* grant or org-admin derivation, via the canonical workspace-permission resolver).
* Lineage rows are visible to any admin of the CURRENT workspace, who may have no
* access to the other side of an edge; the flag drives per-action gating in the
* Forks UI. Resolved per node - lineage children lists are small and bounded.
*/
async function withViewerAccess<T extends { id: string; organizationId: string | null }>(
node: T,
viewerId: string
): Promise<T & { viewerAccessible: boolean }> {
const permission = await getEffectiveWorkspacePermission(viewerId, node)
return { ...node, viewerAccessible: permission !== null }
}
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(getForkLineageContract, req, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
const [rawParent, rawChildren, run] = await Promise.all([
getForkParent(workspaceId),
getForkChildren(workspaceId),
getUndoableRunForTarget(db, workspaceId),
])
const [parent, children] = await Promise.all([
rawParent ? withViewerAccess(rawParent, session.user.id) : null,
Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))),
])
let undoableRun: {
otherWorkspaceId: string
otherName: string
direction: 'push' | 'pull'
} | null = null
if (run) {
const [other] = await db
.select({ name: workspace.name })
.from(workspace)
.where(eq(workspace.id, run.sourceWorkspaceId))
.limit(1)
undoableRun = {
otherWorkspaceId: run.sourceWorkspaceId,
otherName: other?.name ?? 'workspace',
direction: run.direction,
}
}
return NextResponse.json({
workspaceId,
parent,
children: children.map((child) => ({
...child,
createdAt: child.createdAt.toISOString(),
})),
undoableRun,
})
}
)
@@ -0,0 +1,104 @@
import { db } from '@sim/db'
import { type NextRequest, NextResponse } from 'next/server'
import {
getForkMappingContract,
updateForkMappingContract,
} 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 { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import {
applyForkMappingEntries,
getForkMappingView,
validateForkMappingTargets,
} from '@/ee/workspace-forking/lib/mapping/mapping-service'
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(getForkMappingContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction } = parsed.data.query
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
const { entries } = await getForkMappingView({
edge: auth.edge,
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
})
return NextResponse.json({
childWorkspaceId: auth.edge.childWorkspaceId,
parentWorkspaceId: auth.edge.parentWorkspaceId,
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
entries,
})
}
)
export const PUT = 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(updateForkMappingContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, entries, dependentValues } = parsed.data.body
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
// edge lock promote/rollback use, with a bounded wait.
const updated = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
const applied = await applyForkMappingEntries(
tx,
auth.edge,
session.user.id,
direction,
entries
)
// Store dependent-field values with the mapping (each named workflow's stored set is
// replaced by exactly what was sent - promote's reconcile semantics, scoped to the
// payload's workflows since a mapping save has no promote plan). Omitted = untouched;
// rows for a workflow that never becomes a sync replace target are inert (promote
// loads the store scoped to its plan's targets).
if (dependentValues !== undefined) {
const targetWorkflowIds = Array.from(
new Set(dependentValues.map((entry) => entry.workflowId))
)
await reconcileForkDependentValues(
tx,
auth.edge.childWorkspaceId,
targetWorkflowIds,
dependentValues.map((entry) => ({
targetWorkflowId: entry.workflowId,
targetBlockId: entry.blockId,
subBlockKey: entry.subBlockKey,
value: entry.value,
}))
)
}
return applied
})
return NextResponse.json({ success: true as const, updated })
}
)
@@ -0,0 +1,122 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
const logger = createLogger('WorkspaceForkPromoteAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
const result = await promoteFork({
edge: auth.edge,
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
direction,
userId: session.user.id,
actorName: session.user.name ?? undefined,
dependentValues,
copyResources,
requestId,
})
const body = {
promoteRunId: result.promoteRunId,
updated: result.updated,
created: result.created,
archived: result.archived,
redeployed: result.redeployed,
deployFailed: result.deployFailed,
unmappedRequired: result.unmappedRequired,
blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
}
if (result.blocked) {
logger.info(`[${requestId}] Promote blocked (${result.blocked})`, {
sourceWorkspaceId: auth.sourceWorkspaceId,
targetWorkspaceId: auth.targetWorkspaceId,
})
return NextResponse.json(body)
}
recordAudit({
workspaceId: auth.targetWorkspaceId,
actorId: session.user.id,
action: AuditAction.WORKSPACE_FORK_PROMOTED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: auth.targetWorkspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: auth.target.name,
description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`,
metadata: {
direction,
sourceWorkspaceId: auth.sourceWorkspaceId,
updated: result.updated,
created: result.created,
archived: result.archived,
redeployed: result.redeployed,
},
request: req,
})
const otherName =
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
await recordBackgroundWork(db, {
workspaceId: id,
kind: 'fork_sync',
status:
result.deployFailed > 0 ||
result.needsConfiguration.length > 0 ||
result.clearedOptional.length > 0
? 'completed_with_warnings'
: 'completed',
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
otherWorkspaceId,
otherWorkspaceName: otherName,
direction,
updated: result.updated,
created: result.created,
archived: result.archived,
redeployed: result.redeployed,
deployFailed: result.deployFailed,
updatedNames: result.updatedNames,
createdNames: result.createdNames,
archivedNames: result.archivedNames,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record sync activity`, {
error: getErrorMessage(error),
})
)
return NextResponse.json(body)
}
)
@@ -0,0 +1,26 @@
import { db } from '@sim/db'
import { type NextRequest, NextResponse } from 'next/server'
import { getForkResourcesContract } 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 { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
import { listForkCopyableResources } from '@/ee/workspace-forking/lib/mapping/resources'
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(getForkResourcesContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
await assertWorkspaceAdminAccess(id, session.user.id)
const resources = await listForkCopyableResources(db, id)
return NextResponse.json(resources)
}
)
@@ -0,0 +1,85 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { rollbackForkContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz'
import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback'
const logger = createLogger('WorkspaceForkRollbackAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(rollbackForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId } = parsed.data.body
const target = await assertCanRollback(id, session.user.id)
const result = await rollbackFork({
targetWorkspaceId: id,
otherWorkspaceId,
userId: session.user.id,
requestId,
})
recordAudit({
workspaceId: id,
actorId: session.user.id,
action: AuditAction.WORKSPACE_FORK_ROLLED_BACK,
resourceType: AuditResourceType.WORKSPACE,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: target.name,
description: `Rolled back the last promote into "${target.name}"`,
metadata: { otherWorkspaceId, ...result },
request: req,
})
// Durable audit entry scoped to this workspace so the undo shows in its Manage Forks
// → Activity log. Non-critical: a failure must not fail the (committed) rollback.
const [other] = await db
.select({ name: workspace.name })
.from(workspace)
.where(eq(workspace.id, otherWorkspaceId))
.limit(1)
const otherName = other?.name ?? 'the source workspace'
await recordBackgroundWork(db, {
workspaceId: id,
kind: 'fork_rollback',
status: result.skipped > 0 ? 'completed_with_warnings' : 'completed',
message: `Undid the last sync from "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
otherWorkspaceId,
otherWorkspaceName: otherName,
restored: result.restored,
removed: result.archived,
unarchived: result.unarchived,
skipped: result.skipped,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record rollback activity`, {
error: getErrorMessage(error),
})
)
return NextResponse.json(result)
}
)
@@ -0,0 +1,68 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createFork } from '@/ee/workspace-forking/lib/create-fork'
import { assertCanFork } from '@/ee/workspace-forking/lib/lineage/authz'
const logger = createLogger('WorkspaceForkAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const { id: sourceWorkspaceId } = await context.params
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id)
const parsed = await parseRequest(forkWorkspaceContract, req, context)
if (!parsed.success) return parsed.response
const copy = parsed.data.body.copy
const result = await createFork({
source,
policy,
userId: session.user.id,
actorName: session.user.name ?? undefined,
name: parsed.data.body.name,
selection: {
files: copy?.files ?? [],
tables: copy?.tables ?? [],
knowledgeBases: copy?.knowledgeBases ?? [],
customTools: copy?.customTools ?? [],
skills: copy?.skills ?? [],
mcpServers: copy?.mcpServers ?? [],
workflowMcpServers: copy?.workflowMcpServers ?? [],
},
requestId,
})
recordAudit({
workspaceId: result.workspace.id,
actorId: session.user.id,
action: AuditAction.WORKSPACE_FORKED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: result.workspace.id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.workspace.name,
description: `Forked workspace from "${source.name}"`,
metadata: {
parentWorkspaceId: source.id,
workflowsCopied: result.workflowsCopied,
},
request: req,
})
logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`)
return NextResponse.json(result, { status: 201 })
}
)
@@ -0,0 +1,49 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import { unlinkForkContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz'
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(unlinkForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId } = parsed.data.body
const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id)
const result = await unlinkForkEdge(edge, requestId)
if (result.unlinked) {
recordAudit({
workspaceId: id,
actorId: session.user.id,
action: AuditAction.WORKSPACE_FORK_UNLINKED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: current.name,
description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`,
metadata: {
otherWorkspaceId,
childWorkspaceId: edge.childWorkspaceId,
parentWorkspaceId: edge.parentWorkspaceId,
},
request: req,
})
}
return NextResponse.json(result)
}
)