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
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:
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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user