d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
441 lines
20 KiB
TypeScript
441 lines
20 KiB
TypeScript
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 }))
|
|
)
|
|
}
|