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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,440 @@
import { mcpServers, workflow } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type {
ForkClearedRef,
ForkCopyableKind,
ForkSyncBlocker,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
coerceObjectArray,
isRecord,
type SubBlockRecord,
} from '@/lib/workflows/persistence/remap-internal-ids'
import {
buildSubBlockValues,
type CanonicalModeOverrides,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import { collectForkDependentReconfigs } from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
filterExistingForkTargets,
loadForkCopyableResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
import {
selectForkSyncBlockingRefs,
toForkSyncBlockers,
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import {
createCanonicalModeGates,
type ForkReference,
type ForkReferenceResolver,
type ForkRemapKind,
REQUIRED_KINDS,
remapForkSubBlocks,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/**
* Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential,
* env-var) gate Sync through the kind-level required gate with their own messaging, so they must
* not double-report here (a credential is also preserved by name once mapped, an env-var always).
* `knowledge-document` follows its parent KB - a document under an unmapped KB is implied by the
* KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. Every other kind's
* entry IS a sync blocker (cause `reference`/`workflow`): a sync proceeds only when zero
* references would clear.
*/
const CLEARED_REF_EXCLUDED_KINDS = new Set<ForkRemapKind>([...REQUIRED_KINDS, 'knowledge-document'])
interface ClearedRefItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
sourceMeta: { name: string }
}
export interface CollectForkClearedRefsParams {
items: ClearedRefItem[]
sourceStates: Map<string, WorkflowState>
/** Plan resolver (persisted mappings + env identity), to detect which refs are currently unmapped. */
resolver: ForkReferenceResolver
/** Source workflow id -> target id for THIS sync; a ref to a workflow absent here is cleared. */
workflowIdMap: Map<string, string>
/** Same block-id resolver the sync uses, so a candidate's blockId matches the written block. */
resolveBlockId: ForkBlockIdResolver
/** `${kind}:${sourceId}` -> source resource label, for the `sourceLabel` display. */
sourceLabels: Map<string, string>
/** Source workflow id -> name, for `workflow`-kind candidate labels. */
sourceWorkflowNames: Map<string, string>
}
/** Strip an advanced-mode `_N` suffix so a subblock key matches its config id. */
function baseSubBlockId(key: string): string {
return key.replace(/_\d+$/, '')
}
/**
* Cross-workflow references (`workflow-selector`, multi-select `workflowSelector`, the
* workspace-event trigger's multi-select `workflowIds` dropdown, nested `workflow_input` tools)
* in a block's subBlocks. Mirrors the detection in
* {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that
* remap would clear - the free-form manual fields (`manualWorkflowId`, `manualWorkflowIds`) are
* user-owned and never remapped/cleared, so they are intentionally excluded (the `workflowIds`
* branch is gated on TYPE `dropdown` because the logs block's `workflowIds` is a manual
* `short-input`). Returns one entry per referenced workflow id with its owning subblock key.
*/
function collectForkWorkflowReferences(
subBlocks: SubBlockRecord,
config: ReturnType<typeof getBlock>,
canonicalModes: CanonicalModeOverrides | undefined
): Array<{ workflowId: string; subBlockKey: string }> {
const out: Array<{ workflowId: string; subBlockKey: string }> = []
// Collapse each canonical pair to its ACTIVE member and skip condition-hidden fields: only a
// value that serializes is a ref that a sync would clear (the advanced
// `manualWorkflowId`/`manualWorkflowIds` are user-owned and preserved verbatim, an inactive
// operation's selector never executes) - neither may become an unresolvable sync blocker.
// Shares {@link createCanonicalModeGates} with the reference scan, so the scalar `workflowId`
// pair, the deployments block's scalar `workflowSelector` pair, and the logs block's
// multi-select `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A
// missing config or a non-pair member is never skipped (no-pair states keep emitting).
const gates = createCanonicalModeGates(
config?.subBlocks,
buildSubBlockValues(subBlocks),
canonicalModes
)
const detectionSkipped = (key: string) =>
gates.isDormantMember(key) || gates.isConditionHidden(key)
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (!subBlock || typeof subBlock !== 'object') continue
const baseKey = baseSubBlockId(key)
if (
subBlock.type === 'workflow-selector' &&
typeof subBlock.value === 'string' &&
subBlock.value
) {
// Only the SELECTOR is remapped/cleared; the manual member is user-owned and preserved
// verbatim, so skip the dormant selector when advanced/manual mode is active.
if (detectionSkipped(key)) continue
out.push({ workflowId: subBlock.value, subBlockKey: key })
} else if (
baseKey === 'workflowSelector' ||
(subBlock.type === 'dropdown' && baseKey === 'workflowIds')
) {
if (detectionSkipped(key)) continue
const ids = Array.isArray(subBlock.value)
? subBlock.value
: typeof subBlock.value === 'string'
? subBlock.value.split(',').map((entry) => entry.trim())
: []
for (const id of ids) {
if (typeof id === 'string' && id) out.push({ workflowId: id, subBlockKey: key })
}
} else if (subBlock.type === 'tool-input') {
const { array } = coerceObjectArray(subBlock.value)
if (!array) continue
for (const tool of array) {
if (
isRecord(tool) &&
tool.type === 'workflow_input' &&
isRecord(tool.params) &&
typeof tool.params.workflowId === 'string' &&
tool.params.workflowId
) {
out.push({ workflowId: tool.params.workflowId, subBlockKey: key })
}
}
}
}
return out
}
/**
* Compute the per-block/field references this sync WILL blank in the target, for the pre-sync
* "what will be cleared" list. Three causes (see {@link ForkClearedRef}):
* - `reference`: an unmapped remappable resource (credential / KB / table / file / MCP server /
* custom tool / skill). The client filters these against the live mapping + copy selection, so an
* item disappears once mapped or selected for copy. Env vars (preserved) and documents (follow
* their KB) are excluded.
* - `workflow`: a cross-workflow reference to a workflow not carried into the target - always cleared.
* - `dependent`: a create-target dependent selector the source configured that a remapped parent
* clears. Carries `parentKind`/`parentSourceId` so the client can drop it once a KB parent is
* mapped or copied (the document follows its KB); a credential's label or a table's column is
* cleared on any parent remap, so it stays.
*
* Pure (no DB): the caller supplies the plan, source states, resolver, block-id resolver, and the
* source label maps. Block + field labels come from the block registry / block state.
*/
export function collectForkClearedRefCandidates(
params: CollectForkClearedRefsParams
): ForkClearedRef[] {
const { items, sourceStates, resolver, workflowIdMap, resolveBlockId, sourceLabels } = params
const out: ForkClearedRef[] = []
const labelFor = (kind: string, sourceId: string) =>
sourceLabels.get(`${kind}:${sourceId}`) ?? sourceId
for (const item of items) {
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
const config = getBlock(block.type)
const blockLabel = block.name
const targetBlockId = resolveBlockId(item.targetWorkflowId, sourceBlockId)
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const fieldLabel = (subBlockKey: string) =>
config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ??
subBlockKey
// Cause `reference`: unmapped remappable resource refs (per block/field). `blockType` +
// `canonicalModes` gate detection to the ACTIVE canonical member, matching the plan's
// reference scan - a dormant member's stale value is not a real reference, so it must not
// become a blocker with no mapping entry to resolve it. `sourceDeleted` starts false; the
// caller annotates it via {@link annotateForkClearedRefSourceLiveness} (DB check).
const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', {
blockId: targetBlockId,
blockName: blockLabel,
blockType: block.type,
canonicalModes: block.data?.canonicalModes,
})
for (const ref of scan.unmapped) {
if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue
out.push({
targetWorkflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
blockId: targetBlockId,
blockLabel,
fieldLabel: fieldLabel(ref.subBlockKey),
kind: ref.kind,
sourceId: ref.sourceId,
sourceLabel: labelFor(ref.kind, ref.sourceId),
cause: 'reference',
sourceDeleted: false,
})
}
// Cause `workflow`: refs to a workflow not carried into the target.
for (const wfRef of collectForkWorkflowReferences(
subBlocks,
config,
block.data?.canonicalModes
)) {
if (workflowIdMap.has(wfRef.workflowId)) continue
out.push({
targetWorkflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
blockId: targetBlockId,
blockLabel,
fieldLabel: fieldLabel(wfRef.subBlockKey),
kind: 'workflow',
sourceId: wfRef.workflowId,
sourceLabel: params.sourceWorkflowNames.get(wfRef.workflowId) ?? wfRef.workflowId,
cause: 'workflow',
})
}
}
}
// Cause `dependent`: create-target dependent selectors the source configured that a remapped
// parent clears. Only `replace` targets get the in-place reconfigure flow; a created target has
// no draft to re-pick against, so these would clear silently - surface them here.
const workflowNameByTarget = new Map(items.map((i) => [i.targetWorkflowId, i.sourceMeta.name]))
for (const dependent of collectForkDependentReconfigs(
items,
sourceStates,
resolveBlockId,
'create'
)) {
if (dependent.currentValue === '') continue
out.push({
targetWorkflowId: dependent.targetWorkflowId,
workflowName: workflowNameByTarget.get(dependent.targetWorkflowId) ?? '',
blockId: dependent.targetBlockId,
blockLabel: dependent.blockName,
// Nested tool fields keep a plain `title` for the reconfigure UI; warnings need the
// tool disambiguator so two tools with the same field name stay distinguishable.
fieldLabel: dependent.toolName
? `${dependent.toolName}: ${dependent.title}`
: dependent.title,
kind: dependent.parentKind,
sourceId: dependent.parentSourceId,
sourceLabel: labelFor(dependent.parentKind, dependent.parentSourceId),
cause: 'dependent',
// The dependsOn parent (its KB/credential/table). The client drops this entry once the parent
// is mapped or copied ONLY when the child follows it (a document under a KB); a credential's
// label or a table's column is cleared on any parent remap, so it stays.
parentKind: dependent.parentKind,
parentSourceId: dependent.parentSourceId,
})
}
return out
}
/**
* Fill each `reference`-cause entry's `sourceDeleted` flag by checking whether its resource still
* exists (not deleted/archived) in the SOURCE workspace. Reuses {@link filterExistingForkTargets}
* - a per-kind, exact-id (cap-free) liveness check with the canonical archived/deleted filters -
* pointed at the source workspace instead of a target. One batched round per kind present; a
* no-op (zero queries) when no reference-cause entries exist. Files check by storage key, matching
* how `file` references are recorded.
*/
export async function annotateForkClearedRefSourceLiveness(
executor: DbOrTx,
sourceWorkspaceId: string,
clearedRefs: ForkClearedRef[]
): Promise<ForkClearedRef[]> {
const idsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const ref of clearedRefs) {
if (ref.cause !== 'reference') continue
;(idsByKind[ref.kind] ??= new Set()).add(ref.sourceId)
}
if (Object.keys(idsByKind).length === 0) return clearedRefs
const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind)
return clearedRefs.map((ref) =>
ref.cause === 'reference'
? { ...ref, sourceDeleted: !(liveByKind[ref.kind]?.has(ref.sourceId) ?? false) }
: ref
)
}
/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */
const FORK_SYNC_BLOCKER_LIMIT = 100
/**
* Cheap existence check for blocking gate candidates, reusing the plan's already-computed scan
* output instead of re-running the full per-block reference scan:
* - `reference` cause: the collector detects references with the same per-block scan
* ({@link remapForkSubBlocks}) over the same source states the plan already ran, so a
* candidate exists iff some plan-unmapped reference of a non-excluded kind still resolves to
* null through the gate resolver. The gate resolver only ADDS resolutions on top of the plan
* resolver (promote's copy-selection overlay), so filtering the plan's unmapped set through it
* yields exactly the gate's unmapped set. The plan's cascade-only additions (env-var /
* credential) are excluded kinds and never contribute.
* - `workflow` cause: cross-workflow refs are not part of the plan's scan, so walk the blocks
* with the (much lighter) workflow-reference detection only, against the same workflowIdMap
* predicate the collector applies.
* `dependent`-cause candidates never block (see {@link forkSyncBlockerReasonFor}), so they are
* not checked.
*/
function hasForkSyncBlockerCandidates(
planUnmapped: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>,
params: Pick<
CollectForkClearedRefsParams,
'items' | 'sourceStates' | 'resolver' | 'workflowIdMap'
>
): boolean {
const { items, sourceStates, resolver, workflowIdMap } = params
const hasReferenceCandidate = planUnmapped.some(
(reference) =>
!CLEARED_REF_EXCLUDED_KINDS.has(reference.kind) &&
resolver(reference.kind, reference.sourceId) == null
)
if (hasReferenceCandidate) return true
for (const item of items) {
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const block of Object.values(state.blocks)) {
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const workflowRefs = collectForkWorkflowReferences(
subBlocks,
getBlock(block.type),
block.data?.canonicalModes
)
if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true
}
}
return false
}
/**
* The authoritative would-clear gate input for a promote: collect the cleared-ref candidates for
* the sync (against the caller's resolver, which must already account for the copy selection),
* keep the blocking causes (`reference` / `workflow` - dependents stay with the reconfigure
* flow), annotate source liveness, and return them as wire {@link ForkSyncBlocker}s with
* best-effort labels. The happy path (nothing would clear) costs ZERO queries - the collection is
* pure over the pre-read source states - and, when `planUnmapped` is supplied, ZERO re-scans of
* the blocks the plan already scanned; liveness + label reads (and the full candidate collection,
* for identical per-block/field blocker rows) run only when something blocks. Truncated to
* {@link FORK_SYNC_BLOCKER_LIMIT} entries.
*/
export async function collectForkSyncBlockers(
params: Omit<CollectForkClearedRefsParams, 'sourceLabels' | 'sourceWorkflowNames'> & {
executor: DbOrTx
sourceWorkspaceId: string
/**
* The plan's unmapped references (`unmappedRequired` + `unmappedOptional`), when the caller
* computed the plan over the SAME `items`/`sourceStates` inside the same transaction AND the
* gate `resolver` only augments the plan's resolver (never un-resolves a plan-mapped ref) -
* promote's copy-selection overlay satisfies both. Enables the happy-path shortcut via
* {@link hasForkSyncBlockerCandidates}: the full per-block reference scan the plan already
* ran is skipped when no blocking candidate can exist, and re-run (for byte-identical blocker
* rows) when one does. Omit to always collect from scratch.
*/
planUnmapped?: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>
}
): Promise<ForkSyncBlocker[]> {
const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params
if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return []
const candidates = collectForkClearedRefCandidates({
...collectParams,
sourceLabels: new Map(),
sourceWorkflowNames: new Map(),
})
if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return []
const annotated = await annotateForkClearedRefSourceLiveness(
executor,
sourceWorkspaceId,
candidates
)
const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT)
if (blocking.length === 0) return []
// Best-effort display labels (failure path only). Copyable kinds go through the shared label
// loader (live rows only - a deleted source keeps its id label); MCP servers are read without
// the deleted filter so a source-deleted server still names itself; workflow names label the
// `workflow`-cause entries.
const copyableIdsByKind: Partial<Record<ForkCopyableKind, string[]>> = {}
const mcpIds: string[] = []
const workflowIds: string[] = []
for (const { ref } of blocking) {
if (ref.cause === 'workflow') workflowIds.push(ref.sourceId)
else if (ref.kind === 'mcp-server') mcpIds.push(ref.sourceId)
else if (isForkCopyableKind(ref.kind)) (copyableIdsByKind[ref.kind] ??= []).push(ref.sourceId)
}
const [copyableLabels, mcpRows, workflowRows] = await Promise.all([
loadForkCopyableResourceLabels(executor, sourceWorkspaceId, copyableIdsByKind),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string; name: string }>)
: executor
.select({ id: mcpServers.id, name: mcpServers.name })
.from(mcpServers)
.where(
and(eq(mcpServers.workspaceId, sourceWorkspaceId), inArray(mcpServers.id, mcpIds))
),
workflowIds.length === 0
? Promise.resolve([] as Array<{ id: string; name: string }>)
: executor
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(
and(eq(workflow.workspaceId, sourceWorkspaceId), inArray(workflow.id, workflowIds))
),
])
const mcpNames = new Map(mcpRows.map((row) => [row.id, row.name]))
const workflowNames = new Map(workflowRows.map((row) => [row.id, row.name]))
const labelFor = (ref: ForkClearedRef): string => {
if (ref.cause === 'workflow') return workflowNames.get(ref.sourceId) ?? ref.sourceLabel
if (ref.kind === 'mcp-server') return mcpNames.get(ref.sourceId) ?? ref.sourceLabel
return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel
}
return toForkSyncBlockers(
blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason }))
)
}
@@ -0,0 +1,505 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
type ForkCopyableUnmapped,
forkCopyableKindSchema,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
const {
mockUpsertEdgeMappings,
mockDeleteEdgeMappingsByChildResources,
mockCopyForkResourceContainers,
mockPlanForkMappedKbDocumentCopies,
mockPlanForkFileCopies,
} = vi.hoisted(() => ({
mockUpsertEdgeMappings: vi.fn(),
mockDeleteEdgeMappingsByChildResources: vi.fn(),
mockCopyForkResourceContainers: vi.fn(),
mockPlanForkMappedKbDocumentCopies: vi.fn(),
mockPlanForkFileCopies: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
upsertEdgeMappings: mockUpsertEdgeMappings,
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
resourceTypeToForkKind: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies,
copyForkResourceContent: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
executeForkFileBlobCopies: vi.fn(),
}))
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
augmentForkResolver,
buildPromoteCopySelection,
copyPromoteUnmappedResources,
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
hasPromoteCopySelection,
persistPromoteCopiedMappings,
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const candidates: ForkCopyableUnmapped[] = [
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'KB One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'custom-tool',
sourceId: 'ct-1',
label: 'Tool One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'skill',
sourceId: 'sk-1',
label: 'Skill One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'file',
sourceId: 'workspace/SRC/a.png',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
referenced: true,
},
// An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for
// copy exactly like a referenced one - the server treats the two identically.
{
kind: 'table',
sourceId: 'tbl-unref',
label: 'Scratch table',
parentId: null,
parentLabel: null,
referenced: false,
},
]
describe('buildPromoteCopySelection', () => {
it('groups requested ids into the selection by kind and records willResolve keys', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1'], tables: ['tbl-1'], customTools: ['ct-1'], skills: ['sk-1'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(selection.tables).toEqual(['tbl-1'])
expect(selection.customTools).toEqual(['ct-1'])
expect(selection.skills).toEqual(['sk-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(true)
expect(willResolve.has('skill:sk-1')).toBe(true)
})
it('ignores a requested id that is not an actual copy candidate (security)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1', 'kb-not-a-candidate'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(willResolve.has('knowledge-base:kb-not-a-candidate')).toBe(false)
})
it('groups requested file storage keys (security: only actual candidates)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ files: ['workspace/SRC/a.png', 'workspace/SRC/not-referenced.png'] },
candidates
)
expect(selection.files).toEqual(['workspace/SRC/a.png'])
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(true)
expect(willResolve.has('file:workspace/SRC/not-referenced.png')).toBe(false)
})
it('returns an empty selection when nothing is requested', () => {
const { selection, willResolve } = buildPromoteCopySelection(undefined, candidates)
expect(hasPromoteCopySelection(selection)).toBe(false)
expect(willResolve.size).toBe(0)
})
it('accepts an UNREFERENCED candidate exactly like a referenced one', () => {
// The client keeps unreferenced candidates default-unselected, but once the user opts in the
// server validates + copies them through the same path. Its willResolve key matches no
// unmapped reference (nothing references it), so the pre-copy gate is unaffected.
const { selection, willResolve } = buildPromoteCopySelection(
{ tables: ['tbl-unref'] },
candidates
)
expect(selection.tables).toEqual(['tbl-unref'])
expect(willResolve.has('table:tbl-unref')).toBe(true)
})
it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => {
// Reconciliation precedence at the server boundary: a resource the user mapped resolves to a
// target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still
// requests it for copy, only the genuinely-unmapped candidates survive - the map wins.
const onlyTableUnmapped: ForkCopyableUnmapped[] = [
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
]
const { selection, willResolve } = buildPromoteCopySelection(
// kb-1 + the file were mapped (so absent from candidates); only the table remains copyable.
{
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
files: ['workspace/SRC/a.png'],
},
onlyTableUnmapped
)
expect(selection.knowledgeBases).toEqual([])
expect(selection.files).toEqual([])
expect(selection.tables).toEqual(['tbl-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(false)
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(false)
expect(willResolve.has('table:tbl-1')).toBe(true)
})
})
describe('hasPromoteCopySelection', () => {
it('is true only when at least one copyable kind has ids', () => {
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
})
).toBe(true)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
})
).toBe(false)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/file.png'],
mcpServers: [],
})
).toBe(true)
})
})
describe('augmentForkResolver', () => {
it('resolves a just-copied resource via the extra map, else falls through to the base', () => {
const base = (kind: ForkRemapKind, id: string) =>
kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null
const extra = new Map<ForkRemapKind, Map<string, string>>([
['knowledge-base', new Map([['kb-src', 'kb-dst']])],
])
const resolver = augmentForkResolver(base, extra)
expect(resolver('knowledge-base', 'kb-src')).toBe('kb-dst')
expect(resolver('credential', 'cred-src')).toBe('cred-dst')
expect(resolver('table', 'tbl-x')).toBeNull()
})
})
describe('persistPromoteCopiedMappings', () => {
const tx = {} as DbOrTx
const entry: ForkMappingUpsert = {
resourceType: 'knowledge_base',
parentResourceId: 'src-kb',
childResourceId: 'dst-kb',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('pull keeps the source(parent)->target(child) orientation as-is', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry])
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
})
it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry])
// Delete keys on the source child resource (the swapped child id = the original parent id).
expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [
{ resourceType: 'knowledge_base', childResourceId: 'src-kb' },
])
// The swap flips parent/child: the new copy (dst) becomes the parent side on push.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' },
])
})
it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [
{ resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null },
])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
it('returns without writing when there are no entries', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
})
describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
const tx = {} as DbOrTx
// Only edge.childWorkspaceId is read by the copy path.
const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge
// The promote-built persisted-pair resolver; the copy must forward it verbatim so copied
// tables' workflow-group outputs land on the same block ids the workflow writes assign.
const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}`
beforeEach(() => {
vi.clearAllMocks()
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map(),
mappingEntries: [],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkMappedKbDocumentCopies.mockResolvedValue({
documents: [],
docIdMap: new Map(),
mappingEntries: [],
})
})
it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => {
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]),
idMap: new Map([['file-src', 'file-dst']]),
blobTasks: [
{
sourceKey: 'workspace/SRC/a.png',
targetKey: 'workspace/DST/a.png',
context: 'workspace',
fileName: 'a.png',
contentType: 'image/png',
userId: 'user-1',
workspaceId: 'target-ws',
},
],
})
const result = await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/a.png'],
},
workflowIdMap: new Map(),
folderIdMap: new Map([['fld-src', 'fld-dst']]),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
// planForkFileCopies is invoked by storage key (the sync references key files by key).
expect(mockPlanForkFileCopies).toHaveBeenCalledWith(
expect.objectContaining({ fileKeys: ['workspace/SRC/a.png'] })
)
// blobTasks bubble up for the post-commit blob duplication.
expect(result.blobTasks).toHaveLength(1)
// The copied file resolves by storage key for the subblock remap.
expect(result.copyIdMapByKind.get('file')).toEqual(
new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']])
)
// The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a
// re-sync resolves the copy instead of re-copying it.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{
resourceType: 'file',
parentResourceId: 'workspace/SRC/a.png',
childResourceId: 'workspace/DST/a.png',
},
])
// The folder map AND the file key/id maps reach the in-content rewriter.
expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' })
expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' })
expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' })
})
it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => {
// An UNREFERENCED table selected for copy flows through the same container pipeline; its
// mapping row is what makes the next sync resolve the copy instead of re-offering it.
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]),
mappingEntries: [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: ['Scratch table'],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [],
})
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: ['tbl-unref'],
knowledgeBases: [],
files: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
])
})
it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => {
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
// The doc ids come straight from the promote plan's references; the copy must forward them,
// not re-scan every source workflow state inside the locked tx.
referencedDocumentIds: ['doc-1', 'doc-2'],
})
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
expect.objectContaining({
referencedDocumentIds: ['doc-1', 'doc-2'],
// Workflow-publishing MCP servers are fork-create-only; a sync always passes the
// shared pipeline's slot empty. External MCP servers flow through the selection.
selection: expect.objectContaining({ mcpServers: [], workflowMcpServers: [] }),
// The promote-built block-id resolver reaches the table remap unchanged, so copied
// tables' workflow-group outputs use the persisted-pair ids, not the derive.
resolveBlockId,
})
)
expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith(
expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] })
)
})
})
describe('fork copyable kind drift', () => {
it('FORK_COPYABLE_KIND_TO_SELECTION_KEY covers exactly the contract copyable kinds', () => {
expect(Object.keys(FORK_COPYABLE_KIND_TO_SELECTION_KEY).sort()).toEqual(
[...forkCopyableKindSchema.options].sort()
)
})
it('isForkCopyableKind matches the contract copyable kinds and excludes the rest', () => {
for (const kind of forkCopyableKindSchema.options) {
expect(isForkCopyableKind(kind)).toBe(true)
}
const nonCopyable: ForkRemapKind[] = ['credential', 'env-var', 'knowledge-document']
for (const kind of nonCopyable) {
expect(isForkCopyableKind(kind)).toBe(false)
}
})
})
@@ -0,0 +1,356 @@
import type {
ForkCopyableKind,
ForkCopyableUnmapped,
PromoteCopyResources,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
type SerializableForkContentRefMaps,
serializeContentRefMaps,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import { type BlobCopyTask, planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
import {
copyForkResourceContainers,
planForkMappedKbDocumentCopies,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import {
deleteEdgeMappingsByChildResources,
type ForkMappingUpsert,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/**
* The source ids selected for copy at promote, validated against the plan's copyable
* candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing
* MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here.
*/
export interface PromoteCopySelection {
customTools: string[]
skills: string[]
tables: string[]
knowledgeBases: string[]
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: string[]
/** External MCP servers to copy as config rows (OAuth tokens never copied - re-auth). */
mcpServers: string[]
}
/**
* Each copyable kind to its key in {@link PromoteCopySelection}. Keyed on `ForkCopyableKind`
* (the wire contract enum) so TS fails to compile if the copyable enum grows a kind without a
* selection key here, keeping the two in lockstep.
*/
export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record<
ForkCopyableKind,
keyof PromoteCopySelection
> = {
'knowledge-base': 'knowledgeBases',
table: 'tables',
'custom-tool': 'customTools',
skill: 'skills',
file: 'files',
'mcp-server': 'mcpServers',
}
/**
* Intersect the user's requested copy with the plan's actual copyable candidates (referenced or
* not, always unmapped + still existing in the source), so a crafted request can never copy an
* arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}`
* references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key
* simply matches no reference there, which is harmless.
*/
export function buildPromoteCopySelection(
requested: PromoteCopyResources | undefined,
copyableUnmapped: ForkCopyableUnmapped[]
): { selection: PromoteCopySelection; willResolve: Set<string> } {
const allowed = new Map<string, Set<string>>()
for (const candidate of copyableUnmapped) {
const set = allowed.get(candidate.kind)
if (set) set.add(candidate.sourceId)
else allowed.set(candidate.kind, new Set([candidate.sourceId]))
}
const selection: PromoteCopySelection = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
}
const willResolve = new Set<string>()
const apply = (
kind: keyof typeof FORK_COPYABLE_KIND_TO_SELECTION_KEY,
ids: string[] | undefined
) => {
const allowedIds = allowed.get(kind)
if (!allowedIds || !ids) return
const key = FORK_COPYABLE_KIND_TO_SELECTION_KEY[kind]
for (const id of ids) {
if (!allowedIds.has(id)) continue
selection[key].push(id)
willResolve.add(`${kind}:${id}`)
}
}
apply('knowledge-base', requested?.knowledgeBases)
apply('table', requested?.tables)
apply('custom-tool', requested?.customTools)
apply('skill', requested?.skills)
apply('file', requested?.files)
apply('mcp-server', requested?.mcpServers)
return { selection, willResolve }
}
/** Whether any resource is selected for copy. */
export function hasPromoteCopySelection(selection: PromoteCopySelection): boolean {
return (
selection.customTools.length > 0 ||
selection.skills.length > 0 ||
selection.tables.length > 0 ||
selection.knowledgeBases.length > 0 ||
selection.files.length > 0 ||
selection.mcpServers.length > 0
)
}
/**
* Layer the just-copied resources' source->target ids on top of the plan's resolver, so the
* synced workflows' references to those resources resolve to the new copies. The base resolver
* (persisted mappings + env identity) is consulted for everything else.
*/
export function augmentForkResolver(
base: ForkReferenceResolver,
extra: Map<ForkRemapKind, Map<string, string>>
): ForkReferenceResolver {
return (kind, sourceId) => extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
}
export interface PromoteCopyResult {
contentPlan: ForkContentPlan
/** Copied source resource id -> new target id, by remap kind, for resolver augmentation. */
copyIdMapByKind: Map<ForkRemapKind, Map<string, string>>
/**
* Serialized in-content reference maps for the post-commit copy to rewrite copied skill bodies
* (their `sim:` links + embedded URLs) off the locked promote tx - carried through the durable
* content-copy payload, mirroring fork.
*/
contentRefMaps: SerializableForkContentRefMaps
/**
* File blob duplications for copied workspace files, run post-commit by the durable content-copy
* runner (no object-storage I/O inside the locked promote tx). Empty when no files were copied.
*/
blobTasks: BlobCopyTask[]
}
/**
* Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing
* the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull
* fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior
* `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers:
* - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files,
* - documents referenced under a copied knowledge base (auto-placed under that copied KB),
* - documents referenced under an ALREADY-mapped (existing) KB - copied into that existing KB so
* the `document-selector` reference remaps instead of being cleared.
*
* The heavy content (table rows, KB documents + embeddings, file blobs) is returned as a content
* plan + blob tasks for a post-commit, best-effort fill; no object-storage I/O runs inside the
* locked promote tx. Always safe to call (a no-op when nothing is selected and nothing references
* a mapped-KB document).
*/
export async function copyPromoteUnmappedResources(params: {
tx: DbOrTx
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
userId: string
now: Date
selection: PromoteCopySelection
workflowIdMap: Map<string, string>
/** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/<id>`. */
folderIdMap: Map<string, string>
/** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */
resolver: ForkReferenceResolver
/**
* The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over
* derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync
* actually writes - on push the parent keeps its ORIGINAL block ids, never the derive.
*/
resolveBlockId: ForkBlockIdResolver
/**
* Knowledge-document ids the synced workflows reference, already scanned once in the promote
* plan and threaded in so the copy doesn't re-scan every source state inside the locked tx.
* `copyForkResourceContainers` / `planForkMappedKbDocumentCopies` place only those whose parent
* KB is in this copy (or already mapped), so an extra id is FK-safe and simply skipped.
*/
referencedDocumentIds: string[]
}): Promise<PromoteCopyResult> {
const {
tx,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
userId,
now,
selection,
workflowIdMap,
folderIdMap,
resolver,
resolveBlockId,
referencedDocumentIds,
} = params
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
// External MCP servers copy as config rows (like fork); workflow-publishing MCP servers
// are fork-create-only shells (the shared pipeline still takes the slot - pass it empty).
mcpServers: selection.mcpServers,
workflowMcpServers: [],
tables: selection.tables,
knowledgeBases: selection.knowledgeBases,
},
workflowIdMap,
referencedDocumentIds,
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
// rewritten through the same plan resolver that remaps subblock-value env refs.
resolveEnvName: (key) => resolver('env-var', key),
resolveBlockId,
})
// Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob
// duplications deferred to the post-commit runner.
const fileResult =
selection.files.length > 0
? await planForkFileCopies({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
fileKeys: selection.files,
now,
})
: {
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [] as BlobCopyTask[],
}
// U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc
// already placed under a copied KB above (its parent KB is in this copy), so a doc is never
// copied twice.
const containerDocMap = result.idMap.get('knowledge_document') ?? new Map<string, string>()
const mappedKbDocs = await planForkMappedKbDocumentCopies({
tx,
resolver,
referencedDocumentIds,
alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()),
})
result.contentPlan.documents.push(...mappedKbDocs.documents)
// Persist every copied resource's mapping (containers + files + U-docs) so a re-sync resolves
// the copy instead of re-copying it. Files map by storage key; U-docs add knowledge_document rows.
const fileMappingEntries: ForkMappingUpsert[] = Array.from(
fileResult.keyMap,
([source, child]) => ({
resourceType: 'file' as const,
parentResourceId: source,
childResourceId: child,
})
)
await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [
...result.mappingEntries,
...fileMappingEntries,
...mappedKbDocs.mappingEntries,
])
const copyIdMapByKind = new Map<ForkRemapKind, Map<string, string>>()
for (const [resourceType, sourceToTarget] of result.idMap) {
const kind = resourceTypeToForkKind(resourceType)
if (!kind) continue
copyIdMapByKind.set(kind, sourceToTarget)
}
if (fileResult.keyMap.size > 0) copyIdMapByKind.set('file', fileResult.keyMap)
// Merge the container's copied-KB document map with the U-docs map so every copied document
// (under a copied KB or into an existing one) remaps its `document-selector` reference.
const documentIdMap = new Map<string, string>([...containerDocMap, ...mappedKbDocs.docIdMap])
if (documentIdMap.size > 0) copyIdMapByKind.set('knowledge-document', documentIdMap)
// Serialized maps for the post-commit content rewrite (run off the locked promote tx). Mirrors
// fork: workspace + workflow + folder ids plus this copy's own file/skill/table/KB maps, so a
// copied skill body / markdown blob's `sim:` links + embedded file URLs resolve to the new target
// copies instead of the source.
const contentRefMaps = serializeContentRefMaps({
workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId },
workflows: workflowIdMap,
folders: folderIdMap,
fileKeys: fileResult.keyMap,
fileIds: fileResult.idMap,
skills: result.idMap.get('skill'),
tables: result.idMap.get('table'),
knowledgeBases: result.idMap.get('knowledge_base'),
})
return {
contentPlan: result.contentPlan,
copyIdMapByKind,
contentRefMaps,
blobTasks: fileResult.blobTasks,
}
}
/**
* Persist the copied resources' id mappings for the edge. The copy returns entries oriented
* source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a
* push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the
* source child resource so a changed target can't leak a second mapping.
*/
export async function persistPromoteCopiedMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
direction: 'push' | 'pull',
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
if (direction === 'pull') {
await upsertEdgeMappings(tx, childWorkspaceId, userId, entries)
return
}
// Push: re-key on the source child resource. Skip any entry with a null child id (copy entries
// always carry one; the guard narrows the type so neither the swap nor the delete needs a cast).
// After the swap every childResourceId is the original (non-null) parent id, keyed for the
// delete-then-insert that prevents a changed target from leaking a second mapping.
const swapped: ForkMappingUpsert[] = []
const deleteKeys: Array<{
resourceType: ForkMappingUpsert['resourceType']
childResourceId: string
}> = []
for (const entry of entries) {
if (entry.childResourceId == null) continue
swapped.push({
resourceType: entry.resourceType,
parentResourceId: entry.childResourceId,
childResourceId: entry.parentResourceId,
})
deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId })
}
if (swapped.length === 0) return
await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys)
await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped)
}
@@ -0,0 +1,256 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type {
ForkCopyableLabel,
ForkCopyableSourceResource,
} from '@/ee/workspace-forking/lib/mapping/resources'
import {
assembleForkCopyableUnmapped,
buildPromoteWorkflowIdMap,
collectForkCopyableIdsByKind,
collectForkUnreferencedCopyables,
} from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkReference } from '@/ee/workspace-forking/lib/remap/remap-references'
const ref = (kind: ForkReference['kind'], sourceId: string): ForkReference => ({
kind,
sourceId,
subBlockKey: 'sb',
required: false,
})
/**
* `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')
})
})
describe('collectForkCopyableIdsByKind', () => {
it('groups copyable kinds and ignores non-copyable kinds (credential / env-var)', () => {
const byKind = collectForkCopyableIdsByKind([
ref('knowledge-base', 'kb-1'),
ref('knowledge-base', 'kb-2'),
ref('table', 'tbl-1'),
ref('credential', 'cred-1'),
ref('env-var', 'API_KEY'),
ref('custom-tool', 'ct-1'),
ref('skill', 'sk-1'),
ref('file', 'fk-1'),
])
expect(byKind).toEqual({
'knowledge-base': ['kb-1', 'kb-2'],
table: ['tbl-1'],
'custom-tool': ['ct-1'],
skill: ['sk-1'],
file: ['fk-1'],
})
})
})
describe('assembleForkCopyableUnmapped', () => {
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
it('emits a candidate per copyable ref whose label resolved, carrying its labels', () => {
const labels = new Map<string, ForkCopyableLabel>([
['knowledge-base:kb-1', flat('Docs KB')],
['file:fk-1', { label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }],
])
const result = assembleForkCopyableUnmapped(
[ref('knowledge-base', 'kb-1'), ref('file', 'fk-1'), ref('credential', 'cred-1')],
labels
)
expect(result).toEqual([
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'Docs KB',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'file',
sourceId: 'fk-1',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Folder',
referenced: true,
},
])
})
it('drops a copyable ref whose label is missing (no longer exists in the source)', () => {
expect(assembleForkCopyableUnmapped([ref('knowledge-base', 'kb-gone')], new Map())).toEqual([])
})
it('ignores non-copyable kinds entirely (credential / env-var)', () => {
const result = assembleForkCopyableUnmapped(
[ref('credential', 'cred-1'), ref('env-var', 'API_KEY')],
new Map([['credential:cred-1', flat('X')]])
)
expect(result).toEqual([])
})
})
describe('collectForkUnreferencedCopyables', () => {
const source = (
kind: ForkCopyableSourceResource['kind'],
sourceId: string,
label = sourceId
): ForkCopyableSourceResource => ({ kind, sourceId, label, parentId: null, parentLabel: null })
const referencedCandidate = (kind: ForkCopyableSourceResource['kind'], sourceId: string) => ({
kind,
sourceId,
label: sourceId,
parentId: null,
parentLabel: null,
referenced: true,
})
it('emits an unmapped source resource no synced workflow references, flagged referenced: false', () => {
const result = collectForkUnreferencedCopyables(
[source('table', 'tbl-new', 'Scratch table')],
[],
() => null
)
expect(result).toEqual([
{
kind: 'table',
sourceId: 'tbl-new',
label: 'Scratch table',
parentId: null,
parentLabel: null,
referenced: false,
},
])
})
it('dedupes against the referenced candidate set (a referenced resource is never double-listed)', () => {
const result = collectForkUnreferencedCopyables(
[source('knowledge-base', 'kb-1'), source('knowledge-base', 'kb-new')],
[referencedCandidate('knowledge-base', 'kb-1')],
() => null
)
expect(result.map((candidate) => candidate.sourceId)).toEqual(['kb-new'])
})
it('excludes a resource with a persisted mapping (idempotency: a prior copy is never re-offered)', () => {
// A resource copied by a prior sync resolves through its workspace_fork_resource_map row.
const result = collectForkUnreferencedCopyables(
[source('skill', 'sk-copied'), source('skill', 'sk-new')],
[],
(kind, sourceId) => (kind === 'skill' && sourceId === 'sk-copied' ? 'sk-target' : null)
)
expect(result.map((candidate) => candidate.sourceId)).toEqual(['sk-new'])
})
it('does not confuse the same id across kinds when deduping or resolving', () => {
const result = collectForkUnreferencedCopyables(
[source('table', 'shared-id'), source('skill', 'shared-id')],
[referencedCandidate('table', 'shared-id')],
() => null
)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ kind: 'skill', sourceId: 'shared-id', referenced: false })
})
it('carries a file candidate keyed by storage key with its folder grouping', () => {
const result = collectForkUnreferencedCopyables(
[
{
kind: 'file',
sourceId: 'workspace/SRC/new.png',
label: 'new.png',
parentId: 'fld-1',
parentLabel: 'Images',
},
],
[],
() => null
)
expect(result).toEqual([
{
kind: 'file',
sourceId: 'workspace/SRC/new.png',
label: 'new.png',
parentId: 'fld-1',
parentLabel: 'Images',
referenced: false,
},
])
})
})
@@ -0,0 +1,399 @@
import { workflow } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type ForkCopyableKind, forkCopyableKindSchema } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
import {
buildForkResolver,
getEdgeMappingRows,
resourceTypeToForkKind,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
type ForkCopyableLabel,
type ForkCopyableSourceResource,
filterExistingForkTargets,
getWorkspaceEnvKeys,
listForkCopyableSourceResources,
loadForkCopyableResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
type ForkReference,
type ForkReferenceResolver,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/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
/** Source's public-API flag, carried onto the written target (see copyWorkflowStateIntoTarget). */
isPublicApi: boolean
}
}
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[]
/**
* Unmapped resources of copyable kinds that still exist in the source, so a sync can copy
* them into the target instead of requiring a manual mapping (U15). `referenced: true`
* entries are referenced by the synced workflows (default-selected in the modal - skipping
* one clears its references); `referenced: false` entries are used by no synced workflow
* (default-unselected - skipping one breaks nothing). Documents are auto-copied with their
* parent KB and are not listed here. `parentId`/`parentLabel` carry a file's folder grouping
* (null for non-file kinds and root files), for the nested picker.
*/
copyableUnmapped: Array<{
kind: ForkCopyableKind
sourceId: string
label: string
parentId: string | null
parentLabel: string | null
referenced: boolean
}>
willUpdate: number
willCreate: number
willArchive: number
}
/**
* Copyable promote kinds, derived from the wire contract (`forkCopyableKindSchema`) so this
* guard can never drift from the single source of truth: growing the schema automatically
* grows the set. Typed as `ForkRemapKind` so `.has` accepts a broad scan-reference kind.
*/
const COPYABLE_PROMOTE_KINDS = new Set<ForkRemapKind>(forkCopyableKindSchema.options)
export function isForkCopyableKind(kind: ForkRemapKind): kind is ForkCopyableKind {
return COPYABLE_PROMOTE_KINDS.has(kind)
}
/**
* 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
}
/**
* Collect the source ids of referenced-but-unmapped copyable resources, grouped by kind - the input
* to the source-label lookup that builds {@link ForkPromotePlan.copyableUnmapped}. Pure.
*/
export function collectForkCopyableIdsByKind(
unmappedReferences: ForkReference[]
): Partial<Record<ForkCopyableKind, string[]>> {
const byKind: Partial<Record<ForkCopyableKind, string[]>> = {}
for (const reference of unmappedReferences) {
if (!isForkCopyableKind(reference.kind)) continue
;(byKind[reference.kind] ??= []).push(reference.sourceId)
}
return byKind
}
/**
* Assemble the REFERENCED slice of {@link ForkPromotePlan.copyableUnmapped} from the unmapped
* references and the loaded source labels: each copyable reference whose label resolved becomes a
* copy candidate; one whose label is missing (the resource no longer exists in the source) is
* dropped. Pure - split from the DB label load so it is unit-testable.
*/
export function assembleForkCopyableUnmapped(
unmappedReferences: ForkReference[],
copyableLabels: Map<string, ForkCopyableLabel>
): ForkPromotePlan['copyableUnmapped'] {
return unmappedReferences.flatMap((reference) => {
if (!isForkCopyableKind(reference.kind)) return []
const entry = copyableLabels.get(`${reference.kind}:${reference.sourceId}`)
return entry
? [
{
kind: reference.kind,
sourceId: reference.sourceId,
label: entry.label,
parentId: entry.parentId,
parentLabel: entry.parentLabel,
referenced: true,
},
]
: []
})
}
/**
* Assemble the UNREFERENCED slice of {@link ForkPromotePlan.copyableUnmapped}: every copyable
* resource in the source workspace that no synced workflow references (not in the referenced
* candidate set) and that has no target mapping for this edge (the resolver returns null). A
* previously-copied resource resolves through its persisted `workspace_fork_resource_map` row,
* so a re-sync never re-offers it (idempotency). Pure - split from the DB source listing so it
* is unit-testable.
*/
export function collectForkUnreferencedCopyables(
sourceResources: ForkCopyableSourceResource[],
referencedCopyables: ForkPromotePlan['copyableUnmapped'],
resolver: ForkReferenceResolver
): ForkPromotePlan['copyableUnmapped'] {
const referencedKeys = new Set(
referencedCopyables.map((candidate) => `${candidate.kind}:${candidate.sourceId}`)
)
return sourceResources.flatMap((resource) => {
if (referencedKeys.has(`${resource.kind}:${resource.sourceId}`)) return []
if (resolver(resource.kind, resource.sourceId) != null) return []
return [{ ...resource, referenced: false }]
})
}
/**
* 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,
isPublicApi: source.isPublicApi,
},
})
for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), 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)
// Referenced-but-unmapped resources of copyable kinds that still exist in the source, so the
// sync modal can offer to copy them into the target (fork-style) instead of mapping by hand.
const copyableLabels = await loadForkCopyableResourceLabels(
executor,
sourceWorkspaceId,
collectForkCopyableIdsByKind(allUnmapped)
)
const referencedCopyables = assembleForkCopyableUnmapped(allUnmapped, copyableLabels)
// Also offer the source's UNREFERENCED copyable resources with no target mapping (e.g. newly
// created since the fork), default-unselected in the modal. Mapped ones (including everything
// a prior sync copied) resolve non-null and drop out, so a re-sync never re-offers a copy.
const sourceCopyables = await listForkCopyableSourceResources(executor, sourceWorkspaceId)
const copyableUnmapped = [
...referencedCopyables,
...collectForkUnreferencedCopyables(sourceCopyables, referencedCopyables, resolver),
]
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,
copyableUnmapped,
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,622 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork'
const {
mockComputePlan,
mockBuildCopySelection,
mockHasCopySelection,
mockCopyUnmapped,
mockCollectBlockers,
mockLoadBlockMap,
mockBuildBlockIdResolver,
mockResolveFolderMapping,
mockUpsertPromoteRun,
mockLoadSourceDeployedStates,
mockGetUsersWithPermissions,
mockGetMcpServerMeta,
mockCreateTransform,
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
} = vi.hoisted(() => ({
mockComputePlan: vi.fn(),
mockBuildCopySelection: vi.fn(),
mockHasCopySelection: vi.fn(),
mockCopyUnmapped: vi.fn(),
mockCollectBlockers: vi.fn(),
mockLoadBlockMap: vi.fn(),
mockBuildBlockIdResolver: vi.fn(),
mockResolveFolderMapping: vi.fn(),
mockUpsertPromoteRun: vi.fn(),
mockLoadSourceDeployedStates: vi.fn(),
mockGetUsersWithPermissions: vi.fn(),
mockGetMcpServerMeta: vi.fn(),
mockCreateTransform: vi.fn(),
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: vi.fn(),
processWorkflowDeploymentOutboxEvent: vi.fn(),
}))
vi.mock('@/lib/workflows/orchestration/deploy', () => ({
performFullDeploy: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
startBackgroundWork: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
hasForkContentToCopy: vi.fn(() => false),
scheduleForkContentCopy: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
copyWorkflowStateIntoTarget: vi.fn(),
loadTargetDraftSubBlocks: vi.fn(async () => new Map()),
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
resolveForkFolderMapping: mockResolveFolderMapping,
}))
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
sumForkCopyBytes: mockSumForkCopyBytes,
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()),
loadSourceDeployedStates: mockLoadSourceDeployedStates,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
acquireForkEdgeLock: vi.fn(),
acquireForkTargetLock: vi.fn(),
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
loadForkBlockMap: mockLoadBlockMap,
reconcileForkBlockPairs: vi.fn(),
toForkBlockPairs: vi.fn(() => []),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({
loadForkDependentValues: vi.fn(async () => []),
reconcileForkDependentValues: vi.fn(),
// Faithful mirror of the real pure translation (unit-tested in dependent-value-store.test.ts),
// so promote's apply/reconcile paths exercise the actual source-doc-id rewrite.
translateForkDependentValues: vi.fn(
(
values: Array<{ value: string }>,
resolve: (kind: string, sourceId: string) => string | null | undefined
) =>
values.map((entry) => {
if (entry.value === '') return entry
const translated = resolve('knowledge-document', entry.value)
return translated != null && translated !== entry.value
? { ...entry, value: translated }
: entry
})
),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: vi.fn(),
upsertEdgeMappings: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({
collectForkSyncBlockers: mockCollectBlockers,
}))
vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({
// Faithful mirror of the real overlay so a copy's id maps resolve through the augmented
// resolver (the dependent-value translation and MCP meta read depend on it).
augmentForkResolver: vi.fn(
(
base: (kind: string, sourceId: string) => string | null | undefined,
extra: Map<string, Map<string, string>>
) =>
(kind: string, sourceId: string) =>
extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
),
buildPromoteCopySelection: mockBuildCopySelection,
copyPromoteUnmappedResources: mockCopyUnmapped,
hasPromoteCopySelection: mockHasCopySelection,
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
computeForkPromotePlan: mockComputePlan,
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
reconcileForkWorkflowMcpAttachments: vi.fn(async () => ({ affectedServerIds: [] })),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
notifyMcpToolServers: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
upsertPromoteRun: mockUpsertPromoteRun,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
getMcpServerMetaByIds: mockGetMcpServerMeta,
}))
vi.mock('@/ee/workspace-forking/lib/remap/block-identity', () => ({
buildForkBlockIdResolver: mockBuildBlockIdResolver,
}))
vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({
createForkSubBlockTransform: mockCreateTransform,
}))
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
notifyForkWorkflowChanged: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUsersWithPermissions: mockGetUsersWithPermissions,
}))
import { db } from '@sim/db'
import { copyWorkflowStateIntoTarget } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
import type { ForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
const EMPTY_SELECTION = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
}
function makePlan(overrides: Partial<ForkPromotePlan> = {}): ForkPromotePlan {
return {
childWorkspaceId: EDGE.childWorkspaceId,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push',
resolver: () => null,
items: [],
workflowIdMap: new Map(),
archivedTargetIds: [],
archivedTargets: [],
references: [],
unmappedRequired: [],
unmappedOptional: [],
mcpReauthServerIds: [],
inlineSecretSources: [],
copyableUnmapped: [],
willUpdate: 0,
willCreate: 0,
willArchive: 0,
...overrides,
}
}
const BLOCKER: ForkSyncBlocker = {
workflowName: 'Caller',
blockLabel: 'Table Block',
fieldLabel: 'Table',
kind: 'table',
sourceId: 'tbl-1',
sourceLabel: 'Orders',
reason: 'unmapped-copyable',
}
function promoteParams() {
return {
edge: EDGE as never,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push' as const,
userId: 'user-1',
}
}
/** A copy result carrying no content/id maps, for tests that only need the copy to run. */
function emptyCopyResult() {
return {
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(db.transaction).mockImplementation(
async (cb: (tx: unknown) => unknown) => cb({}) as never
)
mockGetUsersWithPermissions.mockResolvedValue([])
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map(),
})
mockComputePlan.mockResolvedValue(makePlan())
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set<string>(),
})
mockHasCopySelection.mockReturnValue(false)
mockCollectBlockers.mockResolvedValue([])
mockLoadBlockMap.mockResolvedValue(new Map())
mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId)
mockResolveFolderMapping.mockResolvedValue(new Map())
mockUpsertPromoteRun.mockResolvedValue('run-1')
mockGetMcpServerMeta.mockResolvedValue(new Map())
mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks)
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
})
describe('promoteFork gates', () => {
it('blocks an over-quota copy selection before any lock, read, or write', async () => {
mockSumForkCopyBytes.mockResolvedValue(999_999)
mockAssertForkStorageHeadroom.mockRejectedValue(
new Error(
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
)
)
await expect(
promoteFork({
...promoteParams(),
copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] },
})
).rejects.toThrow('Not enough storage to copy the selected resources')
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
// Fails fast: no source-state loads, no locked transaction, no writes of any kind.
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
expect(db.transaction).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => {
await promoteFork({
...promoteParams(),
copyResources: {
files: ['workspace/src-ws/key-1'],
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
},
})
expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1)
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
fileKeys: ['workspace/src-ws/key-1'],
knowledgeBaseIds: ['kb-1'],
})
})
it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
unmappedRequired: [
{ kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true },
],
})
)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('unmapped')
expect(result.unmappedRequired).toEqual([
{ kind: 'credential', sourceId: 'c1', required: true, blockName: undefined },
])
expect(result.blockers).toEqual([])
expect(mockCollectBlockers).not.toHaveBeenCalled()
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => {
mockCollectBlockers.mockResolvedValue([BLOCKER])
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('cleared-refs')
expect(result.blockers).toEqual([BLOCKER])
expect(result.promoteRunId).toBe('')
expect(result.updated).toBe(0)
expect(result.created).toBe(0)
expect(result.archived).toBe(0)
// Blocked before the first write: no folder creation, no resource copy, no undo point.
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockCopyUnmapped).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => {
const planResolver = vi.fn(() => 'plan-resolved')
mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver }))
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set(['table:t1']),
})
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledTimes(1)
const gateParams = mockCollectBlockers.mock.calls[0][0]
// A copy-selected reference resolves through the overlay (never hits the plan resolver);
// everything else falls through to the plan's persisted-mapping resolver.
expect(gateParams.resolver('table', 't1')).toBe('t1')
expect(planResolver).not.toHaveBeenCalled()
expect(gateParams.resolver('table', 't2')).toBe('plan-resolved')
expect(planResolver).toHaveBeenCalledWith('table', 't2')
})
it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => {
// Copied tables' workflow-group outputs must land on the block ids the sync actually writes
// (persisted pairs preferred over derive), so the copy receives the resolver built from the
// loaded block map - the identical instance the cleared-refs gate uses.
const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}`
mockBuildBlockIdResolver.mockReturnValue(resolver)
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
})
await promoteFork(promoteParams())
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver)
expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver)
})
it('proceeds when zero references would clear (empty blocker list)', async () => {
const plan = makePlan()
mockComputePlan.mockResolvedValue(plan)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBeNull()
expect(result.blockers).toEqual([])
expect(result.promoteRunId).toBe('run-1')
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({
sourceWorkspaceId: 'src-ws',
items: plan.items,
workflowIdMap: plan.workflowIdMap,
})
)
expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1)
})
it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => {
const unmappedOptional = [
{ kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false },
]
mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional }))
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({ planUnmapped: unmappedOptional })
)
})
it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => {
// Two references resolving to the SAME target and one unmapped: the read must cover the
// distinct mapped target ids only (one bounded query, unmapped ids dropped).
const resolver = (kind: string, id: string) => {
if (kind !== 'mcp-server') return null
if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt'
return null
}
mockComputePlan.mockResolvedValue(
makePlan({
resolver,
references: [
{ kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false },
{ kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false },
{ kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false },
],
})
)
mockGetMcpServerMeta.mockResolvedValue(
new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]])
)
await promoteFork(promoteParams())
expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1)
expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt'])
// The transform receives a lookup resolving the TARGET id to its row metadata, so remapped
// tool-input entries rewrite their embedded serverUrl/serverName from the target server.
expect(mockCreateTransform).toHaveBeenCalledTimes(1)
const [, transformOptions] = mockCreateTransform.mock.calls[0]
expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({
name: 'Target Server',
url: 'https://target.example/mcp',
})
expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined()
})
})
describe('promoteFork dependent values', () => {
it('unions the dependent-value picks into the copy discovery set (a re-picked document must be copied)', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
references: [
{
kind: 'knowledge-document',
sourceId: 'doc-a',
subBlockKey: 'documentSelector',
required: false,
},
],
})
)
// No container selection: the document candidates alone must trigger the copy pass.
mockHasCopySelection.mockReturnValue(false)
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
await promoteFork({
...promoteParams(),
dependentValues: [
// Duplicates the plan's own scan -> deduped.
{ workflowId: 'wf-t', blockId: 'b1', subBlockKey: 'documentSelector', value: 'doc-a' },
// A fresh pick the source state does not reference -> must join the discovery set.
{ workflowId: 'wf-t', blockId: 'b2', subBlockKey: 'documentSelector', value: 'doc-b' },
// Cleared values are skipped; non-document values ride along (DB-filtered downstream).
{ workflowId: 'wf-t', blockId: 'b3', subBlockKey: 'folder', value: '' },
{ workflowId: 'wf-t', blockId: 'b4', subBlockKey: 'folder', value: 'INBOX' },
],
})
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].referencedDocumentIds).toEqual([
'doc-a',
'doc-b',
'INBOX',
])
})
it('translates a source document id under a copy-resolved KB for BOTH the written state and the store', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The KB is copy-selected; the copy assigns the picked source document its copied id.
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
...emptyCopyResult(),
copyIdMapByKind: new Map([['knowledge-document', new Map([['doc-src', 'doc-copy']])]]),
})
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
const result = await promoteFork({
...promoteParams(),
copyResources: { knowledgeBases: ['kb-src'] },
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-src',
},
],
})
expect(result.blocked).toBeNull()
// The apply map the workflow write receives carries the COPIED id: the dependent-value
// apply runs AFTER the reference remap and wins for its subblock, so a raw source id
// would clobber the remapped value in the written state.
expect(vi.mocked(copyWorkflowStateIntoTarget)).toHaveBeenCalledTimes(1)
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe('doc-copy')
// The store persists the translated value too, so the next sync (whose parent is then
// MAPPED via the persisted copy mapping) pre-fills a document id that resolves in the target.
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-copy',
},
]
)
})
it('keeps a mapped parent dependent value verbatim (a target-space value never re-translates)', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The value joins the discovery candidates, so the copy pass runs - and resolves nothing.
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
await promoteFork({
...promoteParams(),
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
],
})
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe(
'doc-tgt-existing'
)
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
]
)
})
})
File diff suppressed because it is too large Load Diff
@@ -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('@/ee/workspace-forking/lib/lineage/lineage', () => ({
resolveForkEdge: mockResolveForkEdge,
acquireForkTargetLock: mockAcquireTargetLock,
acquireForkEdgeLock: mockAcquireEdgeLock,
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
getLatestPromoteRunForTarget: mockGetLatestRun,
deleteAllPromoteRunsForTarget: mockDeleteAllRuns,
}))
vi.mock('@/ee/workspace-forking/lib/promote/reactivate-in-tx', () => ({
reactivateDeployedVersionInTx: mockReactivate,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: mockUndeploy,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: mockDeleteIdentity,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: mockEnqueueUndeploy,
processWorkflowDeploymentOutboxEvent: mockProcessOutbox,
}))
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
notifyForkWorkflowChanged: mockNotify,
}))
import { db } from '@sim/db'
import { rollbackFork } from '@/ee/workspace-forking/lib/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,300 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, inArray, isNull } from 'drizzle-orm'
import {
enqueueWorkflowUndeploySideEffects,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import {
acquireForkEdgeLock,
acquireForkTargetLock,
resolveForkEdge,
setForkLockTimeout,
} from '@/ee/workspace-forking/lib/lineage/lineage'
import { deleteWorkflowIdentityByIds } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
deleteAllPromoteRunsForTarget,
getLatestPromoteRunForTarget,
} from '@/ee/workspace-forking/lib/promote/promote-run-store'
import { reactivateDeployedVersionInTx } from '@/ee/workspace-forking/lib/promote/reactivate-in-tx'
import { notifyForkWorkflowChanged } from '@/ee/workspace-forking/lib/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))
// Archive their chat deployments too (matching `archiveWorkflow`): the sync carried a
// chat onto each created target, and leaving it live would keep a working chat URL (with
// the copied auth config) pointing at the archived workflow. The undeploy above already
// cleans webhooks + MCP tools via the outbox; chats have no undeploy hook.
await tx
.update(chat)
.set({ archivedAt: now, isActive: false, updatedAt: now })
.where(and(inArray(chat.workflowId, created), isNull(chat.archivedAt)))
// 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,121 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import {
forkSyncBlockerReasonFor,
selectForkSyncBlockingRefs,
toForkSyncBlockers,
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
const base = {
targetWorkflowId: 'wf-tgt',
workflowName: 'Workflow',
blockId: 'block-1',
blockLabel: 'Block',
fieldLabel: 'Field',
sourceLabel: 'Source',
}
const referenceRef = (
kind: ReferenceRef['kind'],
sourceId: string,
sourceDeleted = false
): ReferenceRef => ({ ...base, cause: 'reference', kind, sourceId, sourceDeleted })
const workflowRef = (sourceId: string): ForkClearedRef => ({
...base,
cause: 'workflow',
kind: 'workflow',
sourceId,
})
const dependentRef = (parentKind: DependentRef['parentKind']): DependentRef => ({
...base,
cause: 'dependent',
kind: parentKind,
sourceId: 'parent-src',
parentKind,
parentSourceId: 'parent-src',
})
describe('forkSyncBlockerReasonFor', () => {
it('maps a live unmapped copyable-kind reference to unmapped-copyable (map or copy)', () => {
for (const kind of [
'table',
'knowledge-base',
'file',
'custom-tool',
'skill',
// External MCP servers are copyable too (config rows; OAuth tokens never copied).
'mcp-server',
] as const) {
expect(forkSyncBlockerReasonFor(referenceRef(kind, 'src-1'))).toBe('unmapped-copyable')
}
})
it('maps a source-deleted reference of ANY kind to source-deleted (no exemption)', () => {
expect(forkSyncBlockerReasonFor(referenceRef('table', 'tbl-gone', true))).toBe('source-deleted')
expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-gone', true))).toBe(
'source-deleted'
)
expect(forkSyncBlockerReasonFor(referenceRef('file', 'workspace/SRC/gone.png', true))).toBe(
'source-deleted'
)
})
it('maps a workflow-cause entry to workflow-missing', () => {
expect(forkSyncBlockerReasonFor(workflowRef('wf-other'))).toBe('workflow-missing')
})
it('never blocks a dependent-cause entry (the reconfigure flow owns dependents)', () => {
expect(forkSyncBlockerReasonFor(dependentRef('credential'))).toBeNull()
expect(forkSyncBlockerReasonFor(dependentRef('knowledge-base'))).toBeNull()
})
it('defensively ignores kinds the collector excludes (credential / env-var / document)', () => {
// These never reach the cleared list (excluded by the collector); if one leaked, the
// kind-level required gate owns credentials/env-vars, so this path must not double-block.
expect(forkSyncBlockerReasonFor(referenceRef('credential', 'c1'))).toBeNull()
expect(forkSyncBlockerReasonFor(referenceRef('env-var', 'KEY'))).toBeNull()
expect(forkSyncBlockerReasonFor(referenceRef('knowledge-document', 'doc-1'))).toBeNull()
})
})
describe('selectForkSyncBlockingRefs / toForkSyncBlockers', () => {
it('keeps reference + workflow causes with their reasons and drops dependents', () => {
const refs: ForkClearedRef[] = [
referenceRef('table', 'tbl-1'),
referenceRef('mcp-server', 'srv-1'),
referenceRef('skill', 'sk-gone', true),
workflowRef('wf-other'),
dependentRef('credential'),
]
const blocking = selectForkSyncBlockingRefs(refs)
expect(blocking.map(({ ref, reason }) => [ref.sourceId, reason])).toEqual([
['tbl-1', 'unmapped-copyable'],
['srv-1', 'unmapped-copyable'],
['sk-gone', 'source-deleted'],
['wf-other', 'workflow-missing'],
])
})
it('maps blocking entries to the wire blocker shape', () => {
const blocking = selectForkSyncBlockingRefs([referenceRef('table', 'tbl-1')])
expect(toForkSyncBlockers(blocking)).toEqual([
{
workflowName: 'Workflow',
blockLabel: 'Block',
fieldLabel: 'Field',
kind: 'table',
sourceId: 'tbl-1',
sourceLabel: 'Source',
reason: 'unmapped-copyable',
},
])
})
})
@@ -0,0 +1,63 @@
import {
type ForkClearedRef,
type ForkSyncBlocker,
type ForkSyncBlockerReason,
forkCopyableKindSchema,
} from '@/lib/api/contracts/workspace-fork'
/**
* Pure sync-blocker taxonomy, shared by the server gate (promote) and the modal's blocker
* rendering. A sync is allowed only when ZERO references would clear in any synced target
* workflow; every would-clear entry of cause `reference` or `workflow` is a blocker with an
* actionable reason. `dependent`-cause entries are NOT blockers - the dependent/reconfigure
* flow owns them (its own required gating), and a credential-anchored dependent clears on any
* parent remap, so blocking on it would be unresolvable.
*/
/** Copyable kinds derived from the wire contract, so the reason split can never drift. */
const COPYABLE_BLOCKER_KINDS: ReadonlySet<string> = new Set(forkCopyableKindSchema.options)
/**
* The blocker reason for a would-clear entry, or null when the entry does not block
* (`dependent` cause, and - defensively - any kind the cleared-ref collector excludes):
* - `workflow` cause -> `workflow-missing` (deploy the referenced workflow in the source, or
* remove the reference).
* - `reference` + source deleted -> `source-deleted` (map the dead id to a live target
* resource, or fix/archive the source workflow).
* - `reference` + copyable kind (incl. external MCP servers) -> `unmapped-copyable` (map it
* or select it for copy).
*/
export function forkSyncBlockerReasonFor(ref: ForkClearedRef): ForkSyncBlockerReason | null {
if (ref.cause === 'workflow') return 'workflow-missing'
if (ref.cause !== 'reference') return null
if (ref.sourceDeleted) return 'source-deleted'
if (COPYABLE_BLOCKER_KINDS.has(ref.kind)) return 'unmapped-copyable'
// Credential / env-var / knowledge-document never reach the cleared list (excluded by the
// collector; the first two gate via the kind-level required gate, documents follow their KB).
return null
}
/** The would-clear entries that BLOCK the sync, paired with their reason. */
export function selectForkSyncBlockingRefs(
clearedRefs: ForkClearedRef[]
): Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> {
return clearedRefs.flatMap((ref) => {
const reason = forkSyncBlockerReasonFor(ref)
return reason ? [{ ref, reason }] : []
})
}
/** Map blocking entries to the wire {@link ForkSyncBlocker} shape of the promote gate error. */
export function toForkSyncBlockers(
blocking: Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }>
): ForkSyncBlocker[] {
return blocking.map(({ ref, reason }) => ({
workflowName: ref.workflowName,
blockLabel: ref.blockLabel,
fieldLabel: ref.fieldLabel,
kind: ref.kind,
sourceId: ref.sourceId,
sourceLabel: ref.sourceLabel,
reason,
}))
}