feat(workspaces): fork + push/pull (#5210)

* feat(workspaces): fork + push/pull

* type fix

* fix tests

* progress on ux

* remove modal section

* improve UI of modal

* update more ui

* make rollback part of the footer

* track skipped count correctly

* address comments

* make it workspace admin level

* update skipped count

* address more comments

* deal with unbounded memory possibility

* fix deleted kb article bug

* no deployed workflow case

* UI/UX cleanup

* fix oauth dropdown case

* fix oauth selector issue

* infra work + activity log

* consolidate migration

* update modal state

* more UI simplification

* grammar

* update audit report ui

* perf improvements

* fix tool input scenarios and add dependsOn UI handling

* minor comments

* fix webhook stability issues + drift detection removal

* make dependsOn subblock mapping cleanly stored

* fix: harden fork dependent-value mapping (clear stale rows, identity-guard first-sync fallback, perf + cleanup)

* address comments

* update comment

* enforce admin perms for activity api

* fix required + dependsOn combo
This commit is contained in:
Vikhyath Mondreti
2026-06-28 13:13:57 -07:00
committed by GitHub
parent 3766582e7d
commit 5a8134119a
90 changed files with 48261 additions and 206 deletions
@@ -1,3 +1,4 @@
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,6 +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'
const logger = createLogger('OutboxProcessorAPI')
@@ -33,12 +35,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
minRemainingMs: 95_000,
})
logger.info('Outbox processing completed', { requestId, ...result })
// Reap fork background-work rows stuck `processing` past their TTL (worker crash /
// restart has no in-task hook). Independent of the outbox; a failure here must not
// fail the outbox run, so it's guarded separately.
let reapedBackgroundWork = 0
try {
reapedBackgroundWork = await reapStaleBackgroundWork(db)
} catch (error) {
logger.error('Background-work reap failed', { requestId, error: toError(error).message })
}
logger.info('Outbox processing completed', { requestId, ...result, reapedBackgroundWork })
return NextResponse.json({
success: true,
requestId,
result,
reapedBackgroundWork,
})
} catch (error) {
logger.error('Outbox processing failed', { requestId, error: toError(error).message })
@@ -0,0 +1,45 @@
import { db } from '@sim/db'
import { type NextRequest, NextResponse } from 'next/server'
import { getWorkspaceBackgroundWorkContract } 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 { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
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(getWorkspaceBackgroundWorkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const access = await checkWorkspaceAccess(id, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.canAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const rows = await listSurfacedBackgroundWork(db, id)
return NextResponse.json({
items: rows.map((row) => ({
id: row.id,
workspaceId: row.workspaceId,
workflowId: row.workflowId,
kind: row.kind,
status: row.status,
message: row.message,
error: row.error,
metadata: row.metadata ?? null,
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
})),
})
}
)
@@ -0,0 +1,160 @@
import { db } from '@sim/db'
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 '@/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 {
collectForkDependentReconfigs,
collectForkResourceUsages,
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
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'
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. 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.
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow] = await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
])
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)
}
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
),
}))
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),
})
}
)
@@ -0,0 +1,56 @@
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 { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
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 [parent, run] = await Promise.all([
getForkParent(workspaceId),
getUndoableRunForTarget(db, workspaceId),
])
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,
undoableRun,
})
}
)
@@ -0,0 +1,75 @@
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 '@/lib/workspaces/fork/lineage/authz'
import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
import {
applyForkMappingEntries,
getForkMappingView,
validateForkMappingTargets,
} from '@/lib/workspaces/fork/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 } = 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)
return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
})
return NextResponse.json({ success: true as const, updated })
}
)
@@ -0,0 +1,118 @@
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 '@/lib/workspaces/fork/background-work/store'
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
import { promoteFork } from '@/lib/workspaces/fork/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 } = 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,
dependentValues,
requestId,
})
const body = {
promoteRunId: result.promoteRunId,
updated: result.updated,
created: result.created,
archived: result.archived,
redeployed: result.redeployed,
deployFailed: result.deployFailed,
unmappedRequired: result.unmappedRequired,
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,
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 '@/lib/workspaces/fork/lineage/authz'
import { listForkCopyableResources } from '@/lib/workspaces/fork/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,84 @@
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 '@/lib/workspaces/fork/background-work/store'
import { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz'
import { rollbackFork } from '@/lib/workspaces/fork/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,
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,67 @@
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 '@/lib/workspaces/fork/create-fork'
import { assertCanFork } from '@/lib/workspaces/fork/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 ?? [],
},
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 })
}
)
@@ -6,6 +6,7 @@ import {
resolveToolParamSync,
} from '@/lib/workflows/tool-input/synthetic-subblocks'
import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types'
import { DependencyBlockTypeProvider } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -17,6 +18,8 @@ interface ToolSubBlockRendererProps {
toolIndex: number
subBlock: BlockSubBlockConfig
effectiveParamId: string
/** The tool's block type (e.g. `gmail`), so its params' selectors resolve dependencies. */
toolType: string
toolParams: Record<string, string> | undefined
onParamChange: (toolIndex: number, paramId: string, value: string) => void
disabled: boolean
@@ -44,6 +47,7 @@ export function ToolSubBlockRenderer({
toolIndex,
subBlock,
effectiveParamId,
toolType,
toolParams,
onParamChange,
disabled,
@@ -118,13 +122,15 @@ export function ToolSubBlockRenderer({
}
return (
<SubBlock
blockId={blockId}
config={config}
isPreview={false}
disabled={disabled}
canonicalToggle={canonicalToggle}
dependencyContext={toolParams}
/>
<DependencyBlockTypeProvider value={toolType}>
<SubBlock
blockId={blockId}
config={config}
isPreview={false}
disabled={disabled}
canonicalToggle={canonicalToggle}
dependencyContext={toolParams}
/>
</DependencyBlockTypeProvider>
)
}
@@ -2111,6 +2111,7 @@ export const ToolInput = memo(function ToolInput({
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
toolType={tool.type}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
@@ -0,0 +1,19 @@
'use client'
import { createContext, useContext } from 'react'
const DependencyBlockTypeContext = createContext<string | null>(null)
/**
* Provider set by tool-input param rendering (value = the tool's block type, e.g. `gmail`).
*/
export const DependencyBlockTypeProvider = DependencyBlockTypeContext.Provider
/**
* The block type whose config should drive dependency (`dependsOn`) canonical resolution
* for the current subblock. Null for normal blocks (resolve against the host block). Set
* to the tool's type for tool-input params, so a nested tool's selector resolves its
* parents against the TOOL's config (e.g. a Gmail tool's `credential` -> `oauthCredential`,
* which the host Agent block's subblocks don't define) and can fetch its options.
*/
export const useDependencyBlockType = () => useContext(DependencyBlockTypeContext)
@@ -15,6 +15,7 @@ import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useDependencyBlockType } from './use-dependency-block-type'
/**
* Centralized dependsOn gating for sub-block components.
@@ -33,7 +34,13 @@ export function useDependsOnGate(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const dependencyBlockType = useDependencyBlockType()
const blockConfig = dependencyBlockType
? getBlock(dependencyBlockType)
: blockState?.type
? getBlock(blockState.type)
: null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
@@ -19,6 +19,8 @@ import {
Mail,
Pencil,
Plus,
Rocket,
Shuffle,
SquareArrowUpRight,
Trash,
Unlock,
@@ -68,6 +70,10 @@ interface ContextMenuProps {
onUploadLogo?: () => void
showUploadLogo?: boolean
disableUploadLogo?: boolean
onFork?: () => void
onSync?: () => void
showFork?: boolean
showSync?: boolean
}
/**
@@ -118,6 +124,10 @@ export function ContextMenu({
onUploadLogo,
showUploadLogo = false,
disableUploadLogo = false,
onFork,
onSync,
showFork = false,
showSync = false,
}: ContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasStatusSection =
@@ -131,6 +141,7 @@ export function ContextMenu({
(showLock && onToggleLock) ||
(showUploadLogo && onUploadLogo)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
const hasForkSection = (showFork && onFork) || (showSync && onSync)
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -294,6 +305,35 @@ export function ContextMenu({
)}
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
hasForkSection && <DropdownMenuSeparator />}
{showFork && onFork && (
<DropdownMenuItem
onSelect={() => {
onFork()
onClose()
}}
>
<Shuffle />
Manage Forks
</DropdownMenuItem>
)}
{showSync && onSync && (
<DropdownMenuItem
onSelect={() => {
onSync()
onClose()
}}
>
<Rocket />
Sync workspace
</DropdownMenuItem>
)}
{(hasNavigationSection ||
hasStatusSection ||
hasEditSection ||
hasCopySection ||
hasForkSection) &&
(showLeave || showDelete) && <DropdownMenuSeparator />}
{showLeave && onLeave && (
<DropdownMenuItem
@@ -0,0 +1,330 @@
'use client'
import { Fragment, useState } from 'react'
import { formatDateTime } from '@sim/utils/formatting'
import { ChevronDown, Loader } from '@/components/emcn'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
import { cn } from '@/lib/core/utils/cn'
import { useWorkspaceBackgroundWork } from '@/hooks/queries/background-work'
const HEADER_TEXT = 'font-medium text-[var(--text-tertiary)] text-caption'
const ROW_TEXT = 'font-medium text-[var(--text-primary)] text-caption'
/** Fixed column widths shared by the header and rows so they stay aligned. */
const COL = {
BY: 'w-[132px]',
CHEVRON: 'w-[14px]',
} as const
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
function countList(pairs: Array<[number | undefined, string]>): string {
return pairs
.filter(([n]) => (n ?? 0) > 0)
.map(([n, verb]) => `${n} ${verb}`)
.join(' · ')
}
/** A named, collapsible group (one resource kind or change action) of a job's report. */
interface ReportGroup {
label: string
names: string[]
}
/** A job's expanded report: collapsible named groups plus plain notes (counts / warnings). */
interface JobReport {
groups: ReportGroup[]
notes: Array<{ value: string; warning?: boolean }>
}
/** The audit-row title, derived per kind from the job's metadata. */
function jobTitle(job: BackgroundWorkItem): string {
const m = job.metadata
switch (job.kind) {
case 'fork_content_copy':
return m?.childWorkspaceName
? `Forked into "${m.childWorkspaceName}"`
: (job.message ?? 'Fork')
case 'fork_sync':
if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
return m.direction === 'pull'
? `Pulled from "${m.otherWorkspaceName}"`
: `Pushed to "${m.otherWorkspaceName}"`
case 'fork_rollback':
return m?.otherWorkspaceName
? `Undid sync from "${m.otherWorkspaceName}"`
: (job.message ?? 'Rollback')
default:
return job.message ?? 'Activity'
}
}
/** Build a job's report (collapsible named groups + plain notes) from its metadata. */
function jobReport(job: BackgroundWorkItem): JobReport {
const m = job.metadata
const groups: ReportGroup[] = []
const notes: JobReport['notes'] = []
if (!m) return { groups, notes }
const addGroup = (label: string, names: string[] | undefined) => {
if (names && names.length > 0) groups.push({ label, names })
}
if (job.kind === 'fork_sync') {
addGroup('Updated', m.updatedNames)
addGroup('Created', m.createdNames)
addGroup('Archived', m.archivedNames)
// Pre-names entries fall back to the count summary (redeployed mirrors updated).
if (groups.length === 0) {
const counts = countList([
[m.updated, 'updated'],
[m.created, 'created'],
[m.archived, 'archived'],
])
if (counts) notes.push({ value: counts })
}
if (m.needsConfiguration && m.needsConfiguration.length > 0) {
for (const item of m.needsConfiguration) {
notes.push({
value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
warning: true,
})
}
}
if (m.clearedOptional && m.clearedOptional.length > 0) {
for (const item of m.clearedOptional) {
notes.push({
value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
})
}
}
if (m.deployFailed && m.deployFailed > 0) {
notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
}
return { groups, notes }
}
if (job.kind === 'fork_rollback') {
const counts = countList([
[m.restored, 'restored'],
[m.unarchived, 'unarchived'],
[m.removed, 'removed'],
[m.skipped, 'skipped'],
])
if (counts) notes.push({ value: counts })
return { groups, notes }
}
// fork_content_copy: a named breakdown of everything copied, by kind.
addGroup('Workflows', m.workflowNames)
addGroup('Knowledge bases', m.knowledgeBaseNames)
addGroup('Tables', m.tableNames)
addGroup('Files', m.fileNames)
addGroup('Custom tools', m.customToolNames)
addGroup('Skills', m.skillNames)
addGroup('MCP servers', m.mcpServerNames)
// Pre-names entries fall back to the per-kind counts.
if (groups.length === 0) {
const counts = [
[m.workflowsCopied, 'workflow'],
[m.knowledgeBases, 'knowledge base'],
[m.tables, 'table'],
[m.files, 'file'],
]
.filter(([n]) => ((n as number | undefined) ?? 0) > 0)
.map(([n, noun]) => plural(n as number, noun as string))
.join(' · ')
if (counts) notes.push({ value: counts })
}
if (m.failed && m.failed > 0) {
notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
}
return { groups, notes }
}
/** Status indicator: the platform loader while active, a colored dot once terminal. */
function JobStatusIndicator({ status }: { status: BackgroundWorkItem['status'] }) {
if (status === 'pending' || status === 'processing') {
return <Loader animate className='size-[12px] shrink-0 text-[var(--text-tertiary)]' />
}
const color =
status === 'failed'
? 'bg-[var(--text-error)]'
: status === 'completed_with_warnings'
? 'bg-[var(--badge-amber-text)]'
: 'bg-[var(--indicator-active)]'
const label =
status === 'failed'
? 'Failed'
: status === 'completed_with_warnings'
? 'Completed with warnings'
: 'Done'
return <span className={cn('size-[6px] shrink-0 rounded-full', color)} title={label} />
}
/** A collapsed report group ("Label N ⌄") that expands to its scrollable name list. */
function ReportGroupRow({ group }: { group: ReportGroup }) {
const [open, setOpen] = useState(false)
return (
<div className='flex flex-col gap-1'>
<button
type='button'
onClick={() => setOpen((value) => !value)}
className='flex w-full items-center gap-2 text-left text-[var(--text-secondary)] text-caption transition-colors hover:text-[var(--text-primary)]'
>
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
<span className='shrink-0 text-[var(--text-muted)]'>{group.names.length}</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
open && 'rotate-180'
)}
/>
</button>
{open ? (
<div className='max-h-44 overflow-y-auto pb-1'>
{group.names.map((name) => (
<div
key={name}
className='truncate text-[var(--text-tertiary)] text-caption leading-6'
title={name}
>
{name}
</div>
))}
</div>
) : null}
</div>
)
}
/** One audit-log row: status + title + actor; expands to the timestamp + report. */
function ForkJobRow({ job }: { job: BackgroundWorkItem }) {
const [expanded, setExpanded] = useState(false)
const report = jobReport(job)
const title = jobTitle(job)
return (
<Fragment>
<div
role='button'
tabIndex={0}
className='flex h-[36px] cursor-pointer items-center px-4 transition-colors duration-100 hover-hover:bg-[var(--surface-6)] dark:hover-hover:bg-[var(--border)]'
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setExpanded((value) => !value)
}
}}
>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<JobStatusIndicator status={job.status} />
<span className={cn('min-w-0 truncate', ROW_TEXT)} title={title}>
{title}
</span>
</div>
<span
className={cn(COL.BY, 'shrink-0 truncate text-[var(--text-tertiary)]', ROW_TEXT)}
title={job.metadata?.actorName ?? undefined}
>
{job.metadata?.actorName || '—'}
</span>
<ChevronDown
className={cn(
'h-[6px] shrink-0 text-[var(--text-icon)] transition-transform',
COL.CHEVRON,
expanded && 'rotate-180'
)}
/>
</div>
{expanded ? (
<div className='flex flex-col gap-1.5 px-4 pb-3 pl-[42px]'>
<span className='text-[var(--text-muted)] text-caption'>
{formatDateTime(new Date(job.startedAt))}
</span>
{report.groups.map((group) => (
<ReportGroupRow key={group.label} group={group} />
))}
{report.notes.map((note) => (
<span
key={note.value}
className={cn(
'text-caption',
note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-tertiary)]'
)}
>
{note.value}
</span>
))}
{job.error ? (
<span className='text-[var(--text-error)] text-caption'>{job.error}</span>
) : null}
</div>
) : null}
</Fragment>
)
}
/** Audit-log table of fork jobs, mirroring the deployment-versions table chrome. */
function ForkJobsTable({ jobs }: { jobs: BackgroundWorkItem[] }) {
return (
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-4'>
<span className={cn('flex-1', HEADER_TEXT)}>Activity</span>
<span className={cn(COL.BY, 'shrink-0', HEADER_TEXT)}>By</span>
<span className={cn(COL.CHEVRON, 'shrink-0')} />
</div>
<div className='bg-[var(--surface-2)]'>
{jobs.map((job) => (
<ForkJobRow key={job.id} job={job} />
))}
</div>
</div>
)
}
interface ForkActivityPanelProps {
/** The triggering operation is currently running (mutation in flight). */
pending?: boolean
pendingLabel?: string
/** Poll the durable fork-job audit trail for this workspace. */
backgroundWorkspaceId?: string
}
/**
* The "Activity" tab for Manage Forks: a durable audit log of every fork, sync, and
* rollback as its own row (status, title, actor), each expanding to the timestamp and a
* collapsible per-kind breakdown of what changed. A loader shows while the current
* action runs.
*/
export function ForkActivityPanel({
pending = false,
pendingLabel = 'Working…',
backgroundWorkspaceId,
}: ForkActivityPanelProps) {
const { data: jobs = [] } = useWorkspaceBackgroundWork(backgroundWorkspaceId)
if (!pending && jobs.length === 0) {
return (
<div className='px-2 py-8 text-center text-[var(--text-muted)] text-small'>
Nothing here yet. Forks, syncs, and rollbacks will appear here.
</div>
)
}
return (
<div className='flex flex-col gap-5 px-2'>
{pending ? (
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Loader animate className='size-[14px] shrink-0 text-[var(--text-tertiary)]' />
<span>{pendingLabel}</span>
</div>
) : null}
{jobs.length > 0 ? <ForkJobsTable jobs={jobs} /> : null}
</div>
)
}
@@ -0,0 +1,443 @@
'use client'
import { useEffect, useId, useMemo, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Checkbox,
ChevronDown,
Chip,
ChipConfirmModal,
ChipCopyInput,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalFooter,
type ChipModalFooterSlotAction,
ChipModalHeader,
ChipModalTabs,
Tooltip,
toast,
} from '@/components/emcn'
import type {
ForkCopyableResource,
GetForkResourcesResponse,
} from '@/lib/api/contracts/workspace-fork'
import { cn } from '@/lib/core/utils/cn'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { ForkActivityPanel } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel'
import {
type ForkDirection,
useForkResources,
useForkWorkspace,
useRollbackFork,
} from '@/hooks/queries/workspace-fork'
interface ForkWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
sourceWorkspaceId: string
sourceWorkspaceName: string
/** The last sync into this workspace that can be undone (drives the rollback action). */
undoableRun: { otherWorkspaceId: string; otherName: string; direction: ForkDirection } | null
/** Whether the user is under their workspace cap; creating a fork is gated on this. */
canFork: boolean
/** Sends the user to upgrade (billing) when they try to fork at the cap. */
onUpgrade: () => void
}
type ResourceKey = Exclude<keyof GetForkResourcesResponse, 'deployedWorkflowCount'>
type ResourceSelection = Record<ResourceKey, Set<string>>
const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
{ key: 'files', label: 'Files' },
{ key: 'tables', label: 'Tables' },
{ key: 'knowledgeBases', label: 'Knowledge bases' },
{ key: 'customTools', label: 'Custom tools' },
{ key: 'skills', label: 'Skills' },
{ key: 'mcpServers', label: 'MCP servers' },
]
/** Show the inline search once a kind has more entries than fit comfortably. */
const SEARCH_THRESHOLD = 8
const emptySelection = (): ResourceSelection => ({
files: new Set(),
tables: new Set(),
knowledgeBases: new Set(),
customTools: new Set(),
skills: new Set(),
mcpServers: new Set(),
})
/**
* One expandable resource kind in the fork picker: a tri-state "select all" header
* (count of selected / total) plus, when expanded, a searchable scrollable list of
* individual resources so the user can copy a specific subset.
*/
function ResourceKindRow({
label,
items,
selected,
onToggleAll,
onToggleItem,
disabled,
}: {
label: string
items: ForkCopyableResource[]
selected: Set<string>
onToggleAll: (selectAll: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
disabled: boolean
}) {
const [expanded, setExpanded] = useState(false)
const [query, setQuery] = useState('')
const fieldId = useId()
const total = items.length
const selectedCount = selected.size
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
const filtered = useMemo(() => {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return items
return items.filter((item) => item.label.toLowerCase().includes(trimmed))
}, [items, query])
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all ${label}`}
checked={headerState}
onCheckedChange={() => onToggleAll(headerState !== true)}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='ml-6 flex flex-col gap-1'>
{total > SEARCH_THRESHOLD ? (
<ChipInput
icon={Search}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${label.toLowerCase()}`}
disabled={disabled}
/>
) : null}
<div className='flex max-h-44 flex-col gap-0.5 overflow-y-auto'>
{filtered.map((item) => {
const isChecked = selected.has(item.id)
const itemId = `${fieldId}-${item.id}`
return (
<label
key={item.id}
htmlFor={itemId}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:text-[var(--text-primary)]'
)}
>
<Checkbox
id={itemId}
size='sm'
checked={isChecked}
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
disabled={disabled}
/>
<span className='truncate'>{item.label}</span>
</label>
)
})}
{filtered.length === 0 ? (
<p className='py-1 text-[var(--text-secondary)] text-xs'>No matches</p>
) : null}
</div>
</div>
) : null}
</div>
)
}
/**
* Names and creates a fork of the current workspace, lets the user pick which
* resources to copy (whole kinds or a specific subset), then navigates into the new
* fork. Unselected resources leave the corresponding workflow subblocks empty.
*/
export function ForkWorkspaceModal({
open,
onOpenChange,
sourceWorkspaceId,
sourceWorkspaceName,
undoableRun,
canFork,
onUpgrade,
}: ForkWorkspaceModalProps) {
const router = useRouter()
const forkWorkspace = useForkWorkspace()
const rollback = useRollbackFork()
const resources = useForkResources(sourceWorkspaceId, open)
const [name, setName] = useState('')
const [selected, setSelected] = useState<ResourceSelection>(emptySelection)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config')
const [forkedWorkspace, setForkedWorkspace] = useState<{ id: string; name: string } | null>(null)
const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
useEffect(() => {
if (open) {
setName(`${sourceWorkspaceName} (fork)`)
setSelected(emptySelection())
setError(null)
setActiveTab('config')
setForkedWorkspace(null)
setConfirmRollbackOpen(false)
}
}, [open, sourceWorkspaceName])
const isForking = forkWorkspace.isPending
const availableKinds = useMemo(
() => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
[resources.data]
)
// A fork always produces a usable workspace: deployed workflows are copied, and
// when the source has none, create-fork seeds a blank starter workflow (plus any
// selected resources). So forking is never blocked - we just set expectations when
// there are no deployed workflows to carry over.
const noDeployedWorkflows =
Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
const handleSubmit = () => {
// At a workspace cap, creating a fork is the only gated action - send the user to
// upgrade rather than blocking the whole modal (rollback / Activity stay reachable).
if (!canFork) {
onUpgrade()
return
}
const trimmed = name.trim()
if (!trimmed || isForking) return
setError(null)
const copy = resources.data
? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]))
: undefined
forkWorkspace.mutate(
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
{
onSuccess: (result) => {
toast.success(`Forked into "${result.workspace.name}"`)
setForkedWorkspace({ id: result.workspace.id, name: result.workspace.name })
setActiveTab('activity')
},
onError: (err) => setError(err.message || 'Failed to fork workspace'),
}
)
}
const openFork = () => {
if (!forkedWorkspace) return
onOpenChange(false)
router.push(`/workspace/${forkedWorkspace.id}/w`)
}
// Rollback undoes the last sync INTO this workspace, restoring each affected workflow
// to its prior deployed version. Lives in the Activity tab's footer.
const runRollback = async () => {
if (!undoableRun) return
try {
await rollback.mutateAsync({
workspaceId: sourceWorkspaceId,
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
})
toast.success(`Undid sync from "${undoableRun.otherName}"`)
setConfirmRollbackOpen(false)
setActiveTab('activity')
} catch (err) {
toast.error(getErrorMessage(err, 'Undo failed'))
}
}
const rollbackDisabled = rollback.isPending || !undoableRun
const rollbackTooltip = undoableRun
? `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`
: 'No sync to roll back yet.'
const rollbackChip = (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className={rollbackDisabled ? 'inline-flex cursor-not-allowed' : 'inline-flex'}>
<Chip
variant='destructive'
flush
onClick={() => setConfirmRollbackOpen(true)}
disabled={rollbackDisabled}
className={rollbackDisabled ? 'pointer-events-none' : undefined}
>
Rollback
</Chip>
</span>
</Tooltip.Trigger>
<Tooltip.Content>{rollbackTooltip}</Tooltip.Content>
</Tooltip.Root>
)
const rollbackAction: ChipModalFooterSlotAction[] =
activeTab === 'activity' && undoableRun ? [{ custom: rollbackChip }] : []
return (
<>
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Manage Forks'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Manage Forks</ChipModalHeader>
<ChipModalBody>
<ChipModalTabs
tabs={[
{ value: 'config', label: 'Fork' },
{ value: 'activity', label: 'Activity' },
]}
value={activeTab}
onChange={(value) => setActiveTab(value as 'config' | 'activity')}
className='mx-2'
/>
{activeTab === 'activity' ? (
<ForkActivityPanel
pending={isForking || rollback.isPending}
pendingLabel={rollback.isPending ? 'Undoing last sync…' : 'Creating fork…'}
backgroundWorkspaceId={sourceWorkspaceId}
/>
) : (
<>
<div className='flex flex-col gap-7 px-2'>
<SettingsSection label='Forking from'>
<ChipCopyInput value={sourceWorkspaceName} aria-label='Forking from' />
</SettingsSection>
<SettingsSection
label='Name'
headerAccessory={
<span className='text-[var(--text-error)]' title='Required'>
*
</span>
}
>
<ChipInput
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSubmit()
}
}}
placeholder='Workspace name'
maxLength={100}
autoComplete='off'
disabled={isForking}
aria-label='Workspace name'
/>
</SettingsSection>
{availableKinds.length > 0 ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{availableKinds.map((kind) => (
<ResourceKindRow
key={kind.key}
label={kind.label}
items={resources.data?.[kind.key] ?? []}
selected={selected[kind.key]}
onToggleAll={(selectAll) =>
setSelected((prev) => ({
...prev,
[kind.key]: selectAll
? new Set((resources.data?.[kind.key] ?? []).map((item) => item.id))
: new Set<string>(),
}))
}
onToggleItem={(id, checked) =>
setSelected((prev) => {
const next = new Set(prev[kind.key])
if (checked) next.add(id)
else next.delete(id)
return { ...prev, [kind.key]: next }
})
}
disabled={isForking}
/>
))}
<p className='text-[var(--text-muted)] text-caption'>
Unselected resources leave their workflow fields empty in the fork.
</p>
</div>
</SettingsSection>
) : null}
{noDeployedWorkflows ? (
<p className='text-[var(--text-muted)] text-caption'>
No deployed workflows to copy — your fork will start with a blank workflow.
</p>
) : null}
</div>
<ChipModalError>{error ?? undefined}</ChipModalError>
</>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isForking}
secondaryActions={rollbackAction.length > 0 ? rollbackAction : undefined}
primaryAction={
activeTab === 'activity'
? forkedWorkspace
? { label: 'Open fork', onClick: openFork }
: { label: 'Done', onClick: () => onOpenChange(false) }
: {
label: isForking ? 'Forking...' : 'Fork',
onClick: handleSubmit,
// At the cap the button stays clickable (no name needed) so it can route to upgrade.
disabled: isForking || (canFork && !name.trim()),
}
}
/>
</ChipModal>
<ChipConfirmModal
open={confirmRollbackOpen}
onOpenChange={setConfirmRollbackOpen}
srTitle='Undo last sync'
title='Undo last sync'
text={[
'This restores each affected workflow to its ',
{ text: 'prior deployed version', bold: true },
' and removes workflows the sync created. Continue?',
]}
confirm={{
label: 'Rollback',
onClick: () => void runRollback(),
pending: rollback.isPending,
pendingLabel: 'Rolling back...',
}}
/>
</>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { ChipCombobox, type ComboboxOption, Loader } from '@/components/emcn'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface DependentFieldSelectorProps {
selectorKey: SelectorKey
/** Full selector context, including the newly-chosen parent value. */
context: Record<string, string>
/** False until the parent (credential/KB) target is chosen. */
enabled: boolean
value: string
onChange: (value: string) => void
title: string
}
/**
* A controlled, standalone selector for the sync modal's pre-sync reconfigure: fetches
* options via the shared selector data layer (the same `useSelectorOptions` registry the
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
* {@link ConnectorSelectorField}.
*/
export function DependentFieldSelector({
selectorKey,
context,
enabled,
value,
onChange,
title,
}: DependentFieldSelectorProps) {
const selectorContext = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
Object.assign(ctx, context)
return ctx
}, [context])
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
context: selectorContext,
enabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((option) => ({ label: option.label, value: option.id })),
[options]
)
if (isLoading && enabled) {
return (
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
<Loader className='size-3.5' animate />
Loading…
</div>
)
}
return (
<ChipCombobox
className='w-full'
options={comboboxOptions}
value={value || undefined}
onChange={(next) => onChange(next)}
searchable
searchPlaceholder={`Search ${title.toLowerCase()}...`}
placeholder={`Select ${title.toLowerCase()}`}
disabled={!enabled}
emptyMessage={`No ${title.toLowerCase()} found`}
/>
)
}
@@ -0,0 +1,268 @@
'use client'
import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
import { cn } from '@/lib/core/utils/cn'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector'
import {
dependentKey,
effectiveDependentValue,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
import type { SelectorKey } from '@/hooks/selectors/types'
/** Stable empty array so a workflow with no dependents reuses one reference (no per-map alloc). */
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
interface ReconfigBlock {
targetBlockId: string
blockName: string
fields: ForkDependentReconfig[]
}
/** Group a workflow's dependent fields by their block, sorted by block name. */
function groupByBlock(fields: ForkDependentReconfig[]): ReconfigBlock[] {
const byBlock = new Map<string, ReconfigBlock>()
for (const field of fields) {
let block = byBlock.get(field.targetBlockId)
if (!block) {
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
byBlock.set(field.targetBlockId, block)
}
block.fields.push(field)
}
return Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
}
interface ResourceReconfigureProps {
/** Every workflow this resource is used in (from the diff's `resourceUsages`). */
workflows: ForkResourceUsage['workflows']
/** This resource's dependent fields across all its workflows (from `dependentReconfigs`). */
dependents: ForkDependentReconfig[]
/** The chosen target id (credential/KB/table) the selectors query against. */
parentTargetValue: string
/** True when the target was changed in-session: start blank (the old value won't resolve). */
parentChanged: boolean
/**
* The target workspace the dependent selectors query against (direction-aware: the parent on
* push, the child on pull). Workspace-scoped selectors like `table.columns` and sim workflow
* pickers gate on it - the canvas supplies it from the active workspace, so the modal must too.
*/
workspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/**
* Always-on per-resource reconfigure listing: every workflow the resource is used in, each a
* chevron row that expands to its blocks + dependent selectors so the user can (re)configure
* them at any time - not only right after a target swap. A workflow with nothing configurable
* (a secret/file, or a credential with no dependent selector here) renders greyed and
* non-expandable with a tooltip, so the usage is still visible.
*/
export function ResourceReconfigure({
workflows,
dependents,
parentTargetValue,
parentChanged,
workspaceId,
reconfig,
setReconfig,
}: ResourceReconfigureProps) {
// Group each workflow's dependents into blocks once per (workflows, dependents) change, so
// the grouping doesn't re-run on every parent re-render (setTargets / setReconfig fire often
// during the editing step). Bucket dependents by target workflow in a single pass first, so
// the per-workflow lookup is O(1) instead of a fresh `.filter` per workflow (O(W x D)).
const workflowBlocks = useMemo(() => {
const dependentsByWorkflow = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependents) {
const list = dependentsByWorkflow.get(dependent.targetWorkflowId)
if (list) list.push(dependent)
else dependentsByWorkflow.set(dependent.targetWorkflowId, [dependent])
}
return workflows.map((workflow) => ({
workflowId: workflow.workflowId,
workflowName: workflow.workflowName,
blocks: groupByBlock(dependentsByWorkflow.get(workflow.workflowId) ?? EMPTY_DEPENDENTS),
}))
}, [workflows, dependents])
if (workflows.length === 0) return null
return (
<div className='mt-4'>
<SettingsSection label='Workflows'>
<div className='flex flex-col gap-1.5'>
{workflowBlocks.map((workflow) => (
<ReconfigWorkflowRow
key={workflow.workflowId}
workflowName={workflow.workflowName}
blocks={workflow.blocks}
parentTargetValue={parentTargetValue}
parentChanged={parentChanged}
workspaceId={workspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
))}
</div>
</SettingsSection>
</div>
)
}
interface ReconfigWorkflowRowProps {
workflowName: string
blocks: ReconfigBlock[]
parentTargetValue: string
parentChanged: boolean
workspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */
function ReconfigWorkflowRow({
workflowName,
blocks,
parentTargetValue,
parentChanged,
workspaceId,
reconfig,
setReconfig,
}: ReconfigWorkflowRowProps) {
// Auto-open a row that has a required dependent so it's visible without hunting through
// chevrons (a required field is what gates Sync). Deterministic from the block config at mount
// (lazy initializer, no effect/flicker); the user can still collapse it, and it won't reopen on
// re-render - only if the row remounts (its workflow changes).
const [open, setOpen] = useState(() =>
blocks.some((block) => block.fields.some((field) => field.required))
)
const configurable = blocks.length > 0
return (
<div className={cn('flex flex-col gap-1', configurable && open && 'pb-2')}>
{/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed,
non-expandable row uses a native title tooltip to explain why. */}
<button
type='button'
disabled={!configurable}
onClick={() => setOpen((value) => !value)}
title={configurable ? undefined : 'Used here, but nothing to configure for this resource'}
className={cn(
'flex w-full items-center gap-2 text-left text-sm transition-colors',
configurable
? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
: 'cursor-default text-[var(--text-muted)]'
)}
>
<span className='min-w-0 flex-1 truncate'>{workflowName}</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
configurable ? open && 'rotate-180' : 'opacity-40'
)}
/>
</button>
{configurable && open
? blocks.map((block) => (
<BlockReconfig
key={block.targetBlockId}
block={block}
parentTargetValue={parentTargetValue}
parentChanged={parentChanged}
workspaceId={workspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
))
: null}
</div>
)
}
interface BlockReconfigProps {
block: ReconfigBlock
parentTargetValue: string
parentChanged: boolean
workspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/** One block card: its dependent selectors, chained so a parent feeds its in-block children. */
function BlockReconfig({
block,
parentTargetValue,
parentChanged,
workspaceId,
reconfig,
setReconfig,
}: BlockReconfigProps) {
// A field's effective value: the user's re-pick, else the stored value (stable parent) - but
// blank after a parent change, since the old value no longer resolves. Shared with the modal.
const effectiveValue = (field: ForkDependentReconfig) =>
effectiveDependentValue(field, reconfig, parentChanged)
// Chain re-picks: a field that provides a SelectorContext key feeds its effective value to
// its in-block descendants (a spreadsheet drives the sheet selector). Track only WHICH keys
// an in-block field provides (a Set) - the readiness check below tests membership, never a value.
const providedValues: Record<string, string> = {}
const providedContextKeys = new Set<string>()
for (const field of block.fields) {
if (field.providesContextKey) {
providedContextKeys.add(field.providesContextKey)
const value = effectiveValue(field)
if (value) providedValues[field.providesContextKey] = value
}
}
return (
<div className='ml-2 flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] p-3'>
<span className='font-medium text-[var(--text-secondary)] text-small'>{block.blockName}</span>
{block.fields.map((field) => {
// Disabled until the parent target is set AND every in-block parent it depends on has
// a value, so a child never queries a stale upstream value.
const ready = field.consumesContextKeys.every(
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
)
return (
<div key={dependentKey(field)} className='flex flex-col gap-1'>
<span className='text-[var(--text-tertiary)] text-caption'>
{field.title}
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
</span>
<DependentFieldSelector
selectorKey={field.selectorKey as SelectorKey}
context={{
...field.context,
...providedValues,
// Target workspace, for workspace-scoped selectors like table.columns.
workspaceId,
[field.parentContextKey]: parentTargetValue,
}}
enabled={parentTargetValue !== '' && ready}
value={effectiveValue(field)}
onChange={(value) =>
setReconfig((prev) => {
const nextState = { ...prev, [dependentKey(field)]: value }
// A changed parent invalidates its children's stale re-picks.
const providedKey = field.providesContextKey
if (providedKey) {
for (const sibling of block.fields) {
if (sibling.consumesContextKeys.includes(providedKey)) {
delete nextState[dependentKey(sibling)]
}
}
}
return nextState
})
}
title={field.title}
/>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,22 @@
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
export function dependentKey(dependent: ForkDependentReconfig): string {
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
}
/**
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
* stored value was for the previous parent and won't resolve against the new one. Shared by the
* modal (gate + payload) and the per-block selector so the rule can't drift between them.
*/
export function effectiveDependentValue(
field: ForkDependentReconfig,
reconfig: Record<string, string>,
parentChanged: boolean
): string {
const repicked = reconfig[dependentKey(field)]
if (repicked !== undefined) return repicked
return parentChanged ? '' : field.currentValue
}
@@ -0,0 +1,652 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight } from 'lucide-react'
import {
Badge,
ChipCombobox,
ChipConfirmModal,
ChipDropdown,
ChipModal,
ChipModalBody,
ChipModalFooter,
type ChipModalFooterSlotAction,
ChipModalHeader,
toast,
} from '@/components/emcn'
import type {
ForkDependentReconfig,
ForkLineageNodeApi,
ForkMappingEntry,
ForkResourceUsage,
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure'
import {
dependentKey,
effectiveDependentValue,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
import {
type ForkDirection,
useForkDiff,
useForkMapping,
usePromoteFork,
useUpdateForkMapping,
} from '@/hooks/queries/workspace-fork'
interface PromoteWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
parent: ForkLineageNodeApi | null
}
const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}`
/**
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
* the dependents). Pure over (entry, in-session targets) so both the inline render and the
* Sync gate / override collection share one predicate instead of drifting copies.
*/
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
if (next === '') return false
return entry.suggested || next !== (entry.targetId ?? '')
}
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
/**
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
* letting ResourceReconfigure's grouping memo skip recompute across the editing step's frequent
* re-renders.
*/
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
/**
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
* on the parent (target) side, so a parent target can back only one source - a second source
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
* share one fork target, which resolves correctly), so pull passes the empty map and never
* disables. Excludes `exclude` so a source never disables its own current selection.
*/
function takenTargetOwners(
items: ForkMappingEntry[],
targets: Record<string, string>,
exclude: ForkMappingEntry
): Map<string, string> {
const owners = new Map<string, string>()
for (const item of items) {
if (entryKey(item) === entryKey(exclude)) continue
const target = targets[entryKey(item)] ?? item.targetId ?? ''
if (target !== '') owners.set(target, item.sourceLabel)
}
return owners
}
/** Section label + display order per mapping kind (one mapping step per kind). */
const MAPPING_SECTION: Record<ForkMappingEntry['kind'], { label: string; order: number }> = {
credential: { label: 'Credentials', order: 0 },
'env-var': { label: 'Secrets', order: 1 },
table: { label: 'Tables', order: 2 },
'knowledge-base': { label: 'Knowledge bases', order: 3 },
'knowledge-document': { label: 'Knowledge documents', order: 4 },
file: { label: 'Files', order: 5 },
'mcp-server': { label: 'MCP servers', order: 6 },
'custom-tool': { label: 'Custom tools', order: 7 },
skill: { label: 'Skills', order: 8 },
}
interface EdgeOption {
value: string
label: string
otherWorkspaceId: string
direction: ForkDirection
}
/**
* Fork sync surface. Along the parent edge it force pushes/pulls: the overview
* picks a direction and lists each resource kind's mapping status, then Sync.
* "Edit mappings" steps through every kind (Back/Next, each source a
* settings-style section + full-width target) to set or review targets before
* landing back on Sync - which always confirms the overwrite first. The durable record of
* every sync is the Activity log in Manage Forks, so this modal just closes on
* success.
*/
export function PromoteWorkspaceModal({
open,
onOpenChange,
workspaceId,
parent,
}: PromoteWorkspaceModalProps) {
// Sync is only ever performed along the parent edge (from a fork toward its
// parent). Child edges are intentionally not exposed here - a parent manages its
// forks (read-only list) rather than pushing/pulling into them.
const edgeOptions = useMemo<EdgeOption[]>(() => {
if (!parent) return []
return [
{
value: `push:${parent.id}`,
label: `Push to ${parent.name}`,
otherWorkspaceId: parent.id,
direction: 'push',
},
{
value: `pull:${parent.id}`,
label: `Pull from ${parent.name}`,
otherWorkspaceId: parent.id,
direction: 'pull',
},
]
}, [parent])
const [selectedKey, setSelectedKey] = useState('')
// User's IN-SESSION mapping overrides only - NOT the source of truth. The
// displayed/persisted target falls back to each entry's stored `targetId`
// (see `targetFor`), so a reopened edge shows its remembered mappings even
// though React Query's structural sharing keeps `entries` referentially stable
// (a target-seeding effect gated on `entries` would never re-run there).
const [targets, setTargets] = useState<Record<string, string>>({})
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
// `dependentKey`. Folded into the full effective set sent on sync, which promote persists as
// the stored mapping - so the selection survives every future sync without re-picking.
const [reconfig, setReconfig] = useState<Record<string, string>>({})
// Wizard step: 0 is the overview; 1..N edit one resource kind each, entered via
// "Edit mappings". Backing out of step 1 returns to the overview.
const [step, setStep] = useState(0)
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (open) {
setSelectedKey(edgeOptions[0]?.value ?? '')
}
}, [open, edgeOptions])
// Restart at the overview and drop in-session overrides whenever it (re)opens or
// the direction changes - the mapping set, and therefore the steps, depend on the
// direction.
useEffect(() => {
setStep(0)
setTargets({})
setReconfig({})
}, [open, selectedKey])
const selected = edgeOptions.find((option) => option.value === selectedKey)
const otherWorkspaceId = selected?.otherWorkspaceId
const direction = selected?.direction ?? 'push'
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled: open })
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled: open })
const updateMapping = useUpdateForkMapping()
const promote = usePromoteFork()
const entries = useMemo(() => mapping.data?.entries ?? [], [mapping.data])
const dependentReconfigs = useMemo(
() => diff.data?.dependentReconfigs ?? [],
[diff.data?.dependentReconfigs]
)
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
// Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat
// ResourceReconfigure's grouping memo.
const dependentsByParent = useMemo(() => {
const map = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependentReconfigs) {
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
const list = map.get(key)
if (list) list.push(dependent)
else map.set(key, [dependent])
}
return map
}, [dependentReconfigs])
// Effective target for an entry: the user's in-session override if present,
// else the persisted mapping from the server. Read directly from `entries` so
// a reopened edge reflects stored mappings without a seeding effect.
const targetFor = (entry: ForkMappingEntry) => targets[entryKey(entry)] ?? entry.targetId ?? ''
const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '')
// Every workflow a mapping entry's resource is used in, for the always-on reconfigure
// listing rendered beneath that mapping (so the credential/KB stays in context).
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
resourceUsages.find(
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
)?.workflows ?? EMPTY_USAGES
// This entry's dependent fields (its credential/KB's selectors), from the memoized grouping.
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
// Group mappings by resource type - one step per kind, required types first.
const groupedEntries = useMemo(() => {
const groups = new Map<ForkMappingEntry['kind'], ForkMappingEntry[]>()
for (const entry of entries) {
const list = groups.get(entry.kind)
if (list) list.push(entry)
else groups.set(entry.kind, [entry])
}
return Array.from(groups, ([kind, items]) => ({
kind,
label: MAPPING_SECTION[kind].label,
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
}, [entries])
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
// and several times per field across the Sync gate, the value helper, and the payload build.
const entriesByParent = useMemo(() => {
const map = new Map<string, ForkMappingEntry>()
for (const entry of entries) map.set(entryKey(entry), entry)
return map
}, [entries])
// The mapping entry a dependent field hangs off (its credential/KB), for change + target lookups.
const entryForDependent = (field: ForkDependentReconfig) =>
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
// The value sent + displayed for a dependent (delegates to the shared rule): the user's
// re-pick, else the stored value - blank when this field's parent target changed in-session.
// Callers that already resolved the parent pass it in to skip a second lookup.
const dependentValueFor = (
field: ForkDependentReconfig,
parent = entryForDependent(field)
): string =>
effectiveDependentValue(
field,
reconfig,
parent ? shouldReconfigureEntry(parent, targets) : false
)
// Every required dependent whose parent IS mapped must have a value before sync. A dependent
// whose parent target is still empty can't be picked yet (its selector is disabled) and is
// gated by `requiredComplete` on the parent instead, so it's skipped here.
const reconfigComplete = dependentReconfigs.every((field) => {
if (!field.required) return true
const parent = entryForDependent(field)
if (!parent || targetFor(parent) === '') return true
return dependentValueFor(field, parent) !== ''
})
// Per-kind status for the overview listing: "Fully mapped" or "n/total mapped",
// flagged when a REQUIRED target is still missing (which blocks Sync). Reads the
// effective (override-or-persisted) target so it reflects both remembered mappings
// and in-session edits.
const kindSummaries = groupedEntries.map((group) => {
const total = group.items.length
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
const requiredPending = group.items.some((entry) => entry.required && targetFor(entry) === '')
return { kind: group.kind, label: group.label, total, mapped, requiredPending }
})
// Step 0 is the overview; each subsequent step edits one resource kind, entered via
// "Edit mappings". Reconfigure cards render inline under the changed mapping (not as
// their own steps) so the credential/KB context stays visible. `safeStep` guards
// against a group count that shrank on refetch.
const stepCount = 1 + groupedEntries.length
const safeStep = Math.min(step, Math.max(0, stepCount - 1))
const isLastStep = safeStep >= stepCount - 1
const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null
// Gate Sync on the diff being loaded too, not just the mapping: until `diff.data` arrives
// `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true and `runPromote` would
// omit `dependentValues` - i.e. Sync before the diff loads would bypass dependent gating.
const syncDisabled =
submitting ||
!otherWorkspaceId ||
!requiredComplete ||
!reconfigComplete ||
mapping.isLoading ||
!diff.data
const headsUp =
(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ||
(diff.data?.inlineSecretSources.length ?? 0) > 0
const runPromote = async () => {
if (!otherWorkspaceId) return
setSubmitting(true)
try {
await updateMapping.mutateAsync({
workspaceId,
body: {
otherWorkspaceId,
direction,
entries: entries.map((entry) => ({
resourceType: entry.resourceType,
sourceId: entry.sourceId,
targetId: targetFor(entry) || null,
})),
},
})
// Send the full stored mapping for every dependent whose parent is mapped (its effective
// value - re-pick, stored, or blank-after-change). Promote persists this verbatim as the
// stored mapping and applies it; fields whose parent isn't mapped yet are omitted (they
// can't be configured). This is the whole "what's in the mapping goes in" contract.
const dependentValues = dependentReconfigs.flatMap((field) => {
const parent = entryForDependent(field)
if (!parent || targetFor(parent) === '') return []
return [
{
workflowId: field.targetWorkflowId,
blockId: field.targetBlockId,
subBlockKey: field.subBlockKey,
value: dependentValueFor(field, parent),
},
]
})
const result = await promote.mutateAsync({
workspaceId,
body: {
otherWorkspaceId,
direction,
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`, which
// means "every dependent went away" and must reconcile/clear the live replace targets'
// stored rows. Collapsing `[]` into omission would make the backend PRESERVE stale rows.
// Only omit before the diff loads (set unknown), so the existing store is left untouched.
...(diff.data ? { dependentValues } : {}),
},
})
if (!result.promoteRunId) {
if (result.unmappedRequired.length > 0) {
toast.error('Map all required credentials and secrets first')
return
}
toast.error('Sync did not complete')
return
}
const target = parent?.name ?? 'the workspace'
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
const needsConfig = result.needsConfiguration
const clearedOptional = result.clearedOptional
// List the affected blocks, naming the workflow for a single one and falling back to
// a count across many. Block names ("Gmail 2") are far more actionable than the
// generic field titles ("Label") behind them.
const formatWhere = (list: Array<{ workflowName: string; blocks: string[] }>) => {
const totalBlocks = list.reduce((sum, workflow) => sum + workflow.blocks.length, 0)
if (list.length === 1) return `${list[0].blocks.join(', ')} in ${list[0].workflowName}`
return `${totalBlocks} block${totalBlocks === 1 ? '' : 's'} across ${list.length} workflows`
}
const optionalBlocks = clearedOptional.reduce(
(sum, workflow) => sum + workflow.blocks.length,
0
)
// Appended to a higher-priority warning so a cleared optional filter is never hidden.
const optionalSuffix =
optionalBlocks > 0
? ` (+${optionalBlocks} block${optionalBlocks === 1 ? '' : 's'} with optional fields cleared)`
: ''
if (needsConfig.length > 0) {
toast.warning(`${label}. Re-check ${formatWhere(needsConfig)}.${optionalSuffix}`)
} else if (result.deployFailed > 0) {
const n = result.deployFailed
toast.warning(
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.${optionalSuffix}`
)
} else if (clearedOptional.length > 0) {
toast.warning(
`${label}. Optional settings cleared — re-check ${formatWhere(clearedOptional)}.`
)
} else {
toast.success(label)
}
onOpenChange(false)
} catch (error) {
toast.error(getErrorMessage(error, 'Sync failed'))
} finally {
setSubmitting(false)
}
}
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
return [...(diff.data?.workflows ?? [])].sort(
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
)
}, [diff.data?.workflows])
// Right-cluster action sitting immediately left of the primary. The overview pairs
// "Edit mappings" with Sync (entering the step walk); every editing step pairs Back
// with Next (or with Sync on the last step). Back out of step 1 lands on the
// overview, restoring the "Edit mappings · Sync" pair.
const syncPrimaryAdjacent: ChipModalFooterSlotAction | undefined =
safeStep === 0
? groupedEntries.length > 0
? { label: 'Edit mappings', onClick: () => setStep(1), disabled: submitting }
: undefined
: { label: 'Back', onClick: () => setStep(safeStep - 1), disabled: submitting }
return (
<>
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Sync workspace'>
<ChipModalHeader onClose={() => onOpenChange(false)}>
{currentGroup ? `Sync workspace: ${currentGroup.label}` : 'Sync workspace'}
</ChipModalHeader>
<ChipModalBody>
{safeStep === 0 ? (
<div className='flex flex-col gap-7 px-2'>
<SettingsSection label='Sync'>
<ChipDropdown
value={selectedKey}
onChange={setSelectedKey}
options={edgeOptions}
placeholder='Select action'
align='start'
fullWidth
/>
</SettingsSection>
{/* Always shown once the diff loads so the user sees the section even with nothing
deployed - an empty change list means the source has no deployed workflows (every
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
{diff.data ? (
<SettingsSection label='Deployed Workflows'>
{workflowChanges.length > 0 ? (
<div className='flex max-h-40 flex-col gap-1 overflow-y-auto'>
{workflowChanges.map((change, index) => {
const renamed = change.currentName !== change.otherName
return (
<div
key={`${change.action}:${change.currentName}:${index}`}
className='flex min-w-0 items-center gap-1.5'
>
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
{change.currentName}
</span>
{renamed ? (
<>
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
{change.otherName}
</span>
</>
) : null}
</div>
)
})}
</div>
) : (
<div className='text-[var(--text-muted)] text-small'>
{direction === 'push'
? `No deployed workflows. Deploy workflows to push changes to ${parent?.name ?? 'the parent'}.`
: `No deployed workflows in ${parent?.name ?? 'the parent'} to pull.`}
</div>
)}
</SettingsSection>
) : null}
{headsUp ? (
<SettingsSection label='Heads up'>
{(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ? (
<div className='text-[var(--text-muted)] text-small'>
{diff.data?.mcpReauthServerIds.length} MCP server(s) use OAuth and must be
re-authorized in the target workspace.
</div>
) : null}
{(diff.data?.inlineSecretSources.length ?? 0) > 0 ? (
<div className='mt-1 text-[var(--text-muted)] text-small'>
{diff.data?.inlineSecretSources.length} inline secret(s) can't be auto-mapped
— set them in the target workspace.
</div>
) : null}
</SettingsSection>
) : null}
{kindSummaries.length > 0 ? (
<SettingsSection label='Mappings'>
<div className='flex flex-col gap-2'>
{kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => {
const complete = mapped === total
return (
<div key={kind} className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-body)] text-small'>{label}</span>
<Badge
variant={
complete ? 'green' : requiredPending ? 'amber' : 'gray-secondary'
}
size='sm'
dot
>
{complete ? 'Fully mapped' : `${mapped}/${total} mapped`}
</Badge>
</div>
)
})}
</div>
</SettingsSection>
) : null}
</div>
) : currentGroup ? (
<div className='flex flex-col gap-7 px-2'>
{currentGroup.items.map((entry) => {
// On push, a parent target can back only one source; disable any target
// another source already took (named in the hint) so the user can't create a
// mapping that would be silently dropped on save. Pull allows sharing a target.
const takenOwners =
direction === 'push'
? takenTargetOwners(currentGroup.items, targets, entry)
: EMPTY_TARGET_OWNERS
return (
<SettingsSection
key={entryKey(entry)}
label={entry.sourceLabel}
headerAccessory={
entry.required ? (
<span className='text-[var(--text-error)]' title='Required to sync'>
*
</span>
) : undefined
}
>
<ChipCombobox
className='w-full'
options={entry.candidates.map((candidate) => {
const owner = takenOwners.get(candidate.id)
return {
label: owner
? `${candidate.label} · mapped to ${owner}`
: candidate.label,
value: candidate.id,
disabled: owner !== undefined,
}
})}
value={targetFor(entry) || undefined}
onChange={(value) => {
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
// Changing the parent invalidates any in-session re-picks of its
// dependents - they were chosen against the old account and won't resolve
// against the new one, so drop them; otherwise a stale re-pick (which
// wins over the parent-changed check) would be sent to the new account.
setReconfig((prev) => {
let changed = false
const next = { ...prev }
for (const dependent of dependentsForEntry(entry)) {
const key = dependentKey(dependent)
if (key in next) {
delete next[key]
changed = true
}
}
return changed ? next : prev
})
}}
placeholder='Select target'
/>
{entry.candidatesTruncated ? (
<div className='mt-1 text-[var(--text-muted)] text-small'>
This workspace has more options than shown here. If you don't see the right
one, narrow it down by name.
</div>
) : null}
{/* Always-on: every workflow this resource is used in, each expandable to
its blocks + dependent selectors (greyed when nothing to configure). */}
<ResourceReconfigure
workflows={usagesForEntry(entry)}
dependents={dependentsForEntry(entry)}
parentTargetValue={targetFor(entry)}
parentChanged={shouldReconfigureEntry(entry, targets)}
workspaceId={diff.data?.targetWorkspaceId ?? ''}
reconfig={reconfig}
setReconfig={setReconfig}
/>
</SettingsSection>
)
})}
</div>
) : null}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
hideCancel
primaryAdjacentAction={syncPrimaryAdjacent}
primaryAction={
safeStep >= 1 && !isLastStep
? { label: 'Next', onClick: () => setStep(safeStep + 1), disabled: submitting }
: {
label: submitting ? 'Working...' : 'Sync',
onClick: () => setConfirmSyncOpen(true),
disabled: syncDisabled,
disabledTooltip: !requiredComplete
? 'Map all required secrets first'
: !reconfigComplete
? 'Reconfigure all required fields first'
: undefined,
}
}
/>
</ChipModal>
<ChipConfirmModal
open={confirmSyncOpen}
onOpenChange={setConfirmSyncOpen}
srTitle='Sync workspace'
title='Overwrite target workspace'
text={[
'The target may have been modified since the last sync. Syncing will ',
{ text: 'overwrite any changes', bold: true },
' there. Continue?',
]}
confirm={{
label: 'Sync',
onClick: () => {
setConfirmSyncOpen(false)
void runPromote()
},
pending: submitting,
pendingLabel: 'Syncing...',
}}
/>
</>
)
}
@@ -0,0 +1,28 @@
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useWorkspaceOwnerBilling } from '@/hooks/queries/workspace'
const isBillingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isForkingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_FORKING_ENABLED'))
/**
* Client mirror of the server fork EE gate (`assertForkingEnabled`): on Sim Cloud
* the active workspace's billed account (its owner's rolled-up plan) must be
* Enterprise; on self-hosted it's the `NEXT_PUBLIC_FORKING_ENABLED` override. Used
* to hide the fork UI (and skip the lineage query) for workspaces that cannot fork.
*
* Gating on the WORKSPACE's plan (not the viewer's) is what matches the server,
* which checks the workspace org's plan: a viewer who belongs to a different
* Enterprise org no longer sees fork UI on a non-Enterprise workspace, and a
* member of an Enterprise workspace isn't denied it just because their own
* highest plan is lower. The server gate remains the security boundary.
*
* Self-hosted relies on `NEXT_PUBLIC_FORKING_ENABLED` / `NEXT_PUBLIC_BILLING_ENABLED`
* mirroring the server's `FORKING_ENABLED` / `BILLING_ENABLED`; set each pair
* together or the UI and API will disagree.
*/
export function useForkingAvailable(workspaceId?: string): boolean {
const { data } = useWorkspaceOwnerBilling(isBillingEnabledClient ? workspaceId : undefined)
if (!isBillingEnabledClient) return isForkingEnabledClient
return getSubscriptionAccessState(data).hasUsableEnterpriseAccess
}
@@ -18,12 +18,17 @@ import {
} from '@/components/emcn'
import { ManageWorkspace, PanelLeft } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
import { ForkWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import { PromoteWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal'
import { useForkingAvailable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available'
import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
import { useForkLineage } from '@/hooks/queries/workspace-fork'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -96,6 +101,8 @@ function WorkspaceHeaderImpl({
}: WorkspaceHeaderProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const [isForkModalOpen, setIsForkModalOpen] = useState(false)
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
@@ -120,6 +127,14 @@ function WorkspaceHeaderImpl({
}, [])
const { navigateToSettings } = useSettingsNavigation()
const forkingAvailable = useForkingAvailable(workspaceId)
const { canAdmin } = useUserPermissionsContext()
// Forking and sync rewrite workflow state and deployments en masse, so they are
// workspace-admin only (org owners/admins derive workspace admin server-side via
// the resolved viewer permission). Every fork route re-checks this; gating the
// entry points here just keeps the UI honest. The server remains the boundary.
const canUseForking = forkingAvailable && canAdmin
const { data: forkLineage } = useForkLineage(workspaceId, canUseForking)
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
@@ -238,6 +253,16 @@ function WorkspaceHeaderImpl({
onUploadLogo(capturedWorkspaceRef.current.id)
}
// Always open Manage Forks - rollback and the durable Activity log live here and must
// stay reachable at a workspace cap. Only creating a NEW fork is gated (in the modal).
const handleForkAction = () => {
setIsForkModalOpen(true)
}
const handleSyncAction = () => {
setIsPromoteModalOpen(true)
}
/**
* Handle leave workspace after confirmation
*/
@@ -635,6 +660,9 @@ function WorkspaceHeaderImpl({
const contextCanAdmin = capturedPermissions === 'admin'
const capturedWorkspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
const isOwner = capturedWorkspace && sessionUserId === capturedWorkspace.ownerId
// Only the active row can offer fork actions: its lineage/availability is the
// data loaded for `workspaceId`. Sync needs a parent; Manage needs children.
const showForkInContext = capturedWorkspaceRef.current?.id === workspaceId && canUseForking
return (
<ContextMenu
@@ -646,9 +674,13 @@ function WorkspaceHeaderImpl({
onDelete={handleDeleteAction}
onLeave={handleLeaveAction}
onUploadLogo={handleUploadLogoAction}
onFork={handleForkAction}
onSync={handleSyncAction}
showRename={true}
showUploadLogo={!!onUploadLogo}
showLeave={!isOwner && !!onLeaveWorkspace}
showFork={showForkInContext}
showSync={showForkInContext && Boolean(forkLineage?.parent)}
disableRename={!contextCanAdmin}
disableDelete={!contextCanAdmin || workspaces.length <= 1}
disableUploadLogo={!contextCanAdmin}
@@ -673,6 +705,23 @@ function WorkspaceHeaderImpl({
inviteDisabledReason={inviteDisabledReason}
organizationId={activeWorkspaceFull?.organizationId ?? null}
/>
<ForkWorkspaceModal
open={isForkModalOpen}
onOpenChange={setIsForkModalOpen}
sourceWorkspaceId={workspaceId}
sourceWorkspaceName={activeWorkspace?.name || 'Workspace'}
undoableRun={forkLineage?.undoableRun ?? null}
canFork={canCreateWorkspace}
onUpgrade={() => {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
}}
/>
<PromoteWorkspaceModal
open={isPromoteModalOpen}
onOpenChange={setIsPromoteModalOpen}
workspaceId={workspaceId}
parent={forkLineage?.parent ?? null}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
+25
View File
@@ -0,0 +1,25 @@
import { task } from '@trigger.dev/sdk'
import {
type ForkContentCopyPayload,
runForkContentCopy,
} from '@/lib/workspaces/fork/copy/content-copy-runner'
/**
* Trigger.dev wrapper for the post-fork heavy-content copy (table rows, KB
* documents + embeddings, file blobs). Backgrounding keeps the fork request fast
* and lets the copy survive app deploys. `maxAttempts: 1` — the copy is
* non-transactional best-effort (per-row inserts with fresh ids), so a blind
* re-run would duplicate rows; a partial failure simply leaves the fork's content
* incomplete (the workflows themselves committed synchronously).
*/
export const forkContentCopyTask = task({
id: 'fork-content-copy',
retry: { maxAttempts: 1 },
queue: {
name: 'fork-content-copy',
concurrencyLimit: 10,
},
run: async (payload: ForkContentCopyPayload) => {
await runForkContentCopy(payload)
},
})
@@ -927,10 +927,12 @@ export type ChipModalFooterSlotAction = ChipModalFooterAction | ChipModalFooterC
export interface ChipModalFooterProps {
/**
* Dismiss handler for the always-present Cancel button. Like the header's
* close (X), Cancel is structural — it always reads "Cancel" and there is no
* prop to relabel or remove it. Its enabled state can be controlled via
* {@link ChipModalFooterProps.cancelDisabled}.
* Dismiss handler for the Cancel button. For standard form footers Cancel is
* structural — it always reads "Cancel" and cannot be relabeled. Its enabled
* state is controlled via {@link ChipModalFooterProps.cancelDisabled}. A
* multi-step/wizard footer whose own Back navigation plus the header close (X)
* already cover dismissal may suppress it via
* {@link ChipModalFooterProps.hideCancel}.
*/
onCancel: () => void
/**
@@ -940,13 +942,33 @@ export interface ChipModalFooterProps {
* @default false
*/
cancelDisabled?: boolean
/**
* Suppresses the Cancel button entirely. Reserve for multi-step/wizard footers
* where the in-footer Back navigation plus the header close (X) already provide
* dismissal, so a third dismiss affordance is redundant. Standard one-shot form
* footers keep Cancel — do not hide it merely to declutter.
* @default false
*/
hideCancel?: boolean
/** Primary action, anchored bottom-right (e.g. Save, Create, Delete). */
primaryAction: ChipModalFooterAction
/**
* An action rendered immediately to the LEFT of the {@link primaryAction},
* inside the right-anchored cluster (after the structural Cancel). Use for the
* trailing half of a paired control that reads as ONE unit with the primary —
* canonically a wizard's `Back` sitting beside `Next`, or a "skip ahead"
* shortcut beside the primary — where docking it to the far-left
* {@link secondaryActions} slot would visually divorce it from the primary it
* pairs with. Rendered as a bare {@link Chip} (same chrome as Cancel) so the
* filled primary stays the sole emphasized control; accepts a
* {@link ChipModalFooterCustomAction} for chip-chrome controls.
*/
primaryAdjacentAction?: ChipModalFooterSlotAction
/**
* Auxiliary actions docked to the far-left, opposite the Cancel/primary
* cluster, rendered in order on the cluster's `gap-2` rhythm — e.g. Delete
* in an edit flow, Back in a wizard, or chip-chrome controls (a date + time
* picker pair in a scheduling footer) via
* in an edit flow, a wizard's "skip ahead" shortcut, or chip-chrome controls
* (a date + time picker pair in a scheduling footer) via
* {@link ChipModalFooterCustomAction}. Like a `Resource` header's actions,
* each entry is a constrained {@link ChipModalFooterSlotAction} — consumers
* describe intent, never chrome.
@@ -1013,7 +1035,9 @@ function renderFooterSlotAction(action: ChipModalFooterSlotAction): React.ReactN
function ChipModalFooter({
onCancel,
cancelDisabled,
hideCancel = false,
primaryAction,
primaryAdjacentAction,
secondaryActions,
}: ChipModalFooterProps) {
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
@@ -1041,9 +1065,12 @@ function ChipModalFooter({
) : undefined
}
>
<Chip flush onClick={onCancel} disabled={cancelDisabled}>
Cancel
</Chip>
{hideCancel ? null : (
<Chip flush onClick={onCancel} disabled={cancelDisabled}>
Cancel
</Chip>
)}
{primaryAdjacentAction ? renderFooterSlotAction(primaryAdjacentAction) : null}
{showsDisabledTooltip ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
+43
View File
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type BackgroundWorkItem,
getWorkspaceBackgroundWorkContract,
} from '@/lib/api/contracts/workspace-fork'
export const backgroundWorkKeys = {
all: ['backgroundWork'] as const,
lists: () => [...backgroundWorkKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const,
}
async function fetchWorkspaceBackgroundWork(
workspaceId: string,
signal?: AbortSignal
): Promise<BackgroundWorkItem[]> {
const data = await requestJson(getWorkspaceBackgroundWorkContract, {
params: { id: workspaceId },
signal,
})
return data.items
}
const isActive = (item: BackgroundWorkItem) =>
item.status === 'pending' || item.status === 'processing'
/**
* Durable "background work in progress" status for a workspace (fork content copy +
* any deployment side-effects). Poll-first per the best-practice for long jobs: the
* status survives a reload (it's a DB row), and we only keep polling while something is
* still running, then stop. Refetch on focus catches changes after the tab was away.
*/
export function useWorkspaceBackgroundWork(workspaceId?: string) {
return useQuery({
queryKey: backgroundWorkKeys.list(workspaceId),
queryFn: ({ signal }) => fetchWorkspaceBackgroundWork(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 5_000,
refetchInterval: (query) => ((query.state.data ?? []).some(isActive) ? 5_000 : false),
refetchOnWindowFocus: true,
})
}
+180
View File
@@ -0,0 +1,180 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type ForkWorkspaceBody,
forkWorkspaceContract,
getForkDiffContract,
getForkLineageContract,
getForkMappingContract,
getForkResourcesContract,
type PromoteForkBody,
promoteForkContract,
type RollbackForkBody,
rollbackForkContract,
type UpdateForkMappingBody,
updateForkMappingContract,
} from '@/lib/api/contracts/workspace-fork'
import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces'
import { backgroundWorkKeys } from '@/hooks/queries/background-work'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { workspaceKeys } from '@/hooks/queries/workspace'
export type ForkDirection = 'push' | 'pull'
export const forkKeys = {
all: ['workspace-fork'] as const,
lineages: () => [...forkKeys.all, 'lineage'] as const,
lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const,
mappings: () => [...forkKeys.all, 'mapping'] as const,
mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
[...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
diffs: () => [...forkKeys.all, 'diff'] as const,
diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
[...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
resourcesAll: () => [...forkKeys.all, 'resources'] as const,
resources: (workspaceId?: string) => [...forkKeys.resourcesAll(), workspaceId ?? ''] as const,
}
export function useForkResources(workspaceId?: string, enabled = true) {
return useQuery({
queryKey: forkKeys.resources(workspaceId),
queryFn: ({ signal }) =>
requestJson(getForkResourcesContract, { params: { id: workspaceId as string }, signal }),
enabled: Boolean(workspaceId) && enabled,
staleTime: 30 * 1000,
})
}
export function useForkLineage(workspaceId?: string, enabled = true) {
return useQuery({
queryKey: forkKeys.lineage(workspaceId),
queryFn: ({ signal }) =>
requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }),
enabled: Boolean(workspaceId) && enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
export function useForkWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: ForkWorkspaceBody }) =>
requestJson(forkWorkspaceContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSuccess: (data) => {
// Merge the new fork into the active list cache before invalidation so the
// immediate navigation into it can't race a stale list and trip the
// not-in-workspaces redirect (mirrors useCreateWorkspace).
const newWorkspace = data.workspace
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
if (!previous) {
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null, creationPolicy: null }
}
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
return previous
}
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
})
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
},
})
}
export function useForkMapping(args: {
workspaceId?: string
otherWorkspaceId?: string
direction: ForkDirection
enabled?: boolean
}) {
return useQuery({
queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction),
queryFn: ({ signal }) =>
requestJson(getForkMappingContract, {
params: { id: args.workspaceId as string },
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
signal,
}),
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
staleTime: 15 * 1000,
placeholderData: keepPreviousData,
})
}
export function useUpdateForkMapping() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: UpdateForkMappingBody }) =>
requestJson(updateForkMappingContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
},
})
}
export function useForkDiff(args: {
workspaceId?: string
otherWorkspaceId?: string
direction: ForkDirection
enabled?: boolean
}) {
return useQuery({
queryKey: forkKeys.diff(args.workspaceId, args.otherWorkspaceId, args.direction),
queryFn: ({ signal }) =>
requestJson(getForkDiffContract, {
params: { id: args.workspaceId as string },
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
signal,
}),
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
staleTime: 10 * 1000,
placeholderData: keepPreviousData,
})
}
export function usePromoteFork() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: PromoteForkBody }) =>
requestJson(promoteForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
// A sync changes lineage (undoable run), mappings, and the diff - not the
// workspace's copyable resource inventory, so leave `resources` cached.
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
// A sync rewrites the target workflows' drafts AND redeploys them. The promote
// result doesn't expose the affected ids, so invalidate all deployment caches:
// otherwise a target workflow whose deployed state was already cached compares its
// fresh draft against the stale (pre-sync) deployed snapshot and falsely shows
// "Update" instead of "Live".
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
},
})
}
export function useRollbackFork() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: RollbackForkBody }) =>
requestJson(rollbackForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
// Rollback changes lineage, mappings, and the diff - not the copyable resource
// inventory, so leave `resources` cached (mirrors usePromoteFork).
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
// Rollback restores the target workflows' drafts + reactivates a prior deployment,
// so the cached deployed snapshots are stale - refresh them so change detection
// doesn't falsely show "Update" (mirrors usePromoteFork).
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
},
})
}
@@ -0,0 +1,62 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
forkMappableResourceTypeSchema,
updateForkMappingBodySchema,
} from '@/lib/api/contracts/workspace-fork'
describe('forkMappableResourceTypeSchema', () => {
it('rejects the system-managed workflow type', () => {
expect(forkMappableResourceTypeSchema.safeParse('workflow').success).toBe(false)
})
it('accepts user-mappable resource types', () => {
for (const type of [
'oauth_credential',
'service_account_credential',
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
'custom_tool',
'skill',
]) {
expect(forkMappableResourceTypeSchema.safeParse(type).success).toBe(true)
}
})
})
describe('updateForkMappingBodySchema', () => {
const base = { otherWorkspaceId: 'ws-1', direction: 'push' as const }
it('rejects a body that maps a workflow-type entry', () => {
const result = updateForkMappingBodySchema.safeParse({
...base,
entries: [{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' }],
})
expect(result.success).toBe(false)
})
it('accepts mappable entries, including a cleared (null target) mapping', () => {
const result = updateForkMappingBodySchema.safeParse({
...base,
entries: [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
{ resourceType: 'oauth_credential', sourceId: 'cred-1', targetId: null },
],
})
expect(result.success).toBe(true)
})
it('rejects an entry with an empty sourceId', () => {
const result = updateForkMappingBodySchema.safeParse({
...base,
entries: [{ resourceType: 'env_var', sourceId: '', targetId: 'API_KEY' }],
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,477 @@
import { z } from 'zod'
import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workspaceSchema } from '@/lib/api/contracts/workspaces'
const workspaceIdParamsSchema = z.object({ id: nonEmptyIdSchema })
export const forkRemapKindSchema = z.enum([
'credential',
'env-var',
'knowledge-base',
'knowledge-document',
'table',
'file',
'mcp-server',
'custom-tool',
'skill',
])
export const forkResourceTypeSchema = z.enum([
'workflow',
'oauth_credential',
'service_account_credential',
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
'custom_tool',
'skill',
])
/**
* Resource types a user may map via the mapping editor. Excludes `workflow`:
* workflow identity is system-managed (seeded at fork, maintained by promote,
* dissolved by rollback) and must never be written through the mapping editor, or
* a crafted entry could repoint a promote at the wrong target workflow.
*/
export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude(['workflow'])
export type ForkMappableResourceType = z.infer<typeof forkMappableResourceTypeSchema>
export const forkDirectionSchema = z.enum(['push', 'pull'])
export const forkLineageNodeSchema = z.object({
id: z.string(),
name: z.string(),
organizationId: z.string().nullable(),
})
export const getForkLineageContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/lineage',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
workspaceId: z.string(),
parent: forkLineageNodeSchema.nullable(),
/** The most recent undoable promote into this workspace, for the rollback UI. */
undoableRun: z
.object({
otherWorkspaceId: z.string(),
otherName: z.string(),
direction: forkDirectionSchema,
})
.nullable(),
}),
},
})
export type ForkLineageNodeApi = z.output<typeof forkLineageNodeSchema>
export type GetForkLineageResponse = z.output<typeof getForkLineageContract.response.schema>
const forkResourceIdList = z.array(nonEmptyIdSchema).max(2000).optional()
export const forkResourceSelectionSchema = z.object({
files: forkResourceIdList,
tables: forkResourceIdList,
knowledgeBases: forkResourceIdList,
customTools: forkResourceIdList,
skills: forkResourceIdList,
mcpServers: forkResourceIdList,
})
export const forkWorkspaceBodySchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long').optional(),
copy: forkResourceSelectionSchema.optional(),
})
export const forkWorkspaceContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork',
params: workspaceIdParamsSchema,
body: forkWorkspaceBodySchema,
response: {
mode: 'json',
schema: z.object({
// Full workspace row so the client can merge it into the workspace-list cache
// (parity with create), not just the lineage node.
workspace: workspaceSchema,
workflowsCopied: z.number().int(),
}),
},
})
export type ForkWorkspaceBody = z.input<typeof forkWorkspaceBodySchema>
export type ForkWorkspaceResponse = z.output<typeof forkWorkspaceContract.response.schema>
export const forkCopyableResourceSchema = z.object({ id: z.string(), label: z.string() })
export type ForkCopyableResource = z.output<typeof forkCopyableResourceSchema>
export const getForkResourcesContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/resources',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
files: z.array(forkCopyableResourceSchema),
tables: z.array(forkCopyableResourceSchema),
knowledgeBases: z.array(forkCopyableResourceSchema),
customTools: z.array(forkCopyableResourceSchema),
skills: z.array(forkCopyableResourceSchema),
mcpServers: z.array(forkCopyableResourceSchema),
deployedWorkflowCount: z.number().int(),
}),
},
})
export type GetForkResourcesResponse = z.output<typeof getForkResourcesContract.response.schema>
export const forkMappingCandidateSchema = z.object({
id: z.string(),
label: z.string(),
providerId: z.string().optional(),
})
export const forkMappingEntrySchema = z.object({
kind: forkRemapKindSchema,
resourceType: forkMappableResourceTypeSchema,
sourceId: z.string(),
sourceLabel: z.string(),
targetId: z.string().nullable(),
/** True when `targetId` is an unconfirmed auto-suggestion (no persisted mapping yet). */
suggested: z.boolean(),
required: z.boolean(),
candidates: z.array(forkMappingCandidateSchema),
/**
* True when the target workspace has more candidates of this kind than the picker
* loads, so the list shown is partial. The UI surfaces a "refine to find more"
* notice; the chosen target is validated by exact id on save (never capped).
*/
candidatesTruncated: z.boolean(),
})
export type ForkMappingEntry = z.output<typeof forkMappingEntrySchema>
export const getForkMappingQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema.default('push'),
})
export const getForkMappingContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/mapping',
params: workspaceIdParamsSchema,
query: getForkMappingQuerySchema,
response: {
mode: 'json',
schema: z.object({
childWorkspaceId: z.string(),
parentWorkspaceId: z.string(),
sourceWorkspaceId: z.string(),
targetWorkspaceId: z.string(),
entries: z.array(forkMappingEntrySchema),
}),
},
})
export type GetForkMappingResponse = z.output<typeof getForkMappingContract.response.schema>
export const updateForkMappingBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
entries: z
.array(
z.object({
resourceType: forkMappableResourceTypeSchema,
sourceId: z.string().min(1),
targetId: z.string().min(1).nullable(),
})
)
.max(5000),
})
export const updateForkMappingContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/fork/mapping',
params: workspaceIdParamsSchema,
body: updateForkMappingBodySchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true), updated: z.number().int() }),
},
})
export type UpdateForkMappingBody = z.input<typeof updateForkMappingBodySchema>
export const forkUnmappedReferenceSchema = z.object({
kind: forkRemapKindSchema,
sourceId: z.string(),
required: z.boolean(),
blockName: z.string().optional(),
})
export const forkWorkflowChangeSchema = z.object({
action: z.enum(['update', 'create', 'archive']),
/** Workflow name in the workspace the modal is open in. */
currentName: z.string(),
/** Workflow name in the sync-partner workspace (differs from `currentName` after a rename). */
otherName: z.string(),
})
/**
* A configured selector field (Gmail label, Slack channel, KB document, ...) that
* `dependsOn` a remappable parent resource - a credential or a knowledge base - so a
* sync clears it whenever that parent's target changes. The modal renders a controlled
* selector against the newly-chosen parent (using `selectorKey` + `context` + the new
* parent value under `parentContextKey`) so the user re-picks the value in place,
* pre-sync, instead of having it cleared and reconfigured after. `targetBlockId` is the
* deterministic fork block id, matching the engine, so the re-pick lands on the right
* block. Only fields the source actually had set are emitted (the active operation's),
* so blocks aren't padded with every operation variant.
*/
export const forkDependentReconfigSchema = z.object({
/** The remappable parent resource kind whose target swap clears this field. */
parentKind: z.enum(['credential', 'knowledge-base', 'table']),
/** Source id of that parent (matches a mapping entry's `sourceId`). */
parentSourceId: z.string(),
/** SelectorContext key the new parent value is supplied under (`oauthCredential` | `knowledgeBaseId` | `tableId`). */
parentContextKey: z.string(),
targetWorkflowId: z.string(),
targetBlockId: z.string(),
blockName: z.string(),
subBlockKey: z.string(),
selectorKey: z.string(),
title: z.string(),
/**
* The field's stored value (from the persisted mapping), so the always-on reconfigure listing
* pre-fills the selector with what the user last set. Empty string when unset; for an edge
* that predates the store the TARGET's currently-configured value is the fallback (never the
* source's, which would overwrite the target's own selection on the first sync). After a
* parent target CHANGE the modal ignores this and starts blank, since the old value no longer
* resolves against the new parent.
*/
currentValue: z.string(),
/** Whether the field is required - a required empty field blocks Sync. */
required: z.boolean(),
/**
* SelectorContext key this field's own value supplies to its in-block descendants
* (e.g. a spreadsheet feeds `spreadsheetId` to the sheet selector). Lets the modal
* chain re-picks: a re-picked parent updates its children's selector context.
*/
providesContextKey: z.string().optional(),
/**
* SelectorContext keys this field needs from in-block siblings (excluding the parent
* the modal already supplies). The modal keeps the field disabled until each sibling
* that provides one of these keys has been re-picked, so a child never queries a stale
* upstream value.
*/
consumesContextKeys: z.array(z.string()),
/** Source-derived selector context (sans the parent key the modal supplies). */
context: z.record(z.string(), z.string()),
})
export type ForkDependentReconfig = z.output<typeof forkDependentReconfigSchema>
/**
* Every workflow a mapped resource (any kind) is used in, for the always-on reconfigure
* listing under each mapping entry. Joined to the entry by `(parentKind, parentSourceId)`;
* the modal cross-references {@link forkDependentReconfigSchema} per workflow to decide which
* rows are expandable (have configurable dependents) vs. greyed (used here, nothing to tune).
*/
export const forkResourceUsageSchema = z.object({
parentKind: forkRemapKindSchema,
parentSourceId: z.string(),
workflows: z.array(
z.object({
/** Deterministic fork (target) workflow id - matches a dependent's `targetWorkflowId`. */
workflowId: z.string(),
workflowName: z.string(),
})
),
})
export type ForkResourceUsage = z.output<typeof forkResourceUsageSchema>
export const getForkDiffQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
})
export const getForkDiffContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/diff',
params: workspaceIdParamsSchema,
query: getForkDiffQuerySchema,
response: {
mode: 'json',
schema: z.object({
sourceWorkspaceId: z.string(),
targetWorkspaceId: z.string(),
willUpdate: z.number().int(),
willCreate: z.number().int(),
willArchive: z.number().int(),
/** Per-workflow change list for the sync preview. */
workflows: z.array(forkWorkflowChangeSchema),
unmappedRequired: z.array(forkUnmappedReferenceSchema),
unmappedOptional: z.array(forkUnmappedReferenceSchema),
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: z.array(z.string()),
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
inlineSecretSources: z.array(z.string()),
/** Configured selector fields per parent (credential/KB), for the pre-sync reconfigure. */
dependentReconfigs: z.array(forkDependentReconfigSchema),
/** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */
resourceUsages: z.array(forkResourceUsageSchema),
}),
},
})
export type GetForkDiffResponse = z.output<typeof getForkDiffContract.response.schema>
export type ForkWorkflowChange = z.output<typeof forkWorkflowChangeSchema>
/**
* A workflow whose required dependent fields a sync cleared because their parent
* (e.g. a credential) was changed - the target must re-pick them. The workflow's
* draft holds the synced state but it was NOT redeployed (the prior version keeps
* running), so nothing runs broken until the user reconfigures.
*/
export const forkNeedsConfigurationSchema = z.object({
workflowName: z.string(),
/** Names of the blocks in the workflow that need a re-check (deduplicated). */
blocks: z.array(z.string()).min(1),
})
export type ForkNeedsConfiguration = z.output<typeof forkNeedsConfigurationSchema>
/**
* One dependent field's value in the stored mapping. The sync modal sends the full set for
* every dependent whose parent is mapped; promote persists them to
* `workspace_fork_dependent_value` and applies them verbatim to the target blocks, so the
* user's selection survives every future sync without re-picking. `blockId` is the
* deterministic fork block id, so the value lands on the right block.
*/
export const forkDependentValueEntrySchema = z.object({
workflowId: nonEmptyIdSchema,
blockId: nonEmptyIdSchema,
subBlockKey: z.string().min(1, 'subBlockKey is required'),
value: z.string(),
})
export type ForkDependentValueEntry = z.input<typeof forkDependentValueEntrySchema>
export const promoteForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
/**
* The full stored mapping of dependent-field values; persisted to
* `workspace_fork_dependent_value` and applied to the target blocks verbatim. Omitting the
* field leaves the stored mapping untouched (the store stays the source of truth); sending
* an explicit `[]` clears it for the written replace targets.
*/
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
})
export const promoteForkContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork/promote',
params: workspaceIdParamsSchema,
body: promoteForkBodySchema,
response: {
mode: 'json',
schema: z.object({
promoteRunId: z.string(),
updated: z.number().int(),
created: z.number().int(),
archived: z.number().int(),
redeployed: z.number().int(),
deployFailed: z.number().int(),
unmappedRequired: z.array(forkUnmappedReferenceSchema),
/** Workflows whose required dependent fields the target must re-pick post-sync. */
needsConfiguration: z.array(forkNeedsConfigurationSchema),
/** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */
clearedOptional: z.array(forkNeedsConfigurationSchema),
}),
},
})
export type PromoteForkBody = z.input<typeof promoteForkBodySchema>
export type PromoteForkResponse = z.output<typeof promoteForkContract.response.schema>
/** Structured detail for a background job, surfaced in the Activity tab's expand row. */
export const backgroundWorkMetadataSchema = z
.object({
/** Display name of the user who performed the action (denormalized at write time). */
actorName: z.string().optional(),
// Fork content copy
childWorkspaceId: z.string().optional(),
childWorkspaceName: z.string().optional(),
workflowsCopied: z.number().int().optional(),
tables: z.number().int().optional(),
knowledgeBases: z.number().int().optional(),
files: z.number().int().optional(),
copied: z.number().int().optional(),
failed: z.number().int().optional(),
/** Names of the resources a fork copied, by kind, for the report breakdown. */
workflowNames: z.array(z.string()).optional(),
tableNames: z.array(z.string()).optional(),
knowledgeBaseNames: z.array(z.string()).optional(),
fileNames: z.array(z.string()).optional(),
customToolNames: z.array(z.string()).optional(),
skillNames: z.array(z.string()).optional(),
mcpServerNames: z.array(z.string()).optional(),
// Sync / rollback
otherWorkspaceName: z.string().optional(),
direction: z.enum(['push', 'pull']).optional(),
updated: z.number().int().optional(),
created: z.number().int().optional(),
archived: z.number().int().optional(),
redeployed: z.number().int().optional(),
deployFailed: z.number().int().optional(),
restored: z.number().int().optional(),
unarchived: z.number().int().optional(),
removed: z.number().int().optional(),
skipped: z.number().int().optional(),
/** Names of the workflows a sync changed, by action, for the report breakdown. */
updatedNames: z.array(z.string()).optional(),
createdNames: z.array(z.string()).optional(),
archivedNames: z.array(z.string()).optional(),
/** Workflows whose required dependent fields a sync left for the target to re-pick. */
needsConfiguration: z.array(forkNeedsConfigurationSchema).optional(),
/** Workflows whose optional dependent fields a sync cleared (FYI, non-blocking). */
clearedOptional: z.array(forkNeedsConfigurationSchema).optional(),
})
.nullable()
export const backgroundWorkItemSchema = z.object({
id: z.string(),
workspaceId: z.string(),
workflowId: z.string().nullable(),
kind: z.enum(['deployment_side_effects', 'fork_content_copy', 'fork_sync', 'fork_rollback']),
status: z.enum(['pending', 'processing', 'completed', 'completed_with_warnings', 'failed']),
message: z.string().nullable(),
error: z.string().nullable(),
metadata: backgroundWorkMetadataSchema,
startedAt: z.string(),
completedAt: z.string().nullable(),
})
export type BackgroundWorkMetadata = z.output<typeof backgroundWorkMetadataSchema>
export const getWorkspaceBackgroundWorkContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/background-work',
params: workspaceIdParamsSchema,
response: {
mode: 'json',
schema: z.object({ items: z.array(backgroundWorkItemSchema) }),
},
})
export type BackgroundWorkItem = z.output<typeof backgroundWorkItemSchema>
export type GetWorkspaceBackgroundWorkResponse = z.output<
typeof getWorkspaceBackgroundWorkContract.response.schema
>
export const rollbackForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
})
export const rollbackForkContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/fork/rollback',
params: workspaceIdParamsSchema,
body: rollbackForkBodySchema,
response: {
mode: 'json',
schema: z.object({
restored: z.number().int(),
archived: z.number().int(),
unarchived: z.number().int(),
/** Snapshot workflows that no longer exist and couldn't be reactivated. */
skipped: z.number().int(),
}),
},
})
export type RollbackForkBody = z.input<typeof rollbackForkBodySchema>
export type RollbackForkResponse = z.output<typeof rollbackForkContract.response.schema>
+3
View File
@@ -23,6 +23,9 @@ export const workspaceSchema = z.object({
inviteMembersEnabled: z.boolean().optional(),
inviteDisabledReason: z.string().nullable().optional(),
inviteUpgradeRequired: z.boolean().optional(),
// Source workspace id when this was created as a fork (null otherwise). Optional
// because not every workspace response builder includes the column.
forkedFromWorkspaceId: z.string().nullable().optional(),
})
export type Workspace = z.output<typeof workspaceSchema>
+6
View File
@@ -160,6 +160,12 @@ export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED)
*/
export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED)
/**
* Is workspace forking enabled via env var override
* This bypasses hosted (Enterprise) requirements for self-hosted deployments
*/
export const isForkingEnabled = isTruthy(env.FORKING_ENABLED)
/**
* Is E2B enabled for remote code execution
*/
+3
View File
@@ -411,6 +411,7 @@ export const env = createEnv({
AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
@@ -510,6 +511,7 @@ export const env = createEnv({
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: z.boolean().optional(), // Show the "Workflow" column type in user tables (defaults to false)
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
@@ -549,6 +551,7 @@ export const env = createEnv({
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED,
NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED,
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
+8 -182
View File
@@ -14,11 +14,17 @@ import {
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, min } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import {
remapConditionIdsInSubBlocks,
remapVariableIdsInSubBlocks,
remapWorkflowReferencesInSubBlocks,
type SubBlockRecord,
sanitizeSubBlocksForDuplicate,
} from '@/lib/workflows/persistence/remap-internal-ids'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import type { Variable } from '@/stores/variables/types'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('WorkflowDuplicateHelper')
@@ -54,43 +60,6 @@ interface DuplicateWorkflowResult {
edgesCount: number
subflowsCount: number
}
/**
* Untrusted shape of a persisted block subBlocks JSON column. We narrow `type`/`value`
* with runtime checks before mutating; the index signature exists because callers pass
* the raw record back to drizzle without knowing which subBlock keys it contains.
*/
type SubBlockRecord = Record<string, { type?: unknown; value?: unknown; [key: string]: unknown }>
/**
* Untrusted shape of a single entry inside a `variables-input` value array. The
* `variableId` slot is widened to `unknown` so we are forced to type-narrow before
* trusting it as a remap key — persisted JSON may legitimately predate the field.
*/
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
)
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function isSystemSubBlockKey(key: string, ids: Set<string> | string[]): boolean {
const idList = Array.isArray(ids) ? ids : Array.from(ids)
return idList.some((id) => key === id || key.startsWith(`${id}_`))
}
function sanitizeSubBlocksForDuplicate(subBlocks: SubBlockRecord): SubBlockRecord {
const sanitized: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (isSystemSubBlockKey(key, TRIGGER_RUNTIME_SUBBLOCK_IDS)) continue
if (isSystemSubBlockKey(key, DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS)) continue
sanitized[key] = subBlock
}
return sanitized
}
async function assertTargetFolderMutable(
tx: DbOrTx,
@@ -124,149 +93,6 @@ async function assertTargetFolderMutable(
}
}
function remapVariableAssignment(value: unknown, varIdMap: Map<string, string>): unknown {
if (Array.isArray(value)) {
return value.map((item) => remapVariableAssignment(item, varIdMap))
}
if (!isRecord(value)) {
return value
}
const assignment = value as VariableAssignment
const next: Record<string, unknown> = {}
for (const [key, nestedValue] of Object.entries(assignment)) {
next[key] = remapVariableAssignment(nestedValue, varIdMap)
}
if (typeof assignment.variableId === 'string') {
const newVarId = varIdMap.get(assignment.variableId)
if (newVarId) {
next.variableId = newVarId
} else {
logger.warn('Skipping unknown variable reference during duplication', {
variableId: assignment.variableId,
})
}
}
return next
}
function remapVariableInputValue(value: unknown, varIdMap: Map<string, string>): unknown {
if (value == null) {
return value
}
if (Array.isArray(value)) {
return remapVariableAssignment(value, varIdMap)
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return value
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
throw new Error('Variables input assignments could not be parsed for duplication')
}
if (Array.isArray(parsed)) {
return remapVariableAssignment(parsed, varIdMap)
}
throw new Error('Variables input assignments must be an array')
}
throw new Error('Variables input assignments must be an array')
}
/**
* Remaps old variable IDs to new variable IDs inside block subBlocks.
* Specifically targets `variables-input` subblocks whose value is an array
* of variable assignments containing a `variableId` field.
*/
function remapVariableIdsInSubBlocks(
subBlocks: SubBlockRecord,
varIdMap: Map<string, string>
): SubBlockRecord {
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (subBlock && typeof subBlock === 'object' && subBlock.type === 'variables-input') {
updated[key] = {
...subBlock,
value: remapVariableInputValue(subBlock.value, varIdMap),
}
} else {
updated[key] = subBlock
}
}
return updated
}
function remapWorkflowReferencesInSubBlocks(
subBlocks: SubBlockRecord,
workflowIdMap: Map<string, string> | undefined
): SubBlockRecord {
if (!workflowIdMap?.size) return subBlocks
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
subBlock.type === 'workflow-selector' &&
typeof subBlock.value === 'string'
) {
updated[key] = {
...subBlock,
value: workflowIdMap.get(subBlock.value) ?? subBlock.value,
}
continue
}
updated[key] = subBlock
}
return updated
}
/**
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
* Returns a new object without mutating the input.
*/
function remapConditionIdsInSubBlocks(
subBlocks: Record<string, any>,
oldBlockId: string,
newBlockId: string
): Record<string, any> {
const updated: Record<string, any> = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
typeof subBlock.value === 'string'
) {
try {
const parsed = JSON.parse(subBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
continue
}
} catch {
// Not valid JSON, skip
}
}
updated[key] = subBlock
}
return updated
}
/**
* Duplicate a workflow with all its blocks, edges, and subflows
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
@@ -0,0 +1,150 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
coerceObjectArray,
remapWorkflowReferencesInSubBlocks,
type SubBlockRecord,
} from '@/lib/workflows/persistence/remap-internal-ids'
describe('remapWorkflowReferencesInSubBlocks', () => {
const map = new Map([
['wf-src', 'wf-dst'],
['sub-src', 'sub-dst'],
])
it('remaps a top-level workflow-selector value', () => {
const subBlocks: SubBlockRecord = {
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.target.value).toBe('wf-dst')
})
it('remaps a nested workflow_input tool workflowId in a tool-input array', () => {
const subBlocks: SubBlockRecord = {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
{ type: 'custom-tool', customToolId: 'ct-1' },
],
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
expect(tools[0].params?.workflowId).toBe('sub-dst')
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
})
it('handles a JSON-stringified tool-input value', () => {
const subBlocks: SubBlockRecord = {
tools: {
id: 'tools',
type: 'tool-input',
value: JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-src' } }]),
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.tools.value).toBe(
JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-dst' } }])
)
})
it('leaves unknown workflow ids and non-workflow tools untouched', () => {
const subBlocks: SubBlockRecord = {
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
tools: {
id: 'tools',
type: 'tool-input',
value: [{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } }],
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.sel.value).toBe('wf-unknown')
expect(result.tools).toBe(subBlocks.tools)
})
it('returns the input unchanged when the id map is empty', () => {
const subBlocks: SubBlockRecord = {
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
}
expect(remapWorkflowReferencesInSubBlocks(subBlocks, new Map())).toBe(subBlocks)
})
it('clears an unmapped workflow-selector when clearUnmapped is set (cross-workspace)', () => {
const subBlocks: SubBlockRecord = {
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.sel.value).toBe('')
})
it('drops an unmapped workflow_input tool when clearUnmapped is set', () => {
const subBlocks: SubBlockRecord = {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } },
{ type: 'workflow_input', params: { workflowId: 'sub-src' } },
],
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
const tools = result.tools.value as Array<{ params?: { workflowId?: string } }>
expect(tools).toHaveLength(1)
expect(tools[0].params?.workflowId).toBe('sub-dst')
})
it('remaps the advanced-mode manualWorkflowId override', () => {
const subBlocks: SubBlockRecord = {
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.manualWorkflowId.value).toBe('wf-dst')
})
it('remaps a comma-separated manualWorkflowIds list', () => {
const subBlocks: SubBlockRecord = {
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst')
})
it('drops unmapped ids from a manualWorkflowIds list when clearUnmapped is set', () => {
const subBlocks: SubBlockRecord = {
manualWorkflowIds: {
id: 'manualWorkflowIds',
type: 'short-input',
value: 'wf-src,wf-unknown',
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.manualWorkflowIds.value).toBe('wf-dst')
})
it('remaps a multi-select workflowSelector array', () => {
const subBlocks: SubBlockRecord = {
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
})
})
describe('coerceObjectArray', () => {
it('returns arrays directly', () => {
expect(coerceObjectArray([{ a: 1 }])).toEqual({ array: [{ a: 1 }], wasString: false })
})
it('parses JSON-string arrays', () => {
expect(coerceObjectArray('[{"a":1}]')).toEqual({ array: [{ a: 1 }], wasString: true })
})
it('returns null for non-array values', () => {
expect(coerceObjectArray('hi')).toEqual({ array: null, wasString: false })
expect(coerceObjectArray(42)).toEqual({ array: null, wasString: false })
})
})
@@ -0,0 +1,292 @@
import { createLogger } from '@sim/logger'
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('WorkflowRemapInternalIds')
/**
* Untrusted shape of a persisted block subBlocks JSON column. Callers narrow
* `type`/`value` with runtime checks before mutating; the index signature exists
* because the raw record is handed back to drizzle without knowing which subBlock
* keys it contains.
*/
export type SubBlockRecord = Record<
string,
{ type?: unknown; value?: unknown; [key: string]: unknown }
>
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
)
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
/** Coerce a subblock value that holds a JSON array (stored as an array or a JSON string). */
export function coerceObjectArray(value: unknown): { array: unknown[] | null; wasString: boolean } {
if (Array.isArray(value)) return { array: value, wasString: false }
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) return { array: parsed, wasString: true }
} catch {}
}
return { array: null, wasString: false }
}
export function isSystemSubBlockKey(key: string, ids: Set<string> | string[]): boolean {
const idList = Array.isArray(ids) ? ids : Array.from(ids)
return idList.some((id) => key === id || key.startsWith(`${id}_`))
}
/** Strip trigger-runtime and non-credential system subblocks for a fresh copy. */
export function sanitizeSubBlocksForDuplicate(subBlocks: SubBlockRecord): SubBlockRecord {
const sanitized: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (isSystemSubBlockKey(key, TRIGGER_RUNTIME_SUBBLOCK_IDS)) continue
if (isSystemSubBlockKey(key, DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS)) continue
sanitized[key] = subBlock
}
return sanitized
}
function remapVariableAssignment(value: unknown, varIdMap: Map<string, string>): unknown {
if (Array.isArray(value)) {
return value.map((item) => remapVariableAssignment(item, varIdMap))
}
if (!isRecord(value)) {
return value
}
const assignment = value as VariableAssignment
const next: Record<string, unknown> = {}
for (const [key, nestedValue] of Object.entries(assignment)) {
next[key] = remapVariableAssignment(nestedValue, varIdMap)
}
if (typeof assignment.variableId === 'string') {
const newVarId = varIdMap.get(assignment.variableId)
if (newVarId) {
next.variableId = newVarId
} else {
logger.warn('Skipping unknown variable reference during copy', {
variableId: assignment.variableId,
})
}
}
return next
}
function remapVariableInputValue(value: unknown, varIdMap: Map<string, string>): unknown {
if (value == null) {
return value
}
if (Array.isArray(value)) {
return remapVariableAssignment(value, varIdMap)
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return value
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
throw new Error('Variables input assignments could not be parsed for copy')
}
if (Array.isArray(parsed)) {
return remapVariableAssignment(parsed, varIdMap)
}
throw new Error('Variables input assignments must be an array')
}
throw new Error('Variables input assignments must be an array')
}
/**
* Remap old variable IDs to new variable IDs inside block subBlocks, targeting
* `variables-input` subblocks whose value is an array of variable assignments.
*/
export function remapVariableIdsInSubBlocks(
subBlocks: SubBlockRecord,
varIdMap: Map<string, string>
): SubBlockRecord {
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (subBlock && typeof subBlock === 'object' && subBlock.type === 'variables-input') {
updated[key] = {
...subBlock,
value: remapVariableInputValue(subBlock.value, varIdMap),
}
} else {
updated[key] = subBlock
}
}
return updated
}
/**
* Rewrite cross-workflow references through a workflow id map: single
* `workflow-selector` / `manualWorkflowId` values, multi-workflow lists
* (`workflowSelector` multi-select + comma-separated `manualWorkflowIds`, as used
* by the logs block), and `workflow_input` sub-workflow tools nested in a
* `tool-input` array (an agent calling another workflow as a tool).
*
* `clearUnmapped` controls the cross-workspace case: fork/promote pass `true` so a
* reference to a workflow that wasn't copied is cleared/dropped rather than left
* pointing at the source workspace (a silent cross-workspace execution). Same-
* workspace duplication leaves it `false` to preserve references to untouched
* sibling workflows.
*/
export function remapWorkflowReferencesInSubBlocks(
subBlocks: SubBlockRecord,
workflowIdMap: Map<string, string> | undefined,
options?: { clearUnmapped?: boolean }
): SubBlockRecord {
if (!workflowIdMap?.size) return subBlocks
const clearUnmapped = options?.clearUnmapped ?? false
const remapScalar = (value: string): string => {
const mapped = workflowIdMap.get(value)
if (mapped) return mapped
return clearUnmapped ? '' : value
}
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (subBlock && typeof subBlock === 'object') {
const baseKey = key.replace(/_\d+$/, '')
if (
(subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') &&
typeof subBlock.value === 'string' &&
subBlock.value
) {
updated[key] = { ...subBlock, value: remapScalar(subBlock.value) }
continue
}
if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') {
const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped)
if (remapped !== subBlock.value) {
updated[key] = { ...subBlock, value: remapped }
continue
}
}
if (subBlock.type === 'tool-input') {
const remapped = remapWorkflowInputTools(subBlock.value, workflowIdMap, clearUnmapped)
if (remapped !== subBlock.value) {
updated[key] = { ...subBlock, value: remapped }
continue
}
}
}
updated[key] = subBlock
}
return updated
}
/**
* Rewrite a multi-workflow value (comma-separated string or array of workflow ids)
* through a workflow id map. Unmapped ids are dropped when `clearUnmapped` is set
* (cross-workspace) and preserved otherwise. Returns the original reference when
* nothing changed.
*/
function remapWorkflowIdList(
value: unknown,
workflowIdMap: Map<string, string>,
clearUnmapped: boolean
): unknown {
const remapId = (id: string): string | null => {
const mapped = workflowIdMap.get(id)
if (mapped) return mapped
return clearUnmapped ? null : id
}
if (Array.isArray(value)) {
let changed = false
const next: unknown[] = []
for (const item of value) {
if (typeof item !== 'string' || !item) {
next.push(item)
continue
}
const mapped = remapId(item)
if (mapped === null) {
changed = true
continue
}
if (mapped !== item) changed = true
next.push(mapped)
}
return changed ? next : value
}
if (typeof value === 'string' && value) {
const next: string[] = []
for (const id of value.split(',').map((entry) => entry.trim())) {
if (!id) continue
const mapped = remapId(id)
if (mapped !== null) next.push(mapped)
}
const joined = next.join(',')
return joined === value ? value : joined
}
return value
}
/**
* Rewrite `workflow_input` tools' `params.workflowId` through a workflow id map.
* When `clearUnmapped` is set, a tool pointing at a workflow that wasn't copied is
* dropped (it can't be referenced cross-workspace).
*/
function remapWorkflowInputTools(
value: unknown,
workflowIdMap: Map<string, string>,
clearUnmapped: boolean
): unknown {
const { array, wasString } = coerceObjectArray(value)
if (!array) return value
let changed = false
const next = array.flatMap((tool) => {
if (!isRecord(tool) || tool.type !== 'workflow_input' || !isRecord(tool.params)) return [tool]
const workflowId = tool.params.workflowId
if (typeof workflowId !== 'string') return [tool]
const mapped = workflowIdMap.get(workflowId)
if (mapped) {
if (mapped === workflowId) return [tool]
changed = true
return [{ ...tool, params: { ...tool.params, workflowId: mapped } }]
}
if (clearUnmapped) {
changed = true
return []
}
return [tool]
})
if (!changed) return value
return wasString ? JSON.stringify(next) : next
}
/**
* Remap condition/router block IDs within subBlocks when a block is copied with
* a new ID. Returns a new object without mutating the input.
*/
export function remapConditionIdsInSubBlocks(
subBlocks: SubBlockRecord,
oldBlockId: string,
newBlockId: string
): SubBlockRecord {
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
typeof subBlock.value === 'string'
) {
try {
const parsed = JSON.parse(subBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
continue
}
} catch {}
}
updated[key] = subBlock
}
return updated
}
@@ -637,6 +637,23 @@ export async function deployWorkflow(params: {
}
}
// Refuse to deploy an archived (soft-deleted) workflow. Checked under the row
// lock so it's atomic with a concurrent fork rollback that archives a
// promote-created workflow: a stale promote deploy can never resurrect it into
// an archived-but-deployed (incoherent) state.
const [archivedRow] = await tx
.select({ archivedAt: workflow.archivedAt })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (archivedRow?.archivedAt != null) {
return {
success: false as const,
error: 'Cannot deploy an archived workflow',
errorCode: 'validation' as const,
}
}
currentState = params.workflowState ?? (await loadWorkflowDeploymentSnapshot(workflowId, tx))
if (!currentState) {
return {
@@ -682,7 +682,13 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record<strin
)
}
function getToolInputParamConfigs({
/**
* Resolve a stored tool's params to their subBlock configs (the same resolution
* the search index + UI use). Exported so cross-workspace remapping (fork/promote)
* can detect and rewrite the workspace-scoped resource ids nested inside block
* tools, not just the top-level selectors.
*/
export function getToolInputParamConfigs({
tool,
parentCanonicalModes,
credentialTypeById,
@@ -0,0 +1,252 @@
import { backgroundWorkStatus } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, desc, eq, inArray, isNull, lte } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('ForkBackgroundWork')
export type BackgroundWorkKind =
| 'deployment_side_effects'
| 'fork_content_copy'
| 'fork_sync'
| 'fork_rollback'
export type BackgroundWorkStatusValue =
| 'pending'
| 'processing'
| 'completed'
| 'completed_with_warnings'
| 'failed'
/** Statuses surfaced as recent jobs - active, completed, or a recent issue. */
const SURFACED_STATUSES: BackgroundWorkStatusValue[] = [
'pending',
'processing',
'completed',
'completed_with_warnings',
'failed',
]
/** Cap on recent jobs returned for a workspace's Activity tab. */
const BACKGROUND_WORK_LIST_LIMIT = 20
/**
* An active (pending/processing) row older than this is treated as abandoned: the
* worker crashed or restarted before {@link finishBackgroundWork}, and a hard crash
* (Trigger CRASHED / process death) fires no in-task hook. The outbox-processor cron
* sweeps these via {@link reapStaleBackgroundWork}, marking them `failed` so the UI
* surfaces the failure instead of spinning forever.
*/
const STALE_ACTIVE_MS = 30 * 60 * 1000
/** Terminal rows older than this are pruned by the cron so the audit trail stays bounded. */
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
export interface BackgroundWorkRow {
id: string
workspaceId: string
workflowId: string | null
kind: BackgroundWorkKind
status: BackgroundWorkStatusValue
message: string | null
error: string | null
metadata: unknown
startedAt: Date
completedAt: Date | null
updatedAt: Date
}
/**
* Begin tracking a unit of background work, returning its id. Any prior row for the
* same scope (workspace + workflow + kind) is removed first so exactly one status per
* scope is live - a fresh run supersedes a stale completed/failed one.
*/
export async function startBackgroundWork(
executor: DbOrTx,
params: {
workspaceId: string
workflowId?: string | null
kind: BackgroundWorkKind
message?: string
metadata?: unknown
/**
* Replace any prior row for the same scope (workspace + workflow + kind) so exactly
* one status per scope is live (default). Pass `false` to keep an append-only audit
* trail - e.g. fork jobs, where each fork is a distinct historical entry.
*/
supersede?: boolean
}
): Promise<string> {
const { workspaceId, workflowId = null, kind, message, metadata, supersede = true } = params
if (supersede) {
await executor
.delete(backgroundWorkStatus)
.where(
and(
eq(backgroundWorkStatus.workspaceId, workspaceId),
eq(backgroundWorkStatus.kind, kind),
workflowId == null
? isNull(backgroundWorkStatus.workflowId)
: eq(backgroundWorkStatus.workflowId, workflowId)
)
)
}
const id = generateId()
const now = new Date()
await executor.insert(backgroundWorkStatus).values({
id,
workspaceId,
workflowId,
kind,
status: 'processing',
message: message ?? null,
metadata: metadata ?? null,
startedAt: now,
updatedAt: now,
})
return id
}
/**
* Record a synchronous operation directly as a terminal audit entry (no `processing`
* phase). Append-only - used by sync/rollback, which complete in-request, so they show
* up in the same workspace audit log as fork jobs.
*/
export async function recordBackgroundWork(
executor: DbOrTx,
params: {
workspaceId: string
kind: BackgroundWorkKind
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
message?: string
error?: string
metadata?: unknown
}
): Promise<void> {
const now = new Date()
await executor.insert(backgroundWorkStatus).values({
id: generateId(),
workspaceId: params.workspaceId,
workflowId: null,
kind: params.kind,
status: params.status,
message: params.message ?? null,
error: params.error ?? null,
metadata: params.metadata ?? null,
startedAt: now,
completedAt: now,
updatedAt: now,
})
}
/** Mark a tracked unit of work terminal (completed / completed_with_warnings / failed). */
export async function finishBackgroundWork(
executor: DbOrTx,
id: string,
params: {
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
message?: string
error?: string
metadata?: unknown
}
): Promise<void> {
const now = new Date()
// Merge metadata so terminal counts (copied/failed) augment what start recorded
// (child name, per-kind plan) instead of replacing it.
let metadata: unknown
if (params.metadata !== undefined) {
const [existing] = await executor
.select({ metadata: backgroundWorkStatus.metadata })
.from(backgroundWorkStatus)
.where(eq(backgroundWorkStatus.id, id))
.limit(1)
metadata = { ...toMetadataRecord(existing?.metadata), ...toMetadataRecord(params.metadata) }
}
await executor
.update(backgroundWorkStatus)
.set({
status: params.status,
message: params.message ?? null,
error: params.error ?? null,
...(params.metadata !== undefined ? { metadata } : {}),
completedAt: now,
updatedAt: now,
})
.where(eq(backgroundWorkStatus.id, id))
}
/** Coerce an unknown jsonb metadata value to a plain record for safe merging. */
function toMetadataRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
/**
* Recent background-work jobs for a workspace - the durable audit record the Activity
* tab renders, most-recent first and capped. Fork jobs are append-only (one row per
* fork), so this is the workspace's fork history; older rows are pruned by the cron.
*/
export async function listSurfacedBackgroundWork(
executor: DbOrTx,
workspaceId: string
): Promise<BackgroundWorkRow[]> {
const rows = await executor
.select()
.from(backgroundWorkStatus)
.where(
and(
eq(backgroundWorkStatus.workspaceId, workspaceId),
inArray(backgroundWorkStatus.status, SURFACED_STATUSES)
)
)
.orderBy(desc(backgroundWorkStatus.updatedAt))
.limit(BACKGROUND_WORK_LIST_LIMIT)
return rows as BackgroundWorkRow[]
}
/**
* Fail background-work rows stuck in an active state past {@link STALE_ACTIVE_MS}: the
* worker crashed or restarted before writing a terminal status, and a hard crash has
* no in-task hook to recover from. Marks them `failed` so the UI shows the failure
* rather than an indefinitely-spinning banner. Touches ONLY `background_work_status`.
* Returns the count reaped; invoked from the outbox-processor cron.
*/
export async function reapStaleBackgroundWork(executor: DbOrTx): Promise<number> {
const now = new Date()
const cutoff = new Date(now.getTime() - STALE_ACTIVE_MS)
const reaped = await executor
.update(backgroundWorkStatus)
.set({
status: 'failed',
error: 'Background work did not finish in time (the worker may have restarted).',
completedAt: now,
updatedAt: now,
})
.where(
and(
inArray(backgroundWorkStatus.status, ['pending', 'processing']),
lte(backgroundWorkStatus.startedAt, cutoff)
)
)
.returning({ id: backgroundWorkStatus.id })
// Retention: the append-only fork audit trail would otherwise grow forever, so drop
// terminal rows past the retention window. The Activity tab caps display separately.
await executor
.delete(backgroundWorkStatus)
.where(
and(
inArray(backgroundWorkStatus.status, ['completed', 'completed_with_warnings', 'failed']),
lte(backgroundWorkStatus.updatedAt, new Date(now.getTime() - RETENTION_MS))
)
)
if (reaped.length > 0) {
logger.warn('Reaped stale background-work rows', {
count: reaped.length,
thresholdMs: STALE_ACTIVE_MS,
})
}
return reaped.length
}
@@ -0,0 +1,59 @@
import { db } from '@sim/db'
import { getErrorMessage } from '@sim/utils/errors'
import { finishBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
import { executeForkFileBlobCopies } from '@/lib/workspaces/fork/copy/copy-files'
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resources'
/**
* Serializable payload for the post-fork heavy-content copy. Runs either as a
* Trigger.dev task (`background/fork-content-copy`) or inline via `runDetached`
* when Trigger.dev is disabled - both call {@link runForkContentCopy}.
*/
export interface ForkContentCopyPayload {
contentPlan: ForkContentPlan
blobTasks: BlobCopyTask[]
/**
* `background_work_status` row to finish when the copy ends, so the source workspace's
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
* after the fork commits so it's visible immediately.
*/
statusId?: string
requestId?: string
}
/**
* Copy the heavy fork content after the fork transaction has committed: table
* rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort
* and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most
* once - never blindly retried. Per-resource failures are counted (not thrown), so
* the run finishes `completed_with_warnings` rather than failing the whole copy.
*/
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
const { contentPlan, blobTasks, statusId, requestId } = payload
try {
const resourceCounts = await copyForkResourceContent({ contentPlan, requestId })
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId)
const copied = resourceCounts.copied + fileCounts.copied
const failed = resourceCounts.failed + fileCounts.failed
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: failed > 0 ? 'completed_with_warnings' : 'completed',
message:
failed > 0
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
metadata: { copied, failed },
})
}
} catch (error) {
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'failed',
error: getErrorMessage(error, 'Background resource copy failed'),
}).catch(() => {})
}
throw error
}
}
@@ -0,0 +1,146 @@
import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
import type { StorageContext } from '@/lib/uploads/shared/types'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
const logger = createLogger('WorkspaceForkCopyFiles')
export interface BlobCopyTask {
sourceKey: string
targetKey: string
context: StorageContext
fileName: string
contentType: string
userId: string
workspaceId: string
}
export interface PlanForkFileCopiesResult {
/**
* source storage key -> child storage key. `file-upload` subblocks reference
* files by storage key (not `workspace_files.id`), so the fork remap keys on the
* storage key. File identity is not persisted in the fork resource map - files
* are a fork-copy-only resource (not remapped on promote).
*/
keyMap: Map<string, string>
/** Blob duplications to run after the fork transaction commits. */
blobTasks: BlobCopyTask[]
}
/**
* Insert child `workspace_files` metadata rows for the selected files (new id +
* new storage key) and return the source→child storage-key map plus the blob
* copies to run post-commit. The metadata row must exist before the blob upload
* (its idempotent metadata insert reuses the row), and both must run after the
* child workspace row exists (FK). Runs in the fork transaction; blob I/O is
* deferred to {@link executeForkFileBlobCopies}.
*/
export async function planForkFileCopies(params: {
tx: DbOrTx
sourceWorkspaceId: string
childWorkspaceId: string
userId: string
fileIds: string[]
now: Date
}): Promise<PlanForkFileCopiesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, fileIds, now } = params
const keyMap = new Map<string, string>()
const blobTasks: BlobCopyTask[] = []
if (fileIds.length === 0) return { keyMap, blobTasks }
// Batch the metadata read (one query for all selected files) instead of a per-file
// lookup: non-deleted, scoped to the source workspace, and restricted to durable
// `workspace` files. Only workspace files are forkable - chat/copilot/mothership
// uploads are session-scoped and their chat-bound unique index can't be duplicated -
// so any non-workspace id passed here is ignored rather than copied.
const metas = await tx
.select()
.from(workspaceFiles)
.where(
and(
inArray(workspaceFiles.id, fileIds),
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
for (const meta of metas) {
const childFileId = generateId()
// Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve
// API can infer the storage context; a bare `{id}/...` key has no context prefix.
const targetKey = generateWorkspaceFileKey(childWorkspaceId, meta.originalName)
await tx.insert(workspaceFiles).values({
...meta,
id: childFileId,
key: targetKey,
workspaceId: childWorkspaceId,
userId,
folderId: null,
deletedAt: null,
uploadedAt: now,
})
keyMap.set(meta.key, targetKey)
blobTasks.push({
sourceKey: meta.key,
targetKey,
context: meta.context as StorageContext,
fileName: meta.originalName,
contentType: meta.contentType,
userId,
workspaceId: childWorkspaceId,
})
}
return { keyMap, blobTasks }
}
/**
* Duplicate each planned file blob to its new key. `uploadFile`'s metadata insert
* is idempotent on the key (the row was already created in the transaction), so
* this only copies bytes. Best-effort: a failed blob leaves the metadata row
* pointing at a missing object, which the user can re-upload.
*/
export async function executeForkFileBlobCopies(
blobTasks: BlobCopyTask[],
requestId = 'unknown'
): Promise<{ copied: number; failed: number }> {
let copied = 0
let failed = 0
for (const task of blobTasks) {
try {
const buffer = await downloadFile({
key: task.sourceKey,
context: task.context,
maxBytes: MAX_FILE_SIZE,
})
await uploadFile({
file: buffer,
fileName: task.fileName,
contentType: task.contentType,
context: task.context,
customKey: task.targetKey,
preserveKey: true,
metadata: {
userId: task.userId,
workspaceId: task.workspaceId,
originalName: task.fileName,
},
})
copied += 1
} catch (error) {
failed += 1
logger.warn(`[${requestId}] Failed to copy file blob during fork`, {
targetKey: task.targetKey,
error: getErrorMessage(error),
})
}
}
return { copied, failed }
}
@@ -0,0 +1,435 @@
import { db } from '@sim/db'
import {
customTools,
document,
embedding,
knowledgeBase,
mcpServers,
skill,
userTableDefinitions,
userTableRows,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { generateMcpServerId } from '@/lib/mcp/utils'
import type { TableSchema } from '@/lib/table/types'
import type {
ForkMappingUpsert,
ForkResourceType,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups'
const logger = createLogger('WorkspaceForkCopyResources')
/** Page size for the post-transaction bulk content copy (keyset-paginated). */
const CONTENT_PAGE = 500
export interface CopyResourcesParams {
tx: DbOrTx
sourceWorkspaceId: string
childWorkspaceId: string
userId: string
now: Date
/** Source resource ids selected for copy, by kind. */
selection: {
customTools: string[]
skills: string[]
mcpServers: string[]
tables: string[]
knowledgeBases: string[]
}
/** source workflow id -> child workflow id, for table workflow-group remap. */
workflowIdMap: Map<string, string>
}
export interface ForkContentPlanEntry {
sourceId: string
childId: string
}
/** Bulk content to copy AFTER the fork transaction commits (best-effort, batched). */
export interface ForkContentPlan {
sourceWorkspaceId: string
childWorkspaceId: string
tables: ForkContentPlanEntry[]
knowledgeBases: ForkContentPlanEntry[]
}
/** Display names of the copied resources, by kind, for the fork activity report. */
export interface ForkCopiedResourceNames {
tables: string[]
knowledgeBases: string[]
customTools: string[]
skills: string[]
mcpServers: string[]
}
export interface CopyResourcesResult {
/** source resource id -> child resource id, keyed by fork resource type. */
idMap: Map<ForkResourceType, Map<string, string>>
/** Identity mapping rows to persist for every copied resource. */
mappingEntries: ForkMappingUpsert[]
/** Heavy row/document/embedding content to copy post-commit. */
contentPlan: ForkContentPlan
/** Names of the copied resources, by kind, for the fork report breakdown. */
names: ForkCopiedResourceNames
}
function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkResourceType) {
let map = idMap.get(type)
if (!map) {
map = new Map()
idMap.set(type, map)
}
return map
}
/**
* Copy the selected resources' **container rows** into the child workspace inside
* the fork transaction: custom tools, skills, and MCP server configs (each a
* single row), plus table definitions and knowledge-base rows (without their bulk
* rows / documents / embeddings). This keeps the fork transaction bounded to
* O(selected resources) single-row writes. The heavy content (table rows, KB
* documents + embeddings) is returned as a {@link ForkContentPlan} for
* {@link copyForkResourceContent} to copy best-effort after commit. Secrets are
* never copied: MCP OAuth tokens are omitted (re-auth required) and KB connectors
* are not copied (the child is a content snapshot without live sync).
*/
export async function copyForkResourceContainers(
params: CopyResourcesParams
): Promise<CopyResourcesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now, selection, workflowIdMap } = params
const idMap = new Map<ForkResourceType, Map<string, string>>()
const mappingEntries: ForkMappingUpsert[] = []
const contentPlan: ForkContentPlan = {
sourceWorkspaceId,
childWorkspaceId,
tables: [],
knowledgeBases: [],
}
const names: ForkCopiedResourceNames = {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
}
const record = (type: ForkResourceType, sourceId: string, childId: string) => {
setId(idMap, type).set(sourceId, childId)
mappingEntries.push({
resourceType: type,
parentResourceId: sourceId,
childResourceId: childId,
})
}
if (selection.customTools.length > 0) {
const rows = await tx
.select()
.from(customTools)
.where(
and(
inArray(customTools.id, selection.customTools),
eq(customTools.workspaceId, sourceWorkspaceId)
)
)
const inserts: (typeof customTools.$inferInsert)[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
userId,
createdAt: now,
updatedAt: now,
})
record('custom_tool', row.id, childId)
names.customTools.push(row.title)
}
if (inserts.length > 0) await tx.insert(customTools).values(inserts)
}
if (selection.skills.length > 0) {
const rows = await tx
.select()
.from(skill)
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
const inserts: (typeof skill.$inferInsert)[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
userId,
createdAt: now,
updatedAt: now,
})
record('skill', row.id, childId)
names.skills.push(row.name)
}
if (inserts.length > 0) await tx.insert(skill).values(inserts)
}
if (selection.mcpServers.length > 0) {
const rows = await tx
.select()
.from(mcpServers)
.where(
and(
inArray(mcpServers.id, selection.mcpServers),
eq(mcpServers.workspaceId, sourceWorkspaceId),
isNull(mcpServers.deletedAt)
)
)
// `generateMcpServerId` is deterministic on (workspace, url), so two selected
// servers with the same normalized URL derive the same child id. Insert once
// and map both source ids to the surviving child rather than aborting the fork.
const insertsByChildId = new Map<string, typeof mcpServers.$inferInsert>()
for (const row of rows) {
const childId = row.url ? generateMcpServerId(childWorkspaceId, row.url) : generateId()
record('mcp_server', row.id, childId)
if (insertsByChildId.has(childId)) continue
names.mcpServers.push(row.name)
insertsByChildId.set(childId, {
...row,
id: childId,
workspaceId: childWorkspaceId,
createdBy: userId,
// Secrets are never copied across workspaces: drop the registered OAuth
// client + any auth headers so the child re-authenticates from scratch.
oauthClientId: null,
oauthClientSecret: null,
headers: {},
connectionStatus: 'disconnected',
lastConnected: null,
lastError: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
}
if (insertsByChildId.size > 0) {
await tx
.insert(mcpServers)
.values([...insertsByChildId.values()])
.onConflictDoNothing()
}
}
if (selection.tables.length > 0) {
const definitions = await tx
.select()
.from(userTableDefinitions)
.where(
and(
inArray(userTableDefinitions.id, selection.tables),
eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
for (const definition of definitions) {
const childTableId = generateId()
const remappedSchema = remapForkTableWorkflowGroups(
definition.schema as TableSchema,
workflowIdMap
)
inserts.push({
...definition,
id: childTableId,
workspaceId: childWorkspaceId,
schema: remappedSchema,
createdBy: userId,
rowsVersion: 0,
// Start at 0 - the post-commit content copy raises it to the rows actually
// copied, so a failed/partial copy never advertises the source's count.
rowCount: 0,
archivedAt: null,
createdAt: now,
updatedAt: now,
})
record('table', definition.id, childTableId)
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
names.tables.push(definition.name)
}
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
}
if (selection.knowledgeBases.length > 0) {
const bases = await tx
.select()
.from(knowledgeBase)
.where(
and(
inArray(knowledgeBase.id, selection.knowledgeBases),
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
isNull(knowledgeBase.deletedAt)
)
)
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
for (const base of bases) {
const childKbId = generateId()
inserts.push({
...base,
id: childKbId,
workspaceId: childWorkspaceId,
userId,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
record('knowledge_base', base.id, childKbId)
contentPlan.knowledgeBases.push({ sourceId: base.id, childId: childKbId })
names.knowledgeBases.push(base.name)
}
if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts)
}
return { idMap, mappingEntries, contentPlan, names }
}
/**
* Copy the heavy resource content described by a {@link ForkContentPlan} AFTER the
* fork transaction has committed: table rows, and KB documents + embeddings. Reads
* and writes are keyset-paginated so peak memory is bounded to one page, and each
* resource is copied in its own short statements (never one long transaction).
* Best-effort: a failure on one resource is logged and the others continue - the
* fork itself (workflows + container rows) already succeeded.
*/
export async function copyForkResourceContent(params: {
contentPlan: ForkContentPlan
requestId?: string
}): Promise<{ copied: number; failed: number }> {
const { contentPlan, requestId = 'unknown' } = params
const { childWorkspaceId } = contentPlan
let copiedResources = 0
let failedResources = 0
for (const table of contentPlan.tables) {
try {
let copied = 0
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? eq(userTableRows.tableId, table.sourceId)
: and(eq(userTableRows.tableId, table.sourceId), gt(userTableRows.id, afterId))
const rows = await db
.select()
.from(userTableRows)
.where(where)
.orderBy(asc(userTableRows.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
await db.insert(userTableRows).values(
rows.map((row) => ({
...row,
id: generateId(),
tableId: table.childId,
workspaceId: childWorkspaceId,
}))
)
copied += rows.length
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
await db
.update(userTableDefinitions)
.set({ rowCount: copied })
.where(eq(userTableDefinitions.id, table.childId))
copiedResources += 1
} catch (error) {
failedResources += 1
logger.warn(`[${requestId}] Failed to copy table rows during fork`, {
sourceTableId: table.sourceId,
error: getErrorMessage(error),
})
}
}
for (const kb of contentPlan.knowledgeBases) {
try {
let afterDocId: string | null = null
for (;;) {
// Only copy LIVE documents - exclude soft-deleted and archived rows, matching
// how the rest of the KB system treats them as gone (chunks/tags/search filter
// both). A fork must not resurrect documents removed from the source base.
const liveDocs = and(
eq(document.knowledgeBaseId, kb.sourceId),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
const where: SQL<unknown> | undefined =
afterDocId === null ? liveDocs : and(liveDocs, gt(document.id, afterDocId))
const docs = await db
.select()
.from(document)
.where(where)
.orderBy(asc(document.id))
.limit(CONTENT_PAGE)
if (docs.length === 0) break
for (const doc of docs) {
const childDocId = generateId()
await db.insert(document).values({
...doc,
id: childDocId,
knowledgeBaseId: kb.childId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
await copyDocumentEmbeddings(doc.id, childDocId, kb.childId)
}
afterDocId = docs[docs.length - 1].id
if (docs.length < CONTENT_PAGE) break
}
copiedResources += 1
} catch (error) {
failedResources += 1
logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, {
sourceKnowledgeBaseId: kb.sourceId,
error: getErrorMessage(error),
})
}
}
return { copied: copiedResources, failed: failedResources }
}
async function copyDocumentEmbeddings(
sourceDocumentId: string,
childDocumentId: string,
childKnowledgeBaseId: string
): Promise<void> {
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? eq(embedding.documentId, sourceDocumentId)
: and(eq(embedding.documentId, sourceDocumentId), gt(embedding.id, afterId))
const rows = await db
.select()
.from(embedding)
.where(where)
.orderBy(asc(embedding.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
await db.insert(embedding).values(
rows.map((row) => ({
...row,
id: generateId(),
documentId: childDocumentId,
knowledgeBaseId: childKnowledgeBaseId,
}))
)
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
}
@@ -0,0 +1,62 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildWorkflowNameRegistry } from '@/lib/workspaces/fork/copy/copy-workflows'
describe('buildWorkflowNameRegistry', () => {
it('reports a name as taken by another workflow in the same folder', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f1', 'Onboarding', null)).toBe(true)
expect(reg.isTaken('f1', 'Onboarding', 'w2')).toBe(true)
})
it('excludes the workflow itself so a replace can keep its own name', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f1', 'Onboarding', 'w1')).toBe(false)
})
it('is folder-scoped: the same name in another folder is free', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f2', 'Onboarding', null)).toBe(false)
expect(reg.isTaken(null, 'Onboarding', null)).toBe(false)
})
it('treats the root (null) folder distinctly, matching coalesce(folderId, "")', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: null, name: 'Root WF' }])
expect(reg.isTaken(null, 'Root WF', null)).toBe(true)
expect(reg.isTaken('f1', 'Root WF', null)).toBe(false)
})
it('claims a new name so a later workflow in the same copy loop sees it taken', () => {
const reg = buildWorkflowNameRegistry([])
expect(reg.isTaken('f1', 'Report', null)).toBe(false)
reg.claim('f1', 'Report', 'wA')
expect(reg.isTaken('f1', 'Report', null)).toBe(true)
expect(reg.isTaken('f1', 'Report', 'wA')).toBe(false)
})
it('releases the prior name when a workflow is renamed (claim moves keys)', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Old' }])
reg.claim('f1', 'New', 'w1')
expect(reg.isTaken('f1', 'Old', null)).toBe(false)
expect(reg.isTaken('f1', 'New', null)).toBe(true)
})
it('re-claiming the same (folder, name) is a no-op', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Same' }])
reg.claim('f1', 'Same', 'w1')
expect(reg.isTaken('f1', 'Same', 'w1')).toBe(false)
expect(reg.isTaken('f1', 'Same', null)).toBe(true)
})
it('handles multiple holders (legacy duplicates) and partial release', () => {
const reg = buildWorkflowNameRegistry([
{ id: 'w1', folderId: 'f1', name: 'Dup' },
{ id: 'w2', folderId: 'f1', name: 'Dup' },
])
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(true)
reg.claim('f1', 'Other', 'w2')
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false)
})
})
@@ -0,0 +1,548 @@
import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import {
remapConditionIdsInSubBlocks,
remapVariableIdsInSubBlocks,
remapWorkflowReferencesInSubBlocks,
type SubBlockRecord,
sanitizeSubBlocksForDuplicate,
} from '@/lib/workflows/persistence/remap-internal-ids'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
deriveForkBlockId,
type ForkBlockIdResolver,
} from '@/lib/workspaces/fork/remap/block-identity'
import {
applyDependentOverrides,
collectClearedDependents,
type NeedsConfigurationField,
} from '@/lib/workspaces/fork/remap/remap-references'
import type {
BlockData,
BlockState,
Loop,
Parallel,
SubBlockState,
WorkflowState,
Variable as WorkflowStateVariable,
} from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkspaceForkCopyWorkflows')
type SubBlockTransform = (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord
interface ResolveForkFolderMappingParams {
tx: DbOrTx
sourceWorkspaceId: string
targetWorkspaceId: string
userId: string
now: Date
}
/**
* Mirror the source workspace's folder tree into the target workspace, creating
* folders as needed and reusing target folders that already match by name within
* the same (mapped) parent. Returns a map from source folder id to target folder
* id so copied workflows can be placed in the corresponding folder.
*/
export async function resolveForkFolderMapping({
tx,
sourceWorkspaceId,
targetWorkspaceId,
userId,
now,
}: ResolveForkFolderMappingParams): Promise<Map<string, string>> {
const map = new Map<string, string>()
const sourceFolders = await tx
.select()
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt))
)
if (sourceFolders.length === 0) return map
const targetFolders = await tx
.select()
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt))
)
const targetByKey = new Map<string, string>()
for (const folder of targetFolders) {
targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id)
}
const byId = new Map(sourceFolders.map((folder) => [folder.id, folder]))
const ordered: typeof sourceFolders = []
const seen = new Set<string>()
const visit = (folder: (typeof sourceFolders)[number]) => {
if (seen.has(folder.id)) return
const parent = folder.parentId ? byId.get(folder.parentId) : undefined
if (parent) visit(parent)
seen.add(folder.id)
ordered.push(folder)
}
for (const folder of sourceFolders) visit(folder)
const newFolders: (typeof sourceFolders)[number][] = []
for (const folder of ordered) {
const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null
const key = `${mappedParentId ?? ''}::${folder.name}`
const existing = targetByKey.get(key)
if (existing) {
map.set(folder.id, existing)
continue
}
const newFolderId = generateId()
map.set(folder.id, newFolderId)
targetByKey.set(key, newFolderId)
newFolders.push({
...folder,
id: newFolderId,
userId,
workspaceId: targetWorkspaceId,
parentId: mappedParentId,
locked: false,
createdAt: now,
updatedAt: now,
})
}
if (newFolders.length > 0) {
await tx.insert(workflowFolder).values(newFolders)
}
return map
}
// `\u0000` (a NUL byte) can never appear in a Postgres text column, so it is a
// collision-free separator between the folder id and the name.
const workflowNameKey = (folderId: string | null, name: string) => `${folderId ?? ''}\u0000${name}`
/**
* In-memory registry of a workspace's active workflow names, keyed by
* (folderId, name) - the exact columns of the `workflow_workspace_folder_name_active_unique`
* partial index. Lets fork/promote resolve name collisions across many copied workflows
* from a single load instead of one `nameTaken` query per workflow inside the (locked)
* transaction.
*
* Correctness is still guaranteed by that DB unique index; this is only a proactive
* collision avoider. A stale snapshot (e.g. a concurrent non-fork rename mid-promote) can
* therefore only cause a rare, retry-able unique violation - never a duplicate name -
* exactly as the prior per-workflow check-then-write already could.
*/
export interface WorkflowNameRegistry {
/** True when (folderId, name) is held by any active workflow other than `excludeWorkflowId`. */
isTaken(folderId: string | null, name: string, excludeWorkflowId: string | null): boolean
/** Record that `workflowId` now holds (folderId, name), releasing any name it held before. */
claim(folderId: string | null, name: string, workflowId: string): void
}
/** Build a {@link WorkflowNameRegistry} from already-loaded rows (pure - unit-testable). */
export function buildWorkflowNameRegistry(
rows: Array<{ id: string; folderId: string | null; name: string }>
): WorkflowNameRegistry {
const holdersByKey = new Map<string, Set<string>>()
const keyByWorkflow = new Map<string, string>()
for (const row of rows) {
const key = workflowNameKey(row.folderId, row.name)
const holders = holdersByKey.get(key)
if (holders) holders.add(row.id)
else holdersByKey.set(key, new Set([row.id]))
keyByWorkflow.set(row.id, key)
}
return {
isTaken(folderId, name, excludeWorkflowId) {
const holders = holdersByKey.get(workflowNameKey(folderId, name))
if (!holders) return false
for (const id of holders) if (id !== excludeWorkflowId) return true
return false
},
claim(folderId, name, workflowId) {
const newKey = workflowNameKey(folderId, name)
const prevKey = keyByWorkflow.get(workflowId)
if (prevKey === newKey) return
if (prevKey) holdersByKey.get(prevKey)?.delete(workflowId)
const holders = holdersByKey.get(newKey)
if (holders) holders.add(workflowId)
else holdersByKey.set(newKey, new Set([workflowId]))
keyByWorkflow.set(workflowId, newKey)
},
}
}
/** Load every active workflow name in a workspace into a {@link WorkflowNameRegistry}. */
export async function loadWorkflowNameRegistry(
executor: DbOrTx,
workspaceId: string
): Promise<WorkflowNameRegistry> {
const rows = await executor
.select({ id: workflow.id, folderId: workflow.folderId, name: workflow.name })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
return buildWorkflowNameRegistry(rows)
}
/**
* Batched read of the current DRAFT subBlocks for a set of (replace) target
* workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole
* promote so the locked apply phase doesn't do N per-workflow loads; called
* pre-write so it reflects the target state the user configured before this sync
* overwrites it. Promote uses it to detect required dependents the sync left empty
* (see {@link collectClearedDependents}); fork-create has no prior target and skips it.
*/
export async function loadTargetDraftSubBlocks(
executor: DbOrTx,
workflowIds: string[]
): Promise<Map<string, Map<string, SubBlockRecord>>> {
const byWorkflow = new Map<string, Map<string, SubBlockRecord>>()
if (workflowIds.length === 0) return byWorkflow
const rows = await executor
.select({
workflowId: workflowBlocks.workflowId,
blockId: workflowBlocks.id,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(inArray(workflowBlocks.workflowId, workflowIds))
for (const row of rows) {
let blocks = byWorkflow.get(row.workflowId)
if (!blocks) {
blocks = new Map<string, SubBlockRecord>()
byWorkflow.set(row.workflowId, blocks)
}
blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord)
}
return byWorkflow
}
/**
* Pick a non-colliding name for a copied workflow against the preloaded registry, which
* mirrors the workspace's (folder, name, not-archived, exclude-self) predicate from one
* query instead of one per candidate. Mirrors {@link deduplicateWorkflowName}'s ` (n)`
* numbering, but reads from memory so the copy loop issues no per-workflow name queries.
*/
function resolveTargetWorkflowName(
registry: WorkflowNameRegistry,
folderId: string | null,
name: string,
excludeWorkflowId: string | null
): string {
const taken = (candidate: string) => registry.isTaken(folderId, candidate, excludeWorkflowId)
if (!taken(name)) return name
for (let i = 2; i < 100; i++) {
const candidate = `${name} (${i})`
if (!taken(candidate)) return candidate
}
return `${name} (${generateId().slice(0, 6)})`
}
export interface CopyWorkflowResult {
targetWorkflowId: string
mode: 'create' | 'replace'
name: string
blocksCount: number
edgesCount: number
subflowsCount: number
/**
* `dependsOn` fields (top-level and nested tool-input) a remapped parent left empty
* that weren't restored from the target draft - the parent legitimately changed.
* Carries `required` per field: promote skips redeploy + gates on required ones and
* surfaces optional ones so a cleared filter never broadens behavior silently.
*/
clearedDependents: NeedsConfigurationField[]
/**
* Source block id -> assigned target block id, so the caller can persist the
* block-identity pairs (see `recordForkBlockPairs`) that keep promotes reversible.
*/
blockIdMapping: Map<string, string>
}
export interface CopyWorkflowStateParams {
tx: DbOrTx
targetWorkflowId: string
targetWorkspaceId: string
userId: string
mode: 'create' | 'replace'
now: Date
/** Source workflow's deployed state (the only thing fork/promote copies). */
sourceState: WorkflowState
/** Source workflow metadata for naming, folder placement, and sort order. */
sourceMeta: {
name: string
description: string | null
folderId: string | null
sortOrder: number
}
/** source workflow id -> target workflow id, for `workflow-selector` references */
workflowIdMap: Map<string, string>
/** source folder id -> target folder id */
folderIdMap: Map<string, string>
/** Optional resource-reference remap applied to every block's subBlocks. */
transformSubBlocks?: SubBlockTransform
/**
* The target workflow's current draft subBlocks (block id -> subBlocks), for
* `replace` mode only. When present, required dependents that the sync left empty
* (the parent change cleared and the stored mapping didn't fill) are reported in
* {@link CopyWorkflowResult.needsConfiguration}.
*/
targetCurrentBlocks?: Map<string, SubBlockRecord>
/**
* Per-block (block id -> subBlock key -> value) stored dependent values applied last,
* after the reference transform cleared the source's, so the stored mapping is the sole
* source of truth for what each dependent selector resolves to.
*/
dependentOverrides?: Map<string, Map<string, string>>
/**
* Preloaded name registry so name-collision resolution reads from memory instead of one
* query per workflow inside the tx. Build once per copy loop via {@link loadWorkflowNameRegistry}.
*/
nameRegistry: WorkflowNameRegistry
/**
* Resolve each source block to its target block id, reusing the persisted counterpart
* when one exists so a push keeps the parent's original block ids (and webhook URLs)
* instead of re-deriving them (see {@link buildForkBlockIdResolver}). Omitted on fork
* creation, where every id is derived fresh.
*/
resolveBlockId?: ForkBlockIdResolver
requestId?: string
}
/**
* Copy a source workflow's deployed `WorkflowState` into a target workflow,
* assigning deterministic block ids (so trigger webhook URLs and external block
* references stay stable across promotes) and applying the resource-reference
* transform. Writes the remapped state to the target's draft via
* `saveWorkflowToNormalizedTables`. In `create` mode a new workflow row is
* inserted (undeployed); in `replace` mode the existing target row is kept and
* its draft is overwritten. Deploying the target (and capturing the rollback
* point) is the caller's responsibility.
*/
export async function copyWorkflowStateIntoTarget(
params: CopyWorkflowStateParams
): Promise<CopyWorkflowResult> {
const {
tx,
targetWorkflowId,
targetWorkspaceId,
userId,
mode,
now,
sourceState,
sourceMeta,
workflowIdMap,
folderIdMap,
transformSubBlocks,
targetCurrentBlocks,
dependentOverrides,
nameRegistry,
resolveBlockId,
requestId = 'unknown',
} = params
const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null
const varIdMapping = new Map<string, string>()
const remappedVariables: Record<string, WorkflowStateVariable> = {}
for (const [oldVarId, variable] of Object.entries(sourceState.variables ?? {})) {
const newVarId = generateId()
varIdMapping.set(oldVarId, newVarId)
remappedVariables[newVarId] = { ...variable, id: newVarId }
}
const blockIdMapping = new Map<string, string>()
for (const oldBlockId of Object.keys(sourceState.blocks)) {
blockIdMapping.set(
oldBlockId,
resolveBlockId
? resolveBlockId(targetWorkflowId, oldBlockId)
: deriveForkBlockId(targetWorkflowId, oldBlockId)
)
}
const newBlocks: Record<string, BlockState> = {}
const clearedDependents: NeedsConfigurationField[] = []
for (const [oldBlockId, block] of Object.entries(sourceState.blocks)) {
const newBlockId = blockIdMapping.get(oldBlockId)!
let updatedData = block.data
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
const dataObj = block.data as Record<string, unknown>
if (typeof dataObj.parentId === 'string' && blockIdMapping.has(dataObj.parentId)) {
updatedData = {
...dataObj,
parentId: blockIdMapping.get(dataObj.parentId)!,
extent: 'parent',
} as BlockData
}
}
// double-cast-allowed: SubBlockState is structurally a SubBlockRecord entry but lacks the open index signature SubBlockRecord declares
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
let subBlocks: SubBlockRecord = sanitizedSource
if (transformSubBlocks) {
subBlocks = transformSubBlocks(subBlocks, block.type)
}
if (varIdMapping.size > 0) {
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
}
// Cross-workspace copy: clear references to workflows that weren't copied
// rather than leave them pointing at the source workspace.
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
clearUnmapped: true,
})
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
// Apply the stored dependent values for this block (the modal's mapping). The reference
// transform already cleared the source's dependent values when their parent was remapped,
// so the stored mapping is the SOLE source of truth - no implicit "preserve the target's
// value" path. Allowlisted (top-level + nested tool params) inside applyDependentOverrides
// so a crafted value can't touch a parent/credential field or inject a bogus subblock.
const targetCurrent = targetCurrentBlocks?.get(newBlockId)
const blockOverrides = dependentOverrides?.get(newBlockId)
if (blockOverrides && blockOverrides.size > 0) {
subBlocks = applyDependentOverrides(subBlocks, block.type, blockOverrides)
}
// Dependents the TARGET had configured that the parent change cleared and nothing
// restored: the target must re-pick required ones (promote skips this workflow's
// redeploy) and is told about optional ones. Keyed on the target draft so a field the
// source carried but the target never set isn't flagged.
if (mode === 'replace' && targetCurrent) {
clearedDependents.push(
...collectClearedDependents(block.type, newBlockId, block.name, targetCurrent, subBlocks)
)
}
newBlocks[newBlockId] = {
...block,
id: newBlockId,
// double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires
subBlocks: subBlocks as unknown as Record<string, SubBlockState>,
data: updatedData,
}
}
const newEdges = sourceState.edges.flatMap((edge) => {
const newSource = blockIdMapping.get(edge.source)
const newTarget = blockIdMapping.get(edge.target)
if (!newSource || !newTarget) {
logger.warn(`[${requestId}] Skipping edge with unmapped block reference during fork copy`, {
edgeId: edge.id,
})
return []
}
const newSourceHandle = edge.sourceHandle
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return [
{
...edge,
id: generateId(),
source: newSource,
target: newTarget,
sourceHandle: newSourceHandle,
targetHandle: edge.targetHandle,
},
]
})
const newLoops: Record<string, Loop> = {}
for (const [oldId, loop] of Object.entries(sourceState.loops ?? {})) {
const newId = blockIdMapping.get(oldId) ?? oldId
newLoops[newId] = {
...loop,
id: newId,
nodes: loop.nodes.flatMap((nodeId) => {
const mapped = blockIdMapping.get(nodeId)
return mapped ? [mapped] : []
}),
}
}
const newParallels: Record<string, Parallel> = {}
for (const [oldId, parallel] of Object.entries(sourceState.parallels ?? {})) {
const newId = blockIdMapping.get(oldId) ?? oldId
newParallels[newId] = {
...parallel,
id: newId,
nodes: parallel.nodes.flatMap((nodeId) => {
const mapped = blockIdMapping.get(nodeId)
return mapped ? [mapped] : []
}),
}
}
const resolvedName = resolveTargetWorkflowName(
nameRegistry,
targetFolderId,
sourceMeta.name,
mode === 'replace' ? targetWorkflowId : null
)
// Claim the resolved name so the next workflow in this copy loop sees it taken. The DB
// write below uses the same (folderId, name), so the registry stays consistent with it.
nameRegistry.claim(targetFolderId, resolvedName, targetWorkflowId)
if (mode === 'create') {
await tx.insert(workflow).values({
id: targetWorkflowId,
userId,
workspaceId: targetWorkspaceId,
folderId: targetFolderId,
sortOrder: sourceMeta.sortOrder,
name: resolvedName,
description: sourceMeta.description,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
locked: false,
variables: remappedVariables,
})
} else {
await tx
.update(workflow)
.set({
name: resolvedName,
description: sourceMeta.description,
folderId: targetFolderId,
variables: remappedVariables,
lastSynced: now,
updatedAt: now,
})
.where(eq(workflow.id, targetWorkflowId))
}
const remappedState: WorkflowState = {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
variables: remappedVariables,
}
const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx)
if (!saved.success) {
throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`)
}
return {
targetWorkflowId,
mode,
name: resolvedName,
blocksCount: Object.keys(newBlocks).length,
edgesCount: newEdges.length,
subflowsCount: Object.keys(newLoops).length + Object.keys(newParallels).length,
clearedDependents,
blockIdMapping,
}
}
@@ -0,0 +1,188 @@
import { db, runOutsideTransactionContext } from '@sim/db'
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkspaceForkDeployBridge')
/**
* Hard ceiling on how many deployed workflows one fork/promote loads into memory at
* once (each as a full `WorkflowState`). There is no per-workspace workflow cap in
* the product, so this is the safety valve: real workspaces hold tens to low
* hundreds, making this ~5-10x headroom that never blocks legitimate use, it sits
* below the fork feature's other item caps (resource selection 2000, mapping
* entries 5000 - both lighter-weight than full states), and it bounds a pathological
* workspace to a few hundred MB of transient state instead of an unbounded load.
*/
export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000
export interface DeployedWorkflowSummary {
id: string
name: string
description: string | null
folderId: string | null
sortOrder: number
}
/**
* Workflows in a workspace that are deployed and not archived - the only ones that
* fork/promote. Requires an actually-active deployment version, not just the
* `isDeployed` flag: a workflow flagged deployed with no active version (a "ghost"
* left by an inconsistent state) has nothing to copy, so excluding it here keeps the
* diff/plan counts aligned with what apply actually writes instead of over-reporting
* then silently skipping it. Correlated `exists` (not a join) so a workflow is never
* double-listed if more than one active version row ever exists.
*/
export async function listDeployedWorkflows(
executor: DbOrTx,
workspaceId: string
): Promise<DeployedWorkflowSummary[]> {
return executor
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder,
})
.from(workflow)
.where(
and(
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
exists(
db
.select({ one: sql`1` })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
)
)
)
}
/** The active deployment version number for a workflow, or null when it has none. */
export async function getActiveDeploymentVersionNumber(
executor: DbOrTx,
workflowId: string
): Promise<number | null> {
const [row] = await executor
.select({ version: workflowDeploymentVersion.version })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
return row?.version ?? null
}
/**
* Batched {@link getActiveDeploymentVersionNumber}: the active deployed version per
* workflow id, so promote's apply phase resolves every prior version in one query
* instead of N round-trips inside the (locked) transaction. Workflows with no active
* version are absent from the map.
*/
export async function getActiveDeploymentVersionNumbers(
executor: DbOrTx,
workflowIds: string[]
): Promise<Map<string, number>> {
if (workflowIds.length === 0) return new Map()
const rows = await executor
.select({
workflowId: workflowDeploymentVersion.workflowId,
version: workflowDeploymentVersion.version,
})
.from(workflowDeploymentVersion)
.where(
and(
inArray(workflowDeploymentVersion.workflowId, workflowIds),
eq(workflowDeploymentVersion.isActive, true)
)
)
return new Map(rows.map((row) => [row.workflowId, row.version]))
}
/**
* Read a source workspace's deployed workflows and each one's active deployed state
* on the global pool. Fork/promote callers MUST run this BEFORE opening their
* transaction: doing these heavy per-workflow reads inside the tx checks out a
* SECOND pooled connection while the tx holds the first, which can deadlock the
* pool at saturation (primary pool max is 15). The source is read-only for the
* operation, so a pre-transaction snapshot is the value that gets force-pushed.
*
* Holds every source state in memory at once (bounded by the workspace's deployed
* workflow count) - the apply step needs each state to write its target inside the
* single atomic transaction, so it cannot stream them one at a time.
*/
export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promise<{
deployedWorkflows: DeployedWorkflowSummary[]
sourceStates: Map<string, WorkflowState>
}> {
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
// Fail fast on the cheap count before loading any heavy state into memory.
if (deployedWorkflows.length > MAX_FORK_DEPLOYED_WORKFLOWS) {
throw new ForkError(
`This workspace has ${deployedWorkflows.length} deployed workflows, which exceeds the fork/sync limit of ${MAX_FORK_DEPLOYED_WORKFLOWS}.`,
400
)
}
// Read states in bounded-concurrency batches instead of one serial await per workflow:
// serial cost is O(workflows) round trips (this also runs on the diff preview, refetched
// while the sync modal is open). The cap keeps concurrent global-pool checkouts well
// under the pool max even at the workflow ceiling, and this runs BEFORE any transaction.
const sourceStates = new Map<string, WorkflowState>()
const READ_CONCURRENCY = 5
for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) {
const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY)
const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId)))
batch.forEach((wf, index) => {
const state = states[index]
if (state) sourceStates.set(wf.id, state)
})
}
return { deployedWorkflows, sourceStates }
}
/**
* Read a workflow's active deployed state as a `WorkflowState`. Returns null ONLY
* when the workflow genuinely has no active deployment (a legitimate skip); real
* DB/migration errors propagate so the caller fails loudly instead of silently
* dropping the workflow from the fork/promote. Block migrations (credential remap
* to current ids) are applied so copied references reflect current resources.
*/
export async function readDeployedState(
workflowId: string,
workspaceId: string
): Promise<WorkflowState | null> {
// This reads the (unchanged) SOURCE workspace on the global pool. Callers like
// promote run it inside their transaction, so escape the tx context: the read
// must not join the promote's transaction (and the tripwire forbids global-pool
// queries inside a tx). Outside a transaction this is a no-op.
return runOutsideTransactionContext(async () => {
const version = await getActiveDeploymentVersionNumber(db, workflowId)
if (version == null) {
logger.warn('No active deployment for workflow during fork/promote', { workflowId })
return null
}
const data = await loadDeployedWorkflowState(workflowId, workspaceId)
return {
blocks: data.blocks,
edges: data.edges,
loops: data.loops,
parallels: data.parallels,
variables: (data.variables ?? {}) as Record<string, Variable>,
}
})
}
+406
View File
@@ -0,0 +1,406 @@
import { db } from '@sim/db'
import { permissions, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import type { PermissionType } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { Workspace } from '@/lib/api/contracts/workspaces'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { runDetached } from '@/lib/core/utils/background'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
finishBackgroundWork,
startBackgroundWork,
} from '@/lib/workspaces/fork/background-work/store'
import {
type ForkContentCopyPayload,
runForkContentCopy,
} from '@/lib/workspaces/fork/copy/content-copy-runner'
import { planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files'
import {
copyForkResourceContainers,
type ForkCopiedResourceNames,
} from '@/lib/workspaces/fork/copy/copy-resources'
import {
copyWorkflowStateIntoTarget,
loadWorkflowNameRegistry,
resolveForkFolderMapping,
} from '@/lib/workspaces/fork/copy/copy-workflows'
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
import {
type ForkBlockPair,
reconcileForkBlockPairs,
toForkBlockPairs,
} from '@/lib/workspaces/fork/mapping/block-map-store'
import {
type ForkMappingUpsert,
type ForkResourceType,
seedEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
import { WORKSPACE_MODE } from '@/lib/workspaces/policy'
const logger = createLogger('WorkspaceForkCreate')
/** Source resource ids the user selected to copy into the child, by kind. */
export interface ForkResourceSelection {
files: string[]
tables: string[]
knowledgeBases: string[]
customTools: string[]
skills: string[]
mcpServers: string[]
}
const EMPTY_SELECTION: ForkResourceSelection = {
files: [],
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
}
export interface CreateForkParams {
source: WorkspaceWithOwner
policy: WorkspaceCreationPolicy
userId: string
/** Display name of the user forking, recorded on the activity entry. */
actorName?: string
name?: string
selection?: ForkResourceSelection
requestId?: string
}
export interface CreateForkResult {
/** Full child workspace row so callers can merge it into the workspace-list cache. */
workspace: Workspace
workflowsCopied: number
}
const FORK_KIND_TO_RESOURCE_TYPE: Partial<Record<ForkRemapKind, ForkResourceType>> = {
'custom-tool': 'custom_tool',
skill: 'skill',
'mcp-server': 'mcp_server',
table: 'table',
'knowledge-base': 'knowledge_base',
}
/**
* Create a fork of `source`: a new child workspace that copies the parent's
* **deployed** workflows (left undeployed in the child), snapshots the parent's
* member list, copies the user-selected resources (files, tables, knowledge bases,
* custom tools, skills, MCP server configs) with fresh ids, and records the
* source→child identity for each. Workflow references to copied resources are
* rewritten to the child ids; references to resources that were not copied (and
* all credential references) are cleared; env-var references are preserved.
*/
export async function createFork(params: CreateForkParams): Promise<CreateForkResult> {
const { source, policy, userId, requestId = 'unknown' } = params
const selection = params.selection ?? EMPTY_SELECTION
const childName = params.name?.trim() || `${source.name} (fork)`
// Read the source's deployed workflows + states BEFORE the transaction so these
// global-pool reads don't check out a second pooled connection from inside the
// fork tx (which can deadlock the pool at saturation).
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(source.id)
const forkedWorkflowNames: string[] = []
let forkedResourceNames: ForkCopiedResourceNames = {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
}
const { result, blobTasks, contentPlan } = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
const now = new Date()
const childWorkspaceId = generateId()
await tx.insert(workspace).values({
id: childWorkspaceId,
name: childName,
ownerId: userId,
organizationId: policy.organizationId,
workspaceMode: policy.workspaceMode,
billedAccountUserId: policy.billedAccountUserId,
allowPersonalApiKeys: true,
forkedFromWorkspaceId: source.id,
createdAt: now,
updatedAt: now,
})
const sourcePermissions = await tx
.select({ userId: permissions.userId, permissionType: permissions.permissionType })
.from(permissions)
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, source.id)))
const permissionByUser = new Map<string, PermissionType>()
for (const row of sourcePermissions) {
permissionByUser.set(row.userId, row.permissionType)
}
permissionByUser.set(userId, 'admin')
if (
policy.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
policy.billedAccountUserId &&
policy.billedAccountUserId !== userId
) {
permissionByUser.set(policy.billedAccountUserId, 'admin')
}
await tx.insert(permissions).values(
Array.from(permissionByUser.entries()).map(([memberUserId, permissionType]) => ({
id: generateId(),
entityType: 'workspace' as const,
entityId: childWorkspaceId,
userId: memberUserId,
permissionType,
createdAt: now,
updatedAt: now,
}))
)
const workflowIdMap = new Map<string, string>()
for (const wf of deployedWorkflows) workflowIdMap.set(wf.id, generateId())
const fileResult = await planForkFileCopies({
tx,
sourceWorkspaceId: source.id,
childWorkspaceId,
userId,
fileIds: selection.files,
now,
})
const resourceResult = await copyForkResourceContainers({
tx,
sourceWorkspaceId: source.id,
childWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
mcpServers: selection.mcpServers,
tables: selection.tables,
knowledgeBases: selection.knowledgeBases,
},
workflowIdMap,
})
forkedResourceNames = resourceResult.names
const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => {
if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null
const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind]
if (!resourceType) return null
return resourceResult.idMap.get(resourceType)?.get(sourceId) ?? null
}
const transform = createForkBootstrapTransform(resolveCopied)
const folderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId: source.id,
targetWorkspaceId: childWorkspaceId,
userId,
now,
})
// The child is brand new, so this loads an empty registry; name collisions can only
// arise among the copied workflows themselves, which the in-loop claims resolve.
const nameRegistry = await loadWorkflowNameRegistry(tx, childWorkspaceId)
let workflowsCopied = 0
// Seed the block-identity map (parent block -> derived child block) so a later push of
// this fork resolves each child block back to the parent's ORIGINAL id instead of
// re-deriving and re-keying the parent's webhook URLs.
const blockPairs: ForkBlockPair[] = []
const sourceWorkflowIds: string[] = []
for (const wf of deployedWorkflows) {
const sourceState = sourceStates.get(wf.id)
if (!sourceState) continue
const targetWorkflowId = workflowIdMap.get(wf.id)!
const copyResult = await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId,
targetWorkspaceId: childWorkspaceId,
userId,
mode: 'create',
now,
sourceState,
sourceMeta: {
name: wf.name,
description: wf.description,
folderId: wf.folderId,
sortOrder: wf.sortOrder,
},
workflowIdMap,
folderIdMap,
transformSubBlocks: transform,
nameRegistry,
requestId,
})
// Creation copies parent -> child, so the source side is the parent.
blockPairs.push(...toForkBlockPairs(copyResult.blockIdMapping, true, wf.id, targetWorkflowId))
sourceWorkflowIds.push(wf.id)
workflowsCopied += 1
forkedWorkflowNames.push(wf.name)
}
await reconcileForkBlockPairs(tx, childWorkspaceId, true, sourceWorkflowIds, blockPairs)
// A fork carries only DEPLOYED workflows. When the source has none (e.g. it was
// itself just forked and never redeployed), seed a default workflow so the child
// is a usable workspace rather than a blank one with no workflow at all - the same
// starter "New workspace" creates. Any copied resources still land alongside it.
if (workflowsCopied === 0) {
const defaultWorkflowId = generateId()
await tx.insert(workflow).values({
id: defaultWorkflowId,
userId,
workspaceId: childWorkspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(defaultWorkflowId, workflowState, tx)
}
const seedEntries: ForkMappingUpsert[] = []
for (const [sourceWorkflowId, childWorkflowId] of workflowIdMap.entries()) {
seedEntries.push({
resourceType: 'workflow',
parentResourceId: sourceWorkflowId,
childResourceId: childWorkflowId,
})
}
seedEntries.push(...resourceResult.mappingEntries)
await seedEdgeMappings(tx, childWorkspaceId, userId, seedEntries)
logger.info(`[${requestId}] Created fork ${childWorkspaceId} from ${source.id}`, {
workflowsCopied,
mappingsSeeded: seedEntries.length,
})
return {
result: {
workspace: {
id: childWorkspaceId,
name: childName,
ownerId: userId,
organizationId: policy.organizationId,
workspaceMode: policy.workspaceMode,
billedAccountUserId: policy.billedAccountUserId,
allowPersonalApiKeys: true,
forkedFromWorkspaceId: source.id,
},
workflowsCopied,
},
blobTasks: fileResult.blobTasks,
contentPlan: resourceResult.contentPlan,
}
})
// Bulk content (table rows, KB documents + embeddings) and file blobs are copied
// AFTER the fork commits, in the background, so the fork request returns as soon
// as the workflows exist and is never blocked on (or timed out by) heavy I/O.
// Trigger.dev runs it out-of-process (surviving deploys); without it, runDetached
// runs it inline best-effort. Both are batched/bounded internally.
const hasContent =
contentPlan.tables.length > 0 || contentPlan.knowledgeBases.length > 0 || blobTasks.length > 0
// Record a durable job for EVERY fork (the fork already committed), scoped to the
// SOURCE workspace - that's where the fork was initiated and where its Activity tab
// lives, so the record survives a reload of the fork modal. When there is heavy
// content to copy in the background the row stays `processing` until the runner
// finishes it (merging in copied/failed); otherwise the fork is already complete.
const forkedName = result.workspace.name
// The fork already committed; failing to record the tracking row must not turn it into
// a 500. Log and continue without a status row - the background content copy below still
// runs (its runner no-ops the status update when statusId is absent).
let statusId: string | undefined
try {
statusId = await startBackgroundWork(db, {
workspaceId: source.id,
kind: 'fork_content_copy',
// Append-only: each fork is a distinct entry in the source workspace's fork history.
supersede: false,
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
metadata: {
childWorkspaceId: result.workspace.id,
childWorkspaceName: forkedName,
actorName: params.actorName,
workflowsCopied: result.workflowsCopied,
tables: contentPlan.tables.length,
knowledgeBases: contentPlan.knowledgeBases.length,
files: blobTasks.length,
workflowNames: forkedWorkflowNames,
tableNames: forkedResourceNames.tables,
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
fileNames: blobTasks.map((task) => task.fileName),
customToolNames: forkedResourceNames.customTools,
skillNames: forkedResourceNames.skills,
mcpServerNames: forkedResourceNames.mcpServers,
},
})
} catch (error) {
logger.error(`[${requestId}] Failed to record fork background-work status`, {
childWorkspaceId: result.workspace.id,
error: getErrorMessage(error),
})
}
if (!hasContent) {
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'completed',
message: `Forked into "${forkedName}"`,
metadata: { copied: 0, failed: 0 },
}).catch(() => {})
}
return result
}
const payload: ForkContentCopyPayload = { contentPlan, blobTasks, statusId, requestId }
try {
if (isTriggerDevEnabled) {
const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/fork-content-copy'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof forkContentCopyTask>('fork-content-copy', payload, {
region: await resolveTriggerRegion(),
})
} else {
runDetached('fork-content-copy', () => runForkContentCopy(payload))
}
} catch (error) {
// The fork itself succeeded; only scheduling the background copy failed. Surface
// it on the status row instead of failing the (committed) fork response.
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
childWorkspaceId: result.workspace.id,
error: getErrorMessage(error),
})
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'failed',
error: getErrorMessage(error, 'Could not start the background copy'),
}).catch(() => {})
}
}
return result
}
@@ -0,0 +1,142 @@
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
import { HttpError } from '@/lib/core/utils/http-error'
import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
/** Direction of a promote, relative to the workspace the caller is acting from. */
export type PromoteDirection = 'push' | 'pull'
/**
* Enterprise-only gate shared by every fork/promote route. On Sim Cloud the gate
* is the Enterprise plan; on self-hosted it's `FORKING_ENABLED`, which 404s when
* unset so a newer image doesn't silently expose forking. Mirrors the data-drains
* gate - this repo gates EE features by plan + env flag, not by directory.
*/
async function assertForkingEnabled(organizationId: string | null): Promise<void> {
if (!isBillingEnabled && !isForkingEnabled) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
if (isBillingEnabled) {
const hasEnterprise = organizationId
? await isOrganizationOnEnterprisePlan(organizationId)
: false
if (!hasEnterprise) {
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
}
}
}
/**
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
* `withRouteHandler` maps it to the right HTTP status and forwards the
* client-safe `message`.
*/
export class ForkError extends HttpError {
readonly statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = 'ForkError'
this.statusCode = statusCode
}
}
async function requireWorkspace(
workspaceId: string,
userId: string
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.exists || !access.workspace) {
throw new ForkError('Workspace not found', 404)
}
await assertForkingEnabled(access.workspace.organizationId)
return { workspace: access.workspace, canAdmin: access.canAdmin }
}
/** Require admin access; returns the (active) workspace. */
export async function assertWorkspaceAdminAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
if (!canAdmin) {
throw new ForkError('Admin access is required for this workspace', 403)
}
return workspace
}
export interface ForkAuthorization {
source: WorkspaceWithOwner
policy: WorkspaceCreationPolicy
}
/**
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
* source (a fork copies its deployed workflows and resources en masse) and must
* pass the workspace-creation policy for the parent's org (the child inherits the
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
*
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
* personal source (org `null`) - rather than the acting user's membership org,
* which the policy would otherwise fall back to when the source is personal.
*/
export async function assertCanFork(
sourceWorkspaceId: string,
userId: string
): Promise<ForkAuthorization> {
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const policy = await getWorkspaceCreationPolicy({
userId,
activeOrganizationId: source.organizationId,
pinOrganization: true,
})
if (!policy.canCreate) {
throw new ForkError(
policy.reason ?? 'You cannot create another workspace on your current plan',
policy.status >= 400 ? policy.status : 403
)
}
return { source, policy }
}
export interface PromoteAuthorization {
edge: ForkEdge
source: WorkspaceWithOwner
target: WorkspaceWithOwner
sourceWorkspaceId: string
targetWorkspaceId: string
}
/**
* Authorize a promote along the strict edge between `currentWorkspaceId` and
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
* reads the source's deployed workflows/resources and force-replaces the target's,
* and the sync surface is only ever offered to workspace admins. `push` sends
* current -> other; `pull` brings other -> current.
*/
export async function assertCanPromote(
currentWorkspaceId: string,
otherWorkspaceId: string,
direction: PromoteDirection,
userId: string
): Promise<PromoteAuthorization> {
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
}
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
export async function assertCanRollback(
targetWorkspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
}
@@ -0,0 +1,108 @@
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { and, eq, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
export interface ForkLineageNode {
id: string
name: string
organizationId: string | null
}
export interface ForkEdge {
childWorkspaceId: string
parentWorkspaceId: string
}
/**
* The parent workspace id a fork was created from, or null when the workspace
* is not a fork (or has been archived).
*/
export async function getForkParentId(workspaceId: string): Promise<string | null> {
const [row] = await db
.select({ parentId: workspace.forkedFromWorkspaceId })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
return row?.parentId ?? null
}
/** The parent lineage node for a fork, or null when it has no live parent. */
export async function getForkParent(workspaceId: string): Promise<ForkLineageNode | null> {
const parentId = await getForkParentId(workspaceId)
if (!parentId) return null
const [row] = await db
.select({
id: workspace.id,
name: workspace.name,
organizationId: workspace.organizationId,
})
.from(workspace)
.where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt)))
.limit(1)
return row ?? null
}
/**
* Resolve the strict fork edge between two workspaces, identifying which is the
* child (the one whose `forkedFromWorkspaceId` points at the other). Returns
* null when the two workspaces are not a direct parent/child pair.
*/
export async function resolveForkEdge(
workspaceAId: string,
workspaceBId: string
): Promise<ForkEdge | null> {
if (workspaceAId === workspaceBId) return null
if ((await getForkParentId(workspaceAId)) === workspaceBId) {
return { childWorkspaceId: workspaceAId, parentWorkspaceId: workspaceBId }
}
if ((await getForkParentId(workspaceBId)) === workspaceAId) {
return { childWorkspaceId: workspaceBId, parentWorkspaceId: workspaceAId }
}
return null
}
/**
* How long a fork transaction waits for a lock before aborting. Bounds the wait on the
* target/edge advisory locks (and any incidental row lock) so a contended sync into the
* same target fails fast and returns its pooled connection instead of piling waiters up
* and stagnating the pool at scale. 10s favors completing a legit sync queued behind an
* in-flight one, while still tripping on a pathological hold. Connection-level timeouts
* are not used (PlanetScale rejects them) - this is transaction-scoped only.
*/
const FORK_LOCK_TIMEOUT_MS = 10_000
/**
* Apply {@link FORK_LOCK_TIMEOUT_MS} to the current transaction (`set_config(local)`),
* so it covers `pg_advisory_xact_lock` waits too. Call at the very start of a fork
* transaction, before acquiring any lock.
*/
export async function setForkLockTimeout(tx: DbOrTx): Promise<void> {
await tx.execute(sql`select set_config('lock_timeout', ${`${FORK_LOCK_TIMEOUT_MS}ms`}, true)`)
}
/**
* Serialize concurrent promote/rollback on a fork edge with a transaction-scoped
* advisory lock keyed by the edge (the child workspace id). `hashtextextended`
* (64-bit, matching every other advisory lock in the repo) makes a collision
* between distinct keys astronomically unlikely; a collision would only cause
* unnecessary serialization, never a correctness issue.
*/
export async function acquireForkEdgeLock(tx: DbOrTx, childWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-edge:${childWorkspaceId}`}, 0))`
)
}
/**
* Serialize every promote/rollback whose TARGET is this workspace. Sibling forks
* promote into the same parent on different edge locks, so the edge lock alone does
* not serialize them; this lock does, keeping concurrent syncs into one target from
* interleaving and keeping rollback's "newest sync" check race-free. Always acquire
* this BEFORE {@link acquireForkEdgeLock} so the two are taken in a consistent order.
*/
export async function acquireForkTargetLock(tx: DbOrTx, targetWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-target:${targetWorkspaceId}`}, 0))`
)
}
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { toForkBlockPairs } from '@/lib/workspaces/fork/mapping/block-map-store'
describe('toForkBlockPairs', () => {
const mapping = new Map([
['src-1', 'tgt-1'],
['src-2', 'tgt-2'],
])
it('orients source->target as parent->child on pull/create (source = parent)', () => {
expect(toForkBlockPairs(mapping, true, 'wf-parent', 'wf-child')).toEqual([
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'src-1',
childBlockId: 'tgt-1',
},
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'src-2',
childBlockId: 'tgt-2',
},
])
})
it('orients source->target as child->parent on push (source = child)', () => {
expect(toForkBlockPairs(mapping, false, 'wf-child', 'wf-parent')).toEqual([
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'tgt-1',
childBlockId: 'src-1',
},
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'tgt-2',
childBlockId: 'src-2',
},
])
})
it('returns an empty list for an empty mapping', () => {
expect(toForkBlockPairs(new Map(), true, 'wf-parent', 'wf-child')).toEqual([])
})
})
@@ -0,0 +1,120 @@
import { workspaceForkBlockMap } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import type { ForkBlockMap } from '@/lib/workspaces/fork/remap/block-identity'
/** One persisted block-identity pair for an edge (carries both workflow sides). */
export interface ForkBlockPair {
parentWorkflowId: string
parentBlockId: string
childWorkflowId: string
childBlockId: string
}
/**
* Load an edge's persisted block-identity pairs into both lookup directions, each entry
* carrying its target-side workflow so the resolver can scope a reuse to the right workflow
* (see {@link buildForkBlockIdResolver}). Empty for an edge that predates the map (every
* block id then derives, exactly as before).
*/
export async function loadForkBlockMap(
executor: DbOrTx,
childWorkspaceId: string
): Promise<ForkBlockMap> {
const rows = await executor
.select({
parentWorkflowId: workspaceForkBlockMap.parentWorkflowId,
parentBlockId: workspaceForkBlockMap.parentBlockId,
childWorkflowId: workspaceForkBlockMap.childWorkflowId,
childBlockId: workspaceForkBlockMap.childBlockId,
})
.from(workspaceForkBlockMap)
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
const parentToChild = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
const childToParent = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
for (const row of rows) {
parentToChild.set(row.parentBlockId, {
targetBlockId: row.childBlockId,
targetWorkflowId: row.childWorkflowId,
})
childToParent.set(row.childBlockId, {
targetBlockId: row.parentBlockId,
targetWorkflowId: row.parentWorkflowId,
})
}
return { parentToChild, childToParent }
}
/**
* Orient one workflow's copy mapping (`sourceBlockId -> targetBlockId`) into block pairs.
* On pull/create the source is the parent; on push it's the child. The workflow ids are
* fixed for the whole mapping (one source workflow copied into one target workflow).
*/
export function toForkBlockPairs(
blockIdMapping: ReadonlyMap<string, string>,
sourceIsParent: boolean,
sourceWorkflowId: string,
targetWorkflowId: string
): ForkBlockPair[] {
const parentWorkflowId = sourceIsParent ? sourceWorkflowId : targetWorkflowId
const childWorkflowId = sourceIsParent ? targetWorkflowId : sourceWorkflowId
const pairs: ForkBlockPair[] = []
for (const [sourceBlockId, targetBlockId] of blockIdMapping) {
pairs.push({
parentWorkflowId,
childWorkflowId,
parentBlockId: sourceIsParent ? sourceBlockId : targetBlockId,
childBlockId: sourceIsParent ? targetBlockId : sourceBlockId,
})
}
return pairs
}
/**
* Replace the persisted pairs for the promoted SOURCE workflows with the live ones. Deleting
* by the (stable) source-side workflow id first does two things: it nukes pairs for blocks
* the source deleted since the last sync (e.g. a removed trigger), and it clears the old pair
* for a workflow whose target was archived + re-created - so the map always reflects the live
* lineage and a stale pair can never re-home a block onto an archived workflow's id. Block
* identity is otherwise immutable, so on a steady sync this just deletes and re-inserts the
* same pairs. `sourceIsParent` is true on pull/create, false on push.
*/
export async function reconcileForkBlockPairs(
executor: DbOrTx,
childWorkspaceId: string,
sourceIsParent: boolean,
sourceWorkflowIds: string[],
pairs: ForkBlockPair[]
): Promise<void> {
if (sourceWorkflowIds.length > 0) {
const sourceWorkflowColumn = sourceIsParent
? workspaceForkBlockMap.parentWorkflowId
: workspaceForkBlockMap.childWorkflowId
await executor
.delete(workspaceForkBlockMap)
.where(
and(
eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId),
inArray(sourceWorkflowColumn, sourceWorkflowIds)
)
)
}
if (pairs.length === 0) return
const now = new Date()
await executor
.insert(workspaceForkBlockMap)
.values(
pairs.map((pair) => ({
id: generateId(),
childWorkspaceId,
parentWorkflowId: pair.parentWorkflowId,
parentBlockId: pair.parentBlockId,
childWorkflowId: pair.childWorkflowId,
childBlockId: pair.childBlockId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoNothing()
}
@@ -0,0 +1,148 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
import type {
ForkReference,
ForkReferenceResolver,
} from '@/lib/workspaces/fork/remap/remap-references'
/** Executor that returns the queued result arrays in the order queries are issued. */
function queuedExecutor(results: unknown[][]): DbOrTx {
let index = 0
const builder = {
from: () => builder,
where: () => Promise.resolve(results[index++] ?? []),
}
return { select: () => builder } as unknown as DbOrTx
}
function ref(kind: ForkReference['kind'], sourceId: string): ForkReference {
return { kind, sourceId, subBlockKey: 'tools', required: false }
}
const resolveNone: ForkReferenceResolver = () => null
const resolveAll: ForkReferenceResolver = (_kind, sourceId) => sourceId
describe('detectForkCascadeReferences', () => {
it('returns empty when there are no content references', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([]),
sourceWorkspaceId: 'ws',
references: [ref('credential', 'cred-1'), ref('table', 'tbl-1')],
resolve: resolveNone,
})
expect(result.references).toEqual([])
expect(result.unmapped).toEqual([])
expect(result.mcpReauthServerIds).toEqual([])
})
it('surfaces env keys from custom tool code as required unmapped env-var refs', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: 'fetch(`{{API_KEY}}`)' }]]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0]).toMatchObject({
kind: 'env-var',
sourceId: 'API_KEY',
required: true,
})
expect(result.unmapped).toHaveLength(1)
})
it('marks env-var cascade refs mapped when the resolver finds them in the target', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: '{{API_KEY}}' }]]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1')],
resolve: resolveAll,
})
expect(result.references).toHaveLength(1)
expect(result.unmapped).toHaveLength(0)
})
it('extracts env keys from MCP url/headers and flags oauth servers for re-auth', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{
id: 'mcp-1',
name: 'Server',
url: 'https://x/{{HOST_KEY}}',
headers: { Authorization: '{{TOKEN}}' },
authType: 'headers',
},
{ id: 'mcp-2', name: 'OAuth Server', url: 'https://y', headers: {}, authType: 'oauth' },
],
]),
sourceWorkspaceId: 'ws',
references: [ref('mcp-server', 'mcp-1'), ref('mcp-server', 'mcp-2')],
resolve: resolveNone,
})
const envIds = result.references
.filter((r) => r.kind === 'env-var')
.map((r) => r.sourceId)
.sort()
expect(envIds).toEqual(['HOST_KEY', 'TOKEN'])
expect(result.mcpReauthServerIds).toEqual(['mcp-2'])
})
it('flags literal MCP header values as inline secrets (not env)', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{
id: 'mcp-1',
name: 'Server',
url: 'https://x',
headers: { Authorization: 'sk-literal' },
authType: 'headers',
},
],
]),
sourceWorkspaceId: 'ws',
references: [ref('mcp-server', 'mcp-1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(0)
expect(result.inlineSecretSources).toHaveLength(1)
})
it('surfaces KB connector credentials as required credential refs', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[{ id: 'kc-1', knowledgeBaseId: 'kb-1', credentialId: 'cred-9', encryptedApiKey: null }],
]),
sourceWorkspaceId: 'ws',
references: [ref('knowledge-base', 'kb-1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0]).toMatchObject({
kind: 'credential',
sourceId: 'cred-9',
required: true,
})
})
it('dedupes a shared env key referenced by two custom tools', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{ id: 't1', title: 'A', code: '{{SHARED}}' },
{ id: 't2', title: 'B', code: '{{SHARED}}' },
],
]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1'), ref('custom-tool', 't2')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0].sourceId).toBe('SHARED')
})
})
@@ -0,0 +1,180 @@
import { customTools, knowledgeConnector, mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
ENV_REF_PATTERN,
type ForkReference,
type ForkReferenceResolver,
} from '@/lib/workspaces/fork/remap/remap-references'
function extractEnvKeys(text: string): string[] {
const keys = new Set<string>()
for (const match of text.matchAll(ENV_REF_PATTERN)) {
if (match[1]) keys.add(match[1])
}
return Array.from(keys)
}
export interface ForkCascadeResult {
/** Transitive env-var / credential references discovered inside referenced resources. */
references: ForkReference[]
unmapped: ForkReference[]
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: string[]
/** Human-readable descriptions of inline secrets that cannot be mapped (review-only). */
inlineSecretSources: string[]
}
const EMPTY: ForkCascadeResult = {
references: [],
unmapped: [],
mcpReauthServerIds: [],
inlineSecretSources: [],
}
/**
* Walk the bodies of resources a workflow references (custom tools, MCP servers,
* knowledge bases) and surface the secrets they carry transitively: `{{ENV}}`
* keys inside custom tool code and MCP url/headers, and credential ids on KB
* connectors. These become additional required env-var / credential references
* (validated for existence in the target via `resolve`). OAuth MCP servers and
* inline connector keys are surfaced separately for review since they cannot be
* id-mapped. Reads only the source workspace's resources.
*/
export async function detectForkCascadeReferences(params: {
executor: DbOrTx
sourceWorkspaceId: string
references: ForkReference[]
resolve: ForkReferenceResolver
}): Promise<ForkCascadeResult> {
const { executor, sourceWorkspaceId, references, resolve } = params
const customToolIds = new Set<string>()
const mcpServerIds = new Set<string>()
const knowledgeBaseIds = new Set<string>()
for (const reference of references) {
if (reference.kind === 'custom-tool') customToolIds.add(reference.sourceId)
else if (reference.kind === 'mcp-server') mcpServerIds.add(reference.sourceId)
else if (reference.kind === 'knowledge-base') knowledgeBaseIds.add(reference.sourceId)
}
if (customToolIds.size === 0 && mcpServerIds.size === 0 && knowledgeBaseIds.size === 0) {
return EMPTY
}
const refs = new Map<string, ForkReference>()
const unmapped = new Map<string, ForkReference>()
const mcpReauthServerIds = new Set<string>()
const inlineSecretSources: string[] = []
const recordEnv = (key: string, sourceLabel: string) => {
const dedupeKey = `env-var:${key}`
if (refs.has(dedupeKey)) return
const reference: ForkReference = {
kind: 'env-var',
sourceId: key,
subBlockKey: '(cascade)',
blockName: sourceLabel,
required: true,
}
refs.set(dedupeKey, reference)
if (resolve('env-var', key) == null) unmapped.set(dedupeKey, reference)
}
const recordCredential = (credentialId: string, sourceLabel: string) => {
const dedupeKey = `credential:${credentialId}`
if (refs.has(dedupeKey)) return
const reference: ForkReference = {
kind: 'credential',
sourceId: credentialId,
subBlockKey: '(cascade)',
blockName: sourceLabel,
required: true,
}
refs.set(dedupeKey, reference)
if (resolve('credential', credentialId) == null) unmapped.set(dedupeKey, reference)
}
if (customToolIds.size > 0) {
const tools = await executor
.select({ id: customTools.id, title: customTools.title, code: customTools.code })
.from(customTools)
.where(
and(
inArray(customTools.id, Array.from(customToolIds)),
eq(customTools.workspaceId, sourceWorkspaceId)
)
)
for (const tool of tools) {
for (const key of extractEnvKeys(tool.code ?? '')) {
recordEnv(key, `Custom tool: ${tool.title}`)
}
}
}
if (mcpServerIds.size > 0) {
const servers = await executor
.select({
id: mcpServers.id,
name: mcpServers.name,
url: mcpServers.url,
headers: mcpServers.headers,
authType: mcpServers.authType,
})
.from(mcpServers)
.where(
and(
inArray(mcpServers.id, Array.from(mcpServerIds)),
eq(mcpServers.workspaceId, sourceWorkspaceId)
)
)
for (const server of servers) {
const label = `MCP server: ${server.name}`
if (server.url) {
for (const key of extractEnvKeys(server.url)) recordEnv(key, label)
}
const headers = (server.headers ?? {}) as Record<string, unknown>
for (const [headerName, headerValue] of Object.entries(headers)) {
if (typeof headerValue !== 'string') continue
const keys = extractEnvKeys(headerValue)
if (keys.length > 0) {
for (const key of keys) recordEnv(key, label)
} else if (server.authType === 'headers' && headerValue) {
inlineSecretSources.push(`${label} (header ${headerName})`)
}
}
if (server.authType === 'oauth') mcpReauthServerIds.add(server.id)
}
}
if (knowledgeBaseIds.size > 0) {
const connectors = await executor
.select({
id: knowledgeConnector.id,
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
credentialId: knowledgeConnector.credentialId,
encryptedApiKey: knowledgeConnector.encryptedApiKey,
})
.from(knowledgeConnector)
.where(
and(
inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)),
isNull(knowledgeConnector.deletedAt)
)
)
for (const connector of connectors) {
if (connector.credentialId) {
recordCredential(connector.credentialId, `Knowledge base connector`)
} else if (connector.encryptedApiKey) {
inlineSecretSources.push(`Knowledge base connector ${connector.id} (API key)`)
}
}
}
return {
references: Array.from(refs.values()),
unmapped: Array.from(unmapped.values()),
mcpReauthServerIds: Array.from(mcpReauthServerIds),
inlineSecretSources,
}
}
@@ -0,0 +1,516 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import {
collectForkDependentReconfigs,
collectForkResourceUsages,
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
} from '@/lib/workspaces/fork/remap/block-identity'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
const sourceState = (
blockType: string,
subBlocks: Record<string, { value: unknown }>
): WorkflowState =>
({
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
const replaceItem = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace' as const,
}
// No persisted block map in these unit tests, so the resolver derives - matching the
// `deriveForkBlockId(...)` ids the expectations assert.
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
describe('collectForkDependentReconfigs', () => {
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
condition: { field: 'operation', value: 'read' },
},
// A different operation's variant -> excluded by its condition, not by emptiness.
{
id: 'otherFolder',
title: 'Move To Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
condition: { field: 'operation', value: 'move' },
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', {
credential: { value: 'cred-src' },
operation: { value: 'read' },
folder: { value: 'INBOX' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'folder',
selectorKey: 'gmail.labels',
title: 'Label',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
},
])
})
it('emits a knowledge-base-dependent document selector', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
},
{
id: 'documentSelector',
title: 'Document',
type: 'document-selector',
canonicalParamId: 'documentId',
selectorKey: 'knowledge.documents',
dependsOn: ['knowledgeBaseSelector'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('knowledge', {
knowledgeBaseSelector: { value: 'kb-src' },
documentSelector: { value: 'doc-src' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
parentContextKey: 'knowledgeBaseId',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
title: 'Document',
currentValue: 'doc-src',
required: true,
consumesContextKeys: [],
context: {},
},
])
})
it('offers an active credential selector even when the source left it empty', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
// The source has the credential but no label - the user must still be able to set one
// during the swap (a prior sync may have cleared it), so the selector is offered.
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: '' } }),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ subBlockKey: 'folder', parentSourceId: 'cred-src' })
})
it('still skips a selector whose parent credential is unset', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const states = new Map<string, WorkflowState>([
['wf-src', sourceState('gmail', { credential: { value: '' }, folder: { value: 'INBOX' } })],
])
expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([])
})
it('skips create-mode targets and credentialSet refs', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const created = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: 'INBOX' } }),
],
])
expect(
collectForkDependentReconfigs(
[{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'create' }],
created,
resolve
)
).toEqual([])
const orgSet = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', {
credential: { value: 'credentialSet:cs-1' },
folder: { value: 'INBOX' },
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], orgSet, resolve)).toEqual([])
})
it('walks the transitive chain and tags the context key a re-pick provides', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'spreadsheetId',
title: 'Spreadsheet',
type: 'file-selector',
canonicalParamId: 'spreadsheetId',
selectorKey: 'google.drive',
dependsOn: ['credential'],
},
{
id: 'sheetName',
title: 'Sheet',
type: 'sheet-selector',
selectorKey: 'google.sheets',
dependsOn: ['spreadsheetId'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('google_sheets', {
credential: { value: 'cred-src' },
spreadsheetId: { value: 'ss-src' },
sheetName: { value: 'Sheet1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
// Both the spreadsheet (direct) and its sheet (transitive) are offered, in order.
expect(result.map((entry) => entry.subBlockKey)).toEqual(['spreadsheetId', 'sheetName'])
const spreadsheet = result.find((entry) => entry.subBlockKey === 'spreadsheetId')
expect(spreadsheet?.parentKind).toBe('credential')
expect(spreadsheet?.providesContextKey).toBe('spreadsheetId')
const sheet = result.find((entry) => entry.subBlockKey === 'sheetName')
expect(sheet?.parentKind).toBe('credential')
expect(sheet?.required).toBe(true)
// The sheet consumes the spreadsheet's key, so the modal gates it on that re-pick.
expect(sheet?.consumesContextKeys).toEqual(['spreadsheetId'])
// The source spreadsheet rides in context; the modal overlays the re-picked one.
expect(sheet?.context.spreadsheetId).toBe('ss-src')
})
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'tools[0].folder',
selectorKey: 'gmail.labels',
title: 'Gmail 1: Label',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
},
])
})
it('offers a nested tool selector even when the source left it empty', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{ type: 'gmail', title: 'Gmail 1', params: { credential: 'cred-src', folder: '' } },
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ subBlockKey: 'tools[0].folder', title: 'Gmail 1: Label' })
})
it('evaluates a nested tool selector condition against the tool-level operation', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
// Active only under read - and `operation` lives at the tool level, not params.
condition: { field: 'operation', value: 'read_gmail' },
},
])
return undefined as unknown as BlockConfig
})
const reading = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'read_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], reading, resolve)).toHaveLength(1)
// Same tool under a different operation -> the read-only label is gated off.
const sending = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'send_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], sending, resolve)).toEqual([])
})
it('anchors on a table selector for its column dependents', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
canonicalParamId: 'tableId',
},
{
id: 'conflictColumnSelector',
title: 'Column',
type: 'column-selector',
canonicalParamId: 'conflictColumn',
selectorKey: 'table.columns',
dependsOn: ['tableSelector'],
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('table', {
tableSelector: { value: 'tbl-src' },
conflictColumnSelector: { value: 'col1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentKind: 'table',
parentSourceId: 'tbl-src',
parentContextKey: 'tableId',
subBlockKey: 'conflictColumnSelector',
selectorKey: 'table.columns',
})
})
})
describe('collectForkResourceUsages', () => {
const usageItem = (
sourceWorkflowId: string,
targetWorkflowId: string,
name: string,
mode: 'create' | 'replace' = 'replace'
) => ({ sourceWorkflowId, targetWorkflowId, mode, sourceMeta: { name } })
// The reference scan reads each subblock entry's own `type`, so credential usages need
// typed entries (unlike the dependent collector, which keys off the block config).
const credentialState = (credentialId: string): WorkflowState =>
({
blocks: {
'block-1': {
id: 'block-1',
type: 'gmail',
name: 'Block',
subBlocks: { credential: { id: 'credential', type: 'oauth-input', value: credentialId } },
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
it('lists each replace workflow a resource is used in, with its (target) name', () => {
const states = new Map<string, WorkflowState>([
['wf-a', credentialState('cred-src')],
['wf-b', credentialState('cred-src')],
])
const result = collectForkResourceUsages(
[usageItem('wf-a', 'wf-tgt-a', 'Workflow A'), usageItem('wf-b', 'wf-tgt-b', 'Workflow B')],
states
)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
workflows: [
{ workflowId: 'wf-tgt-a', workflowName: 'Workflow A' },
{ workflowId: 'wf-tgt-b', workflowName: 'Workflow B' },
],
},
])
})
it('skips create-mode targets (the source config carries over on first sync)', () => {
const states = new Map<string, WorkflowState>([['wf-a', credentialState('cred-src')]])
expect(
collectForkResourceUsages([usageItem('wf-a', 'wf-tgt-a', 'A', 'create')], states)
).toEqual([])
})
})
@@ -0,0 +1,331 @@
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import {
buildSelectorContextFromBlock,
SELECTOR_CONTEXT_FIELDS,
} from '@/lib/workflows/subblocks/context'
import {
buildCanonicalIndex,
buildSubBlockValues,
evaluateSubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import {
isSubBlockRequired,
scanWorkflowReferences,
} from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const isSelectorContextKey = (
key: string
): key is Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0] =>
SELECTOR_CONTEXT_FIELDS.has(key as Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0])
interface ReconfigItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
}
/**
* Parent anchor types a dependent selector can hang off, with the SelectorContext key
* the new parent value is supplied under. A parent is a remappable resource (rewritten
* source->target on sync) whose target swap clears its dependents. MCP servers are
* intentionally excluded: their tool dependent has no `selectorKey` and a separate
* (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing.
*/
const PARENT_ANCHORS: ReadonlyArray<{
subBlockType: string
parentKind: ForkDependentReconfig['parentKind']
parentContextKey: string
}> = [
{ subBlockType: 'oauth-input', parentKind: 'credential', parentContextKey: 'oauthCredential' },
{
subBlockType: 'knowledge-base-selector',
parentKind: 'knowledge-base',
parentContextKey: 'knowledgeBaseId',
},
{ subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' },
]
interface EmitAnchoredParams {
/** The block (top-level) or tool config whose subblocks are scanned for anchors. */
config: NonNullable<ReturnType<typeof getBlock>>
/** Flat id -> value map for that config (top-level subblock values, or a tool's params). */
values: Record<string, unknown>
/** Block/tool type + its subblock shape, for building the source selector context. */
contextBlockType: string
contextSubBlocks: Record<string, { value?: unknown }>
blockName: string
targetWorkflowId: string
/** Memoized so the deterministic target block id is derived at most once per block. */
resolveTargetBlockId: () => string
/** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */
makeSubBlockKey: (dependentId: string) => string
makeTitle: (dependent: SubBlockConfig) => string
/**
* Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block
* re-picks. Top-level chains; nested tool params don't (a tool's chain would need
* per-tool context scoping - out of scope - and the common nested case is a single
* credential-anchored field).
*/
chaining: boolean
out: ForkDependentReconfig[]
}
/**
* Emit one config's credential/KB/table-anchored selector dependents that the source had
* configured. Shared by the top-level subblock scan and the nested `tool-input` tool scan.
* Walks the FULL transitive dependent chain (parent -> child -> grandchild) per anchor and
* dedups fields reachable via multiple anchors/paths.
*/
function emitAnchoredDependents(params: EmitAnchoredParams): void {
const {
config,
values,
contextBlockType,
contextSubBlocks,
blockName,
targetWorkflowId,
resolveTargetBlockId,
makeSubBlockKey,
makeTitle,
chaining,
out,
} = params
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
// A field could hang off two anchors (or be reachable via two paths); emit it once.
const seen = new Set<string>()
for (const anchor of PARENT_ANCHORS) {
for (const anchorCfg of config.subBlocks) {
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
const rawValue = values[anchorCfg.id]
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
// Skip empty and org-scoped credential sets (those carry over unchanged).
if (!parentSourceId || parentSourceId.startsWith('credentialSet:')) continue
// Multi-value parents (comma-joined) can't match a single mapping entry; skip
// (the field falls back to needs-config) rather than mis-bind to one of several.
if (parentSourceId.includes(',')) continue
// Context the dependents need (spreadsheetId, ...) minus the parent key the modal supplies.
const context: Record<string, string> = {}
for (const [key, value] of Object.entries(fullContext)) {
if (key === anchor.parentContextKey) continue
if (typeof value === 'string' && value) context[key] = value
}
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) {
const dependent = configById.get(clear.subBlockId)
if (!dependent?.id || !dependent.selectorKey) continue
// Skip fields gated off by their `condition` - a selector under a now-inactive
// operation (e.g. a move-only label while the block reads) isn't in play. We do
// NOT require a source value: an active selector the source left empty is still
// offered, so the user can set a label/sheet during the swap even when the source
// (or a prior sync) cleared it - the whole point of the in-place re-pick.
if (dependent.condition && !evaluateSubBlockCondition(dependent.condition, values)) continue
// The SelectorContext key this field supplies to its own descendants, so the
// modal can chain re-picks (re-picked spreadsheet feeds the sheet selector).
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[dependent.id] ?? dependent.id
// Dedup by canonical key so a basic/advanced pair (or two paths to the same field)
// is offered exactly once.
if (seen.has(canonicalKey)) continue
seen.add(canonicalKey)
const providesContextKey =
chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined
// The SelectorContext keys this field needs from in-block siblings (e.g. a sheet
// needs the spreadsheet), excluding the anchor key the modal already supplies, so
// the modal can keep a child disabled until its re-picked parent is chosen.
const consumesContextKeys = chaining
? [
...new Set(
getDependsOnFields(dependent.dependsOn)
.map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent)
.filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key))
),
]
: []
// Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the
// modal selector loads the same filtered list the editor would, not all files.
const dependentContext =
typeof dependent.mimeType === 'string' && dependent.mimeType
? { ...context, mimeType: dependent.mimeType }
: context
out.push({
parentKind: anchor.parentKind,
parentSourceId,
parentContextKey: anchor.parentContextKey,
targetWorkflowId,
targetBlockId: resolveTargetBlockId(),
blockName,
subBlockKey: makeSubBlockKey(dependent.id),
selectorKey: dependent.selectorKey,
title: makeTitle(dependent),
// Source value, so the always-on listing pre-fills a stable parent's selector.
currentValue:
typeof values[dependent.id] === 'string' ? (values[dependent.id] as string) : '',
required: isSubBlockRequired(dependent.required, values),
providesContextKey,
consumesContextKeys,
context: dependentContext,
})
}
}
}
}
/**
* Scan the source's deployed workflows for configured selector fields that `dependsOn`
* a remappable parent (a credential, knowledge base, or table) - the fields a sync clears
* whenever that parent's target changes. Covers top-level block subblocks AND selectors
* nested inside `tool-input` tools (Agent/tool blocks), so a Gmail tool's label inside an
* Agent block is offered for re-pick too. Each entry carries the deterministic target
* block id, the parent it hangs off (so the modal can bind it to the newly-chosen target),
* and the source-derived selector context. Every selector active for the source's current
* operation is emitted - including ones the source left empty - so the user can set a
* value in place during the swap even when the source (or a prior sync) had none; only
* selectors gated off by their `condition` (a different operation's variant) are skipped.
* Replace targets only: a freshly created target has nothing configured to swap yet.
*
* `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for
* this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a
* re-pick by a derived id while the sync writes the block under its persisted counterpart,
* and the override would silently miss.
*/
export function collectForkDependentReconfigs(
items: ReconfigItem[],
sourceStates: Map<string, WorkflowState>,
resolveTargetBlockId: ForkBlockIdResolver
): ForkDependentReconfig[] {
const out: ForkDependentReconfig[] = []
for (const item of items) {
if (item.mode !== 'replace') continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
const config = getBlock(block.type)
if (!config) continue
const subBlocks = (block.subBlocks ?? {}) as Record<string, { value?: unknown }>
const sourceValues = buildSubBlockValues(subBlocks)
let cachedTargetBlockId: string | null = null
const resolveBlockId = () =>
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
// Top-level credential/KB/table-anchored selectors.
emitAnchoredDependents({
config,
values: sourceValues,
contextBlockType: block.type,
contextSubBlocks: subBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => id,
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
chaining: true,
out,
})
// Nested `tool-input` tools: each selected tool's own credential-anchored selectors,
// keyed `toolInput[index].paramId` (matching the needs-config key) and titled with the
// tool so the modal re-picks them under the same block card.
for (const cfg of config.subBlocks) {
if (cfg.type !== 'tool-input' || !cfg.id) continue
const { array: tools } = coerceObjectArray(subBlocks[cfg.id]?.value)
if (!tools) continue
for (let index = 0; index < tools.length; index++) {
const tool = tools[index]
if (!isRecord(tool) || typeof tool.type !== 'string') continue
const toolConfig = getBlock(tool.type)
if (!toolConfig) continue
const toolParams = isRecord(tool.params) ? tool.params : {}
// A tool's `operation` is stored at the tool level, not in params, but subblock
// conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it
// in so condition-gating matches the editor's `{ operation, ...params }`.
const toolValues =
typeof tool.operation === 'string'
? { operation: tool.operation, ...toolParams }
: toolParams
const toolContextSubBlocks: Record<string, { value?: unknown }> = {}
for (const [key, value] of Object.entries(toolValues)) {
toolContextSubBlocks[key] = { value }
}
const toolLabel =
typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
const toolInputKey = cfg.id
const toolIndex = index
emitAnchoredDependents({
config: toolConfig,
values: toolValues,
contextBlockType: tool.type,
contextSubBlocks: toolContextSubBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
makeTitle: (dependent) => `${toolLabel}: ${dependent.title ?? dependent.id ?? ''}`,
chaining: false,
out,
})
}
}
}
}
return out
}
interface ResourceUsageItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
/** Source workflow name, shown as the (renamed-aware) target name in the listing. */
sourceMeta: { name: string }
}
/**
* Every workflow each mapped resource (any kind) is used in - the spine of the always-on
* reconfigure listing under a mapping entry. Scans each source workflow's references
* (deduped per workflow, so a resource used by several blocks is one workflow usage) and
* groups them by `(kind, sourceId)`. Unlike {@link collectForkDependentReconfigs} this is
* NOT anchor-limited: it includes resources with no configurable dependent (env vars, files,
* a Gmail block with no active label) so the modal can still list - greyed - the workflows
* they appear in. Replace targets only, mirroring the dependent collector (a freshly created
* target carries the source config over and has nothing to reconcile yet).
*/
export function collectForkResourceUsages(
items: ResourceUsageItem[],
sourceStates: Map<string, WorkflowState>
): ForkResourceUsage[] {
const byResource = new Map<string, ForkResourceUsage>()
for (const item of items) {
if (item.mode !== 'replace') continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
const blocks = Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
// so each resource appears once per workflow here.
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
const key = `${reference.kind}\u0000${reference.sourceId}`
let usage = byResource.get(key)
if (!usage) {
usage = { parentKind: reference.kind, parentSourceId: reference.sourceId, workflows: [] }
byResource.set(key, usage)
}
usage.workflows.push({
workflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
})
}
}
return Array.from(byResource.values())
}
@@ -0,0 +1,131 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import {
forkDependentValueKey,
loadForkDependentValues,
reconcileForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
describe('forkDependentValueKey', () => {
it('builds a stable triple key', () => {
expect(forkDependentValueKey('wf', 'blk', 'folder')).toBe('wf\u0000blk\u0000folder')
})
it("doesn't collide when an id contains a printable separator", () => {
// 'a:b' + 'c' must differ from 'a' + 'b:c' - the NUL separator guarantees it.
expect(forkDependentValueKey('a:b', 'c', 'd')).not.toBe(forkDependentValueKey('a', 'b:c', 'd'))
})
})
describe('loadForkDependentValues', () => {
it('selects the edge rows', async () => {
const where = vi
.fn()
.mockResolvedValue([
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
])
const from = vi.fn(() => ({ where }))
const executor = { select: vi.fn(() => ({ from })) }
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1')
expect(executor.select).toHaveBeenCalledTimes(1)
expect(rows).toEqual([
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
])
})
it('scopes the read to the given target workflows', async () => {
const where = vi.fn().mockResolvedValue([])
const from = vi.fn(() => ({ where }))
const executor = { select: vi.fn(() => ({ from })) }
await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', ['wf-1', 'wf-2'])
expect(executor.select).toHaveBeenCalledTimes(1)
expect(where).toHaveBeenCalledTimes(1)
})
it('short-circuits an empty target filter without querying', async () => {
const executor = { select: vi.fn() }
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', [])
expect(executor.select).not.toHaveBeenCalled()
expect(rows).toEqual([])
})
})
describe('reconcileForkDependentValues', () => {
function fakeExecutor() {
const deleteWhere = vi.fn().mockResolvedValue(undefined)
const insertValues = vi.fn().mockResolvedValue(undefined)
const executor = {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
}
return { executor: executor as unknown as DbOrTx, deleteWhere, insertValues }
}
it('deletes the given workflows then inserts only non-empty values', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
['wf-1'],
[
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
{ targetWorkflowId: 'wf-1', targetBlockId: 'b2', subBlockKey: 'folder', value: '' },
]
)
expect(deleteWhere).toHaveBeenCalledTimes(1)
expect(insertValues).toHaveBeenCalledTimes(1)
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
childWorkspaceId: 'ws-1',
targetWorkflowId: 'wf-1',
targetBlockId: 'b1',
subBlockKey: 'folder',
value: 'INBOX',
})
})
it('skips the delete when no workflows are given, and skips insert when all values are empty', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
[],
[{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: '' }]
)
expect(deleteWhere).not.toHaveBeenCalled()
expect(insertValues).not.toHaveBeenCalled()
})
it('clears a workflow (delete, no insert) when its full set is now empty', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(executor, 'ws-1', ['wf-1'], [])
expect(deleteWhere).toHaveBeenCalledTimes(1)
expect(insertValues).not.toHaveBeenCalled()
})
it('dedupes duplicate field entries (last value wins) so a retried payload cannot trip the unique index', async () => {
const { executor, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
['wf-1'],
[
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'SENT' },
]
)
expect(insertValues).toHaveBeenCalledTimes(1)
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
targetWorkflowId: 'wf-1',
targetBlockId: 'b1',
subBlockKey: 'folder',
value: 'SENT',
})
})
})
@@ -0,0 +1,99 @@
import { workspaceForkDependentValue } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
/** One stored dependent-field value for an edge. */
export interface ForkDependentValue {
targetWorkflowId: string
targetBlockId: string
subBlockKey: string
value: string
}
/** Stable key for a stored value (target workflow + block + subblock). */
export function forkDependentValueKey(
targetWorkflowId: string,
targetBlockId: string,
subBlockKey: string
): string {
// NUL separators so ids/keys containing ':' can't be confused for a different triple.
return `${targetWorkflowId}\u0000${targetBlockId}\u0000${subBlockKey}`
}
/**
* Load an edge's stored dependent values - the single source of truth for what each dependent
* selector (Gmail label, KB document, sheet tab) is set to. Consumed two ways: the diff
* overlays them as the modal's pre-filled value, and a promote applies them verbatim. Pass
* `targetWorkflowIds` to scope the read to a plan's replace targets (matching the
* `(childWorkspaceId, targetWorkflowId)` index) instead of loading the whole edge; an empty
* array short-circuits to no rows.
*/
export async function loadForkDependentValues(
executor: DbOrTx,
childWorkspaceId: string,
targetWorkflowIds?: string[]
): Promise<ForkDependentValue[]> {
if (targetWorkflowIds && targetWorkflowIds.length === 0) return []
const where = targetWorkflowIds
? and(
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
)
: eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId)
return executor
.select({
targetWorkflowId: workspaceForkDependentValue.targetWorkflowId,
targetBlockId: workspaceForkDependentValue.targetBlockId,
subBlockKey: workspaceForkDependentValue.subBlockKey,
value: workspaceForkDependentValue.value,
})
.from(workspaceForkDependentValue)
.where(where)
}
/**
* Replace the stored dependent values for the given target workflows with `values` (the full
* set the modal sent). Deletes those workflows' rows first, then inserts the non-empty values,
* so the store always equals exactly what the user configured - cleared fields drop out, and
* blocks/fields that no longer exist are pruned. Empty values aren't stored (an empty store
* entry and a missing one mean the same thing: unset).
*/
export async function reconcileForkDependentValues(
executor: DbOrTx,
childWorkspaceId: string,
targetWorkflowIds: string[],
values: ForkDependentValue[]
): Promise<void> {
if (targetWorkflowIds.length > 0) {
await executor
.delete(workspaceForkDependentValue)
.where(
and(
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
)
)
}
// Dedupe by the stored (workflow, block, subblock) triple (last value wins) before building
// insert rows, so a duplicated/retried payload entry can't trip the `..._field_unique` index
// and abort the whole sync transaction. Empty values aren't stored.
const deduped = new Map<string, ForkDependentValue>()
for (const entry of values) {
if (entry.value === '') continue
deduped.set(
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
entry
)
}
const rows = Array.from(deduped.values()).map((entry) => ({
id: generateId(),
childWorkspaceId,
targetWorkflowId: entry.targetWorkflowId,
targetBlockId: entry.targetBlockId,
subBlockKey: entry.subBlockKey,
value: entry.value,
}))
if (rows.length === 0) return
await executor.insert(workspaceForkDependentValue).values(rows)
}
@@ -0,0 +1,182 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
const { mockFilterExisting, mockGetCredentialProviders, mockGetEnvKeys } = vi.hoisted(() => ({
mockFilterExisting: vi.fn(),
mockGetCredentialProviders: vi.fn(),
mockGetEnvKeys: vi.fn(),
}))
vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({
listForkResourceCandidates: vi.fn(),
classifyCredentialResourceType: vi.fn(),
getWorkspaceEnvKeys: mockGetEnvKeys,
filterExistingForkTargets: mockFilterExisting,
getCredentialProvidersByIds: mockGetCredentialProviders,
CANDIDATE_LIMIT: 1000,
}))
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
import {
findDuplicateTargetEntry,
validateForkMappingTargets,
} from '@/lib/workspaces/fork/mapping/mapping-service'
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
describe('validateForkMappingTargets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFilterExisting.mockResolvedValue({} as ExistingByKind)
mockGetEnvKeys.mockResolvedValue(new Set<string>())
mockGetCredentialProviders.mockResolvedValue(new Map<string, string | null>())
})
it('rejects a workflow-type entry with a target (identity is system-managed)', async () => {
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('short-circuits without querying when no entry has a target', async () => {
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: null },
])
).resolves.toBeUndefined()
expect(mockFilterExisting).not.toHaveBeenCalled()
expect(mockGetEnvKeys).not.toHaveBeenCalled()
})
it('accepts an env-var whose target key exists in the target workspace', async () => {
mockGetEnvKeys.mockResolvedValue(new Set(['API_KEY']))
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
])
).resolves.toBeUndefined()
})
it('rejects an env-var whose target key is not in the target workspace', async () => {
mockGetEnvKeys.mockResolvedValue(new Set())
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'missing' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('accepts a target validated by exact id even when picker lists are capped', async () => {
// filterExistingForkTargets checks by exact id (cap-free), so a target that would
// sit past the candidate cap still validates.
mockFilterExisting.mockResolvedValue({ table: new Set(['table-1001']) })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-1001' },
])
).resolves.toBeUndefined()
})
it('rejects a target that does not exist in the target workspace', async () => {
mockFilterExisting.mockResolvedValue({ table: new Set<string>() })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-gone' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('rejects a credential whose target provider differs from the source provider', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map([['cred-src', 'google-email']])
: new Map([['cred-tgt', 'google-calendar']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('accepts a credential whose target provider matches the source provider', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map([['cred-src', 'google-email']])
: new Map([['cred-tgt', 'google-email']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
])
).resolves.toBeUndefined()
})
it('rejects a credential whose source is not a credential in the source workspace', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map<string, string | null>() // cred-foreign is not in the source
: new Map([['cred-tgt', 'google-email']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-foreign', targetId: 'cred-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
})
describe('findDuplicateTargetEntry', () => {
it('returns null when every target is used by at most one source', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 't1' },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 't2' },
])
).toBeNull()
})
it('flags two distinct sources mapped to the same target', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'shared' },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 'shared' },
])
).toEqual({ resourceType: 'oauth_credential', targetId: 'shared' })
})
it('ignores cleared (null target) entries', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: null },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: null },
])
).toBeNull()
})
it('does not flag the same source+target repeated', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
])
).toBeNull()
})
it('does not conflate the same target id across resource types', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'same' },
{ resourceType: 'table', sourceId: 'c2', targetId: 'same' },
])
).toBeNull()
})
})
@@ -0,0 +1,421 @@
import { db } from '@sim/db'
import type { ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import { listDeployedWorkflows, readDeployedState } from '@/lib/workspaces/fork/copy/deploy-bridge'
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
import {
buildForkResolver,
deleteEdgeMappingsByChildResources,
type ForkResourceType,
getEdgeMappingRows,
nonCredentialForkKindToResourceType,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
CANDIDATE_LIMIT,
classifyCredentialResourceType,
type ForkResourceCandidate,
filterExistingForkTargets,
getCredentialProvidersByIds,
getWorkspaceEnvKeys,
listForkResourceCandidates,
} from '@/lib/workspaces/fork/mapping/resources'
import {
type ForkReference,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/lib/workspaces/fork/remap/remap-references'
interface ForkMappingViewParams {
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
}
function suggestTarget(
kind: ForkRemapKind,
sourceLabel: string,
sourceProviderId: string | undefined,
candidates: ForkResourceCandidate[]
): string | null {
const normalized = sourceLabel.trim().toLowerCase()
const byLabel = candidates.filter((c) => c.label.trim().toLowerCase() === normalized)
if (kind === 'credential' && sourceProviderId) {
const match = byLabel.find((c) => c.providerId === sourceProviderId)
if (match) return match.id
}
if (byLabel.length === 1) return byLabel[0].id
return null
}
/**
* Build the direction-oriented mapping view: every detected source reference with
* its current target (persisted or env identity), an auto-suggested target by
* name/provider, and the list of target candidates the UI can choose from.
*/
export async function getForkMappingView(
params: ForkMappingViewParams
): Promise<{ entries: ForkMappingEntry[] }> {
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
await Promise.all([
getEdgeMappingRows(db, edge.childWorkspaceId),
getWorkspaceEnvKeys(db, targetWorkspaceId),
getWorkspaceEnvKeys(db, sourceWorkspaceId),
listForkResourceCandidates(db, sourceWorkspaceId),
listForkResourceCandidates(db, targetWorkspaceId),
])
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
const resourceTypeBySourceId = new Map<string, Exclude<ForkResourceType, 'workflow'>>()
for (const row of mappingRows) {
// Workflow identity rows are system-managed, not user-mappable; skip them so a
// scanned reference can never be labeled `workflow` and the view stays within
// the mappable-type contract.
if (row.resourceType === 'workflow') continue
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
if (key) resourceTypeBySourceId.set(key, row.resourceType)
}
// Scan one deployed workflow state at a time and merge deduped references, so
// peak memory stays at a single workflow state rather than all of them at once.
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
const referenceByKey = new Map<string, ForkReference>()
for (const wf of deployedWorkflows) {
const state = await readDeployedState(wf.id, sourceWorkspaceId)
if (!state) continue
const blocks = Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
const cascade = await detectForkCascadeReferences({
executor: db,
sourceWorkspaceId,
references: Array.from(referenceByKey.values()),
resolve: () => null,
})
for (const reference of cascade.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
const references: ForkReference[] = Array.from(referenceByKey.values())
// First pass: resolve each reference's stored target + the data to build its entry,
// collecting stored target ids so existence is checked by exact id (cap-free) - a
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
interface PendingEntry {
reference: ForkReference
resourceType: Exclude<ForkResourceType, 'workflow'>
sourceLabel: string
sourceProviderId: string | undefined
candidates: ForkResourceCandidate[]
storedTargetId: string | null
}
const pending: PendingEntry[] = []
const storedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const reference of references) {
// Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source
// workspace env var is a personal (user-scoped) secret - leave it as-is.
if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue
let resourceType = resourceTypeBySourceId.get(reference.sourceId)
if (!resourceType) {
resourceType =
reference.kind === 'credential'
? await classifyCredentialResourceType(db, reference.sourceId, sourceWorkspaceId)
: nonCredentialForkKindToResourceType(reference.kind)
}
const sourceCandidate = sourceCandidates[reference.kind].find(
(c) => c.id === reference.sourceId
)
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
const sourceProviderId = sourceCandidate?.providerId
// A credential reference only maps to a target credential of the SAME OAuth
// provider - a Gmail (google-email) reference must never offer a Google Calendar
// credential. Non-credential kinds carry no provider, so their full list stands.
const candidates =
reference.kind === 'credential' && sourceProviderId
? targetCandidates[reference.kind].filter(
(candidate) => candidate.providerId === sourceProviderId
)
: targetCandidates[reference.kind]
const storedTargetId = resolver(reference.kind, reference.sourceId) ?? null
if (storedTargetId && reference.kind !== 'env-var') {
;(storedTargetIdsByKind[reference.kind] ??= new Set()).add(storedTargetId)
}
pending.push({
reference,
resourceType,
sourceLabel,
sourceProviderId,
candidates,
storedTargetId,
})
}
// Cap-free existence of every stored target (env vars validated against env keys).
const existingStoredTargets = await filterExistingForkTargets(
db,
targetWorkspaceId,
storedTargetIdsByKind
)
const entries: ForkMappingEntry[] = []
for (const p of pending) {
const targetExists =
p.storedTargetId != null &&
(p.reference.kind === 'env-var'
? targetEnvKeys.has(p.storedTargetId)
: (existingStoredTargets[p.reference.kind]?.has(p.storedTargetId) ?? false))
const currentTargetId = targetExists ? p.storedTargetId : null
// If the retained current target isn't in the (capped) candidate list, append it
// so the picker can still display the current selection.
let candidates = p.candidates
if (currentTargetId && !candidates.some((candidate) => candidate.id === currentTargetId)) {
candidates = [...candidates, { id: currentTargetId, label: currentTargetId }]
}
const targetId =
currentTargetId ??
suggestTarget(p.reference.kind, p.sourceLabel, p.sourceProviderId, candidates)
// True when `targetId` is an unconfirmed name/provider suggestion (no persisted
// mapping). The modal treats a suggestion as a pending change so it shows the
// pre-sync reconfigure rather than letting an accepted suggestion silently clear
// dependents and surface them only after the sync.
const suggested = currentTargetId == null && targetId != null
entries.push({
kind: p.reference.kind,
resourceType: p.resourceType,
sourceId: p.reference.sourceId,
sourceLabel: p.sourceLabel,
targetId,
suggested,
required: p.reference.required,
candidates,
// The full (unfiltered) target list for this kind hit the cap, so the picker is
// showing a partial list - the UI tells the user to refine.
candidatesTruncated: targetCandidates[p.reference.kind].length >= CANDIDATE_LIMIT,
})
}
return { entries }
}
export interface ApplyForkMappingEntry {
resourceType: ForkResourceType
sourceId: string
targetId: string | null
}
/**
* The first target two distinct sources are mapped to (same resourceType + targetId,
* different sourceId), or null when every target is used by at most one source. Cleared
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
* the parent (target) side, so such a pair collides on that unique index and one mapping
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
* parent sources may share one child target, which the resolver handles), so pull does not
* use this guard.
*/
export function findDuplicateTargetEntry(
entries: ApplyForkMappingEntry[]
): { resourceType: ForkResourceType; targetId: string } | null {
const sourcesByTarget = new Map<string, Set<string>>()
for (const entry of entries) {
if (entry.targetId == null) continue
// Null-byte separator so a targetId containing ':' (e.g. credentialSet:...) can't
// be confused with a different (resourceType, targetId) pair.
const key = `${entry.resourceType}\u0000${entry.targetId}`
const sources = sourcesByTarget.get(key)
if (!sources) {
sourcesByTarget.set(key, new Set([entry.sourceId]))
continue
}
sources.add(entry.sourceId)
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
}
return null
}
/**
* Persist mapping edits for a direction. Pull maps a parent source to a child
* target; push maps a child source to a parent target (clearing a push mapping
* deletes the row).
*/
export async function applyForkMappingEntries(
tx: DbOrTx,
edge: ForkEdge,
userId: string,
direction: 'push' | 'pull',
entries: ApplyForkMappingEntry[]
): Promise<number> {
if (entries.length === 0) return 0
if (direction === 'pull') {
// Pull maps a parent source to a child target - one batched upsert.
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.sourceId,
childResourceId: entry.targetId,
}))
)
return entries.length
}
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
// the same target would collide on that index and one would be silently dropped (its
// reference then resolves unmapped). Reject loudly - on push each parent target can back
// only one source. (Pull is the inverse: many parent sources may share one child target,
// which the resolver handles, so pull skips this guard. The modal also disables an
// already-taken target on push so users never reach this error normally.)
const collision = findDuplicateTargetEntry(entries)
if (collision) {
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
throw new ForkError(
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
400
)
}
// Push rows are keyed by the child (source) side, but the table's unique key is on
// the parent side - so clear any existing row for each source first (one grouped
// delete), otherwise changing a push target leaves the old (parent, source) row
// behind and resolution becomes ambiguous. Then upsert the new (target, source)
// rows in one batch; a null target is a cleared mapping (delete only, no reinsert).
await deleteEdgeMappingsByChildResources(
tx,
edge.childWorkspaceId,
entries.map((entry) => ({ resourceType: entry.resourceType, childResourceId: entry.sourceId }))
)
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries
.filter((entry) => entry.targetId != null)
.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.targetId as string,
childResourceId: entry.sourceId,
}))
)
return entries.length
}
/**
* Reject mapping entries whose chosen target does not belong to the target
* workspace, so a caller cannot point a remapped reference (or credential-access
* propagation) at a resource in a workspace they do not administer. Entries whose
* resource type is not user-mappable (only `workflow`, whose identity is
* system-managed) are rejected outright. Credential targets must additionally share
* the source credential's OAuth provider, so a Gmail reference can never be pointed
* at a Google Calendar credential (the UI enforces this; this is the write-side
* boundary that catches direct API calls and stale rows).
*/
export async function validateForkMappingTargets(
sourceWorkspaceId: string,
targetWorkspaceId: string,
entries: ApplyForkMappingEntry[]
): Promise<void> {
const withTarget = entries.filter((entry) => entry.targetId != null)
if (withTarget.length === 0) return
// Collect the exact target ids per kind so existence is checked by id, NOT against
// the display-capped candidate list - a valid target that simply sits past the cap
// must never be rejected on save.
const targetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
let hasEnvVar = false
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) {
// `workflow` is the only null-kind type, and its identity is system-managed by
// fork/promote/rollback. A non-null target for it here is an invalid (or
// crafted) entry the editor must never persist.
throw new ForkError(
`Resource type "${entry.resourceType}" cannot be mapped via the mapping editor`,
400
)
}
if (kind === 'env-var') {
hasEnvVar = true
continue
}
;(targetIdsByKind[kind] ??= new Set()).add(entry.targetId as string)
}
const credentialEntries = withTarget.filter(
(entry) => resourceTypeToForkKind(entry.resourceType) === 'credential'
)
const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([
filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind),
hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set<string>()),
getCredentialProvidersByIds(
db,
sourceWorkspaceId,
credentialEntries.map((entry) => entry.sourceId)
),
getCredentialProvidersByIds(
db,
targetWorkspaceId,
credentialEntries.map((entry) => entry.targetId as string)
),
])
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) continue
const targetId = entry.targetId as string
if (kind === 'env-var') {
if (!targetEnvKeys.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not an environment variable in the target workspace`,
400
)
}
continue
}
if (!existingTargets[kind]?.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not a valid ${kind} in the target workspace`,
400
)
}
if (kind === 'credential') {
// The source must be a real credential in the source workspace. A foreign id
// (not present) would skip the provider check and let a crafted mapping drive
// cross-workspace credential-access propagation on promote.
if (!sourceProviders.has(entry.sourceId)) {
throw new ForkError(
`Source credential "${entry.sourceId}" is not a credential in the source workspace`,
400
)
}
const sourceProviderId = sourceProviders.get(entry.sourceId)
const targetProviderId = targetProviders.get(targetId) ?? null
if (sourceProviderId && targetProviderId !== sourceProviderId) {
throw new ForkError(
`Mapping target "${targetId}" must use the same provider as the source credential`,
400
)
}
}
}
}
@@ -0,0 +1,75 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildForkResolver, type ForkMappingRow } from '@/lib/workspaces/fork/mapping/mapping-store'
const credentialRow: ForkMappingRow = {
id: 'm1',
childWorkspaceId: 'ws-child',
resourceType: 'oauth_credential',
parentResourceId: 'cred-parent',
childResourceId: 'cred-child',
}
describe('buildForkResolver', () => {
it('resolves source->target for a pull (source is parent)', () => {
const resolve = buildForkResolver([credentialRow], { sourceIsParent: true })
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('resolves source->target for a push (source is child)', () => {
const resolve = buildForkResolver([credentialRow], { sourceIsParent: false })
expect(resolve('credential', 'cred-child')).toBe('cred-parent')
})
it('skips unmapped rows (null childResourceId)', () => {
const resolve = buildForkResolver([{ ...credentialRow, childResourceId: null }], {
sourceIsParent: true,
})
expect(resolve('credential', 'cred-parent')).toBeNull()
})
it('drops a mapped target that no longer exists in the target workspace', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
// target cred-child was deleted after the mapping was saved
validTargetIdsByKind: { credential: new Set<string>() },
})
expect(resolve('credential', 'cred-parent')).toBeNull()
})
it('keeps a mapped target that still exists in the target workspace', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
validTargetIdsByKind: { credential: new Set(['cred-child']) },
})
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('does not existence-check kinds absent from validTargetIdsByKind', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
validTargetIdsByKind: { table: new Set<string>() },
})
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('falls back to identity for a workspace env key present in the target', () => {
const resolve = buildForkResolver([], {
sourceIsParent: true,
sourceEnvKeys: new Set(['API_KEY']),
targetEnvKeys: new Set(['API_KEY']),
})
expect(resolve('env-var', 'API_KEY')).toBe('API_KEY')
})
it('leaves a personal (non-source-workspace) env key as-is', () => {
const resolve = buildForkResolver([], {
sourceIsParent: true,
sourceEnvKeys: new Set(['WORKSPACE_KEY']),
targetEnvKeys: new Set(),
})
expect(resolve('env-var', 'PERSONAL_KEY')).toBe('PERSONAL_KEY')
})
})
@@ -0,0 +1,309 @@
import { workspaceForkResourceMap } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, inArray, or, sql } from 'drizzle-orm'
import type { z } from 'zod'
import type { forkResourceTypeSchema } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/lib/workspaces/fork/remap/remap-references'
/** Mapping rows per insert; each row binds ~8 params, keeping well under PG's limit. */
const MAPPING_INSERT_CHUNK = 1000
/** Derived from the wire contract so the DB enum, Zod schema, and TS type stay in lockstep. */
export type ForkResourceType = z.infer<typeof forkResourceTypeSchema>
export interface ForkMappingRow {
id: string
childWorkspaceId: string
resourceType: ForkResourceType
parentResourceId: string
childResourceId: string | null
}
export interface ForkMappingUpsert {
resourceType: ForkResourceType
parentResourceId: string
childResourceId: string | null
}
const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null> = {
workflow: null,
oauth_credential: 'credential',
service_account_credential: 'credential',
env_var: 'env-var',
table: 'table',
knowledge_base: 'knowledge-base',
knowledge_document: 'knowledge-document',
file: 'file',
mcp_server: 'mcp-server',
custom_tool: 'custom-tool',
skill: 'skill',
}
/** The remapper kind a stored resource type participates in, or null when it does not remap. */
export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRemapKind | null {
return RESOURCE_TYPE_TO_FORK_KIND[resourceType]
}
const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record<
Exclude<ForkRemapKind, 'credential'>,
Exclude<ForkResourceType, 'workflow'>
> = {
'env-var': 'env_var',
table: 'table',
'knowledge-base': 'knowledge_base',
'knowledge-document': 'knowledge_document',
file: 'file',
'mcp-server': 'mcp_server',
'custom-tool': 'custom_tool',
skill: 'skill',
}
/**
* Stored resource type for a non-credential remap kind. Credentials are resolved
* separately via `classifyCredentialResourceType` since the type (oauth vs
* service account) depends on the credential row.
*/
export function nonCredentialForkKindToResourceType(
kind: Exclude<ForkRemapKind, 'credential'>
): Exclude<ForkResourceType, 'workflow'> {
return NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE[kind]
}
export async function getEdgeMappingRows(
executor: DbOrTx,
childWorkspaceId: string
): Promise<ForkMappingRow[]> {
const rows = await executor
.select({
id: workspaceForkResourceMap.id,
childWorkspaceId: workspaceForkResourceMap.childWorkspaceId,
resourceType: workspaceForkResourceMap.resourceType,
parentResourceId: workspaceForkResourceMap.parentResourceId,
childResourceId: workspaceForkResourceMap.childResourceId,
})
.from(workspaceForkResourceMap)
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
// Deterministic order so resolver/identity construction is stable if duplicates
// ever exist (the push edit + rollback cleanup prevent them, this is defense).
.orderBy(asc(workspaceForkResourceMap.createdAt), asc(workspaceForkResourceMap.id))
return rows as ForkMappingRow[]
}
/**
* Delete workflow-identity mapping rows by the ids on one side (parent or child).
* Used by rollback to dissolve the identity rows a promote created, so a later
* re-promote of the same source converges instead of leaking a second row.
*/
export async function deleteWorkflowIdentityByIds(
tx: DbOrTx,
childWorkspaceId: string,
side: 'parent' | 'child',
ids: string[]
): Promise<void> {
if (ids.length === 0) return
const sideColumn =
side === 'parent'
? workspaceForkResourceMap.parentResourceId
: workspaceForkResourceMap.childResourceId
await tx
.delete(workspaceForkResourceMap)
.where(
and(
eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId),
eq(workspaceForkResourceMap.resourceType, 'workflow'),
inArray(sideColumn, ids)
)
)
}
/**
* Insert mapping rows that don't already exist (used at fork time to seed every
* detected reference as unmapped). Existing rows are left untouched.
*/
export async function seedEdgeMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
const now = new Date()
// Chunked so a fork copying many resources stays well under the Postgres bind
// parameter limit (each row binds ~8 params).
for (let i = 0; i < entries.length; i += MAPPING_INSERT_CHUNK) {
const batch = entries.slice(i, i + MAPPING_INSERT_CHUNK)
await tx
.insert(workspaceForkResourceMap)
.values(
batch.map((entry) => ({
id: generateId(),
childWorkspaceId,
resourceType: entry.resourceType,
parentResourceId: entry.parentResourceId,
childResourceId: entry.childResourceId,
createdBy: userId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoNothing({
target: [
workspaceForkResourceMap.childWorkspaceId,
workspaceForkResourceMap.resourceType,
workspaceForkResourceMap.parentResourceId,
],
})
}
}
/**
* Insert or update mapping rows in batched, chunked multi-row upserts, setting
* `childResourceId` (the chosen target) from the incoming row. Used when a user
* saves a mapping and to persist promote identity rows - one query per chunk
* instead of one per row, so a large save stays a short transaction.
*
* Entries are deduped by the conflict key (resourceType, parentResourceId), keeping
* the last (matching the prior per-row last-write-wins) so a batch can never trip
* Postgres's "ON CONFLICT DO UPDATE cannot affect row a second time".
*/
export async function upsertEdgeMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
const now = new Date()
const byConflictKey = new Map<string, ForkMappingUpsert>()
for (const entry of entries) {
byConflictKey.set(`${entry.resourceType}:${entry.parentResourceId}`, entry)
}
const deduped = Array.from(byConflictKey.values())
for (let i = 0; i < deduped.length; i += MAPPING_INSERT_CHUNK) {
const batch = deduped.slice(i, i + MAPPING_INSERT_CHUNK)
await tx
.insert(workspaceForkResourceMap)
.values(
batch.map((entry) => ({
id: generateId(),
childWorkspaceId,
resourceType: entry.resourceType,
parentResourceId: entry.parentResourceId,
childResourceId: entry.childResourceId,
createdBy: userId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
workspaceForkResourceMap.childWorkspaceId,
workspaceForkResourceMap.resourceType,
workspaceForkResourceMap.parentResourceId,
],
set: { childResourceId: sql`excluded.child_resource_id`, updatedAt: now },
})
}
}
/**
* Remove mapping rows matched by their child-side (source) resource id, grouped by
* resource type into a single OR-of-INs - one query for the whole push save (the
* unique key is on the parent side, so a changed push target must drop the old
* (parent, source) row before the new one is inserted).
*/
export async function deleteEdgeMappingsByChildResources(
tx: DbOrTx,
childWorkspaceId: string,
pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }>
): Promise<void> {
if (pairs.length === 0) return
const idsByType = new Map<ForkResourceType, string[]>()
for (const { resourceType, childResourceId } of pairs) {
const list = idsByType.get(resourceType)
if (list) list.push(childResourceId)
else idsByType.set(resourceType, [childResourceId])
}
const conditions = Array.from(idsByType, ([resourceType, ids]) =>
and(
eq(workspaceForkResourceMap.resourceType, resourceType),
inArray(workspaceForkResourceMap.childResourceId, ids)
)
)
await tx
.delete(workspaceForkResourceMap)
.where(and(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId), or(...conditions)))
}
export interface BuildForkResolverOptions {
/** When the source side of the promote is the parent workspace (a pull). */
sourceIsParent: boolean
/**
* Env keys present in the target workspace. A workspace-secret env reference with
* no explicit mapping resolves to itself when the same key exists in the target.
*/
targetEnvKeys?: Set<string>
/**
* Env keys defined at the SOURCE workspace level. Only these are workspace secrets
* that can be mapped; any other `{{KEY}}` is a personal (user-scoped) secret that
* resolves identically in any workspace and is left as-is (never mapped/required).
*/
sourceEnvKeys?: Set<string>
/**
* Target ids that still EXIST in the target workspace, per kind, among the mapped
* targets. When a kind is present, a mapped target NOT in its set is treated as
* unmapped (the target was deleted after the mapping was saved), so a dead id is
* never written into the promoted workflow. Kinds absent here are not existence-
* checked (resolved as before).
*/
validTargetIdsByKind?: Partial<Record<ForkRemapKind, Set<string>>>
}
/**
* Build a reference resolver from persisted mapping rows for the chosen
* direction. Translates a source-space resource id to its mapped target id;
* rows whose `childResourceId` is null (unmapped) are skipped. Env keys fall
* back to an identity mapping when the target workspace already has the key.
*/
export function buildForkResolver(
rows: ForkMappingRow[],
options: BuildForkResolverOptions
): ForkReferenceResolver {
const index = new Map<ForkRemapKind, Map<string, string>>()
for (const row of rows) {
const kind = resourceTypeToForkKind(row.resourceType)
if (!kind) continue
if (row.childResourceId == null) continue
const sourceId = options.sourceIsParent ? row.parentResourceId : row.childResourceId
const targetId = options.sourceIsParent ? row.childResourceId : row.parentResourceId
let kindIndex = index.get(kind)
if (!kindIndex) {
kindIndex = new Map()
index.set(kind, kindIndex)
}
kindIndex.set(sourceId, targetId)
}
return (kind, sourceId) => {
const mapped = index.get(kind)?.get(sourceId)
if (mapped != null) {
const validSet = options.validTargetIdsByKind?.[kind]
if (!validSet || validSet.has(mapped)) return mapped
// The mapped target was deleted from the target workspace after the mapping was
// saved. Fall through so the reference resolves as unmapped (surfaced as required
// / cleared if optional) instead of writing a dead id into the promoted workflow.
}
if (kind === 'env-var') {
// Personal/global env vars (not a source workspace secret) are user-scoped and
// resolve identically in any workspace - leave them as-is, never map them.
if (options.sourceEnvKeys && !options.sourceEnvKeys.has(sourceId)) return sourceId
// Workspace secret already present in the target by the same name → identity.
if (options.targetEnvKeys?.has(sourceId)) return sourceId
}
return null
}
}
@@ -0,0 +1,366 @@
import {
credential,
customTools,
knowledgeBase,
mcpServers,
skill,
userTableDefinitions,
workflow,
workflowDeploymentVersion,
workspaceEnvironment,
workspaceFiles,
} from '@sim/db/schema'
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import type { ForkResourceType } from '@/lib/workspaces/fork/mapping/mapping-store'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
export interface ForkResourceCandidate {
id: string
label: string
providerId?: string
}
export const CANDIDATE_LIMIT = 1000
/** The set of env-var keys defined in a workspace (for resolver identity + gating). */
export async function getWorkspaceEnvKeys(
executor: DbOrTx,
workspaceId: string
): Promise<Set<string>> {
const [row] = await executor
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const variables = row?.variables
if (!variables || typeof variables !== 'object') return new Set()
return new Set(Object.keys(variables as Record<string, unknown>))
}
// Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the
// mapping-target picker and the fork-copy picker list - one source of the archived/deleted
// filters so the two pickers can never drift apart. Credentials, env vars (mapping-only),
// and files (copy-only) stay inline in their respective functions.
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
.select({ id: userTableDefinitions.id, label: userTableDefinitions.name })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
.limit(CANDIDATE_LIMIT)
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
.select({ id: knowledgeBase.id, label: knowledgeBase.name })
.from(knowledgeBase)
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt)))
.limit(CANDIDATE_LIMIT)
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
.select({ id: customTools.id, label: customTools.title })
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
.limit(CANDIDATE_LIMIT)
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
.select({ id: skill.id, label: skill.name })
.from(skill)
.where(eq(skill.workspaceId, workspaceId))
.limit(CANDIDATE_LIMIT)
const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
.select({ id: mcpServers.id, label: mcpServers.name })
.from(mcpServers)
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
.limit(CANDIDATE_LIMIT)
/**
* List the resources in a workspace that can serve as mapping targets, grouped by
* remap kind. Used to populate the mapping UI's target pickers and to label the
* source resources being mapped. `knowledge-document` and `file` are intentionally
* left empty for v1 (optional kinds resolved manually).
*/
export async function listForkResourceCandidates(
executor: DbOrTx,
workspaceId: string
): Promise<Record<ForkRemapKind, ForkResourceCandidate[]>> {
const [creds, wsEnvRows, tables, kbs, servers, tools, skills] = await Promise.all([
executor
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
})
.from(credential)
// Only real connections are mappable credentials. `env_workspace`/`env_personal`
// rows live in the same table but are environment variables (surfaced via the
// 'env-var' kind), so they must never appear as credential targets.
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account'])
)
)
.limit(CANDIDATE_LIMIT),
executor
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1),
tableCandidatesQuery(executor, workspaceId),
knowledgeBaseCandidatesQuery(executor, workspaceId),
mcpServerCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
])
const envVariables = wsEnvRows[0]?.variables
const envKeys =
envVariables && typeof envVariables === 'object'
? Object.keys(envVariables as Record<string, unknown>)
: []
return {
credential: creds.map((c) => ({
id: c.id,
label: c.displayName,
providerId: c.providerId ?? undefined,
})),
'env-var': envKeys.map((key) => ({ id: key, label: key })),
table: tables,
'knowledge-base': kbs,
'mcp-server': servers,
'custom-tool': tools,
skill: skills,
'knowledge-document': [],
file: [],
}
}
/**
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the
* target workspace (same archived/deleted filters as `listForkResourceCandidates`).
* Used at promote time so a mapping whose target was deleted after it was saved
* resolves as unmapped (surfaced/cleared) instead of writing a dead id into the
* promoted workflow. Queries the exact ids (not the capped candidate list) so a valid
* target is never wrongly dropped, and only the DB-backed kinds are checked - env-var
* existence is handled by the resolver's `targetEnvKeys`, and `file`/`workflow` are
* resolved by other paths.
*/
export async function filterExistingForkTargets(
executor: DbOrTx,
workspaceId: string,
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
const ids = (kind: ForkRemapKind): string[] => {
const set = idsByKind[kind]
return set && set.size > 0 ? Array.from(set) : []
}
const credIds = ids('credential')
const tableIds = ids('table')
const kbIds = ids('knowledge-base')
const mcpIds = ids('mcp-server')
const toolIds = ids('custom-tool')
const skillIds = ids('skill')
const [creds, tables, kbs, servers, tools, skills] = await Promise.all([
credIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: credential.id })
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account']),
inArray(credential.id, credIds)
)
),
tableIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: userTableDefinitions.id })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt),
inArray(userTableDefinitions.id, tableIds)
)
),
kbIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(
and(
eq(knowledgeBase.workspaceId, workspaceId),
isNull(knowledgeBase.deletedAt),
inArray(knowledgeBase.id, kbIds)
)
),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: mcpServers.id })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt),
inArray(mcpServers.id, mcpIds)
)
),
toolIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: customTools.id })
.from(customTools)
.where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds))),
skillIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: skill.id })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))),
])
const result: Partial<Record<ForkRemapKind, Set<string>>> = {}
if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id))
if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id))
if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id))
if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id))
if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id))
if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id))
return result
}
/**
* Provider id for each given credential id in a workspace, looked up by exact id (no
* candidate cap). Presence in the returned map means the credential exists in the
* workspace, so this doubles as a cap-free existence + provider check for validation.
*/
export async function getCredentialProvidersByIds(
executor: DbOrTx,
workspaceId: string,
ids: string[]
): Promise<Map<string, string | null>> {
if (ids.length === 0) return new Map()
const rows = await executor
.select({ id: credential.id, providerId: credential.providerId })
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account']),
inArray(credential.id, ids)
)
)
return new Map(rows.map((row) => [row.id, row.providerId ?? null]))
}
export interface ForkCopyableResources {
files: ForkResourceCandidate[]
tables: ForkResourceCandidate[]
knowledgeBases: ForkResourceCandidate[]
customTools: ForkResourceCandidate[]
skills: ForkResourceCandidate[]
mcpServers: ForkResourceCandidate[]
/**
* Count of deployed workflows that the fork would copy. When 0, the fork modal shows an
* informational note (forking is never blocked) - create-fork seeds a blank starter
* workflow so the child is still a usable workspace.
*/
deployedWorkflowCount: number
}
/**
* List the resources in a workspace that can be selected for copy at fork time
* (the content kinds — never credentials or env vars). Powers the fork modal's
* resource picker.
*/
export async function listForkCopyableResources(
executor: DbOrTx,
workspaceId: string
): Promise<ForkCopyableResources> {
const [files, tables, kbs, tools, skills, servers, deployed] = await Promise.all([
executor
.select({
id: workspaceFiles.id,
// displayName is nullable; fall back to the (non-null) original name.
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
})
.from(workspaceFiles)
// Only durable workspace files are forkable - chat/copilot/mothership uploads are
// session-scoped attachments (and their chat-bound unique index can't be copied).
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(CANDIDATE_LIMIT),
tableCandidatesQuery(executor, workspaceId),
knowledgeBaseCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
mcpServerCandidatesQuery(executor, workspaceId),
executor
.select({ value: count() })
.from(workflow)
// Match listDeployedWorkflows: a workflow only counts as copyable when it has an
// actually-active deployment version, not just the isDeployed flag, so the fork
// modal's preflight count never over-reports "ghost" deployed workflows.
.where(
and(
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
exists(
executor
.select({ one: sql`1` })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
)
)
),
])
return {
files,
tables,
knowledgeBases: kbs,
customTools: tools,
skills,
mcpServers: servers,
deployedWorkflowCount: deployed[0]?.value ?? 0,
}
}
/** Resolve a credential id to its stored mapping resource type. */
export async function classifyCredentialResourceType(
executor: DbOrTx,
credentialId: string,
workspaceId: string
): Promise<Extract<ForkResourceType, 'oauth_credential' | 'service_account_credential'>> {
const [row] = await executor
.select({ type: credential.type })
.from(credential)
.where(and(eq(credential.id, credentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential'
}
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan'
/**
* `buildPromoteWorkflowIdMap` decides which cross-workflow references survive a
* promote: the resulting map is handed to `remapWorkflowReferencesInSubBlocks`,
* where a hit repoints the reference and a miss (with `clearUnmapped`) blanks it.
* These cases lock in the seed/overlay matrix so the "mapped sibling not in this
* push" repoint and the "deleted / archived / never-mapped" clears can't drift.
*/
describe('buildPromoteWorkflowIdMap', () => {
it("overlays this push's items (replace + create)", () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map(),
existingSourceIds: new Set(),
targetActiveIds: new Set(),
items: [
{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' },
{ sourceWorkflowId: 'b-src', targetWorkflowId: 'b-new' },
],
})
expect(map.get('a-src')).toBe('a-tgt')
expect(map.get('b-src')).toBe('b-new')
expect(map.size).toBe(2)
})
it('repoints a mapped sibling that is not in this push when source exists and target is active', () => {
// B is mapped + still deployed in the target but undeployed in the source, so it
// is not an item this push. A references B and must keep pointing at target-B.
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src']),
targetActiveIds: new Set(['b-tgt']),
items: [{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' }],
})
expect(map.get('b-src')).toBe('b-tgt')
expect(map.get('a-src')).toBe('a-tgt')
})
it('does not seed a mapped pair whose source was deleted (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(), // b-src deleted in the source
targetActiveIds: new Set(['b-tgt']),
items: [],
})
expect(map.has('b-src')).toBe(false)
})
it('does not seed a mapped pair whose target was archived (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src']),
targetActiveIds: new Set(), // b-tgt archived by a prior push
items: [],
})
expect(map.has('b-src')).toBe(false)
})
it('does not map a workflow that was never mapped (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src', 'c-src']),
targetActiveIds: new Set(['b-tgt']),
items: [],
})
expect(map.has('c-src')).toBe(false)
})
it('lets this push override a stale identity mapping (re-created target wins)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['s', 't-old']]),
existingSourceIds: new Set(['s']),
targetActiveIds: new Set(['t-old']),
items: [{ sourceWorkflowId: 's', targetWorkflowId: 't-new' }],
})
expect(map.get('s')).toBe('t-new')
})
})
@@ -0,0 +1,282 @@
import { workflow } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import type { DeployedWorkflowSummary } from '@/lib/workspaces/fork/copy/deploy-bridge'
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
import {
buildForkResolver,
getEdgeMappingRows,
resourceTypeToForkKind,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
filterExistingForkTargets,
getWorkspaceEnvKeys,
} from '@/lib/workspaces/fork/mapping/resources'
import {
type ForkReference,
type ForkReferenceResolver,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/lib/workspaces/fork/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
export interface ForkPromotePlanItem {
sourceWorkflowId: string
targetWorkflowId: string
/** The matched target workflow's current name (for rename-aware mapping), null when creating. */
targetName: string | null
mode: 'create' | 'replace'
sourceMeta: {
name: string
description: string | null
folderId: string | null
sortOrder: number
}
}
export interface ForkPromotePlan {
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
resolver: ForkReferenceResolver
items: ForkPromotePlanItem[]
workflowIdMap: Map<string, string>
/** Previously-mapped target workflows whose source no longer exists (to remove). */
archivedTargetIds: string[]
/** Same as `archivedTargetIds`, with the target workflow name for the preview. */
archivedTargets: Array<{ id: string; name: string }>
references: ForkReference[]
unmappedRequired: ForkReference[]
unmappedOptional: ForkReference[]
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: string[]
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
inlineSecretSources: string[]
willUpdate: number
willCreate: number
willArchive: number
}
/**
* Build the cross-workflow reference map used to rewrite `workflow-selector`,
* `manualWorkflowId`, and `workflow_input` references inside promoted workflows.
*
* Seeded from the persistent identity mappings - not just the workflows in THIS
* push - so a reference to a mapped sibling that isn't part of the current push
* (e.g. a workflow undeployed in the source but still existing and already
* deployed in the target) repoints at the existing target instead of clearing.
* Only pairs whose source still EXISTS and whose target is still ACTIVE are
* seeded: a deleted source (whose target is archived this push) stays unmapped so
* its inbound references clear, and a target archived by a prior push is never
* re-pointed at. The push's own items are overlaid last, so a created workflow
* contributes its fresh target id and a replaced one re-sets the same id.
*/
export function buildPromoteWorkflowIdMap(params: {
identityMap: Map<string, string>
existingSourceIds: Set<string>
targetActiveIds: Set<string>
items: Array<{ sourceWorkflowId: string; targetWorkflowId: string }>
}): Map<string, string> {
const { identityMap, existingSourceIds, targetActiveIds, items } = params
const workflowIdMap = new Map<string, string>()
for (const [sourceId, targetId] of identityMap) {
if (existingSourceIds.has(sourceId) && targetActiveIds.has(targetId)) {
workflowIdMap.set(sourceId, targetId)
}
}
for (const item of items) workflowIdMap.set(item.sourceWorkflowId, item.targetWorkflowId)
return workflowIdMap
}
/**
* Compute everything a promote needs without mutating. Only the source's
* **deployed** workflows participate; each plan item carries the source's active
* deployed state. Targets matched by the persisted workflow identity map are
* replaced; unmatched deployed sources create new targets. A target is archived
* only when it was previously mapped and its source is no longer deployed -
* target-native workflows are never touched. Shared by the diff preview and the
* promote orchestrator.
*/
export async function computeForkPromotePlan(params: {
executor: DbOrTx
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
/**
* Source deployed workflows + their states, read by the caller BEFORE its
* transaction (see `loadSourceDeployedStates`) so the plan never checks out a
* second pooled connection from inside a tx.
*/
deployedSourceWorkflows: DeployedWorkflowSummary[]
sourceStates: Map<string, WorkflowState>
}): Promise<ForkPromotePlan> {
const {
executor,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
deployedSourceWorkflows,
sourceStates,
} = params
const mappingRows = await getEdgeMappingRows(executor, edge.childWorkspaceId)
const [targetEnvKeys, sourceEnvKeys] = await Promise.all([
getWorkspaceEnvKeys(executor, targetWorkspaceId),
getWorkspaceEnvKeys(executor, sourceWorkspaceId),
])
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
// Collect each mapping's chosen target id (per kind) and keep only those that still
// exist in the target workspace, so a target deleted after the mapping was saved
// resolves as unmapped instead of writing a dead id into the promoted workflow.
const mappedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const row of mappingRows) {
const kind = resourceTypeToForkKind(row.resourceType)
if (!kind) continue
const targetId = sourceIsParent ? row.childResourceId : row.parentResourceId
if (targetId == null) continue
const set = mappedTargetIdsByKind[kind] ?? new Set<string>()
set.add(targetId)
mappedTargetIdsByKind[kind] = set
}
const validTargetIdsByKind = await filterExistingForkTargets(
executor,
targetWorkspaceId,
mappedTargetIdsByKind
)
const resolver = buildForkResolver(mappingRows, {
sourceIsParent,
targetEnvKeys,
sourceEnvKeys,
validTargetIdsByKind,
})
const identityMap = new Map<string, string>()
for (const row of mappingRows) {
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
if (sourceIsParent) identityMap.set(row.parentResourceId, row.childResourceId)
else identityMap.set(row.childResourceId, row.parentResourceId)
}
const [targetWorkflows, sourceWorkflowRows] = await Promise.all([
executor
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))),
executor
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, sourceWorkspaceId), isNull(workflow.archivedAt))),
])
const targetActiveIds = new Set(targetWorkflows.map((w) => w.id))
const targetNameById = new Map(targetWorkflows.map((w) => [w.id, w.name]))
// Every source workflow that still EXISTS (deployed or not). A mapped target is
// archived only when its source was DELETED - not merely undeployed. A fresh fork
// leaves the child's workflows undeployed, so pushing back must not archive the
// parent's originals; undeployed sources are simply skipped (target left as-is).
const existingSourceIds = new Set(sourceWorkflowRows.map((w) => w.id))
// Build the items and scan references in one pass from the pre-read source states
// (loaded before the caller's transaction; see loadSourceDeployedStates).
const items: ForkPromotePlanItem[] = []
const referenceByKey = new Map<string, ForkReference>()
for (const source of deployedSourceWorkflows) {
const sourceState = sourceStates.get(source.id)
if (!sourceState) continue
const mappedTargetId = identityMap.get(source.id)
const isReplace = Boolean(mappedTargetId && targetActiveIds.has(mappedTargetId))
const targetWorkflowId = isReplace ? (mappedTargetId as string) : generateId()
items.push({
sourceWorkflowId: source.id,
targetWorkflowId,
targetName: isReplace ? (targetNameById.get(targetWorkflowId) ?? null) : null,
mode: isReplace ? 'replace' : 'create',
sourceMeta: {
name: source.name,
description: source.description,
folderId: source.folderId,
sortOrder: source.sortOrder,
},
})
const blocks = Object.values(sourceState.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
for (const reference of scanWorkflowReferences(blocks, resolver).references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
const workflowIdMap = buildPromoteWorkflowIdMap({
identityMap,
existingSourceIds,
targetActiveIds,
items,
})
const writtenTargetIds = new Set(items.map((item) => item.targetWorkflowId))
const archivedTargetIds: string[] = []
for (const row of mappingRows) {
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId
const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId
if (existingSourceIds.has(mappedSourceId)) continue
if (writtenTargetIds.has(mappedTargetId)) continue
if (targetActiveIds.has(mappedTargetId)) archivedTargetIds.push(mappedTargetId)
}
const archivedTargets = archivedTargetIds.map((id) => ({
id,
name: targetNameById.get(id) ?? id,
}))
const cascade = await detectForkCascadeReferences({
executor,
sourceWorkspaceId,
references: Array.from(referenceByKey.values()),
resolve: resolver,
})
for (const reference of cascade.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
const allReferences = Array.from(referenceByKey.values())
const allUnmapped = allReferences.filter(
(reference) => resolver(reference.kind, reference.sourceId) == null
)
const unmappedRequired = allUnmapped.filter((reference) => reference.required)
const unmappedOptional = allUnmapped.filter((reference) => !reference.required)
const willUpdate = items.filter((i) => i.mode === 'replace').length
const willCreate = items.filter((i) => i.mode === 'create').length
return {
childWorkspaceId: edge.childWorkspaceId,
sourceWorkspaceId,
targetWorkspaceId,
direction,
resolver,
items,
workflowIdMap,
archivedTargetIds,
archivedTargets,
references: allReferences,
unmappedRequired,
unmappedOptional,
mcpReauthServerIds: cascade.mcpReauthServerIds,
inlineSecretSources: cascade.inlineSecretSources,
willUpdate,
willCreate,
willArchive: archivedTargetIds.length,
}
}
@@ -0,0 +1,128 @@
import { workspaceForkPromoteRun } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { desc, eq } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
/**
* A target workflow's pre-promote deployed-version reference. Rollback reactivates
* `priorVersion` (and loads it into the draft); `null` means the target was not
* deployed before the promote, so rollback undeploys it instead.
*/
export interface PromoteRunWorkflowSnapshot {
workflowId: string
priorVersion: number | null
}
export interface PromoteRunSnapshot {
/** Replaced targets: reactivate their prior deployed version on rollback. */
updated: PromoteRunWorkflowSnapshot[]
/** Targets the promote created: undeploy + archive on rollback. */
created: string[]
/** Orphan targets the promote archived: un-archive + reactivate on rollback. */
archived: PromoteRunWorkflowSnapshot[]
}
export interface PromoteRunRow {
id: string
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
snapshot: PromoteRunSnapshot
createdAt: Date
}
/** Replace the edge's undo point with a new run (single-level history). */
export async function upsertPromoteRun(
tx: DbOrTx,
params: {
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
snapshot: PromoteRunSnapshot
userId: string
}
): Promise<string> {
const now = new Date()
const id = generateId()
await tx
.insert(workspaceForkPromoteRun)
.values({
id,
childWorkspaceId: params.childWorkspaceId,
sourceWorkspaceId: params.sourceWorkspaceId,
targetWorkspaceId: params.targetWorkspaceId,
direction: params.direction,
snapshot: params.snapshot,
createdBy: params.userId,
createdAt: now,
})
.onConflictDoUpdate({
target: [workspaceForkPromoteRun.childWorkspaceId, workspaceForkPromoteRun.targetWorkspaceId],
set: {
id,
sourceWorkspaceId: params.sourceWorkspaceId,
direction: params.direction,
snapshot: params.snapshot,
createdBy: params.userId,
createdAt: now,
},
})
return id
}
/**
* Remove EVERY undo point targeting this workspace. Called after a rollback so the
* undo is single-level: only the latest sync into a target is ever undoable, and
* once it is undone there is no stack of older syncs to walk back into.
*/
export async function deleteAllPromoteRunsForTarget(
tx: DbOrTx,
targetWorkspaceId: string
): Promise<void> {
await tx
.delete(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
}
/**
* The newest undo point targeting this workspace. A workspace can be the target of
* several edges (pushes from its children, a pull from its parent), so order by
* recency: this is the ONLY undoable sync - older ones are stale the moment a newer
* sync lands, and rollback refuses them.
*/
export async function getLatestPromoteRunForTarget(
executor: DbOrTx,
targetWorkspaceId: string
): Promise<PromoteRunRow | null> {
const [row] = await executor
.select({
id: workspaceForkPromoteRun.id,
childWorkspaceId: workspaceForkPromoteRun.childWorkspaceId,
sourceWorkspaceId: workspaceForkPromoteRun.sourceWorkspaceId,
targetWorkspaceId: workspaceForkPromoteRun.targetWorkspaceId,
direction: workspaceForkPromoteRun.direction,
snapshot: workspaceForkPromoteRun.snapshot,
createdAt: workspaceForkPromoteRun.createdAt,
})
.from(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
.orderBy(desc(workspaceForkPromoteRun.createdAt))
.limit(1)
if (!row) return null
return { ...row, snapshot: row.snapshot as PromoteRunSnapshot }
}
/**
* The "other" workspace and direction of the latest sync into this target, for the
* UI's undo affordance. `sourceWorkspaceId` is the workspace the sync came from
* (rollback resolves the edge from target + other).
*/
export async function getUndoableRunForTarget(
executor: DbOrTx,
targetWorkspaceId: string
): Promise<{ sourceWorkspaceId: string; direction: 'push' | 'pull' } | null> {
const run = await getLatestPromoteRunForTarget(executor, targetWorkspaceId)
return run ? { sourceWorkspaceId: run.sourceWorkspaceId, direction: run.direction } : null
}
@@ -0,0 +1,767 @@
import { db } from '@sim/db'
import { credential, credentialMember, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
enqueueWorkflowUndeploySideEffects,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import {
copyWorkflowStateIntoTarget,
loadTargetDraftSubBlocks,
loadWorkflowNameRegistry,
resolveForkFolderMapping,
} from '@/lib/workspaces/fork/copy/copy-workflows'
import {
getActiveDeploymentVersionNumbers,
loadSourceDeployedStates,
} from '@/lib/workspaces/fork/copy/deploy-bridge'
import {
acquireForkEdgeLock,
acquireForkTargetLock,
type ForkEdge,
setForkLockTimeout,
} from '@/lib/workspaces/fork/lineage/lineage'
import {
type ForkBlockPair,
loadForkBlockMap,
reconcileForkBlockPairs,
toForkBlockPairs,
} from '@/lib/workspaces/fork/mapping/block-map-store'
import {
type ForkDependentValue,
loadForkDependentValues,
reconcileForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import {
deleteWorkflowIdentityByIds,
type ForkMappingUpsert,
upsertEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
computeForkPromotePlan,
type ForkPromotePlan,
} from '@/lib/workspaces/fork/promote/promote-plan'
import {
type PromoteRunWorkflowSnapshot,
upsertPromoteRun,
} from '@/lib/workspaces/fork/promote/promote-run-store'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import {
createForkSubBlockTransform,
type ForkReference,
} from '@/lib/workspaces/fork/remap/remap-references'
import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket'
import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceForkPromote')
export interface PromoteForkParams {
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
userId: string
/**
* The full stored mapping of dependent-field values the caller is committing (target
* workflow id + deterministic block id + subblock key -> value). Applied to the target
* blocks during the merge and persisted as the stored mapping. OMITTING the field (passing
* `undefined`) leaves the existing stored mapping untouched - the store is the sole source
* of truth and is loaded + applied as-is; an explicit `[]` clears the written replace
* targets' mapping. This distinction keeps a programmatic promote that omits the field from
* silently wiping the user's saved selections.
*/
dependentValues?: Array<{
workflowId: string
blockId: string
subBlockKey: string
value: string
}>
requestId?: string
}
export interface PromoteForkResult {
promoteRunId: string
updated: number
created: number
archived: number
redeployed: number
/**
* Targets whose state was written but whose post-transaction deploy failed. The
* draft holds the synced state; the active deployment still runs the prior version
* until a redeploy. Surfaced (rather than swallowed) so the caller can warn.
*/
deployFailed: number
unmappedRequired: Array<Pick<ForkReference, 'kind' | 'sourceId' | 'required' | 'blockName'>>
blocked: 'unmapped' | null
/** Names of the workflows the sync changed, by action, for the activity report. */
updatedNames: string[]
createdNames: string[]
archivedNames: string[]
/**
* Workflows whose required dependent fields a parent change cleared - the target
* must re-pick them. These were written but intentionally NOT redeployed (the prior
* version keeps running), so the sync never deploys a broken workflow.
*/
needsConfiguration: Array<{ workflowName: string; blocks: string[] }>
/**
* Workflows whose OPTIONAL dependent fields a parent change cleared (e.g. a trigger
* label filter). Redeployed as-is, but surfaced so a cleared filter that would broaden
* behavior is never silent.
*/
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
}
function collectCredentialPairs(plan: ForkPromotePlan): Array<[string, string]> {
const pairs = new Map<string, string>()
for (const reference of plan.references) {
if (reference.kind !== 'credential') continue
const target = plan.resolver('credential', reference.sourceId)
if (target) pairs.set(reference.sourceId, target)
}
return Array.from(pairs.entries())
}
/**
* Grant each mapped target credential the same active members its source credential has,
* intersected with the target workspace's members, so a promoted workflow's collaborators
* can use the remapped credential. Both sides are validated to actually exist in their
* workspace first (so a crafted/stale mapping can't drive cross-workspace access), member
* reads are batched, and inserts are conflict-safe. Side-effect only on `credentialMember`.
*/
async function propagateCredentialAccess(
tx: DbOrTx,
params: {
plan: ForkPromotePlan
sourceWorkspaceId: string
targetWorkspaceId: string
targetMembers: string[]
now: Date
}
): Promise<void> {
const { plan, sourceWorkspaceId, targetWorkspaceId, targetMembers, now } = params
const credentialPairs = collectCredentialPairs(plan)
const propagationTargetIds = credentialPairs.map(([, targetCredId]) => targetCredId)
const propagationSourceIds = credentialPairs.map(([sourceCredId]) => sourceCredId)
const validTargetCredentialIds = new Set<string>()
if (propagationTargetIds.length > 0) {
const validRows = await tx
.select({ id: credential.id })
.from(credential)
.where(
and(
inArray(credential.id, propagationTargetIds),
eq(credential.workspaceId, targetWorkspaceId)
)
)
for (const row of validRows) validTargetCredentialIds.add(row.id)
}
const validSourceCredentialIds = new Set<string>()
if (propagationSourceIds.length > 0) {
const validRows = await tx
.select({ id: credential.id })
.from(credential)
.where(
and(
inArray(credential.id, propagationSourceIds),
eq(credential.workspaceId, sourceWorkspaceId)
)
)
for (const row of validRows) validSourceCredentialIds.add(row.id)
}
const validPairs = credentialPairs.filter(
([sourceCredId, targetCredId]) =>
validSourceCredentialIds.has(sourceCredId) && validTargetCredentialIds.has(targetCredId)
)
if (validPairs.length === 0) return
// Batch all source credentials' active members in one query (instead of one per pair),
// then build a single insert. `targetMembers` becomes a Set for O(1) membership checks.
const targetMemberSet = new Set(targetMembers)
const memberRows = await tx
.select({
credentialId: credentialMember.credentialId,
userId: credentialMember.userId,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
inArray(
credentialMember.credentialId,
validPairs.map(([sourceCredId]) => sourceCredId)
),
eq(credentialMember.status, 'active')
)
)
const membersBySource = new Map<
string,
Array<Pick<(typeof memberRows)[number], 'userId' | 'role'>>
>()
for (const row of memberRows) {
if (!targetMemberSet.has(row.userId)) continue
const list = membersBySource.get(row.credentialId)
if (list) list.push({ userId: row.userId, role: row.role })
else membersBySource.set(row.credentialId, [{ userId: row.userId, role: row.role }])
}
const memberInserts = validPairs.flatMap(([sourceCredId, targetCredId]) =>
(membersBySource.get(sourceCredId) ?? []).map((member) => ({
id: generateId(),
credentialId: targetCredId,
userId: member.userId,
role: member.role,
status: 'active' as const,
joinedAt: now,
createdAt: now,
updatedAt: now,
}))
)
if (memberInserts.length > 0) {
await tx
.insert(credentialMember)
.values(memberInserts)
.onConflictDoNothing({
target: [credentialMember.credentialId, credentialMember.userId],
})
}
}
interface PromoteTxBlocked {
blocked: 'unmapped'
unmappedRequired: PromoteForkResult['unmappedRequired']
}
interface PromoteTxApplied {
blocked: null
promoteRunId: string
deployTargetIds: string[]
/** Actual written/archived counts (post-skip), not the pre-copy plan totals. */
updated: number
created: number
archived: number
/** Source workflows skipped because their deployment vanished between plan and apply. */
skippedItems: Array<{ id: string; name: string }>
/** Target workflow id -> source name, so the deploy-failure report can show names. */
writtenNames: Record<string, string>
/** Names of the changed workflows, by action, for the activity report. */
updatedNames: string[]
createdNames: string[]
archivedNames: string[]
/** Outbox event ids enqueued for archived orphans' undeploy side-effects. */
undeployEventIds: string[]
/**
* Per-target required dependents a parent change cleared (with workflow id so the
* post-commit deploy loop can skip them, keeping the prior version running).
*/
needsConfiguration: Array<{ workflowId: string; workflowName: string; blocks: string[] }>
/** Per-workflow optional dependents a parent change cleared (surfaced, not gated). */
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
}
/**
* Group flat dependent values into the apply map `target workflow -> block id -> subblock -> value`
* that {@link copyWorkflowStateIntoTarget} consumes. Pure (no DB), so the provided-value path can
* build it before the transaction (it doesn't depend on the plan); the omitted path feeds it the
* loaded store rows inside the tx, where the plan's replace targets are known.
*/
function groupDependentOverrides(
values: ForkDependentValue[]
): Map<string, Map<string, Map<string, string>>> {
const byWorkflow = new Map<string, Map<string, Map<string, string>>>()
for (const entry of values) {
let byBlock = byWorkflow.get(entry.targetWorkflowId)
if (!byBlock) {
byBlock = new Map()
byWorkflow.set(entry.targetWorkflowId, byBlock)
}
let byKey = byBlock.get(entry.targetBlockId)
if (!byKey) {
byKey = new Map()
byBlock.set(entry.targetBlockId, byKey)
}
byKey.set(entry.subBlockKey, entry.value)
}
return byWorkflow
}
/**
* Execute a force promote along the edge. Only the source's deployed workflows
* participate: each one's active deployed state is remapped into the target
* (replacing mapped targets in place with deterministic block ids, creating new
* ones, archiving previously-mapped orphans whose source is no longer deployed),
* a version-reference rollback snapshot is captured, credential access is
* propagated, and every promoted target is deployed. The plan is computed inside
* the edge lock so concurrent promotes serialize. A sync always force-replaces the
* target's deployed state (the modal confirms the overwrite up front); it blocks
* without mutating only when required references are unmapped.
*/
export async function promoteFork(params: PromoteForkParams): Promise<PromoteForkResult> {
const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params
const requestId = params.requestId ?? 'unknown'
// Distinguish an OMITTED dependent mapping (leave the store as-is) from an explicit empty
// array (clear it). When values are PROVIDED the apply map is plan-independent, so build it
// here - BEFORE the transaction - to keep the advisory lock tight (pure in-memory, no DB),
// mirroring how the source states are pre-loaded above. The OMITTED path needs the plan's
// replace targets, so it loads + builds inside the tx below.
const dependentValuesProvided = params.dependentValues !== undefined
const providedOverridesByWorkflow = dependentValuesProvided
? groupDependentOverrides(
(params.dependentValues ?? []).map((entry) => ({
targetWorkflowId: entry.workflowId,
targetBlockId: entry.blockId,
subBlockKey: entry.subBlockKey,
value: entry.value,
}))
)
: null
const targetMembers = (await getUsersWithPermissions(targetWorkspaceId)).map((m) => m.userId)
// Read the source's deployed workflows + states BEFORE the transaction so these
// heavy per-workflow reads never check out a second pooled connection from inside
// the promote tx (which can deadlock the pool at saturation). The source is
// read-only here, so this pre-tx snapshot is exactly what gets force-pushed.
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(sourceWorkspaceId)
const txResult: PromoteTxBlocked | PromoteTxApplied = await db.transaction(async (tx) => {
// Bound lock waits so a contended sync into this target fails fast instead of
// stagnating the pool. Must run before acquiring the advisory locks below.
await setForkLockTimeout(tx)
// Target lock before edge lock (consistent ordering): the target lock serializes
// every sync into this target so sibling forks can't interleave writes, and so
// rollback's "newest sync" check stays race-free against a concurrent promote.
await acquireForkTargetLock(tx, targetWorkspaceId)
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
const plan = await computeForkPromotePlan({
executor: tx,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
deployedSourceWorkflows: deployedWorkflows,
sourceStates,
})
if (plan.unmappedRequired.length > 0) {
return {
blocked: 'unmapped',
unmappedRequired: plan.unmappedRequired.map((reference) => ({
kind: reference.kind,
sourceId: reference.sourceId,
required: reference.required,
blockName: reference.blockName,
})),
}
}
const now = new Date()
const transform = createForkSubBlockTransform(plan.resolver)
const folderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId,
targetWorkspaceId,
userId,
now,
})
// Batch every prior-version read (replace + archive targets) into one query before any
// write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so
// they still reflect the active version each target had before this sync.
const priorVersionByTarget = await getActiveDeploymentVersionNumbers(tx, [
...plan.items.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId),
...plan.archivedTargetIds,
])
// Preload the target's active workflow names so per-workflow collision checks read from
// memory instead of one query each inside this locked tx. The DB unique index remains
// the correctness backstop (a stale snapshot only risks a rare, retry-able conflict).
const nameRegistry = await loadWorkflowNameRegistry(tx, targetWorkspaceId)
// Replace targets (the only mode with a prior target state) - reused by the draft preload
// and the dependent-value apply/load below.
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
// Preload the target's current draft subBlocks (replace targets only) so the copy can
// detect dependent fields a parent change cleared that the stored mapping didn't refill
// (surfaced as needs-configuration). One batched query pre-write, so it reflects the
// pre-sync target state.
const targetDraftByWorkflow = await loadTargetDraftSubBlocks(tx, replaceTargetIds)
// The dependent-value apply map (target workflow -> block id -> subblock -> value). When the
// caller PROVIDED values it was built pre-tx (plan-independent); apply it and reconcile the
// store below. When OMITTED the store is the sole source of truth - load the existing values
// for the plan's replace targets (one indexed query) and build the map, skipping the reconcile
// below so an omitted field never wipes the saved mapping.
const overridesByWorkflow =
providedOverridesByWorkflow ??
groupDependentOverrides(
await loadForkDependentValues(tx, edge.childWorkspaceId, replaceTargetIds)
)
// Resolve each source block to its counterpart's EXISTING id (via the persisted block
// map) instead of re-deriving, so a push keeps the parent's original block ids - and the
// webhook URLs derived from them - stable. Falls back to derive for blocks with no pair
// yet (added since the last sync), which are recorded below.
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
const blockMap = await loadForkBlockMap(tx, edge.childWorkspaceId)
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
const blockPairs: ForkBlockPair[] = []
const updatedSnapshots: PromoteRunWorkflowSnapshot[] = []
const createdTargetIds: string[] = []
const writtenItems: typeof plan.items = []
const needsConfiguration: PromoteTxApplied['needsConfiguration'] = []
const clearedOptional: PromoteTxApplied['clearedOptional'] = []
for (const item of plan.items) {
// Use the pre-read source state (loaded above, before the tx). An item only
// exists when its state was present at read time, so this lookup hits; the
// guard stays as defense so the written counts below never over-report.
const sourceState = sourceStates.get(item.sourceWorkflowId)
if (!sourceState) continue
if (item.mode === 'replace') {
const priorVersion = priorVersionByTarget.get(item.targetWorkflowId) ?? null
updatedSnapshots.push({ workflowId: item.targetWorkflowId, priorVersion })
} else {
createdTargetIds.push(item.targetWorkflowId)
}
const copyResult = await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId: item.targetWorkflowId,
targetWorkspaceId,
userId,
mode: item.mode,
now,
sourceState,
sourceMeta: item.sourceMeta,
workflowIdMap: plan.workflowIdMap,
folderIdMap,
transformSubBlocks: transform,
targetCurrentBlocks:
item.mode === 'replace' ? targetDraftByWorkflow.get(item.targetWorkflowId) : undefined,
dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId),
nameRegistry,
resolveBlockId,
requestId,
})
blockPairs.push(
...toForkBlockPairs(
copyResult.blockIdMapping,
sourceIsParent,
item.sourceWorkflowId,
item.targetWorkflowId
)
)
const requiredCleared = copyResult.clearedDependents.filter((field) => field.required)
const optionalCleared = copyResult.clearedDependents.filter((field) => !field.required)
if (requiredCleared.length > 0) {
needsConfiguration.push({
workflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
// Surface the block names (deduped) - the field titles ("Label") aren't useful.
blocks: [...new Set(requiredCleared.map((field) => field.blockName))],
})
}
if (optionalCleared.length > 0) {
clearedOptional.push({
workflowName: item.sourceMeta.name,
blocks: [...new Set(optionalCleared.map((field) => field.blockName))],
})
}
writtenItems.push(item)
}
// Reconcile block-identity pairs for the written source workflows: clears pairs for
// blocks the source dropped (e.g. a deleted trigger) and any stale pair from a re-created
// target, then records the live ones - so the next promote resolves these blocks to these
// same ids and never re-homes one onto an archived workflow's block.
await reconcileForkBlockPairs(
tx,
edge.childWorkspaceId,
sourceIsParent,
writtenItems.map((item) => item.sourceWorkflowId),
blockPairs
)
// Persist / prune the stored dependent mapping. When the caller PROVIDED values, replace
// every written replace-target's stored set (cleared/removed fields drop out so the store
// equals exactly what was sent) AND prune the archived targets' now-dead rows (their
// workflow no longer exists and has no FK to cascade). Scope the inserted values to the
// delete's workflows so a value for a workflow skipped this pass (its source state
// vanished) can't be inserted without first clearing its old row and trip the unique
// constraint. When OMITTED, the store stays the source of truth (already applied above) -
// only prune archived targets, never touch the live replace targets' mapping.
const dependentTargetIds = new Set(
writtenItems.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId)
)
if (dependentValuesProvided) {
await reconcileForkDependentValues(
tx,
edge.childWorkspaceId,
[...dependentTargetIds, ...plan.archivedTargetIds],
(params.dependentValues ?? [])
.filter((entry) => dependentTargetIds.has(entry.workflowId))
.map((entry) => ({
targetWorkflowId: entry.workflowId,
targetBlockId: entry.blockId,
subBlockKey: entry.subBlockKey,
value: entry.value,
}))
)
} else if (plan.archivedTargetIds.length > 0) {
await reconcileForkDependentValues(tx, edge.childWorkspaceId, plan.archivedTargetIds, [])
}
const archivedNames =
plan.archivedTargetIds.length > 0
? (
await tx
.select({ name: workflow.name })
.from(workflow)
.where(inArray(workflow.id, plan.archivedTargetIds))
).map((row) => row.name)
: []
const undeployEventIds: string[] = []
const archivedSnapshots: PromoteRunWorkflowSnapshot[] = []
for (const targetWorkflowId of plan.archivedTargetIds) {
const priorVersion = priorVersionByTarget.get(targetWorkflowId) ?? null
archivedSnapshots.push({ workflowId: targetWorkflowId, priorVersion })
// Enqueue undeploy side-effects (webhook + MCP-tool cleanup) so an archived orphan
// doesn't leak its subscriptions/registrations - mirrors rollback's undeploy path.
await undeployWorkflow({
workflowId: targetWorkflowId,
tx,
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
if (deploymentVersionIds.length === 0) return
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
workflowId: targetWorkflowId,
deploymentVersionIds,
userId,
requestId,
})
undeployEventIds.push(eventId)
},
})
await tx
.update(workflow)
.set({ archivedAt: now, updatedAt: now })
.where(eq(workflow.id, targetWorkflowId))
}
const identityEntries: ForkMappingUpsert[] = writtenItems.map((item) => ({
resourceType: 'workflow' as const,
parentResourceId: direction === 'pull' ? item.sourceWorkflowId : item.targetWorkflowId,
childResourceId: direction === 'pull' ? item.targetWorkflowId : item.sourceWorkflowId,
}))
// The identity upsert keys on the parent side, which on push is the TARGET. A
// source whose previously-mapped target was archived gets a freshly-generated
// target id here, so its old (stale-target) identity row wouldn't be overwritten
// and would leak a second mapping for the same source. Delete every prior identity
// row for these sources (by the source side) first so exactly one row per source
// remains - this also converges any pre-existing duplicates.
await deleteWorkflowIdentityByIds(
tx,
edge.childWorkspaceId,
direction === 'pull' ? 'parent' : 'child',
writtenItems.map((item) => item.sourceWorkflowId)
)
await upsertEdgeMappings(tx, edge.childWorkspaceId, userId, identityEntries)
await propagateCredentialAccess(tx, {
plan,
sourceWorkspaceId,
targetWorkspaceId,
targetMembers,
now,
})
const promoteRunId = await upsertPromoteRun(tx, {
childWorkspaceId: edge.childWorkspaceId,
sourceWorkspaceId,
targetWorkspaceId,
direction,
userId,
snapshot: {
updated: updatedSnapshots,
created: createdTargetIds,
archived: archivedSnapshots,
},
})
// A source whose active deployment vanished between plan and copy is skipped
// above, so report what was actually written - the plan totals would overstate.
const writtenSourceIds = new Set(writtenItems.map((item) => item.sourceWorkflowId))
const skippedItems = plan.items
.filter((item) => !writtenSourceIds.has(item.sourceWorkflowId))
.map((item) => ({ id: item.sourceWorkflowId, name: item.sourceMeta.name }))
if (skippedItems.length > 0) {
logger.warn(
`[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`,
{ sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length }
)
}
return {
blocked: null,
promoteRunId,
deployTargetIds: writtenItems.map((item) => item.targetWorkflowId),
updated: updatedSnapshots.length,
created: createdTargetIds.length,
archived: archivedSnapshots.length,
skippedItems,
writtenNames: Object.fromEntries(
writtenItems.map((item) => [item.targetWorkflowId, item.sourceMeta.name])
),
updatedNames: writtenItems
.filter((item) => item.mode === 'replace')
.map((item) => item.sourceMeta.name),
createdNames: writtenItems
.filter((item) => item.mode !== 'replace')
.map((item) => item.sourceMeta.name),
archivedNames,
undeployEventIds,
needsConfiguration,
clearedOptional,
}
})
if (txResult.blocked !== null) {
const unmappedRequired = txResult.blocked === 'unmapped' ? txResult.unmappedRequired : []
return {
promoteRunId: '',
updated: 0,
created: 0,
archived: 0,
redeployed: 0,
deployFailed: 0,
unmappedRequired,
blocked: txResult.blocked,
updatedNames: [],
createdNames: [],
archivedNames: [],
needsConfiguration: [],
clearedOptional: [],
}
}
// Process archived orphans' undeploy side-effects after commit (durably retried by the
// outbox cron if this dies first), so the locked transaction never held a network call.
for (const eventId of txResult.undeployEventIds) {
try {
await processWorkflowDeploymentOutboxEvent(eventId)
} catch (error) {
logger.warn(`[${requestId}] Deferred archive undeploy side-effect failed (will retry)`, {
eventId,
error: getErrorMessage(error),
})
}
}
let redeployed = 0
const deployFailures: string[] = []
const deployWarnings: string[] = []
// Targets whose required dependents a parent change cleared: their draft holds the
// synced state, but we intentionally skip the redeploy so the prior deployed version
// keeps running instead of going live with an empty required field. The user re-picks
// the field (surfaced via `needsConfiguration`), then a redeploy/next sync deploys it.
const needsConfigTargetIds = new Set(txResult.needsConfiguration.map((n) => n.workflowId))
// Deploy in a deterministic (sorted) order so this UNLOCKED loop acquires workflow
// row locks in the same order as a concurrent rollback's atomic tx (and a sibling
// promote's deploy loop), avoiding deadlocks - see rollback.ts lock ordering.
const deployTargetIds = [...txResult.deployTargetIds].sort((a, b) => a.localeCompare(b))
for (const targetWorkflowId of deployTargetIds) {
// The transaction already force-replaced this target's draft state, so connected
// canvas clients must adopt it (mothership-edit semantics) whether or not the
// subsequent deploy succeeds - otherwise they keep, and may clobber, stale state.
void notifyForkWorkflowChanged(targetWorkflowId)
if (needsConfigTargetIds.has(targetWorkflowId)) continue
try {
const result = await performFullDeploy({ workflowId: targetWorkflowId, userId, requestId })
if (result.success) {
redeployed += 1
// A deploy can succeed but defer/queue some side-effects (trigger/schedule/MCP
// sync). Surface those instead of swallowing them into a clean success.
if (result.warnings?.length) {
const name = txResult.writtenNames[targetWorkflowId] ?? targetWorkflowId
for (const warning of result.warnings) deployWarnings.push(`${name}: ${warning}`)
}
} else {
deployFailures.push(targetWorkflowId)
logger.warn(`[${requestId}] Deploy after promote failed`, {
workflowId: targetWorkflowId,
error: result.error,
})
}
} catch (error) {
deployFailures.push(targetWorkflowId)
logger.error(`[${requestId}] Deploy after promote threw`, {
workflowId: targetWorkflowId,
error: getErrorMessage(error),
})
}
}
if (deployFailures.length > 0) {
logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, {
sourceWorkspaceId,
targetWorkspaceId,
deployFailed: deployFailures.length,
deployFailures,
})
}
if (deployWarnings.length > 0) {
logger.warn(`[${requestId}] Promote deploys emitted warnings`, { deployWarnings })
}
if (txResult.skippedItems.length > 0) {
logger.warn(`[${requestId}] Promote skipped undeployed source workflows`, {
skipped: txResult.skippedItems.map((item) => item.name),
})
}
if (txResult.needsConfiguration.length > 0) {
logger.warn(`[${requestId}] Promote left required dependent fields needing configuration`, {
sourceWorkspaceId,
targetWorkspaceId,
needsConfiguration: txResult.needsConfiguration.map((n) => n.workflowName),
})
}
logger.info(`[${requestId}] Promoted ${sourceWorkspaceId} -> ${targetWorkspaceId}`, {
updated: txResult.updated,
created: txResult.created,
archived: txResult.archived,
redeployed,
deployFailed: deployFailures.length,
needsConfiguration: txResult.needsConfiguration.length,
})
return {
promoteRunId: txResult.promoteRunId,
updated: txResult.updated,
created: txResult.created,
archived: txResult.archived,
redeployed,
deployFailed: deployFailures.length,
unmappedRequired: [],
blocked: null,
updatedNames: txResult.updatedNames,
createdNames: txResult.createdNames,
archivedNames: txResult.archivedNames,
needsConfiguration: txResult.needsConfiguration.map(({ workflowName, blocks }) => ({
workflowName,
blocks,
})),
clearedOptional: txResult.clearedOptional,
}
}
@@ -0,0 +1,138 @@
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface ReactivateDeployedVersionParams {
tx: DbOrTx
workflowId: string
version: number
userId: string
requestId: string
}
export interface ReactivateDeployedVersionResult {
deploymentVersionId: string
/**
* Outbox event id enqueued inside the transaction. Process it AFTER the tx commits
* (or rely on the outbox cron/reaper if the process dies first).
*/
outboxEventId: string
}
/**
* Reactivate a prior deployment version AND restore the workflow's draft to it using
* ONLY DB writes against the provided transaction, enqueuing the deployment
* side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing
* AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion}
* and `performRevertToVersion` so a fork rollback can run atomically under its fork
* advisory lock - the heavy side-effects never run inside the locked tx.
*
* Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo
* and must not be blocked by a workflow/folder lock (that check is also not tx-safe).
* Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state
* on retry.
*
* Returns null when the target version row no longer exists, so the caller can mark the
* workflow skipped rather than failing the whole rollback.
*/
export async function reactivateDeployedVersionInTx(
params: ReactivateDeployedVersionParams
): Promise<ReactivateDeployedVersionResult | null> {
const { tx, workflowId, version, userId, requestId } = params
const now = new Date()
// Lock the workflow row so this serializes with a concurrent (unlocked) promote
// deploy loop, which locks the same row in deployWorkflow - guaranteeing the final
// (active version, draft) pair is always coherent regardless of commit order.
await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
.for('update')
const [versionRow] = await tx
.select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!versionRow) return null
const deployedState = versionRow.state as {
blocks?: Record<string, unknown>
edges?: unknown[]
loops?: Record<string, unknown>
parallels?: Record<string, unknown>
variables?: WorkflowState['variables']
}
if (!deployedState.blocks || !deployedState.edges) {
throw new Error(
`Deployment version ${version} for workflow ${workflowId} has an invalid state structure`
)
}
// Activate the target version (deactivate every other), mark the workflow deployed.
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, workflowId))
// Restore the draft to the deployed version's state.
const hasVariables = Object.hasOwn(deployedState, 'variables')
const restoredState: WorkflowState = {
blocks: deployedState.blocks,
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: now.getTime(),
} as WorkflowState
if (hasVariables) {
restoredState.variables = deployedState.variables || {}
}
const saveResult = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx)
if (!saveResult.success) {
throw new Error(saveResult.error || `Failed to restore draft for workflow ${workflowId}`)
}
await tx
.update(workflow)
.set({
...(hasVariables ? { variables: deployedState.variables || {} } : {}),
lastSynced: now,
updatedAt: now,
})
.where(eq(workflow.id, workflowId))
const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
workflowId,
deploymentVersionId: versionRow.id,
userId,
requestId,
forceRecreateSubscriptions: true,
})
return { deploymentVersionId: versionRow.id, outboxEventId }
}
@@ -0,0 +1,279 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockResolveForkEdge,
mockAcquireTargetLock,
mockAcquireEdgeLock,
mockGetLatestRun,
mockDeleteAllRuns,
mockReactivate,
mockUndeploy,
mockDeleteIdentity,
mockEnqueueUndeploy,
mockProcessOutbox,
mockNotify,
} = vi.hoisted(() => ({
mockResolveForkEdge: vi.fn(),
mockAcquireTargetLock: vi.fn(),
mockAcquireEdgeLock: vi.fn(),
mockGetLatestRun: vi.fn(),
mockDeleteAllRuns: vi.fn(),
mockReactivate: vi.fn(),
mockUndeploy: vi.fn(),
mockDeleteIdentity: vi.fn(),
mockEnqueueUndeploy: vi.fn(),
mockProcessOutbox: vi.fn(),
mockNotify: vi.fn(),
}))
vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({
resolveForkEdge: mockResolveForkEdge,
acquireForkTargetLock: mockAcquireTargetLock,
acquireForkEdgeLock: mockAcquireEdgeLock,
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/lib/workspaces/fork/promote/promote-run-store', () => ({
getLatestPromoteRunForTarget: mockGetLatestRun,
deleteAllPromoteRunsForTarget: mockDeleteAllRuns,
}))
vi.mock('@/lib/workspaces/fork/promote/reactivate-in-tx', () => ({
reactivateDeployedVersionInTx: mockReactivate,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: mockUndeploy,
}))
vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: mockDeleteIdentity,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: mockEnqueueUndeploy,
processWorkflowDeploymentOutboxEvent: mockProcessOutbox,
}))
vi.mock('@/lib/workspaces/fork/socket', () => ({
notifyForkWorkflowChanged: mockNotify,
}))
import { db } from '@sim/db'
import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
/** A fake transaction whose existence query returns the given undeploy ids. */
function makeTx(existingUndeployIds: string[] = []) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(existingUndeployIds.map((id) => ({ id })))),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
})),
}
}
function setTx(existingUndeployIds: string[] = []) {
vi.mocked(db.transaction).mockImplementation(
async (cb: (tx: unknown) => unknown) => cb(makeTx(existingUndeployIds)) as never
)
}
function makeRun(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'run-1',
childWorkspaceId: EDGE.childWorkspaceId,
direction: 'push' as const,
snapshot: { updated: [], created: [], archived: [] },
...overrides,
}
}
describe('rollbackFork', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveForkEdge.mockResolvedValue(EDGE)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' })
mockUndeploy.mockResolvedValue({ success: true })
mockProcessOutbox.mockResolvedValue('completed')
setTx([])
})
it('reactivates updated workflows and processes side-effects after commit', async () => {
const run = makeRun({
snapshot: {
updated: [
{ workflowId: 'wf-b', priorVersion: 5 },
{ workflowId: 'wf-a', priorVersion: 3 },
],
created: [],
archived: [],
},
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
deploymentVersionId: `dv-${workflowId}`,
outboxEventId: `evt-${workflowId}`,
}))
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result).toMatchObject({
restored: 2,
archived: 0,
unarchived: 0,
skipped: 0,
skippedIds: [],
})
// Deterministic (sorted) order: wf-a before wf-b.
expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b'])
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-a')
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-b')
expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith('wf-a')
expect(mockNotify).toHaveBeenCalledWith('wf-b')
})
it('un-archives and reactivates an archived orphan (prior version restored)', async () => {
const run = makeRun({
snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] },
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' })
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.unarchived).toBe(1)
expect(result.restored).toBe(0)
expect(result.archived).toBe(0)
expect(result.skipped).toBe(0)
expect(mockReactivate).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: 'wf-x', version: 2 })
)
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x')
expect(mockNotify).toHaveBeenCalledWith('wf-x')
})
it('aborts with 409 and writes nothing when a newer sync supersedes it mid-flight', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
// Unlocked read returns our run; the in-tx re-check sees a newer run.
mockGetLatestRun.mockResolvedValueOnce(run).mockResolvedValueOnce(makeRun({ id: 'run-2' }))
await expect(
rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
).rejects.toMatchObject({ statusCode: 409 })
// No partial restore: nothing reactivated, no undo point consumed.
expect(mockReactivate).not.toHaveBeenCalled()
expect(mockUndeploy).not.toHaveBeenCalled()
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
expect(mockProcessOutbox).not.toHaveBeenCalled()
})
it('surfaces a skipped reactivation when the version is gone (never silent)', async () => {
const run = makeRun({
snapshot: {
updated: [
{ workflowId: 'wf-a', priorVersion: 3 },
{ workflowId: 'wf-b', priorVersion: 5 },
],
created: [],
archived: [],
},
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) =>
workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' }
)
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.restored).toBe(1)
expect(result.skipped).toBe(1)
expect(result.skippedIds).toEqual(['wf-b'])
expect(mockNotify).not.toHaveBeenCalledWith('wf-b')
})
it('undeploys + archives created workflows and dissolves their identity rows', async () => {
setTx(['wf-c'])
const run = makeRun({
direction: 'push',
snapshot: { updated: [], created: ['wf-c'], archived: [] },
})
mockGetLatestRun.mockResolvedValue(run)
mockUndeploy.mockImplementation(
async ({
onUndeployTransaction,
}: {
onUndeployTransaction?: (
tx: unknown,
r: { deploymentVersionIds: string[] }
) => Promise<void>
}) => {
await onUndeployTransaction?.(makeTx(), { deploymentVersionIds: ['dv-c'] })
return { success: true }
}
)
mockEnqueueUndeploy.mockResolvedValue('undeploy-evt')
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.archived).toBe(1)
expect(result.skipped).toBe(0)
expect(mockUndeploy).toHaveBeenCalledTimes(1)
expect(mockDeleteIdentity).toHaveBeenCalledWith(
expect.anything(),
EDGE.childWorkspaceId,
'parent',
['wf-c']
)
expect(mockProcessOutbox).toHaveBeenCalledWith('undeploy-evt')
})
it('skips a created workflow that was hard-deleted (not archived, surfaced)', async () => {
setTx([]) // wf-c no longer exists
const run = makeRun({ snapshot: { updated: [], created: ['wf-c'], archived: [] } })
mockGetLatestRun.mockResolvedValue(run)
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(mockUndeploy).not.toHaveBeenCalled()
expect(result.skipped).toBe(1)
expect(result.skippedIds).toEqual(['wf-c'])
expect(result.archived).toBe(0)
})
})
@@ -0,0 +1,292 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { inArray } from 'drizzle-orm'
import {
enqueueWorkflowUndeploySideEffects,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
import {
acquireForkEdgeLock,
acquireForkTargetLock,
resolveForkEdge,
setForkLockTimeout,
} from '@/lib/workspaces/fork/lineage/lineage'
import { deleteWorkflowIdentityByIds } from '@/lib/workspaces/fork/mapping/mapping-store'
import {
deleteAllPromoteRunsForTarget,
getLatestPromoteRunForTarget,
} from '@/lib/workspaces/fork/promote/promote-run-store'
import { reactivateDeployedVersionInTx } from '@/lib/workspaces/fork/promote/reactivate-in-tx'
import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket'
const logger = createLogger('WorkspaceForkRollback')
export interface RollbackForkParams {
targetWorkspaceId: string
otherWorkspaceId: string
userId: string
requestId?: string
}
export interface RollbackForkResult {
restored: number
archived: number
unarchived: number
/** Snapshot workflows that no longer exist and so couldn't be restored. */
skipped: number
/** Ids of the skipped workflows (surfaced so the partial restore is never silent). */
skippedIds: string[]
}
/** A single restore action, sorted by workflow id for a deterministic lock order. */
type RollbackOp =
| { workflowId: string; kind: 'reactivate'; version: number }
| { workflowId: string; kind: 'undeploy' }
/**
* Undo the most recent promote into `targetWorkspaceId` in ONE atomic, fork-locked,
* DB-only transaction. Because a concurrent promote takes the same target advisory
* lock for its write transaction, it cannot interleave with the rollback: it runs
* fully before or fully after. If a newer sync superseded our undo point, we abort
* with 409 BEFORE any write, so the operation is strictly all-or-nothing - it never
* leaves a partially reverted target.
*
* The heavy webhook / schedule / MCP re-subscription work is enqueued to the
* deployment outbox INSIDE the transaction and processed AFTER commit (and durably
* retried by the outbox cron/reaper if this process dies first), so the locked
* transaction never holds across a network call. No draft blobs are stored - the
* deployed version is the source of truth.
*/
export async function rollbackFork(params: RollbackForkParams): Promise<RollbackForkResult> {
const { targetWorkspaceId, otherWorkspaceId, userId } = params
const requestId = params.requestId ?? 'unknown'
const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
// Only the most recent sync into the target is undoable. Undoing an older sibling's
// sync while a newer one stands would partially revert the target and strand the
// newer sync's changes.
const run = await getLatestPromoteRunForTarget(db, targetWorkspaceId)
if (!run) {
throw new ForkError('There is no promote to undo for this workspace', 404)
}
if (run.childWorkspaceId !== edge.childWorkspaceId) {
throw new ForkError(
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
409
)
}
const { updated, created, archived } = run.snapshot
// Build the restore ops: reactivate a prior version, or undeploy (created targets +
// updated targets that had no prior deployment). Sort by workflow id so the locked
// transaction acquires workflow row locks in a deterministic order, avoiding
// deadlocks with the (unlocked) promote deploy loop, which locks the same rows.
const undeployIds = [
...created,
...updated.filter((i) => i.priorVersion == null).map((i) => i.workflowId),
]
const toReactivateOps = (
list: Array<{ workflowId: string; priorVersion: number | null }>
): RollbackOp[] =>
list
.filter((item) => item.priorVersion != null)
.map((item) => ({
workflowId: item.workflowId,
kind: 'reactivate' as const,
version: item.priorVersion as number,
}))
const ops: RollbackOp[] = [
...toReactivateOps(updated),
...toReactivateOps(archived),
...undeployIds.map((workflowId) => ({ workflowId, kind: 'undeploy' as const })),
].sort((a, b) => a.workflowId.localeCompare(b.workflowId))
const skipped = new Set<string>()
const outboxEventIds: string[] = []
await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkTargetLock(tx, targetWorkspaceId)
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
// Re-confirm our run is still the newest sync, now under the lock. If a promote
// landed since the unlocked read above, abort with NO writes (tx rolls back).
const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId)
if (!current || current.id !== run.id) {
throw new ForkError(
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
409
)
}
const now = new Date()
// Un-archive the orphans the promote archived BEFORE reactivating them.
if (archived.length > 0) {
await tx
.update(workflow)
.set({ archivedAt: null, updatedAt: now })
.where(
inArray(
workflow.id,
archived.map((i) => i.workflowId)
)
)
}
// Which undeploy targets still exist (created targets can be hard-deleted after the
// promote; a missing one is already gone, so skip rather than fail the rollback).
const existingUndeploy =
undeployIds.length === 0
? new Set<string>()
: new Set(
(
await tx
.select({ id: workflow.id })
.from(workflow)
.where(inArray(workflow.id, undeployIds))
).map((row) => row.id)
)
for (const op of ops) {
if (op.kind === 'reactivate') {
const result = await reactivateDeployedVersionInTx({
tx,
workflowId: op.workflowId,
version: op.version,
userId,
requestId,
})
// A null result means the workflow / version was hard-deleted since the
// promote - record it so the partial restore is surfaced, never silent.
if (!result) {
skipped.add(op.workflowId)
continue
}
outboxEventIds.push(result.outboxEventId)
continue
}
if (!existingUndeploy.has(op.workflowId)) {
skipped.add(op.workflowId)
continue
}
const undeployResult = await undeployWorkflow({
workflowId: op.workflowId,
tx,
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
if (deploymentVersionIds.length === 0) return
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
workflowId: op.workflowId,
deploymentVersionIds,
userId,
requestId,
})
outboxEventIds.push(eventId)
},
})
if (!undeployResult.success) {
// The workflow exists but couldn't be undeployed - abort so we never leave a
// partial undo. The whole tx rolls back and the undo point is preserved.
throw new ForkError(
`Rollback could not undeploy workflow ${op.workflowId}: ${undeployResult.error ?? 'unknown error'}. The undo point is preserved - retry the rollback.`,
500
)
}
}
// Archive the workflows the promote created and dissolve their identity rows.
if (created.length > 0) {
await tx
.update(workflow)
.set({ archivedAt: now, updatedAt: now })
.where(inArray(workflow.id, created))
// A created target is the child side on pull and the parent side on push.
await deleteWorkflowIdentityByIds(
tx,
edge.childWorkspaceId,
run.direction === 'pull' ? 'child' : 'parent',
created
)
}
// Single-level undo: drop every undo point for this target so no older sibling
// sync becomes undoable once this one is undone.
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
})
// After commit: process the enqueued side-effects (webhooks / schedules / MCP). These
// are durable outbox rows, so a crash here is recovered by the outbox cron/reaper -
// failures only warn, they never undo the (committed) restore.
for (const eventId of outboxEventIds) {
try {
await processWorkflowDeploymentOutboxEvent(eventId)
} catch (error) {
logger.warn(
`[${requestId}] Deferred rollback side-effect processing failed (will retry via outbox)`,
{ eventId, error }
)
}
}
if (skipped.size > 0) {
logger.warn(
`[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`,
{
targetWorkspaceId,
skipped: Array.from(skipped),
}
)
}
// Notify connected canvases to adopt the restored state (reactivated drafts + the
// undeployed/archived created targets). Skipped (gone) workflows have no room.
const notifyIds = new Set<string>()
for (const op of ops) {
if (!skipped.has(op.workflowId)) notifyIds.add(op.workflowId)
}
for (const workflowId of notifyIds) void notifyForkWorkflowChanged(workflowId)
// Attribute each skip to its bucket (a workflow is in exactly one) so the counts
// reflect what was actually restored, not the snapshot size.
const createdSet = new Set(created)
const archivedSet = new Set(archived.map((i) => i.workflowId))
const updatedSet = new Set(updated.map((i) => i.workflowId))
let skippedUpdated = 0
let skippedCreated = 0
let skippedArchived = 0
for (const id of skipped) {
if (updatedSet.has(id)) skippedUpdated += 1
else if (createdSet.has(id)) skippedCreated += 1
else if (archivedSet.has(id)) skippedArchived += 1
}
const restored = updated.length - skippedUpdated
const archivedCount = created.length - skippedCreated
const unarchived = archived.length - skippedArchived
const result: RollbackForkResult = {
restored,
archived: archivedCount,
unarchived,
skipped: skipped.size,
skippedIds: Array.from(skipped),
}
logger.info(`[${requestId}] Rolled back promote into ${targetWorkspaceId}`, {
restored: result.restored,
archived: result.archived,
unarchived: result.unarchived,
skipped: result.skipped,
})
return result
}
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { isValidUuid } from '@sim/utils/id'
import { describe, expect, it } from 'vitest'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
type ForkBlockMap,
} from '@/lib/workspaces/fork/remap/block-identity'
describe('deriveForkBlockId', () => {
const targetA = 'wf-target-a'
const targetB = 'wf-target-b'
const block1 = 'block-1'
const block2 = 'block-2'
it('is deterministic for the same (targetWorkflowId, sourceBlockId)', () => {
expect(deriveForkBlockId(targetA, block1)).toBe(deriveForkBlockId(targetA, block1))
})
it('yields different ids for the same source block in different target workflows', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetB, block1))
})
it('yields different ids for different source blocks in the same target workflow', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetA, block2))
})
it('produces a valid UUID string (v5)', () => {
const id = deriveForkBlockId(targetA, block1)
expect(isValidUuid(id)).toBe(true)
expect(id[14]).toBe('5')
})
it('does not collide the colon separator (a:bc vs ab:c)', () => {
expect(deriveForkBlockId('a', 'bc')).not.toBe(deriveForkBlockId('ab', 'c'))
})
})
describe('buildForkBlockIdResolver', () => {
const parentWf = 'wf-parent'
const childWf = 'wf-child'
const parentBlock = 'block-parent'
// The pair the fork created: child block derived from the parent block.
const childBlock = deriveForkBlockId(childWf, parentBlock)
const seededMap: ForkBlockMap = {
parentToChild: new Map([
[parentBlock, { targetBlockId: childBlock, targetWorkflowId: childWf }],
]),
childToParent: new Map([
[childBlock, { targetBlockId: parentBlock, targetWorkflowId: parentWf }],
]),
}
it('push maps a child block back to the parent ORIGINAL id (keeps the webhook URL stable)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, childBlock)).toBe(parentBlock)
// The bug this fixes: without the map, push would re-derive and re-key the parent block.
expect(pushResolve(parentWf, childBlock)).not.toBe(deriveForkBlockId(parentWf, childBlock))
})
it('pull maps a parent block to its existing child id', () => {
const pullResolve = buildForkBlockIdResolver(true, seededMap)
expect(pullResolve(childWf, parentBlock)).toBe(childBlock)
})
it('derives (does NOT reuse) when the target workflow was re-created (different id)', () => {
// Parent workflow archived + re-created as wf-parent-2: the pair points at the old
// workflow, so reusing parentBlock there would collide on the global block PK. Derive.
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve('wf-parent-2', childBlock)).toBe(
deriveForkBlockId('wf-parent-2', childBlock)
)
expect(pushResolve('wf-parent-2', childBlock)).not.toBe(parentBlock)
})
it('derives a fresh id for a source block with no recorded pair (added since last sync)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, 'block-new')).toBe(deriveForkBlockId(parentWf, 'block-new'))
})
it('derives everything when the map is empty (fork creation)', () => {
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
expect(resolve(childWf, parentBlock)).toBe(deriveForkBlockId(childWf, parentBlock))
})
})
@@ -0,0 +1,95 @@
import { createHash } from 'node:crypto'
/**
* Fixed namespace UUID for fork block-identity derivation. Changing this value
* would re-key every forked workflow's block ids, breaking webhook URLs and
* external block references (table workflow groups, chat output configs) across
* promotes - so it must never change.
*/
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'
function uuidToBytes(uuid: string): Buffer {
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
}
/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace))
hash.update(Buffer.from(name, 'utf8'))
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/**
* Derive the target block id for a source block copied into a target workflow.
*
* Identity is deterministic in `(targetWorkflowId, sourceBlockId)`, so a logical
* block keeps the same target id across every promote. This is what keeps trigger
* webhook URLs consistent and keeps external block-id references (table workflow
* groups, chat output configs, sim_trigger_state) valid across promotes. A source
* block that no longer exists simply has no derived target, so the target block
* disappears on the next force-replace.
*/
export function deriveForkBlockId(targetWorkflowId: string, sourceBlockId: string): string {
return uuidV5(`${targetWorkflowId}:${sourceBlockId}`, FORK_BLOCK_NAMESPACE)
}
/** A persisted counterpart: the target block id plus the workflow it belongs to. */
export interface ForkBlockMapEntry {
targetBlockId: string
/** The target-side workflow the pair belongs to (childWorkflowId for parentToChild). */
targetWorkflowId: string
}
/** Persisted block-identity pairs for an edge, indexed for both promote directions. */
export interface ForkBlockMap {
/** parent block id -> { child block, child workflow } (pull/create resolve source=parent). */
parentToChild: ReadonlyMap<string, ForkBlockMapEntry>
/** child block id -> { parent block, parent workflow } (push resolves source=child). */
childToParent: ReadonlyMap<string, ForkBlockMapEntry>
}
/** An empty map - fork creation has no prior pairs, so every block id is derived fresh. */
export const EMPTY_FORK_BLOCK_MAP: ForkBlockMap = {
parentToChild: new Map(),
childToParent: new Map(),
}
/** Resolve a source block to its target block id for a promote (map-or-derive). */
export type ForkBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string
/**
* Build the block-id resolver a promote uses to assign target block ids. It reuses the
* persisted counterpart when one exists AND that pair belongs to the workflow being written,
* else falls back to {@link deriveForkBlockId} (blocks added since the last sync; fork
* creation, which has no map). `sourceIsParent` is true on pull/create (source = parent) and
* false on push (source = child); it selects the lookup direction.
*
* The workflow guard is what makes a re-created target safe: if the original target workflow
* was archived and the promote creates a new one, the recorded pair points at the OLD
* workflow, so it no longer matches and we derive a fresh id - never reusing the archived
* workflow's block id (which would collide on the global `workflow_blocks` primary key).
*
* For a stable workflow this still maps each child block back to the parent's ORIGINAL id on
* push, keeping its trigger webhook URL fixed. The SAME resolver must back
* `copyWorkflowStateIntoTarget` (which writes the blocks) and `collectForkDependentReconfigs`
* (which keys the modal's override by target block id), or the two would disagree.
*/
export function buildForkBlockIdResolver(
sourceIsParent: boolean,
map: ForkBlockMap
): ForkBlockIdResolver {
const existing = sourceIsParent ? map.parentToChild : map.childToParent
return (targetWorkflowId, sourceBlockId) => {
const entry = existing.get(sourceBlockId)
if (entry && entry.targetWorkflowId === targetWorkflowId) return entry.targetBlockId
return deriveForkBlockId(targetWorkflowId, sourceBlockId)
}
}
@@ -0,0 +1,30 @@
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import {
clearDependentsOnRemap,
type ForkRemapKind,
remapForkSubBlocks,
} from '@/lib/workspaces/fork/remap/remap-references'
/**
* Resolves a source resource reference to its copied child id, or null when the
* resource was not copied into the fork. Credentials are never copied (always
* null), so credential references are cleared.
*/
export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string | null
/**
* A `copyWorkflowStateIntoTarget` transform for the initial fork. Runs the shared
* fork remapper in `create` mode: copyable resources the user selected are
* rewritten to their child ids; references to resources that were not copied (and
* all credential references) are cleared so the child workflow's subblocks start
* empty; env-var `{{KEY}}` references are preserved (name-based, they resolve once
* the child defines the key).
*/
export function createForkBootstrapTransform(
resolveCopied: ForkCopyResolver
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
return (subBlocks, blockType) => {
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create')
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
}
}
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { remapForkFileUploadValue } from '@/lib/workspaces/fork/remap/remap-files'
const map = (entries: Record<string, string>) => (key: string) => entries[key] ?? null
describe('remapForkFileUploadValue', () => {
it('rewrites a copied single object key, preserving other fields', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ key: 'child/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 })
})
it('clears a single object whose file was not copied', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
expect(remapForkFileUploadValue(value, map({}))).toBe('')
})
it('remaps copied items and drops uncopied ones in an array', () => {
const value = [
{ key: 'src/a.pdf', name: 'a.pdf' },
{ key: 'src/b.pdf', name: 'b.pdf' },
]
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual([{ key: 'child/a.pdf', name: 'a.pdf' }])
})
it('handles a JSON-stringified value and re-serializes', () => {
const value = JSON.stringify({ key: 'src/a.pdf', name: 'a.pdf' })
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toBe(JSON.stringify({ key: 'child/a.pdf', name: 'a.pdf' }))
})
it('falls back to the path field when there is no key', () => {
const value = { path: 'src/a.pdf', name: 'a.pdf' }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ path: 'child/a.pdf', name: 'a.pdf' })
})
it('returns the value unchanged when no items match', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
const sameKey = map({ 'src/a.pdf': 'src/a.pdf' })
expect(remapForkFileUploadValue(value, sameKey)).toBe(value)
})
it('returns empty/unparseable values untouched', () => {
expect(remapForkFileUploadValue('', map({}))).toBe('')
expect(remapForkFileUploadValue(null, map({}))).toBe(null)
})
})
@@ -0,0 +1,77 @@
/**
* `file-upload` subblock remapping for fork/promote.
*
* A `file-upload` value is a workspace-file reference (or array of them) stored as
* objects `{ key, name, ... }` where `key` is the object-storage key (NOT the
* `workspace_files.id`). Forking copies the blob to a new key; this rewrites each
* reference's key to the copied key, preserving the rest of the object. References
* whose file was not copied are dropped (the field is emptied) rather than left
* pointing at another workspace's blob. External `file-selector` references
* (provider file ids, credential-scoped) are NOT handled here - they carry over
* unchanged.
*/
function parseMaybeJson(value: unknown): { value: unknown; serialized: boolean } {
if (typeof value !== 'string') return { value, serialized: false }
const trimmed = value.trim()
const looksJson =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
if (!looksJson) return { value, serialized: false }
try {
return { value: JSON.parse(trimmed), serialized: true }
} catch {
return { value, serialized: false }
}
}
/** The field a file-upload item uses as its storage key, and that key's value. */
function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: string } | null {
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
const record = item as Record<string, unknown>
for (const field of ['key', 'path', 'name'] as const) {
const value = record[field]
if (typeof value === 'string' && value.trim().length > 0) return { field, key: value }
}
return null
}
/**
* Remap a `file-upload` subblock value. `resolveFileKey(sourceKey)` returns the
* copied target storage key, or null when the file was not copied (drop the ref).
*/
export function remapForkFileUploadValue(
value: unknown,
resolveFileKey: (sourceKey: string) => string | null
): unknown {
const parsed = parseMaybeJson(value)
const isArray = Array.isArray(parsed.value)
const items = isArray ? (parsed.value as unknown[]) : parsed.value ? [parsed.value] : []
if (items.length === 0) return value
const next: unknown[] = []
let changed = false
for (const item of items) {
const keyInfo = fileItemKeyField(item)
if (!keyInfo) {
next.push(item)
continue
}
const targetKey = resolveFileKey(keyInfo.key)
if (targetKey == null) {
changed = true
continue
}
if (targetKey === keyInfo.key) {
next.push(item)
continue
}
changed = true
next.push({ ...(item as Record<string, unknown>), [keyInfo.field]: targetKey })
}
if (!changed) return value
if (next.length === 0) return ''
if (isArray) return parsed.serialized ? JSON.stringify(next) : next
return parsed.serialized ? JSON.stringify(next[0]) : next[0]
}
@@ -0,0 +1,642 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
// The indexer resolves a tool's params via the tool registry; stub it so the
// injected blockConfigs subBlocks drive resolution deterministically in tests.
vi.mock('@/tools/params', () => ({
getToolIdForOperation: () => undefined,
getToolParametersConfig: () => null,
getSubBlocksForToolInput: (
_toolId: string,
_type: string,
_values: unknown,
_modes: unknown,
provided?: { subBlocks?: SubBlockConfig[] }
) => ({ subBlocks: provided?.subBlocks ?? [] }),
formatParameterLabel: (label: string) => label,
}))
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import {
applyDependentOverrides,
collectClearedDependents,
parseNestedDependentKey,
readTargetDraftDependentValue,
remapForkSubBlocks,
remapToolBlockResources,
} from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
const blockConfigs: Record<string, { subBlocks: SubBlockConfig[] }> = {
testblock: {
subBlocks: [
{ id: 'credential', title: 'Credential', type: 'oauth-input', serviceId: 'gmail' },
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
{ id: 'channel', title: 'Channel', type: 'channel-selector', serviceId: 'slack' },
],
},
}
describe('remapToolBlockResources', () => {
it('remaps nested credential + knowledge-base ids and leaves external selectors', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
}
const map: Record<string, string> = {
'credential:cred-src': 'cred-dst',
'knowledge-base:kb-src': 'kb-dst',
}
const result = remapToolBlockResources(tool, {
resolve: (kind, id) => map[`${kind}:${id}`] ?? null,
resolveFileKey: () => null,
clearUnresolved: false,
blockConfigs,
})
expect(result.params).toEqual({
credential: 'cred-dst',
knowledgeBaseId: 'kb-dst',
channel: 'C123',
})
})
it('clears unresolved copyable refs when clearUnresolved is set (fork)', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
}
const result = remapToolBlockResources(tool, {
resolve: () => null,
resolveFileKey: () => null,
clearUnresolved: true,
blockConfigs,
})
expect(result.params).toEqual({ credential: '', knowledgeBaseId: '', channel: 'C123' })
})
it('keeps unresolved refs and records them when not clearing (promote)', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { credential: 'cred-src', channel: 'C123' },
}
const recorded: Array<{ kind: string; id: string; mapped: boolean }> = []
const result = remapToolBlockResources(tool, {
resolve: () => null,
resolveFileKey: () => null,
record: (kind, id, mapped) => recorded.push({ kind, id, mapped }),
clearUnresolved: false,
blockConfigs,
})
expect((result.params as Record<string, unknown>).credential).toBe('cred-src')
expect(recorded).toContainEqual({ kind: 'credential', id: 'cred-src', mapped: false })
})
it('returns the tool unchanged when it has no params', () => {
const tool = { type: 'testblock' }
expect(
remapToolBlockResources(tool, {
resolve: () => null,
resolveFileKey: () => null,
clearUnresolved: true,
blockConfigs,
})
).toBe(tool)
})
it('leaves an advanced-mode manualCredential id untouched (escape hatch)', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { manualCredential: 'mc-src', knowledgeBaseId: 'kb-src' },
}
const result = remapToolBlockResources(tool, {
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
resolveFileKey: () => null,
clearUnresolved: true,
blockConfigs,
})
expect(result.params).toEqual({ manualCredential: 'mc-src', knowledgeBaseId: 'kb-dst' })
})
it('preserves an org-scoped credentialSet ref without remapping or recording it', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { credential: 'credentialSet:cs-1' },
}
const recorded: Array<{ kind: string; id: string; mapped: boolean }> = []
const result = remapToolBlockResources(tool, {
resolve: () => null,
resolveFileKey: () => null,
record: (kind, id, mapped) => recorded.push({ kind, id, mapped }),
clearUnresolved: true,
blockConfigs,
})
expect((result.params as Record<string, string>).credential).toBe('credentialSet:cs-1')
expect(recorded).toHaveLength(0)
})
it('drops only the uncopied entry in a mixed multi-value field', () => {
const tool = {
type: 'testblock',
toolId: 'testblock_run',
params: { knowledgeBaseId: 'kb1,kb2' },
}
const result = remapToolBlockResources(tool, {
resolve: (_kind, id) => (id === 'kb1' ? 'kb1-dst' : null),
resolveFileKey: () => null,
clearUnresolved: true,
blockConfigs,
})
const value = (result.params as Record<string, string>).knowledgeBaseId
expect(value.split(',').filter(Boolean)).toEqual(['kb1-dst'])
})
it('resolves a credential param by id even when its config is filtered out (reactive)', () => {
// blockConfigs has no `credential` subBlock (simulating a reactive-gated field
// hidden from getToolInputParamConfigs); the raw id-scan must still catch it.
const tool = {
type: 'reactiveblock',
toolId: 'reactiveblock_run',
params: { credential: 'cred-src' },
}
const result = remapToolBlockResources(tool, {
resolve: (kind, id) => (kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null),
resolveFileKey: () => null,
clearUnresolved: false,
blockConfigs: { reactiveblock: { subBlocks: [] } },
})
expect((result.params as Record<string, string>).credential).toBe('cred-dst')
})
it('clears a dependent tool param when its parent resource is remapped', () => {
const tool = {
type: 'depblock',
toolId: 'depblock_run',
params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' },
}
const result = remapToolBlockResources(tool, {
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
resolveFileKey: () => null,
clearUnresolved: false,
blockConfigs: {
depblock: {
subBlocks: [
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
{
id: 'documentId',
title: 'Doc',
type: 'document-selector',
dependsOn: ['knowledgeBaseId'],
},
],
},
},
})
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: '' })
})
})
describe('remapForkSubBlocks', () => {
const subBlocks = (): SubBlockRecord => ({
credential: { id: 'credential', type: 'oauth-input', value: 'c-src' },
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' },
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'mc-src' },
})
it('create mode: clears unresolved credentials and remaps copied resources', () => {
const result = remapForkSubBlocks(
subBlocks(),
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
'create'
)
expect(result.subBlocks.credential.value).toBe('')
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
expect(result.references).toHaveLength(0)
})
it('promote mode: keeps + records the basic credential; manual id is escape hatch', () => {
const result = remapForkSubBlocks(
subBlocks(),
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
'promote'
)
// The basic credential is cleared (never carry an invalid cross-workspace id) but
// still surfaced as required so the sync blocks; the advanced manualCredential is an
// escape hatch - preserved verbatim, not recorded.
expect(result.subBlocks.credential.value).toBe('')
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
const unmappedKinds = result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)
expect(unmappedKinds).toContain('credential:c-src')
expect(unmappedKinds).not.toContain('credential:mc-src')
expect(result.unmapped.every((r) => r.kind !== 'knowledge-base')).toBe(true)
})
it('promote mode: preserves a credentialSet ref without flagging it', () => {
const sb: SubBlockRecord = {
triggerCredentials: {
id: 'triggerCredentials',
type: 'oauth-input',
value: 'credentialSet:cs-1',
},
}
const result = remapForkSubBlocks(sb, () => null, 'promote')
expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1')
expect(result.references).toHaveLength(0)
expect(result.unmapped).toHaveLength(0)
})
it('create mode: keeps a credentialSet ref (org-scoped, not cleared)', () => {
const sb: SubBlockRecord = {
triggerCredentials: {
id: 'triggerCredentials',
type: 'oauth-input',
value: 'credentialSet:cs-1',
},
}
const result = remapForkSubBlocks(sb, () => null, 'create')
expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1')
expect(result.references).toHaveLength(0)
})
it('promote mode: rewrites {{ENV}} nested in an array-form tool param', () => {
const sb: SubBlockRecord = {
tools: {
id: 'tools',
type: 'tool-input',
value: [{ type: 'genericblock', params: { subject: 'Hi {{OLD}}' } }],
},
}
const result = remapForkSubBlocks(
sb,
(kind, id) => (kind === 'env-var' && id === 'OLD' ? 'NEW' : null),
'promote'
)
const tools = result.subBlocks.tools.value as Array<{ params: { subject: string } }>
expect(tools[0].params.subject).toBe('Hi {{NEW}}')
})
})
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
const entry = (id: string, type: string, value: unknown) => ({ id, type, value })
describe('collectClearedDependents', () => {
it('flags a required dependent the target had set but the merge left empty', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
required: true,
},
])
)
const targetDraft: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
folder: entry('folder', 'folder-selector', 'INBOX'),
}
const merged: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
folder: entry('folder', 'folder-selector', ''),
}
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
{
blockId: 'b1',
blockName: 'Send Email',
subBlockKey: 'folder',
title: 'Label',
required: true,
},
])
})
it('returns an optional cleared dependent flagged required:false', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
])
)
const targetDraft: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
folder: entry('folder', 'folder-selector', 'INBOX'),
}
const merged: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
folder: entry('folder', 'folder-selector', ''),
}
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
{
blockId: 'b1',
blockName: 'Send Email',
subBlockKey: 'folder',
title: 'Label',
required: false,
},
])
})
it('does not flag a dependent the target never configured (only the source carried it)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
])
)
// The target's fork never set this label, so the merge leaving it empty is not a loss -
// this is the pull case where the parent carried a filter the fork never had.
const targetDraft: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
folder: entry('folder', 'folder-selector', ''),
}
const merged: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
folder: entry('folder', 'folder-selector', ''),
}
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
})
it('does not flag a dependent that ended up with a value (preserved or overridden)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
required: true,
},
])
)
const targetDraft: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
folder: entry('folder', 'folder-selector', 'INBOX'),
}
const merged: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
folder: entry('folder', 'folder-selector', 'INBOX'),
}
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
})
it('does not flag a cleared dependent gated off by its condition', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
required: true,
condition: { field: 'operation', value: 'read' },
},
])
)
// The operation is 'send', so the read-only folder field is inactive - a stale value
// it carried must not be flagged as required.
const targetDraft: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-target'),
operation: entry('operation', 'dropdown', 'send'),
folder: entry('folder', 'folder-selector', 'INBOX'),
}
const merged: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
operation: entry('operation', 'dropdown', 'send'),
folder: entry('folder', 'folder-selector', ''),
}
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
})
it('flags a cleared dependent nested inside a tool-input tool', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
required: true,
},
])
return undefined as unknown as BlockConfig
})
const targetDraft: SubBlockRecord = {
tools: entry('tools', 'tool-input', [
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-target', folder: 'INBOX' } },
]),
}
const merged: SubBlockRecord = {
tools: entry('tools', 'tool-input', [
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
]),
}
expect(collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged)).toEqual([
{
blockId: 'b1',
blockName: 'Agent',
subBlockKey: 'tools[0].folder',
title: 'Gmail: Label',
required: true,
},
])
})
})
describe('applyDependentOverrides', () => {
const gmailConfig = () =>
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
it('applies a top-level re-pick value', () => {
vi.mocked(getBlock).mockReturnValue(gmailConfig())
const subBlocks: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
folder: entry('folder', 'folder-selector', ''),
}
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['folder', 'Label_42']]))
expect((result.folder as { value: unknown }).value).toBe('Label_42')
})
it('rejects an override for a non-dependent / parent key (allowlist)', () => {
vi.mocked(getBlock).mockReturnValue(gmailConfig())
const subBlocks: SubBlockRecord = {
credential: entry('credential', 'oauth-input', 'c-new'),
folder: entry('folder', 'folder-selector', ''),
}
// 'credential' is a parent (no selectorKey) - must never be writable via override.
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['credential', 'evil']]))
expect(result).toBe(subBlocks)
expect((subBlocks.credential as { value: unknown }).value).toBe('c-new')
})
it('applies a nested tool-input re-pick onto the matching tool param', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail') return gmailConfig()
return undefined as unknown as BlockConfig
})
const subBlocks: SubBlockRecord = {
tools: entry('tools', 'tool-input', [
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
]),
}
const result = applyDependentOverrides(
subBlocks,
'agent',
new Map([['tools[0].folder', 'Label_99']])
)
const tools = (result.tools as { value: Array<{ params: { folder: string } }> }).value
expect(tools[0].params.folder).toBe('Label_99')
})
it('rejects a nested override for a non-allowlisted tool param', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail') return gmailConfig()
return undefined as unknown as BlockConfig
})
const subBlocks: SubBlockRecord = {
tools: entry('tools', 'tool-input', [
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
]),
}
// 'credential' inside the tool is a parent - not overridable.
const result = applyDependentOverrides(
subBlocks,
'agent',
new Map([['tools[0].credential', 'evil']])
)
expect(result).toBe(subBlocks)
})
it('ignores a nested override whose tool index is out of range', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail') return gmailConfig()
return undefined as unknown as BlockConfig
})
const subBlocks: SubBlockRecord = {
tools: entry('tools', 'tool-input', [
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
]),
}
const result = applyDependentOverrides(
subBlocks,
'agent',
new Map([['tools[5].folder', 'Label_99']])
)
expect(result).toBe(subBlocks)
})
})
describe('parseNestedDependentKey', () => {
it('parses a nested tool-input key with a numeric index', () => {
expect(parseNestedDependentKey('tools[0].folder')).toEqual({
toolInputId: 'tools',
index: 0,
paramId: 'folder',
})
expect(parseNestedDependentKey('tools[12].channel')).toEqual({
toolInputId: 'tools',
index: 12,
paramId: 'channel',
})
})
it('returns null for a plain top-level key', () => {
expect(parseNestedDependentKey('folder')).toBeNull()
expect(parseNestedDependentKey('credential')).toBeNull()
})
})
describe('readTargetDraftDependentValue', () => {
it('reads a top-level draft value', () => {
const draft: SubBlockRecord = { folder: { value: 'INBOX' } }
expect(readTargetDraftDependentValue(draft, undefined, 'folder')).toBe('INBOX')
})
it('returns empty for a missing or non-string top-level value', () => {
expect(readTargetDraftDependentValue({ folder: { value: 42 } }, undefined, 'folder')).toBe('')
expect(readTargetDraftDependentValue({}, undefined, 'folder')).toBe('')
expect(readTargetDraftDependentValue(undefined, undefined, 'folder')).toBe('')
})
it('reads the target draft nested param when the source/target tool types match at that index', () => {
const target: SubBlockRecord = {
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
}
const source: SubBlockRecord = {
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
}
// Reads the TARGET draft's value (INBOX), gated on a same-type tool at the index.
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
})
it('identity guard: returns empty when the target draft tool type differs from the source dependent tool', () => {
// The source dependent hangs off a Gmail tool at index 0, but the target draft holds a Slack
// tool there - its param value is not this field's value, so nothing is seeded.
const target: SubBlockRecord = {
tools: { value: [{ type: 'slack', params: { folder: 'INBOX' } }] },
}
const source: SubBlockRecord = {
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
}
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
})
it('returns empty when the target draft has no tool at the index', () => {
const target: SubBlockRecord = { tools: { value: [] } }
const source: SubBlockRecord = {
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
}
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
})
it('returns empty when the source tool type cannot be verified', () => {
const target: SubBlockRecord = {
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
}
// No source subBlocks (or no tool at the index) -> identity unverifiable -> do not seed.
expect(readTargetDraftDependentValue(target, undefined, 'tools[0].folder')).toBe('')
expect(readTargetDraftDependentValue(target, { tools: { value: [] } }, 'tools[0].folder')).toBe(
''
)
})
it('handles the JSON-string stored tool array shape', () => {
const target: SubBlockRecord = {
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'INBOX' } }]) },
}
const source: SubBlockRecord = {
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'SENT' } }]) },
}
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
})
})
@@ -0,0 +1,921 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { SubBlockType } from '@sim/workflow-types/blocks'
import type { z } from 'zod'
import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork'
import { createMcpToolId } from '@/lib/mcp/shared'
import {
coerceObjectArray,
isRecord,
type SubBlockRecord,
} from '@/lib/workflows/persistence/remap-internal-ids'
import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer'
import {
getWorkflowSearchSubBlockResourceDefinition,
parseWorkflowSearchSubBlockResources,
type StructuredWorkflowSearchResourceKind,
} from '@/lib/workflows/search-replace/resources/registry'
import {
buildSubBlockValues,
evaluateSubBlockCondition,
isNonEmptyValue,
} from '@/lib/workflows/subblocks/visibility'
import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types'
import { remapForkFileUploadValue } from '@/lib/workspaces/fork/remap/remap-files'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
/**
* Resource kinds the fork remapper rewrites across workspaces, derived from the
* wire contract so the union can't drift from `forkRemapKindSchema`. `workflow`,
* `mcp-tool`, and the service-specific `selector-resource` kinds are deliberately
* excluded: workflow references are remapped via the workflow identity map, and
* MCP tool / selector ids are not workspace-local so they carry over unchanged.
*/
export type ForkRemapKind = z.infer<typeof forkRemapKindSchema>
const logger = createLogger('WorkspaceForkRemapReferences')
const REQUIRED_KINDS = new Set<ForkRemapKind>(['credential', 'env-var'])
/**
* Id-based override kind for a TOOL param's credential, resolved by subblock id so a
* basic `credential` / `triggerCredentials` is caught even when its config is filtered
* out by a reactive condition (the registry path would otherwise skip it). Advanced
* `manual*` ids are an escape hatch - the user owns them (e.g. via a `{{SECRET}}`), so
* they are never auto-remapped.
*/
function getToolParamOverrideKind(paramId: string): ForkRemapKind | null {
const base = paramId.replace(/_\d+$/, '')
if (base === 'manualCredential') return null
if (CREDENTIAL_SUBBLOCK_IDS.has(base)) return 'credential'
return null
}
export const REGISTRY_KIND_TO_FORK_KIND: Partial<
Record<StructuredWorkflowSearchResourceKind, ForkRemapKind>
> = {
'oauth-credential': 'credential',
'knowledge-base': 'knowledge-base',
table: 'table',
'mcp-server': 'mcp-server',
}
// `file` and `knowledge-document` are intentionally excluded from the generic
// registry path. `file-upload` (workspace files) is remapped by storage key via
// `remapForkFileUploadValue`; `file-selector` (external provider file ids,
// credential-scoped) carries over unchanged; `document-selector` is cleared by the
// `dependsOn` rule (clearDependentsOnRemap) when its parent knowledge base is remapped.
// `mcp-tool-selector` is likewise cleared by `dependsOn` when its `mcp-server-selector`
// parent is remapped - the tool list is server-scoped and may differ in the target.
/** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */
export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
/**
* A `credentialSet:<id>` reference points at an ORG-scoped credential set. A fork
* inherits its parent's org, so the set is already valid in the target — these refs
* are preserved verbatim and never treated as a workspace credential to remap/flag.
*/
function isCredentialSetRef(value: string): boolean {
return value.startsWith('credentialSet:')
}
/**
* Resolves a source-workspace resource id (or env key, for `env-var`) to its
* mapped target id. Returns the target id (which may equal the source for env
* keys that exist under the same name), or null/undefined when there is no
* mapping — which surfaces the reference as unmapped.
*/
export type ForkReferenceResolver = (
kind: ForkRemapKind,
sourceId: string
) => string | null | undefined
export interface ForkReference {
kind: ForkRemapKind
sourceId: string
blockId?: string
blockName?: string
subBlockKey: string
required: boolean
}
export interface RemapSubBlocksResult {
subBlocks: SubBlockRecord
references: ForkReference[]
unmapped: ForkReference[]
/** Subblock keys whose resource id was rewritten/cleared this pass (the `dependsOn` parents). */
remappedKeys: Set<string>
}
function remapEnvInValue(
value: unknown,
resolve: ForkReferenceResolver,
record: (sourceId: string, mapped: boolean) => void
): unknown {
if (typeof value === 'string') {
return value.replace(ENV_REF_PATTERN, (full, key: string) => {
const target = resolve('env-var', key)
if (target == null) {
record(key, false)
return full
}
record(key, true)
return `{{${target}}}`
})
}
if (Array.isArray(value)) {
return value.map((item) => remapEnvInValue(item, resolve, record))
}
// Recurse plain objects so `{{ENV}}` nested in array-form tool params (and other
// object-valued subblocks) is rewritten, not just top-level strings/arrays.
if (isRecord(value)) {
let changed = false
const next: Record<string, unknown> = {}
for (const [key, nested] of Object.entries(value)) {
const remapped = remapEnvInValue(nested, resolve, record)
if (remapped !== nested) changed = true
next[key] = remapped
}
return changed ? next : value
}
return value
}
interface ToolBlockRemapOptions {
resolve: ForkReferenceResolver
/** Resolve a copied file storage key; null when the file was not copied. */
resolveFileKey: (sourceKey: string) => string | null
/** Record a detected reference so it surfaces in the mapping UI / cascade. */
record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void
/** Fork-create clears unresolved copyable refs; promote keeps them (surfaced as unmapped). */
clearUnresolved: boolean
/** Injected block configs (production falls back to the block registry). */
blockConfigs?: Parameters<typeof getToolInputParamConfigs>[0]['blockConfigs']
}
/**
* Rewrite the workspace-scoped resource ids nested inside a block tool's `params`
* (credentials, KBs, tables, files, MCP servers). Param→subBlock-config resolution
* reuses `getToolInputParamConfigs` so it matches exactly what the editor/search
* index sees. Custom-tool / MCP / workflow_input tools carry their ids in dedicated
* fields (handled by the callers / the workflow id map), not block params, so they
* pass through here untouched. Returns a new tool object only when something changed.
* After remapping, dependent params (via `dependsOn`) of any changed resource are
* cleared with the same {@link getWorkflowSearchDependentClears} walk search-replace
* uses, so a child scoped to the old parent isn't left stale.
*/
export function remapToolBlockResources(
tool: Record<string, unknown>,
opts: ToolBlockRemapOptions
): Record<string, unknown> {
if (typeof tool.type !== 'string') return tool
const params = tool.params
if (!isRecord(params)) return tool
let nextParams: Record<string, unknown> | null = null
const setParam = (paramId: string, value: unknown) => {
nextParams ??= { ...params }
nextParams[paramId] = value
}
const remappedParamIds = new Set<string>()
// Id-keyed resource params (credential / triggerCredentials / manual* overrides):
// walked from the raw params so they're caught even when their config is filtered
// out by a reactive condition (the registry loop below would otherwise miss them).
for (const paramId of Object.keys(params)) {
const overrideKind = getToolParamOverrideKind(paramId)
if (!overrideKind) continue
const currentValue = params[paramId]
if (typeof currentValue !== 'string' || !currentValue) continue
if (overrideKind === 'credential' && isCredentialSetRef(currentValue)) continue
const target = opts.resolve(overrideKind, currentValue)
opts.record?.(overrideKind, currentValue, target != null)
if (target != null) {
if (target !== currentValue) {
setParam(paramId, target)
remappedParamIds.add(paramId)
}
} else if (opts.clearUnresolved) {
setParam(paramId, '')
remappedParamIds.add(paramId)
}
}
const toolView: ParsedStoredTool = {
type: tool.type,
operation: typeof tool.operation === 'string' ? tool.operation : undefined,
toolId: typeof tool.toolId === 'string' ? tool.toolId : undefined,
customToolId: typeof tool.customToolId === 'string' ? tool.customToolId : undefined,
params,
}
let configs: ReturnType<typeof getToolInputParamConfigs>
try {
configs = getToolInputParamConfigs({ tool: toolView, blockConfigs: opts.blockConfigs })
} catch (error) {
// Unknown block / resolver failure: don't crash the fork/promote, but log so a
// real bug isn't masked. Nested resource ids in this tool stay as-is.
logger.warn('Could not resolve tool params for fork remap', {
toolType: tool.type,
error: getErrorMessage(error),
})
return nextParams ? { ...tool, params: nextParams } : tool
}
for (const { paramId, config } of configs) {
if (getToolParamOverrideKind(paramId)) continue
const definition = getWorkflowSearchSubBlockResourceDefinition(config)
if (!definition) continue
const currentValue = (nextParams ?? params)[paramId]
if (definition.kind === 'file') {
// file-upload (workspace file) remaps by storage key; file-selector (external
// provider id) carries over unchanged.
if (config.type !== 'file-upload') continue
const remapped = remapForkFileUploadValue(currentValue, opts.resolveFileKey)
if (remapped !== currentValue) {
setParam(paramId, remapped)
remappedParamIds.add(paramId)
}
continue
}
const forkKind = REGISTRY_KIND_TO_FORK_KIND[definition.kind]
if (!forkKind) continue
const refs = parseWorkflowSearchSubBlockResources(currentValue, config)
if (refs.length === 0) continue
let value = currentValue
const seen = new Set<string>()
for (const ref of refs) {
if (seen.has(ref.rawValue)) continue
seen.add(ref.rawValue)
if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue
const target = opts.resolve(forkKind, ref.rawValue)
const mapped = target != null
opts.record?.(forkKind, ref.rawValue, mapped)
if (mapped) {
if (target !== ref.rawValue) {
const replaced = definition.codec.replace(value, ref.rawValue, target)
if (replaced.success) value = replaced.nextValue
}
} else if (opts.clearUnresolved) {
// Drop only this unresolved entry (blank it - empties are filtered at parse
// time), so a mixed copied/uncopied multi-value field keeps its copied refs.
const replaced = definition.codec.replace(value, ref.rawValue, '')
if (replaced.success) value = replaced.nextValue
}
}
if (value !== currentValue) {
setParam(paramId, value)
remappedParamIds.add(paramId)
}
}
if (remappedParamIds.size > 0) {
const toolBlockConfig = opts.blockConfigs?.[tool.type] ?? getBlock(tool.type)
const toolSubBlocks = toolBlockConfig?.subBlocks
if (toolSubBlocks) {
const currentParams = nextParams ?? params
for (const paramId of remappedParamIds) {
for (const clear of getWorkflowSearchDependentClears(toolSubBlocks, paramId)) {
if (remappedParamIds.has(clear.subBlockId)) continue
const existing = currentParams[clear.subBlockId]
if (existing === '' || existing == null) continue
setParam(clear.subBlockId, '')
}
}
}
}
if (!nextParams) return tool
return { ...tool, params: nextParams }
}
interface ForkToolInputOptions {
/** Fork-create drops unresolved tools / clears params; promote keeps + records. */
clearUnresolved: boolean
record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void
}
/**
* Rewrite resource references inside a `tool-input` subblock (an array of
* StoredTool). Custom-tool and MCP-server ids live in dedicated fields; every
* other workspace-scoped id (credential, KB, table, file, MCP server) is nested in
* a block tool's `params` and rewritten via {@link remapToolBlockResources}. The
* MCP entry's derived `toolId` is rebuilt when the server id changes. On fork an
* unresolved custom-tool/MCP tool is dropped; on promote it's kept and recorded.
*/
function remapForkToolInputValue(
value: unknown,
resolve: ForkReferenceResolver,
opts: ForkToolInputOptions
): unknown {
const { array, wasString } = coerceObjectArray(value)
if (!array) return value
let changed = false
const next = array.flatMap((tool) => {
if (!isRecord(tool) || typeof tool.type !== 'string') return [tool]
if (tool.type === 'custom-tool' && typeof tool.customToolId === 'string') {
const target = resolve('custom-tool', tool.customToolId)
opts.record?.('custom-tool', tool.customToolId, target != null)
if (target != null) {
if (target !== tool.customToolId) {
changed = true
return [{ ...tool, customToolId: target }]
}
return [tool]
}
if (opts.clearUnresolved) {
changed = true
return []
}
return [tool]
}
if (tool.type === 'mcp' && isRecord(tool.params) && typeof tool.params.serverId === 'string') {
const serverId = tool.params.serverId
const target = resolve('mcp-server', serverId)
opts.record?.('mcp-server', serverId, target != null)
if (target != null) {
if (target !== serverId) {
changed = true
const toolName =
typeof tool.params.toolName === 'string' ? tool.params.toolName : undefined
return [
{
...tool,
params: { ...tool.params, serverId: target },
toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId,
},
]
}
return [tool]
}
if (opts.clearUnresolved) {
changed = true
return []
}
return [tool]
}
const remapped = remapToolBlockResources(tool, {
resolve,
resolveFileKey: (key) => resolve('file', key) ?? null,
record: opts.record,
clearUnresolved: opts.clearUnresolved,
})
if (remapped !== tool) changed = true
return [remapped]
})
if (!changed) return value
return wasString ? JSON.stringify(next) : next
}
/**
* Rewrite skill references inside a `skill-input` subblock (an array of
* StoredSkill). Builtin skills (`builtin-*` ids) are workspace-agnostic and left
* unchanged. On fork an unresolved skill is dropped; on promote it's kept + recorded.
*/
function remapForkSkillInputValue(
value: unknown,
resolve: ForkReferenceResolver,
opts: ForkToolInputOptions
): unknown {
const { array, wasString } = coerceObjectArray(value)
if (!array) return value
let changed = false
const next = array.flatMap((entry) => {
if (!isRecord(entry) || typeof entry.skillId !== 'string') return [entry]
if (entry.skillId.startsWith('builtin-')) return [entry]
const target = resolve('skill', entry.skillId)
opts.record?.('skill', entry.skillId, target != null)
if (target != null) {
if (target !== entry.skillId) {
changed = true
return [{ ...entry, skillId: target }]
}
return [entry]
}
if (opts.clearUnresolved) {
changed = true
return []
}
return [entry]
})
if (!changed) return value
return wasString ? JSON.stringify(next) : next
}
/**
* Single subblock remapper shared by fork-create and promote (the `mode` selects
* the policy - see below). Structured selectors use the search-replace registry
* codecs; advanced-mode `manual*` overrides and nested tool params are handled by
* id; `{{ENV}}` refs are inline string references. Returns the rewritten subBlocks
* plus, in promote mode, the detected and still-unmapped references.
*/
export function remapForkSubBlocks(
subBlocks: SubBlockRecord,
resolve: ForkReferenceResolver,
mode: 'create' | 'promote',
context?: { blockId?: string; blockName?: string }
): RemapSubBlocksResult {
const clearUnresolved = true
const result: SubBlockRecord = {}
const references = new Map<string, ForkReference>()
const unmapped = new Map<string, ForkReference>()
const remappedKeys = new Set<string>()
const recordReference = (key: string, reference: ForkReference, mapped: boolean) => {
if (mode !== 'promote') return
references.set(key, reference)
if (!mapped) unmapped.set(key, reference)
}
for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) {
if (!subBlock || typeof subBlock !== 'object') {
result[subBlockKey] = subBlock
continue
}
let value = subBlock.value
const valueBeforeResource = value
const subBlockType = typeof subBlock.type === 'string' ? subBlock.type : undefined
const definition = getWorkflowSearchSubBlockResourceDefinition(
subBlockType ? { type: subBlockType as SubBlockType } : undefined
)
const forkKind = definition ? REGISTRY_KIND_TO_FORK_KIND[definition.kind] : undefined
if (definition && forkKind && subBlockType) {
const parsed = parseWorkflowSearchSubBlockResources(value, {
type: subBlockType as SubBlockType,
})
const seen = new Set<string>()
for (const ref of parsed) {
if (seen.has(ref.rawValue)) continue
seen.add(ref.rawValue)
if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue
const required = REQUIRED_KINDS.has(forkKind)
const reference: ForkReference = {
kind: forkKind,
sourceId: ref.rawValue,
blockId: context?.blockId,
blockName: context?.blockName,
subBlockKey,
required,
}
const target = resolve(forkKind, ref.rawValue)
const mapped = target != null
recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped)
if (mapped) {
if (target !== ref.rawValue) {
const replaceResult = definition.codec.replace(value, ref.rawValue, target)
if (replaceResult.success) value = replaceResult.nextValue
}
} else if (clearUnresolved) {
// Drop only this unresolved entry (blank it - empties are filtered at
// parse time) so a mixed copied/uncopied multi-value field keeps its rest.
const replaceResult = definition.codec.replace(value, ref.rawValue, '')
if (replaceResult.success) value = replaceResult.nextValue
}
}
}
if (subBlockType === 'file-upload') {
// Workspace-file refs don't sync on promote (the target lacks the source's
// blob); clear them rather than carry a cross-workspace key. On fork, the
// resolver returns the copied key. `file-selector` (external) is untouched.
value = remapForkFileUploadValue(value, (sourceKey) => resolve('file', sourceKey) ?? null)
} else if (subBlockType === 'tool-input' || subBlockType === 'skill-input') {
const record = (kind: ForkRemapKind, sourceId: string, mapped: boolean) =>
recordReference(
`${kind}:${sourceId}`,
{
kind,
sourceId,
blockId: context?.blockId,
blockName: context?.blockName,
subBlockKey,
required: REQUIRED_KINDS.has(kind),
},
mapped
)
value =
subBlockType === 'tool-input'
? remapForkToolInputValue(value, resolve, { clearUnresolved, record })
: remapForkSkillInputValue(value, resolve, { clearUnresolved, record })
}
if (value !== valueBeforeResource) remappedKeys.add(subBlockKey)
// Promote rewrites `{{ENV}}` refs via the resolver; fork preserves them by name.
if (mode === 'promote') {
value = remapEnvInValue(value, resolve, (sourceId, mapped) => {
recordReference(
`env-var:${sourceId}`,
{
kind: 'env-var',
sourceId,
blockId: context?.blockId,
blockName: context?.blockName,
subBlockKey,
required: true,
},
mapped
)
})
}
result[subBlockKey] = { ...subBlock, value }
}
return {
subBlocks: result,
references: Array.from(references.values()),
unmapped: Array.from(unmapped.values()),
remappedKeys,
}
}
/**
* Clear every subblock whose `dependsOn` parent was remapped to a different
* target this pass, so a child scoped to the old parent (a KB's document, a
* Slack channel, a sheet tab) never carries a stale id into the target. Reuses
* the search-replace dependent-clear walk (canonical-pair aware, transitive over
* `dependsOn` chains) so fork/promote and in-editor search-replace clear
* identically. Children of an unchanged parent are preserved; a no-op for
* unknown block types or when nothing was remapped.
*/
export function clearDependentsOnRemap(
subBlocks: SubBlockRecord,
blockType: string,
remappedKeys: ReadonlySet<string>
): SubBlockRecord {
if (remappedKeys.size === 0) return subBlocks
const config = getBlock(blockType)
if (!config) return subBlocks
const toClear = new Set<string>()
for (const key of remappedKeys) {
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) {
if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId)
}
}
let next: SubBlockRecord | null = null
for (const id of toClear) {
const existing = subBlocks[id]
if (!existing || typeof existing !== 'object') continue
if (existing.value === '' || existing.value == null) continue
next ??= { ...subBlocks }
next[id] = { ...existing, value: '' }
}
return next ?? subBlocks
}
/**
* A dependent field a sync left empty (it had a source value). `required` ones gate Sync
* and skip redeploy; optional ones are surfaced so a cleared filter never broadens silently.
*/
export interface NeedsConfigurationField {
blockId: string
blockName: string
subBlockKey: string
title: string
required: boolean
}
/** Evaluate a subblock's `required` (boolean | condition | fn) against a value map. */
export function isSubBlockRequired(
required: SubBlockConfig['required'],
values: Record<string, unknown>
): boolean {
if (required === true) return true
if (!required) return false
// The object/function forms are structurally a SubBlockCondition.
return evaluateSubBlockCondition(
required as Parameters<typeof evaluateSubBlockCondition>[0],
values
)
}
/** Nested `tool-input` dependents (Agent/tool blocks) the TARGET configured that a remap cleared. */
function collectClearedToolParamDependents(
toolInputKey: string,
blockId: string,
blockName: string,
targetCurrentValue: unknown,
mergedValue: unknown,
out: NeedsConfigurationField[]
): void {
const { array: targetTools } = coerceObjectArray(targetCurrentValue)
const { array: mergedTools } = coerceObjectArray(mergedValue)
if (!mergedTools || !targetTools) return
// Index pairing is only safe when the tool sets line up; otherwise skip rather than
// pair a cleared param against the wrong tool.
if (targetTools.length !== mergedTools.length) return
for (let index = 0; index < mergedTools.length; index++) {
const tool = mergedTools[index]
const targetTool = targetTools[index]
if (!isRecord(tool) || typeof tool.type !== 'string') continue
if (!isRecord(targetTool) || targetTool.type !== tool.type) continue
const toolConfig = getBlock(tool.type)
if (!toolConfig) continue
const targetParams = isRecord(targetTool.params) ? targetTool.params : {}
const mergedParams = isRecord(tool.params) ? tool.params : {}
// A tool's `operation` lives at the tool level, not in params, but conditions
// reference it - merge it in so condition/required gating matches the editor.
const mergedValues =
typeof tool.operation === 'string'
? { operation: tool.operation, ...mergedParams }
: mergedParams
const toolLabel = typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
for (const cfg of toolConfig.subBlocks) {
if (!cfg.dependsOn || !cfg.id) continue
// Only flag a param the TARGET tool had configured (not one the source carried in).
if (!isNonEmptyValue(targetParams[cfg.id])) continue
if (isNonEmptyValue(mergedParams[cfg.id])) continue
// Skip fields gated off by their `condition` (a stale value under an inactive
// operation isn't actually required now).
if (cfg.condition && !evaluateSubBlockCondition(cfg.condition, mergedValues)) continue
out.push({
blockId,
blockName,
subBlockKey: `${toolInputKey}[${index}].${cfg.id}`,
title: `${toolLabel}: ${cfg.title ?? cfg.id}`,
required: isSubBlockRequired(cfg.required, mergedValues),
})
}
}
}
/**
* `dependsOn` children the TARGET workspace had configured (in its draft) that the merge
* left empty - the parent they hang off was swapped/remapped and the value wasn't restored
* or re-picked. Keyed on the TARGET draft (not the source) so a field the source carries
* but the target never configured is NOT flagged (e.g. a pull bringing in the parent's
* label filter the fork never set). Covers top-level subblocks AND nested `tool-input`
* params. `required` ones must be re-picked before the workflow can run (promote skips
* their redeploy); optional ones are surfaced so a filter the swap cleared never broadens
* behavior silently. Pure; `mergedSubBlocks` is the final state about to be written,
* `targetCurrentSubBlocks` the target's pre-sync draft.
*/
export function collectClearedDependents(
blockType: string,
blockId: string,
blockName: string,
targetCurrentSubBlocks: SubBlockRecord,
mergedSubBlocks: SubBlockRecord
): NeedsConfigurationField[] {
const config = getBlock(blockType)
if (!config) return []
const targetValues = buildSubBlockValues(targetCurrentSubBlocks)
const mergedValues = buildSubBlockValues(mergedSubBlocks)
const fields: NeedsConfigurationField[] = []
for (const cfg of config.subBlocks) {
if (!cfg.id) continue
// Only flag a field the target had configured (so the user lost their own selection),
// still empty after merge, and currently active (a value under a now-inactive
// `condition`/operation isn't really in play).
if (
cfg.dependsOn &&
isNonEmptyValue(targetValues[cfg.id]) &&
!isNonEmptyValue(mergedValues[cfg.id]) &&
(!cfg.condition || evaluateSubBlockCondition(cfg.condition, mergedValues))
) {
fields.push({
blockId,
blockName,
subBlockKey: cfg.id,
title: cfg.title ?? cfg.id,
required: isSubBlockRequired(cfg.required, mergedValues),
})
}
if (cfg.type === 'tool-input') {
collectClearedToolParamDependents(
cfg.id,
blockId,
blockName,
targetValues[cfg.id],
mergedValues[cfg.id],
fields
)
}
}
return fields
}
/**
* Parse a nested dependent/override key `toolInput[index].paramId` into its parts (the index
* coerced to a number). Returns null for a plain top-level subblock key. Shared by the diff's
* first-sync draft reader and the override apply so both parse the `toolInput[index].paramId`
* shape identically.
*/
export function parseNestedDependentKey(
key: string
): { toolInputId: string; index: number; paramId: string } | null {
const match = /^([^[]+)\[(\d+)\]\.(.+)$/.exec(key)
if (!match) return null
const [, toolInputId, indexStr, paramId] = match
return { toolInputId, index: Number(indexStr), paramId }
}
/**
* Read a dependent field's currently-configured value from a target block's draft subBlocks -
* the first-sync fallback used when the stored mapping has no entry yet. Seeds the diff pre-fill
* from the TARGET (never the source, which would overwrite the target's own selection on an edge
* that predates the store). Identity-aware: for a nested `toolInput[index].paramId` key it only
* reads the target draft's param when the target tool at that index is the SAME tool type the
* SOURCE dependent hangs off; otherwise that index holds a different tool whose value isn't this
* field's. Returns '' when unset or when identity can't be verified.
*
* Both records are read structurally (only each subblock's `value`), so callers can pass either a
* persisted {@link SubBlockRecord} (the target draft) or an in-memory `WorkflowState` block's
* subblocks (the source) without a cast.
*/
export function readTargetDraftDependentValue(
targetDraftSubBlocks: Record<string, { value?: unknown }> | undefined,
sourceSubBlocks: Record<string, { value?: unknown }> | undefined,
subBlockKey: string
): string {
if (!targetDraftSubBlocks) return ''
const nested = parseNestedDependentKey(subBlockKey)
if (nested) {
const { toolInputId, index, paramId } = nested
const targetTool = coerceObjectArray(targetDraftSubBlocks[toolInputId]?.value).array?.[index]
if (!isRecord(targetTool) || typeof targetTool.type !== 'string') return ''
const sourceTool = coerceObjectArray(sourceSubBlocks?.[toolInputId]?.value).array?.[index]
if (!isRecord(sourceTool) || sourceTool.type !== targetTool.type) return ''
const params = isRecord(targetTool.params) ? targetTool.params : {}
const value = params[paramId]
return typeof value === 'string' ? value : ''
}
// TODO(fork): identity-guard top-level reads too - only seed when the target draft's parent
// (credential/KB/table) still equals the mapped target. Threading the parent subblock id and
// mapped target value here is invasive, and a changed parent is already blanked by the modal's
// `parentChanged` logic, leaving only a narrow same-index/different-parent first-sync edge.
const value = targetDraftSubBlocks[subBlockKey]?.value
return typeof value === 'string' ? value : ''
}
/**
* Apply nested `tool-input` overrides onto one tool array, matching by index and
* allowlisting each param to the tool's own reconfigurable dependents (dependsOn +
* selectorKey). Returns the same reference when nothing applied. Handles the array and
* JSON-string stored shapes.
*/
function applyNestedToolOverrides(
value: unknown,
items: ReadonlyArray<{ index: number; paramId: string; value: string }>
): unknown {
const { array, wasString } = coerceObjectArray(value)
if (!array) return value
let changed = false
const merged = array.map((tool, index) => {
const forTool = items.filter((item) => item.index === index)
if (forTool.length === 0) return tool
if (!isRecord(tool) || typeof tool.type !== 'string') return tool
const toolConfig = getBlock(tool.type)
if (!toolConfig) return tool
const allowed = new Set(
toolConfig.subBlocks
.filter((cfg) => cfg.id && cfg.dependsOn && cfg.selectorKey)
.map((cfg) => cfg.id)
)
const params = isRecord(tool.params) ? tool.params : {}
let nextParams: Record<string, unknown> | null = null
for (const item of forTool) {
if (!allowed.has(item.paramId)) continue
nextParams ??= { ...params }
nextParams[item.paramId] = item.value
}
if (!nextParams) return tool
changed = true
return { ...tool, params: nextParams }
})
if (!changed) return value
return wasString ? JSON.stringify(merged) : merged
}
/**
* Apply the stored dependent mapping onto the merged subBlocks (called last, after the
* reference transform cleared the source's dependent values, so the stored mapping is the sole
* source of truth for what each selector resolves to). Allowlisted to the block's
* reconfigurable dependent selectors (`dependsOn` + `selectorKey`) - top-level subblocks AND
* nested `tool-input` params keyed `toolInput[index].paramId` - so a crafted value can never
* set a parent/credential field (bypassing mapping validation) or inject a bogus subblock.
* Returns a new record only when something applied.
*/
export function applyDependentOverrides(
subBlocks: SubBlockRecord,
blockType: string,
overrides: ReadonlyMap<string, string>
): SubBlockRecord {
const config = getBlock(blockType)
if (!config || overrides.size === 0) return subBlocks
const allowedTopLevel = new Set<string>()
const toolInputIds = new Set<string>()
for (const cfg of config.subBlocks) {
if (!cfg.id) continue
if (cfg.dependsOn && cfg.selectorKey) allowedTopLevel.add(cfg.id)
if (cfg.type === 'tool-input') toolInputIds.add(cfg.id)
}
const nestedByTool = new Map<string, Array<{ index: number; paramId: string; value: string }>>()
let next: SubBlockRecord | null = null
for (const [key, value] of overrides) {
const nested = parseNestedDependentKey(key)
if (nested) {
if (!toolInputIds.has(nested.toolInputId)) continue
const list = nestedByTool.get(nested.toolInputId) ?? []
list.push({ index: nested.index, paramId: nested.paramId, value })
nestedByTool.set(nested.toolInputId, list)
continue
}
if (!allowedTopLevel.has(key)) continue
next ??= { ...subBlocks }
// The allowlist already proves this is a real dependent selector, so create the entry
// if the merge dropped it (don't silently skip a legitimate re-pick).
const existing = next[key]
next[key] = existing && typeof existing === 'object' ? { ...existing, value } : { value }
}
for (const [toolInputId, items] of nestedByTool) {
const existing = (next ?? subBlocks)[toolInputId]
if (!existing || typeof existing !== 'object') continue
const updated = applyNestedToolOverrides(existing.value, items)
if (updated === existing.value) continue
next ??= { ...subBlocks }
next[toolInputId] = { ...existing, value: updated }
}
return next ?? subBlocks
}
/**
* Promote-mode remap (keep + record unmapped references). Thin wrapper over
* {@link remapForkSubBlocks}; also used by the reference scan.
*/
export function remapSubBlocks(
subBlocks: SubBlockRecord,
resolve: ForkReferenceResolver,
context?: { blockId?: string; blockName?: string }
): RemapSubBlocksResult {
return remapForkSubBlocks(subBlocks, resolve, 'promote', context)
}
/** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */
export function createForkSubBlockTransform(
resolve: ForkReferenceResolver
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
return (subBlocks, blockType) => {
const result = remapSubBlocks(subBlocks, resolve)
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
}
}
export interface WorkflowReferenceScan {
references: ForkReference[]
unmapped: ForkReference[]
}
/**
* Scan a set of blocks for all remappable references, aggregating unique
* (kind, sourceId) pairs across the workflow. Used by the mapping/diff/promote
* paths to surface what needs mapping and to block on unmapped required refs.
*/
export function scanWorkflowReferences(
blocks: Array<{ id: string; name: string; subBlocks: unknown }>,
resolve: ForkReferenceResolver
): WorkflowReferenceScan {
const references = new Map<string, ForkReference>()
const unmapped = new Map<string, ForkReference>()
for (const block of blocks) {
if (!block.subBlocks || typeof block.subBlocks !== 'object' || Array.isArray(block.subBlocks)) {
continue
}
const blockResult = remapSubBlocks(block.subBlocks as SubBlockRecord, resolve, {
blockId: block.id,
blockName: block.name,
})
for (const reference of blockResult.references) {
const key = `${reference.kind}:${reference.sourceId}`
if (!references.has(key)) references.set(key, reference)
}
for (const reference of blockResult.unmapped) {
const key = `${reference.kind}:${reference.sourceId}`
if (!unmapped.has(key)) unmapped.set(key, reference)
}
}
return {
references: Array.from(references.values()),
unmapped: Array.from(unmapped.values()),
}
}
@@ -0,0 +1,68 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { TableSchema } from '@/lib/table/types'
import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity'
import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups'
describe('remapForkTableWorkflowGroups', () => {
it('remaps a manual group workflowId and outputs[].blockId to child ids', () => {
const map = new Map([['src-wf', 'child-wf']])
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'src-wf',
outputs: [{ blockId: 'src-block', path: 'out', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, map)
const group = result.workflowGroups?.[0]
expect(group?.workflowId).toBe('child-wf')
expect(group?.outputs[0].blockId).toBe(deriveForkBlockId('child-wf', 'src-block'))
expect(group?.outputs[0].columnName).toBe('col_1')
expect(result.columns[0].id).toBe('col_1')
expect(result.columns[0].workflowGroupId).toBe('g1')
})
it('drops a group whose backing workflow was not copied and clears its column wiring', () => {
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'missing-wf',
outputs: [{ blockId: 'b', path: 'p', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map())
expect(result.workflowGroups).toHaveLength(0)
expect(result.columns[0].workflowGroupId).toBeUndefined()
expect(result.columns[0].id).toBe('col_1')
})
it('leaves enrichment groups (empty workflowId) untouched', () => {
const schema: TableSchema = {
columns: [],
workflowGroups: [
{
id: 'g1',
workflowId: '',
enrichmentId: 'enr',
outputs: [{ blockId: '', path: '', columnName: 'col_1', outputId: 'o' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map([['src-wf', 'child-wf']]))
expect(result.workflowGroups?.[0]).toEqual(schema.workflowGroups?.[0])
})
it('returns the schema unchanged when there are no groups', () => {
const schema: TableSchema = { columns: [{ id: 'col_1', name: 'A', type: 'string' }] }
expect(remapForkTableWorkflowGroups(schema, new Map())).toBe(schema)
})
})
@@ -0,0 +1,53 @@
import type { TableSchema } from '@/lib/table/types'
import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity'
/**
* Remap the workflow/block references embedded in a copied table's schema so its
* workflow groups keep working in the child workspace. `workflowGroups[].workflowId`
* is rewritten through the source→child workflow identity map, and each
* `outputs[].blockId` is rewritten to the deterministic forked block id (matching
* `copyWorkflowStateIntoTarget`). Manual groups whose backing workflow was not
* copied are dropped, and any columns wired to a dropped group have their
* `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column
* ids are left untouched.
*/
export function remapForkTableWorkflowGroups(
schema: TableSchema,
workflowIdMap: Map<string, string>
): TableSchema {
const groups = schema.workflowGroups ?? []
if (groups.length === 0) return schema
const droppedGroupIds = new Set<string>()
const remappedGroups = groups.flatMap((group) => {
if (!group.workflowId) return [group]
const childWorkflowId = workflowIdMap.get(group.workflowId)
if (!childWorkflowId) {
droppedGroupIds.add(group.id)
return []
}
return [
{
...group,
workflowId: childWorkflowId,
outputs: group.outputs.map((output) => ({
...output,
blockId: output.blockId
? deriveForkBlockId(childWorkflowId, output.blockId)
: output.blockId,
})),
},
]
})
const columns =
droppedGroupIds.size === 0
? schema.columns
: schema.columns.map((column) =>
column.workflowGroupId && droppedGroupIds.has(column.workflowGroupId)
? { ...column, workflowGroupId: undefined }
: column
)
return { ...schema, columns, workflowGroups: remappedGroups }
}
+43
View File
@@ -0,0 +1,43 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkspaceForkSocket')
async function postToRealtime(path: string, workflowId: string): Promise<void> {
const response = await fetch(`${getSocketServerUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
body: JSON.stringify({ workflowId }),
})
if (!response.ok) {
throw new Error(`${path} responded ${response.status}`)
}
}
/**
* Notify connected canvas clients that a fork promote/rollback force-replaced a
* workflow's state. This mirrors a mothership edit rather than a passive ping:
*
* - `workflow-updated` makes each client reload the workflow from the API the same
* way it reacts to an external full-state edit (copilot / state route), deferring
* while a local diff or unsaved operations are pending so it never clobbers
* in-flight work. Without this the canvas keeps the stale state and a later local
* edit would overwrite the freshly-synced state.
* - `workflow-deployed` refreshes the deployment indicator (the promote/rollback also
* changed which version is deployed).
*
* Best-effort and independent: each notification is attempted regardless of the
* other, and a failure only warns - it never blocks the promote/rollback.
*/
export async function notifyForkWorkflowChanged(workflowId: string): Promise<void> {
const results = await Promise.allSettled([
postToRealtime('/api/workflow-updated', workflowId),
postToRealtime('/api/workflow-deployed', workflowId),
])
for (const result of results) {
if (result.status === 'rejected') {
logger.warn('Fork sync socket notification failed', { workflowId, error: result.reason })
}
}
}
+39
View File
@@ -152,6 +152,45 @@ describe('getWorkspaceCreationPolicy', () => {
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('without pinning, a null active org falls back to the caller membership org', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetUserOrganization.mockResolvedValue({
organizationId: 'user-org',
role: 'admin',
memberId: 'member-1',
})
mockDbResults.value = [[{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: null,
})
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('user-org')
})
it('pins to the source org: a personal source (null) stays personal regardless of caller org', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetUserOrganization.mockResolvedValue({
organizationId: 'user-org',
role: 'admin',
memberId: 'member-1',
})
mockDbResults.value = [[{ value: 0 }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: null,
pinOrganization: true,
})
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.organizationId).toBeNull()
expect(result.billedAccountUserId).toBe('user-1')
})
it('allows org admins on a team plan to create organization workspaces', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
+11 -1
View File
@@ -54,6 +54,13 @@ export interface WorkspaceCreationPolicy {
interface GetWorkspaceCreationPolicyParams {
userId: string
activeOrganizationId?: string | null
/**
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
* (including `null`, which means a personal workspace) and never falls back to the
* caller's membership org. Forks set this so the child always lands in the SOURCE's
* org, not whatever org the acting user happens to belong to.
*/
pinOrganization?: boolean
}
export function isOrganizationWorkspace(
@@ -215,9 +222,12 @@ export async function getInvitePlanCategoryForUser(userId: string): Promise<Plan
export async function getWorkspaceCreationPolicy({
userId,
activeOrganizationId,
pinOrganization = false,
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
const membership = await getUserOrganization(userId)
const organizationId = activeOrganizationId ?? membership?.organizationId ?? null
const organizationId = pinOrganization
? (activeOrganizationId ?? null)
: (activeOrganizationId ?? membership?.organizationId ?? null)
const orgRole =
organizationId == null
? undefined
-1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
+12
View File
@@ -1312,6 +1312,18 @@ cronjobs:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
# Processes the transactional outbox (deployment side-effects retry / dead-letter)
# and reaps stale workspace-fork background-work rows. Both are safety nets behind
# the immediate post-commit processing, so frequent runs keep retries timely.
outboxProcess:
enabled: true
name: outbox-process
schedule: "*/1 * * * *"
path: "/api/webhooks/outbox/process"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
# Global CronJob settings
image:
repository: curlimages/curl
+3
View File
@@ -182,6 +182,9 @@ export const AuditAction = {
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_DELETED: 'workspace.deleted',
WORKSPACE_DUPLICATED: 'workspace.duplicated',
WORKSPACE_FORKED: 'workspace.forked',
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
} as const
export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
@@ -0,0 +1,64 @@
-- Replay-safety: this file ends in a CONCURRENTLY index op below an embedded COMMIT,
-- so a failure there replays the whole file from the top — every statement here is idempotent.
DO $$ BEGIN
CREATE TYPE "public"."workspace_fork_promote_direction" AS ENUM('push', 'pull');
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."workspace_fork_resource_type" AS ENUM('workflow', 'oauth_credential', 'service_account_credential', 'env_var', 'table', 'knowledge_base', 'knowledge_document', 'file', 'mcp_server', 'custom_tool', 'skill');
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_fork_promote_run" (
"id" text PRIMARY KEY NOT NULL,
"child_workspace_id" text NOT NULL,
"source_workspace_id" text NOT NULL,
"target_workspace_id" text NOT NULL,
"direction" "workspace_fork_promote_direction" NOT NULL,
"snapshot" jsonb NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_fork_resource_map" (
"id" text PRIMARY KEY NOT NULL,
"child_workspace_id" text NOT NULL,
"resource_type" "workspace_fork_resource_type" NOT NULL,
"parent_resource_id" text NOT NULL,
"child_resource_id" text,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "forked_from_workspace_id" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_fork_promote_run" ADD CONSTRAINT "workspace_fork_promote_run_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_fork_promote_run" ADD CONSTRAINT "workspace_fork_promote_run_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_fork_resource_map" ADD CONSTRAINT "workspace_fork_resource_map_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_fork_resource_map" ADD CONSTRAINT "workspace_fork_resource_map_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_forked_from_workspace_id_workspace_id_fk" FOREIGN KEY ("forked_from_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE set null ON UPDATE no action NOT VALID;
EXCEPTION WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_fork_promote_run_child_ws_target_unique" ON "workspace_fork_promote_run" USING btree ("child_workspace_id","target_workspace_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_fork_promote_run_target_ws_idx" ON "workspace_fork_promote_run" USING btree ("target_workspace_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_ws_idx" ON "workspace_fork_resource_map" USING btree ("child_workspace_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_ws_type_idx" ON "workspace_fork_resource_map" USING btree ("child_workspace_id","resource_type");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_type_parent_unique" ON "workspace_fork_resource_map" USING btree ("child_workspace_id","resource_type","parent_resource_id");--> statement-breakpoint
-- workspace is an existing table: build its new index CONCURRENTLY so the build never
-- write-locks the relation (runner convention — plain CREATE INDEX takes ACCESS EXCLUSIVE).
COMMIT;--> statement-breakpoint
SET lock_timeout = 0;--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "workspace_forked_from_workspace_id_idx" ON "workspace" USING btree ("forked_from_workspace_id");--> statement-breakpoint
SET lock_timeout = '5s';
@@ -0,0 +1,50 @@
CREATE TYPE "public"."background_work_kind" AS ENUM('deployment_side_effects', 'fork_content_copy', 'fork_sync', 'fork_rollback');--> statement-breakpoint
CREATE TYPE "public"."background_work_status_value" AS ENUM('pending', 'processing', 'completed', 'completed_with_warnings', 'failed');--> statement-breakpoint
CREATE TABLE "background_work_status" (
"id" text PRIMARY KEY NOT NULL,
"workspace_id" text NOT NULL,
"workflow_id" text,
"kind" "background_work_kind" NOT NULL,
"status" "background_work_status_value" NOT NULL,
"message" text,
"error" text,
"metadata" jsonb,
"started_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "workspace_fork_block_map" (
"id" text PRIMARY KEY NOT NULL,
"child_workspace_id" text NOT NULL,
"parent_workflow_id" text NOT NULL,
"parent_block_id" text NOT NULL,
"child_workflow_id" text NOT NULL,
"child_block_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "workspace_fork_dependent_value" (
"id" text PRIMARY KEY NOT NULL,
"child_workspace_id" text NOT NULL,
"target_workflow_id" text NOT NULL,
"target_block_id" text NOT NULL,
"sub_block_key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "background_work_status" ADD CONSTRAINT "background_work_status_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_work_status" ADD CONSTRAINT "background_work_status_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_fork_block_map" ADD CONSTRAINT "workspace_fork_block_map_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_fork_dependent_value" ADD CONSTRAINT "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "background_work_status_workspace_status_idx" ON "background_work_status" USING btree ("workspace_id","status");--> statement-breakpoint
CREATE INDEX "background_work_status_workflow_status_idx" ON "background_work_status" USING btree ("workflow_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "workspace_fork_block_map_child_ws_parent_unique" ON "workspace_fork_block_map" USING btree ("child_workspace_id","parent_block_id");--> statement-breakpoint
CREATE UNIQUE INDEX "workspace_fork_block_map_child_ws_child_unique" ON "workspace_fork_block_map" USING btree ("child_workspace_id","child_block_id");--> statement-breakpoint
CREATE INDEX "workspace_fork_block_map_child_ws_parent_wf_idx" ON "workspace_fork_block_map" USING btree ("child_workspace_id","parent_workflow_id");--> statement-breakpoint
CREATE INDEX "workspace_fork_block_map_child_ws_child_wf_idx" ON "workspace_fork_block_map" USING btree ("child_workspace_id","child_workflow_id");--> statement-breakpoint
CREATE INDEX "workspace_fork_dependent_value_child_ws_wf_idx" ON "workspace_fork_dependent_value" USING btree ("child_workspace_id","target_workflow_id");--> statement-breakpoint
CREATE UNIQUE INDEX "workspace_fork_dependent_value_field_unique" ON "workspace_fork_dependent_value" USING btree ("child_workspace_id","target_workflow_id","target_block_id","sub_block_key");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1744,6 +1744,20 @@
"when": 1782260194924,
"tag": "0249_drop_permission_group_applies_to_all_workspaces",
"breakpoints": true
},
{
"idx": 250,
"version": "7",
"when": 1782365854239,
"tag": "0250_workspace_forking",
"breakpoints": true
},
{
"idx": 251,
"version": "7",
"when": 1782606413439,
"tag": "0251_wakeful_smiling_tiger",
"breakpoints": true
}
]
}
+228
View File
@@ -1325,6 +1325,10 @@ export const workspace = pgTable(
inboxAddress: text('inbox_address'),
inboxProviderId: text('inbox_provider_id'),
archivedAt: timestamp('archived_at'),
forkedFromWorkspaceId: text('forked_from_workspace_id').references(
(): AnyPgColumn => workspace.id,
{ onDelete: 'set null' }
),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
@@ -1332,6 +1336,230 @@ export const workspace = pgTable(
ownerIdIdx: index('workspace_owner_id_idx').on(table.ownerId),
organizationIdIdx: index('workspace_organization_id_idx').on(table.organizationId),
workspaceModeIdx: index('workspace_mode_idx').on(table.workspaceMode),
forkedFromWorkspaceIdx: index('workspace_forked_from_workspace_id_idx').on(
table.forkedFromWorkspaceId
),
})
)
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
'workflow',
'oauth_credential',
'service_account_credential',
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
'custom_tool',
'skill',
])
export const workspaceForkResourceMap = pgTable(
'workspace_fork_resource_map',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
resourceType: workspaceForkResourceTypeEnum('resource_type').notNull(),
parentResourceId: text('parent_resource_id').notNull(),
childResourceId: text('child_resource_id'),
// SET NULL (not CASCADE): deleting the creating user must not delete the fork's
// identity mappings, which the edge depends on for every future promote.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
childWorkspaceIdx: index('workspace_fork_resource_map_child_ws_idx').on(table.childWorkspaceId),
childWorkspaceTypeIdx: index('workspace_fork_resource_map_child_ws_type_idx').on(
table.childWorkspaceId,
table.resourceType
),
childTypeParentUnique: uniqueIndex('workspace_fork_resource_map_child_type_parent_unique').on(
table.childWorkspaceId,
table.resourceType,
table.parentResourceId
),
})
)
/**
* Stable 1:1 block-identity map between a fork (child) and its parent, per edge. Seeded at
* fork creation (parent block -> derived child block) and reconciled on every promote.
* Promote looks a source block up here to reuse its counterpart's EXISTING id instead of
* re-deriving: without it, pushing a fork's workflow over the parent would re-key the
* parent's blocks and change their webhook URLs (the path falls back to the block id).
*
* Each pair records BOTH workflow ids so a lookup can be scoped to the workflow it belongs
* to: a target workflow that was archived and re-created gets a fresh id (the pair no longer
* matches), which avoids reusing an archived workflow's block id and colliding on the global
* `workflow_blocks` primary key. Block ids are plain text (no FK to `workflow_blocks`, which
* is rewritten on every deploy); only the edge (`child_workspace_id`) cascades. A parent
* block can map to different children across sibling forks, so uniqueness is per (edge,
* parent) and per (edge, child).
*/
export const workspaceForkBlockMap = pgTable(
'workspace_fork_block_map',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentWorkflowId: text('parent_workflow_id').notNull(),
parentBlockId: text('parent_block_id').notNull(),
childWorkflowId: text('child_workflow_id').notNull(),
childBlockId: text('child_block_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Pull resolves parent source block -> child target; one child per parent block per edge.
childWsParentBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_parent_unique').on(
table.childWorkspaceId,
table.parentBlockId
),
// Push resolves child source block -> parent target; one parent per child block per edge.
childWsChildBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_child_unique').on(
table.childWorkspaceId,
table.childBlockId
),
// Reconcile deletes a source workflow's pairs by its (stable) workflow id before
// re-inserting the live ones, so index both workflow sides for that sweep.
childWsParentWorkflowIdx: index('workspace_fork_block_map_child_ws_parent_wf_idx').on(
table.childWorkspaceId,
table.parentWorkflowId
),
childWsChildWorkflowIdx: index('workspace_fork_block_map_child_ws_child_wf_idx').on(
table.childWorkspaceId,
table.childWorkflowId
),
})
)
/**
* The user's stored dependent-field re-picks for an edge: a (target workflow, target block,
* subblock) -> selected value mapping (a Gmail label, a KB document, a sheet tab). The sync
* modal reads and writes this, and every promote applies it verbatim - it is the single
* source of truth for dependent values, replacing the old implicit "preserve the target's
* value if the credential is unchanged" path. Block ids are plain text (no FK to
* `workflow_blocks`, which is rewritten on every deploy); only the edge (`child_workspace_id`)
* cascades. The target workflow id encodes direction (push -> parent workflow, pull -> child
* workflow), so no separate direction column is needed.
*/
export const workspaceForkDependentValue = pgTable(
'workspace_fork_dependent_value',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
targetWorkflowId: text('target_workflow_id').notNull(),
targetBlockId: text('target_block_id').notNull(),
subBlockKey: text('sub_block_key').notNull(),
value: text('value').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Reconcile replaces a workflow's stored values by its id, so index that sweep.
childWsWorkflowIdx: index('workspace_fork_dependent_value_child_ws_wf_idx').on(
table.childWorkspaceId,
table.targetWorkflowId
),
// One stored value per (edge, target workflow, target block, subblock).
childWsFieldUnique: uniqueIndex('workspace_fork_dependent_value_field_unique').on(
table.childWorkspaceId,
table.targetWorkflowId,
table.targetBlockId,
table.subBlockKey
),
})
)
export const workspaceForkPromoteDirectionEnum = pgEnum('workspace_fork_promote_direction', [
'push',
'pull',
])
export const workspaceForkPromoteRun = pgTable(
'workspace_fork_promote_run',
{
id: text('id').primaryKey(),
childWorkspaceId: text('child_workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
sourceWorkspaceId: text('source_workspace_id').notNull(),
targetWorkspaceId: text('target_workspace_id').notNull(),
direction: workspaceForkPromoteDirectionEnum('direction').notNull(),
snapshot: jsonb('snapshot').notNull(),
// SET NULL (not CASCADE): deleting the creating user must not delete a pending
// undo point for a target workspace.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// One undo point per (edge, target) so a push (target=parent) and a pull
// (target=child) on the same edge keep independent undo points.
childWorkspaceTargetUnique: uniqueIndex('workspace_fork_promote_run_child_ws_target_unique').on(
table.childWorkspaceId,
table.targetWorkspaceId
),
targetWorkspaceIdx: index('workspace_fork_promote_run_target_ws_idx').on(
table.targetWorkspaceId
),
})
)
export const backgroundWorkKindEnum = pgEnum('background_work_kind', [
'deployment_side_effects',
'fork_content_copy',
'fork_sync',
'fork_rollback',
])
export const backgroundWorkStatusValueEnum = pgEnum('background_work_status_value', [
'pending',
'processing',
'completed',
'completed_with_warnings',
'failed',
])
/**
* Durable status for asynchronous background work (post-sync/rollback deployment
* side-effects and fork content copy), so the canvas can show a "work in progress"
* banner that survives a reload. A row scoped to a single workflow sets `workflowId`;
* workspace-spanning work (fork content copy) leaves it null.
*/
export const backgroundWorkStatus = pgTable(
'background_work_status',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
kind: backgroundWorkKindEnum('kind').notNull(),
status: backgroundWorkStatusValueEnum('status').notNull(),
message: text('message'),
error: text('error'),
metadata: jsonb('metadata'),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceStatusIdx: index('background_work_status_workspace_status_idx').on(
table.workspaceId,
table.status
),
workflowStatusIdx: index('background_work_status_workflow_status_idx').on(
table.workflowId,
table.status
),
})
)
+3
View File
@@ -154,6 +154,9 @@ export const auditMock = {
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_DELETED: 'workspace.deleted',
WORKSPACE_DUPLICATED: 'workspace.duplicated',
WORKSPACE_FORKED: 'workspace.forked',
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
},
AuditResourceType: {
API_KEY: 'api_key',
+10
View File
@@ -1240,6 +1240,16 @@ export const schemaMock = {
error: 'error',
locators: 'locators',
},
workspaceForkDependentValue: {
id: 'id',
childWorkspaceId: 'childWorkspaceId',
targetWorkflowId: 'targetWorkflowId',
targetBlockId: 'targetBlockId',
subBlockKey: 'subBlockKey',
value: 'value',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
/** Custom type export for tsvector */
tsvector: 'tsvector',
}
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 864,
zodRoutes: 864,
totalRoutes: 872,
zodRoutes: 872,
nonZodRoutes: 0,
} as const