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