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
1096 lines
49 KiB
TypeScript
1096 lines
49 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { chat, credential, credentialMember, workflow, workspace } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
|
import type { ForkSyncBlocker, PromoteCopyResources } from '@/lib/api/contracts/workspace-fork'
|
|
import type { DbOrTx } from '@/lib/db/types'
|
|
import { notifyMcpToolServers } from '@/lib/mcp/workflow-mcp-sync'
|
|
import {
|
|
enqueueWorkflowUndeploySideEffects,
|
|
processWorkflowDeploymentOutboxEvent,
|
|
} from '@/lib/workflows/deployment-outbox'
|
|
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
|
|
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
|
|
import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils'
|
|
import { startBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
|
import {
|
|
type ForkContentCopyPayload,
|
|
hasForkContentToCopy,
|
|
type SerializableForkContentRefMaps,
|
|
scheduleForkContentCopy,
|
|
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
|
|
import { copyForkChatDeployments } from '@/ee/workspace-forking/lib/copy/copy-chats'
|
|
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
|
|
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
|
import {
|
|
copyWorkflowStateIntoTarget,
|
|
loadTargetDraftSubBlocks,
|
|
loadWorkflowNameRegistry,
|
|
resolveForkFolderMapping,
|
|
} from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
|
import {
|
|
getActiveDeploymentVersionNumbers,
|
|
loadSourceDeployedStates,
|
|
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
|
import {
|
|
assertForkStorageHeadroom,
|
|
sumForkCopyBytes,
|
|
} from '@/ee/workspace-forking/lib/copy/storage-quota'
|
|
import { reconcileForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
|
|
import {
|
|
acquireForkEdgeLock,
|
|
acquireForkTargetLock,
|
|
type ForkEdge,
|
|
setForkLockTimeout,
|
|
} from '@/ee/workspace-forking/lib/lineage/lineage'
|
|
import {
|
|
type ForkBlockPair,
|
|
loadForkBlockMap,
|
|
reconcileForkBlockPairs,
|
|
toForkBlockPairs,
|
|
} from '@/ee/workspace-forking/lib/mapping/block-map-store'
|
|
import {
|
|
type ForkDependentValue,
|
|
loadForkDependentValues,
|
|
reconcileForkDependentValues,
|
|
translateForkDependentValues,
|
|
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
|
import {
|
|
deleteWorkflowIdentityByIds,
|
|
type ForkMappingUpsert,
|
|
upsertEdgeMappings,
|
|
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
|
import { getMcpServerMetaByIds } from '@/ee/workspace-forking/lib/mapping/resources'
|
|
import { collectForkSyncBlockers } from '@/ee/workspace-forking/lib/promote/cleared-refs'
|
|
import {
|
|
augmentForkResolver,
|
|
buildPromoteCopySelection,
|
|
copyPromoteUnmappedResources,
|
|
hasPromoteCopySelection,
|
|
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
|
|
import {
|
|
computeForkPromotePlan,
|
|
type ForkPromotePlan,
|
|
} from '@/ee/workspace-forking/lib/promote/promote-plan'
|
|
import {
|
|
type PromoteRunWorkflowSnapshot,
|
|
upsertPromoteRun,
|
|
} from '@/ee/workspace-forking/lib/promote/promote-run-store'
|
|
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
|
import {
|
|
createForkSubBlockTransform,
|
|
type ForkReference,
|
|
type ForkReferenceResolver,
|
|
type ForkRemapKind,
|
|
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
|
import { notifyForkWorkflowChanged } from '@/ee/workspace-forking/lib/socket'
|
|
|
|
const logger = createLogger('WorkspaceForkPromote')
|
|
|
|
export interface PromoteForkParams {
|
|
edge: ForkEdge
|
|
sourceWorkspaceId: string
|
|
targetWorkspaceId: string
|
|
direction: 'push' | 'pull'
|
|
userId: string
|
|
/** Initiator's display name, stamped on the sync's content-copy Activity row. */
|
|
actorName?: string
|
|
/**
|
|
* The full stored mapping of dependent-field values the caller is committing (target
|
|
* workflow id + deterministic block id + subblock key -> value). Applied to the target
|
|
* blocks during the merge and persisted as the stored mapping. OMITTING the field (passing
|
|
* `undefined`) leaves the existing stored mapping untouched - the store is the sole source
|
|
* of truth and is loaded + applied as-is; an explicit `[]` clears the written replace
|
|
* targets' mapping. This distinction keeps a programmatic promote that omits the field from
|
|
* silently wiping the user's saved selections.
|
|
*/
|
|
dependentValues?: Array<{
|
|
workflowId: string
|
|
blockId: string
|
|
subBlockKey: string
|
|
value: string
|
|
}>
|
|
/**
|
|
* Unmapped resources (by source id) the caller chose to copy into the target before the sync
|
|
* gate - referenced ones (their references then resolve to the new copy instead of blocking)
|
|
* and unreferenced ones (new in the source, brought along untouched). Validated against the
|
|
* plan's copyable candidates, so an arbitrary id is ignored.
|
|
*/
|
|
copyResources?: PromoteCopyResources
|
|
requestId?: string
|
|
}
|
|
|
|
export interface PromoteForkResult {
|
|
promoteRunId: string
|
|
updated: number
|
|
created: number
|
|
archived: number
|
|
redeployed: number
|
|
/**
|
|
* Targets whose state was written but whose post-transaction deploy failed. The
|
|
* draft holds the synced state; the active deployment still runs the prior version
|
|
* until a redeploy. Surfaced (rather than swallowed) so the caller can warn.
|
|
*/
|
|
deployFailed: number
|
|
unmappedRequired: Array<Pick<ForkReference, 'kind' | 'sourceId' | 'required' | 'blockName'>>
|
|
/**
|
|
* References the sync would have cleared in the target, so it was blocked without writing
|
|
* (`blocked: 'cleared-refs'`). The authoritative in-tx re-check of the diff's would-clear
|
|
* preview: normally the client blocks first, so a non-empty list means the state changed
|
|
* between preview and Sync.
|
|
*/
|
|
blockers: ForkSyncBlocker[]
|
|
blocked: 'unmapped' | 'cleared-refs' | null
|
|
/** Names of the workflows the sync changed, by action, for the activity report. */
|
|
updatedNames: string[]
|
|
createdNames: string[]
|
|
archivedNames: string[]
|
|
/**
|
|
* Workflows whose required dependent fields a parent change cleared - the target
|
|
* must re-pick them. These were written but intentionally NOT redeployed (the prior
|
|
* version keeps running), so the sync never deploys a broken workflow.
|
|
*/
|
|
needsConfiguration: Array<{ workflowName: string; blocks: string[] }>
|
|
/**
|
|
* Workflows whose OPTIONAL dependent fields a parent change cleared (e.g. a trigger
|
|
* label filter). Redeployed as-is, but surfaced so a cleared filter that would broaden
|
|
* behavior is never silent.
|
|
*/
|
|
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
|
|
}
|
|
|
|
function collectCredentialPairs(plan: ForkPromotePlan): Array<[string, string]> {
|
|
const pairs = new Map<string, string>()
|
|
for (const reference of plan.references) {
|
|
if (reference.kind !== 'credential') continue
|
|
const target = plan.resolver('credential', reference.sourceId)
|
|
if (target) pairs.set(reference.sourceId, target)
|
|
}
|
|
return Array.from(pairs.entries())
|
|
}
|
|
|
|
/**
|
|
* Grant each mapped target credential the same active members its source credential has,
|
|
* intersected with the target workspace's members, so a promoted workflow's collaborators
|
|
* can use the remapped credential. Both sides are validated to actually exist in their
|
|
* workspace first (so a crafted/stale mapping can't drive cross-workspace access), member
|
|
* reads are batched, and inserts are conflict-safe. Side-effect only on `credentialMember`.
|
|
*/
|
|
async function propagateCredentialAccess(
|
|
tx: DbOrTx,
|
|
params: {
|
|
plan: ForkPromotePlan
|
|
sourceWorkspaceId: string
|
|
targetWorkspaceId: string
|
|
targetMembers: string[]
|
|
now: Date
|
|
}
|
|
): Promise<void> {
|
|
const { plan, sourceWorkspaceId, targetWorkspaceId, targetMembers, now } = params
|
|
const credentialPairs = collectCredentialPairs(plan)
|
|
const propagationTargetIds = credentialPairs.map(([, targetCredId]) => targetCredId)
|
|
const propagationSourceIds = credentialPairs.map(([sourceCredId]) => sourceCredId)
|
|
|
|
const validTargetCredentialIds = new Set<string>()
|
|
if (propagationTargetIds.length > 0) {
|
|
const validRows = await tx
|
|
.select({ id: credential.id })
|
|
.from(credential)
|
|
.where(
|
|
and(
|
|
inArray(credential.id, propagationTargetIds),
|
|
eq(credential.workspaceId, targetWorkspaceId)
|
|
)
|
|
)
|
|
for (const row of validRows) validTargetCredentialIds.add(row.id)
|
|
}
|
|
|
|
const validSourceCredentialIds = new Set<string>()
|
|
if (propagationSourceIds.length > 0) {
|
|
const validRows = await tx
|
|
.select({ id: credential.id })
|
|
.from(credential)
|
|
.where(
|
|
and(
|
|
inArray(credential.id, propagationSourceIds),
|
|
eq(credential.workspaceId, sourceWorkspaceId)
|
|
)
|
|
)
|
|
for (const row of validRows) validSourceCredentialIds.add(row.id)
|
|
}
|
|
|
|
const validPairs = credentialPairs.filter(
|
|
([sourceCredId, targetCredId]) =>
|
|
validSourceCredentialIds.has(sourceCredId) && validTargetCredentialIds.has(targetCredId)
|
|
)
|
|
if (validPairs.length === 0) return
|
|
|
|
// Batch all source credentials' active members in one query (instead of one per pair),
|
|
// then build a single insert. `targetMembers` becomes a Set for O(1) membership checks.
|
|
const targetMemberSet = new Set(targetMembers)
|
|
const memberRows = await tx
|
|
.select({
|
|
credentialId: credentialMember.credentialId,
|
|
userId: credentialMember.userId,
|
|
role: credentialMember.role,
|
|
})
|
|
.from(credentialMember)
|
|
.where(
|
|
and(
|
|
inArray(
|
|
credentialMember.credentialId,
|
|
validPairs.map(([sourceCredId]) => sourceCredId)
|
|
),
|
|
eq(credentialMember.status, 'active')
|
|
)
|
|
)
|
|
const membersBySource = new Map<
|
|
string,
|
|
Array<Pick<(typeof memberRows)[number], 'userId' | 'role'>>
|
|
>()
|
|
for (const row of memberRows) {
|
|
if (!targetMemberSet.has(row.userId)) continue
|
|
const list = membersBySource.get(row.credentialId)
|
|
if (list) list.push({ userId: row.userId, role: row.role })
|
|
else membersBySource.set(row.credentialId, [{ userId: row.userId, role: row.role }])
|
|
}
|
|
const memberInserts = validPairs.flatMap(([sourceCredId, targetCredId]) =>
|
|
(membersBySource.get(sourceCredId) ?? []).map((member) => ({
|
|
id: generateId(),
|
|
credentialId: targetCredId,
|
|
userId: member.userId,
|
|
role: member.role,
|
|
status: 'active' as const,
|
|
joinedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}))
|
|
)
|
|
if (memberInserts.length > 0) {
|
|
await tx
|
|
.insert(credentialMember)
|
|
.values(memberInserts)
|
|
.onConflictDoNothing({
|
|
target: [credentialMember.credentialId, credentialMember.userId],
|
|
})
|
|
}
|
|
}
|
|
|
|
type PromoteTxBlocked =
|
|
| { blocked: 'unmapped'; unmappedRequired: PromoteForkResult['unmappedRequired'] }
|
|
| { blocked: 'cleared-refs'; blockers: ForkSyncBlocker[] }
|
|
|
|
interface PromoteTxApplied {
|
|
blocked: null
|
|
promoteRunId: string
|
|
deployTargetIds: string[]
|
|
/** Actual written/archived counts (post-skip), not the pre-copy plan totals. */
|
|
updated: number
|
|
created: number
|
|
archived: number
|
|
/** Source workflows skipped because their deployment vanished between plan and apply. */
|
|
skippedItems: Array<{ id: string; name: string }>
|
|
/** Target workflow id -> source name, so the deploy-failure report can show names. */
|
|
writtenNames: Record<string, string>
|
|
/** Names of the changed workflows, by action, for the activity report. */
|
|
updatedNames: string[]
|
|
createdNames: string[]
|
|
archivedNames: string[]
|
|
/** Outbox event ids enqueued for archived orphans' undeploy side-effects. */
|
|
undeployEventIds: string[]
|
|
/**
|
|
* Per-target required dependents a parent change cleared (with workflow id so the
|
|
* post-commit deploy loop can skip them, keeping the prior version running).
|
|
*/
|
|
needsConfiguration: Array<{ workflowId: string; workflowName: string; blocks: string[] }>
|
|
/** Per-workflow optional dependents a parent change cleared (surfaced, not gated). */
|
|
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
|
|
/** Heavy content for resources copied into the target this sync, filled best-effort post-commit. */
|
|
copyContentPlan: ForkContentPlan | null
|
|
/** Serialized in-content maps for the post-commit skill-body rewrite (paired with the plan). */
|
|
copyContentRefMaps: SerializableForkContentRefMaps | null
|
|
/** File blob duplications for copied workspace files, run post-commit by the content-copy runner. */
|
|
copyContentBlobTasks: BlobCopyTask[]
|
|
/** Workflow-publishing MCP servers whose tool attachments changed, notified post-commit. */
|
|
mcpAttachmentServerIds: string[]
|
|
}
|
|
|
|
/**
|
|
* Group flat dependent values into the apply map `target workflow -> block id -> subblock -> value`
|
|
* that {@link copyWorkflowStateIntoTarget} consumes. Pure (no DB). Built inside the tx from the
|
|
* TRANSLATED values (see {@link translateForkDependentValues}) once the post-copy resolver exists.
|
|
*/
|
|
function groupDependentOverrides(
|
|
values: ForkDependentValue[]
|
|
): Map<string, Map<string, Map<string, string>>> {
|
|
const byWorkflow = new Map<string, Map<string, Map<string, string>>>()
|
|
for (const entry of values) {
|
|
let byBlock = byWorkflow.get(entry.targetWorkflowId)
|
|
if (!byBlock) {
|
|
byBlock = new Map()
|
|
byWorkflow.set(entry.targetWorkflowId, byBlock)
|
|
}
|
|
let byKey = byBlock.get(entry.targetBlockId)
|
|
if (!byKey) {
|
|
byKey = new Map()
|
|
byBlock.set(entry.targetBlockId, byKey)
|
|
}
|
|
byKey.set(entry.subBlockKey, entry.value)
|
|
}
|
|
return byWorkflow
|
|
}
|
|
|
|
/**
|
|
* Execute a force promote along the edge. Only the source's deployed workflows
|
|
* participate: each one's active deployed state is remapped into the target
|
|
* (replacing mapped targets in place with deterministic block ids, creating new
|
|
* ones, archiving previously-mapped orphans whose source is no longer deployed),
|
|
* a version-reference rollback snapshot is captured, credential access is
|
|
* propagated, and every promoted target is deployed. The plan is computed inside
|
|
* the edge lock so concurrent promotes serialize. A sync always force-replaces the
|
|
* target's deployed state (the modal confirms the overwrite up front); it blocks
|
|
* without mutating when required references (credentials / secrets) are unmapped OR
|
|
* when any reference would clear in a synced target workflow (the zero-cleared-refs
|
|
* gate - every reference must be mapped, selected for copy, or carried by the sync).
|
|
*/
|
|
export async function promoteFork(params: PromoteForkParams): Promise<PromoteForkResult> {
|
|
const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params
|
|
const requestId = params.requestId ?? 'unknown'
|
|
|
|
// Distinguish an OMITTED dependent mapping (leave the store as-is) from an explicit empty
|
|
// array (clear it). Provided values are normalized to the store row shape here - BEFORE the
|
|
// transaction (pure in-memory, no DB) - mirroring how the source states are pre-loaded above.
|
|
// The apply map itself is built inside the tx: its values are translated through the
|
|
// post-copy resolver (a source document id picked under a copy-resolved KB must land as the
|
|
// copied counterpart), which only exists once the copy has run. The OMITTED path loads the
|
|
// store rows inside the tx too, where the plan's targets are known.
|
|
const dependentValuesProvided = params.dependentValues !== undefined
|
|
const providedDependentValues: ForkDependentValue[] | null = dependentValuesProvided
|
|
? (params.dependentValues ?? []).map((entry) => ({
|
|
targetWorkflowId: entry.workflowId,
|
|
targetBlockId: entry.blockId,
|
|
subBlockKey: entry.subBlockKey,
|
|
value: entry.value,
|
|
}))
|
|
: null
|
|
|
|
// Copied blob bytes (selected workspace files + selected KBs' document blobs) are
|
|
// charged to the initiating user's storage scope exactly as if uploaded to the target
|
|
// workspace, so enforce headroom BEFORE the locked write transaction. The sums scope to
|
|
// the source workspace with the same filters the in-tx copy applies, so a requested id
|
|
// that is not actually copyable (stale/crafted) can only over-count and block - the
|
|
// validated in-tx selection is always a subset. Over quota fails the sync here with the
|
|
// upload path's error shape, before any lock or write.
|
|
const requestedCopyBytes = await sumForkCopyBytes(db, sourceWorkspaceId, {
|
|
fileKeys: params.copyResources?.files,
|
|
knowledgeBaseIds: params.copyResources?.knowledgeBases,
|
|
})
|
|
await assertForkStorageHeadroom({ userId, bytes: requestedCopyBytes })
|
|
|
|
const targetMembers = (await getUsersWithPermissions(targetWorkspaceId)).map((m) => m.userId)
|
|
|
|
// The target workspace's display name seeds carried chat identifiers
|
|
// (`{target-workspace}-{workflow}-{randomnum}`); read pre-tx like the other lookups.
|
|
const [targetWorkspaceRow] = await db
|
|
.select({ name: workspace.name })
|
|
.from(workspace)
|
|
.where(eq(workspace.id, targetWorkspaceId))
|
|
.limit(1)
|
|
const targetWorkspaceName = targetWorkspaceRow?.name ?? 'workspace'
|
|
|
|
// Read the source's deployed workflows + states BEFORE the transaction so these
|
|
// heavy per-workflow reads never check out a second pooled connection from inside
|
|
// the promote tx (which can deadlock the pool at saturation). The source is
|
|
// read-only here, so this pre-tx snapshot is exactly what gets force-pushed.
|
|
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(sourceWorkspaceId)
|
|
|
|
const txResult: PromoteTxBlocked | PromoteTxApplied = await db.transaction(async (tx) => {
|
|
// Bound lock waits so a contended sync into this target fails fast instead of
|
|
// stagnating the pool. Must run before acquiring the advisory locks below.
|
|
await setForkLockTimeout(tx)
|
|
// Target lock before edge lock (consistent ordering): the target lock serializes
|
|
// every sync into this target so sibling forks can't interleave writes, and so
|
|
// rollback's "newest sync" check stays race-free against a concurrent promote.
|
|
await acquireForkTargetLock(tx, targetWorkspaceId)
|
|
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
|
|
|
|
const plan = await computeForkPromotePlan({
|
|
executor: tx,
|
|
edge,
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
direction,
|
|
deployedSourceWorkflows: deployedWorkflows,
|
|
sourceStates,
|
|
})
|
|
|
|
const now = new Date()
|
|
|
|
// Copy the selected unmapped resources (referenced and unreferenced) into the target BEFORE
|
|
// the gate, so a user can copy rather than map each one. The gate is evaluated against the
|
|
// post-copy state (the copy resolves the selected refs), so the copy only runs when the sync
|
|
// will actually proceed - if required refs remain unmapped, we block without copying anything.
|
|
const { selection: copySelection, willResolve } = buildPromoteCopySelection(
|
|
params.copyResources,
|
|
plan.copyableUnmapped
|
|
)
|
|
// plan.unmappedRequired is already references.filter(resolver == null).filter(required), so
|
|
// subtracting the refs the copy will resolve is equivalent to re-scanning the predicate.
|
|
const postCopyUnmappedRequired = plan.unmappedRequired.filter(
|
|
(reference) => !willResolve.has(`${reference.kind}:${reference.sourceId}`)
|
|
)
|
|
if (postCopyUnmappedRequired.length > 0) {
|
|
return {
|
|
blocked: 'unmapped',
|
|
unmappedRequired: postCopyUnmappedRequired.map((reference) => ({
|
|
kind: reference.kind,
|
|
sourceId: reference.sourceId,
|
|
required: reference.required,
|
|
blockName: reference.blockName,
|
|
})),
|
|
}
|
|
}
|
|
|
|
// Resolve each source block to its counterpart's EXISTING id (via the persisted block
|
|
// map) instead of re-deriving, so a push keeps the parent's original block ids - and the
|
|
// webhook URLs derived from them - stable. Falls back to derive for blocks with no pair
|
|
// yet (added since the last sync). Loaded here (read-only) so the would-clear gate below
|
|
// and the write loop share one block map.
|
|
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
|
const blockMap = await loadForkBlockMap(tx, edge.childWorkspaceId)
|
|
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
|
|
|
|
// Zero-cleared-refs gate: the sync proceeds only when NO reference would clear in any
|
|
// synced target workflow (source fully operational -> target fully operational). Evaluated
|
|
// against the plan resolver overlaid with the validated copy selection (a selected copy
|
|
// resolves its references), BEFORE any write. Authoritative versus the diff's unlocked
|
|
// preview - state drift between preview and Sync re-blocks here (TOCTOU) - and it makes the
|
|
// in-tx remap's clear-unresolved behavior an unreachable defense-in-depth backstop. The
|
|
// plan's unmapped references are threaded through so the gate's happy path reuses the plan's
|
|
// scan (computed moments earlier over the same states, inside this same locked tx) instead of
|
|
// re-running the full per-block reference scan; the scan re-runs only when something blocks.
|
|
const gateResolver: ForkReferenceResolver = (kind, sourceId) =>
|
|
willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId)
|
|
const blockers = await collectForkSyncBlockers({
|
|
executor: tx,
|
|
sourceWorkspaceId,
|
|
items: plan.items,
|
|
sourceStates,
|
|
resolver: gateResolver,
|
|
workflowIdMap: plan.workflowIdMap,
|
|
resolveBlockId,
|
|
planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional],
|
|
})
|
|
if (blockers.length > 0) {
|
|
return { blocked: 'cleared-refs', blockers }
|
|
}
|
|
|
|
// Resolve the source->target folder map BEFORE the copy so the folders already exist in the
|
|
// target and the copy can rewrite `sim:folder/<id>` references inside copied skill / markdown
|
|
// bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target
|
|
// folders that already match by name within the same mapped parent. Creation is scoped to
|
|
// the folders that will hold a synced workflow (plus ancestors) - a folder whose subtree
|
|
// syncs nothing is never created empty in the target, though it still maps onto a matching
|
|
// existing target folder so prior syncs' refs keep resolving.
|
|
const folderIdMap = await resolveForkFolderMapping({
|
|
tx,
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
userId,
|
|
now,
|
|
contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId),
|
|
})
|
|
|
|
let resolver = plan.resolver
|
|
let copyContentPlan: ForkContentPlan | null = null
|
|
let copyContentRefMaps: SerializableForkContentRefMaps | null = null
|
|
let copyContentBlobTasks: BlobCopyTask[] = []
|
|
// Every dependent value this sync will apply, as flat store rows: the provided payload, or
|
|
// (omitted) the persisted store for the plan's targets - loaded here, BEFORE the copy, so
|
|
// document picks can join the copy's discovery set below. The apply map + reconcile further
|
|
// down consume these after translating them through the post-copy resolver.
|
|
const flatDependentValues =
|
|
providedDependentValues ??
|
|
(await loadForkDependentValues(
|
|
tx,
|
|
edge.childWorkspaceId,
|
|
plan.items.map((item) => item.targetWorkflowId)
|
|
))
|
|
|
|
// Knowledge-document ids the synced workflows reference, from the plan's already-scanned
|
|
// references (never a re-scan inside this locked tx) - UNIONED with the dependent-value
|
|
// picks: a document re-picked in the sync page's reconfigure selector under a copy-resolved
|
|
// KB isn't referenced by the source STATE, but must still be copied so the applied pick
|
|
// resolves in the target. Non-document values ride along harmlessly: every consumer filters
|
|
// candidates through `inArray(document.id, ...)`, so a label or column id matches no row.
|
|
const referencedDocumentIds = [
|
|
...new Set([
|
|
...plan.references
|
|
.filter((reference) => reference.kind === 'knowledge-document')
|
|
.map((reference) => reference.sourceId),
|
|
...flatDependentValues.map((entry) => entry.value).filter((value) => value !== ''),
|
|
]),
|
|
]
|
|
// Run the copy when the user selected resources to copy OR any document is referenced (a
|
|
// referenced document under an already-mapped KB is auto-copied into that KB so its reference
|
|
// remaps instead of clearing). It runs only after the required-reference gate above, so a
|
|
// blocked sync copies nothing.
|
|
let copyIdMapByKind: Map<ForkRemapKind, Map<string, string>> | null = null
|
|
if (hasPromoteCopySelection(copySelection) || referencedDocumentIds.length > 0) {
|
|
const copyResult = await copyPromoteUnmappedResources({
|
|
tx,
|
|
edge,
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
direction,
|
|
userId,
|
|
now,
|
|
selection: copySelection,
|
|
workflowIdMap: plan.workflowIdMap,
|
|
folderIdMap,
|
|
resolver: plan.resolver,
|
|
// The block map loaded above backs this resolver; copied tables' workflow-group
|
|
// outputs must land on the same target block ids the workflow writes below assign.
|
|
resolveBlockId,
|
|
referencedDocumentIds,
|
|
})
|
|
resolver = augmentForkResolver(plan.resolver, copyResult.copyIdMapByKind)
|
|
copyIdMapByKind = copyResult.copyIdMapByKind
|
|
copyContentPlan = copyResult.contentPlan
|
|
copyContentRefMaps = copyResult.contentRefMaps
|
|
copyContentBlobTasks = copyResult.blobTasks
|
|
}
|
|
|
|
// Target rows for the MAPPED (or just-copied) MCP servers this sync references, so remapped
|
|
// tool-input entries rewrite their embedded `serverUrl`/`serverName` from the target server
|
|
// instead of carrying the source's (which would show a false "URL changed" stale badge in
|
|
// the target UI). Bounded: the plan's references are deduped per (kind, id), so this is one
|
|
// `inArray` read over the distinct referenced servers. Uses the post-copy resolver, so a
|
|
// server copied this sync resolves to its fresh row (same name/url - the rewrite is a no-op).
|
|
const mappedMcpServerTargetIds = [
|
|
...new Set(
|
|
plan.references
|
|
.filter((reference) => reference.kind === 'mcp-server')
|
|
.map((reference) => resolver('mcp-server', reference.sourceId))
|
|
.filter((targetId): targetId is string => targetId != null)
|
|
),
|
|
]
|
|
const mcpServerMetaById = await getMcpServerMetaByIds(
|
|
tx,
|
|
targetWorkspaceId,
|
|
mappedMcpServerTargetIds
|
|
)
|
|
|
|
const transform = createForkSubBlockTransform(resolver, {
|
|
resolveMcpServerMeta: (targetServerId) => mcpServerMetaById.get(targetServerId),
|
|
// Copy provenance: a parent resolved through THIS sync's copy selection keeps its
|
|
// copy-faithful dependents (a copied table's column picks) instead of clearing them.
|
|
isCopiedTarget: (kind, sourceId) => copyIdMapByKind?.get(kind)?.has(sourceId) ?? false,
|
|
})
|
|
|
|
// Batch every prior-version read (replace + archive targets) into one query before any
|
|
// write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so
|
|
// they still reflect the active version each target had before this sync.
|
|
const priorVersionByTarget = await getActiveDeploymentVersionNumbers(tx, [
|
|
...plan.items.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId),
|
|
...plan.archivedTargetIds,
|
|
])
|
|
|
|
// Preload the target's active workflow names so per-workflow collision checks read from
|
|
// memory instead of one query each inside this locked tx. The DB unique index remains
|
|
// the correctness backstop (a stale snapshot only risks a rare, retry-able conflict).
|
|
const nameRegistry = await loadWorkflowNameRegistry(tx, targetWorkspaceId)
|
|
|
|
// Replace targets (the only mode with a prior target state) - reused by the draft preload
|
|
// and the dependent-value apply/load below.
|
|
const replaceTargetIds = plan.items
|
|
.filter((item) => item.mode === 'replace')
|
|
.map((item) => item.targetWorkflowId)
|
|
|
|
// Preload the target's current draft subBlocks (replace targets only) so the copy can
|
|
// detect dependent fields a parent change cleared that the stored mapping didn't refill
|
|
// (surfaced as needs-configuration). One batched query pre-write, so it reflects the
|
|
// pre-sync target state.
|
|
const targetDraftByWorkflow = await loadTargetDraftSubBlocks(tx, replaceTargetIds)
|
|
|
|
// The dependent-value apply map (target workflow -> block id -> subblock -> value), built
|
|
// from the flat values loaded above (the provided payload, or - omitted - the stored
|
|
// mapping, which stays the sole source of truth; the reconcile below is skipped then so an
|
|
// omitted field never wipes it). Values are translated through the post-copy resolver
|
|
// FIRST: the apply runs AFTER the reference remap inside `copyWorkflowStateIntoTarget` and
|
|
// wins for its subblock, so a SOURCE document id picked under a copy-resolved KB must
|
|
// become the copied counterpart here - otherwise the stale source id would clobber the
|
|
// remapped value in the written state. Create targets are included: a value pre-configured
|
|
// for a never-synced workflow (keyed by its deterministic target id) applies on the first
|
|
// sync that creates it.
|
|
const appliedDependentValues = translateForkDependentValues(flatDependentValues, resolver)
|
|
const overridesByWorkflow = groupDependentOverrides(appliedDependentValues)
|
|
|
|
// New block pairs recorded by the write loop (blocks added since the last sync), using the
|
|
// block map + resolver loaded before the would-clear gate above.
|
|
const blockPairs: ForkBlockPair[] = []
|
|
|
|
const updatedSnapshots: PromoteRunWorkflowSnapshot[] = []
|
|
const createdTargetIds: string[] = []
|
|
const writtenItems: typeof plan.items = []
|
|
const needsConfiguration: PromoteTxApplied['needsConfiguration'] = []
|
|
const clearedOptional: PromoteTxApplied['clearedOptional'] = []
|
|
for (const item of plan.items) {
|
|
// Use the pre-read source state (loaded above, before the tx). An item only
|
|
// exists when its state was present at read time, so this lookup hits; the
|
|
// guard stays as defense so the written counts below never over-report.
|
|
const sourceState = sourceStates.get(item.sourceWorkflowId)
|
|
if (!sourceState) continue
|
|
if (item.mode === 'replace') {
|
|
const priorVersion = priorVersionByTarget.get(item.targetWorkflowId) ?? null
|
|
updatedSnapshots.push({ workflowId: item.targetWorkflowId, priorVersion })
|
|
} else {
|
|
createdTargetIds.push(item.targetWorkflowId)
|
|
}
|
|
const copyResult = await copyWorkflowStateIntoTarget({
|
|
tx,
|
|
targetWorkflowId: item.targetWorkflowId,
|
|
targetWorkspaceId,
|
|
userId,
|
|
mode: item.mode,
|
|
now,
|
|
sourceState,
|
|
sourceMeta: item.sourceMeta,
|
|
workflowIdMap: plan.workflowIdMap,
|
|
folderIdMap,
|
|
transformSubBlocks: transform,
|
|
targetCurrentBlocks:
|
|
item.mode === 'replace' ? targetDraftByWorkflow.get(item.targetWorkflowId) : undefined,
|
|
dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId),
|
|
nameRegistry,
|
|
resolveBlockId,
|
|
requestId,
|
|
})
|
|
blockPairs.push(
|
|
...toForkBlockPairs(
|
|
copyResult.blockIdMapping,
|
|
sourceIsParent,
|
|
item.sourceWorkflowId,
|
|
item.targetWorkflowId
|
|
)
|
|
)
|
|
const requiredCleared = copyResult.clearedDependents.filter((field) => field.required)
|
|
const optionalCleared = copyResult.clearedDependents.filter((field) => !field.required)
|
|
if (requiredCleared.length > 0) {
|
|
needsConfiguration.push({
|
|
workflowId: item.targetWorkflowId,
|
|
workflowName: item.sourceMeta.name,
|
|
// Surface the block names (deduped) - the field titles ("Label") aren't useful.
|
|
blocks: [...new Set(requiredCleared.map((field) => field.blockName))],
|
|
})
|
|
}
|
|
if (optionalCleared.length > 0) {
|
|
clearedOptional.push({
|
|
workflowName: item.sourceMeta.name,
|
|
blocks: [...new Set(optionalCleared.map((field) => field.blockName))],
|
|
})
|
|
}
|
|
writtenItems.push(item)
|
|
}
|
|
|
|
// Reconcile block-identity pairs for the written source workflows: clears pairs for
|
|
// blocks the source dropped (e.g. a deleted trigger) and any stale pair from a re-created
|
|
// target, then records the live ones - so the next promote resolves these blocks to these
|
|
// same ids and never re-homes one onto an archived workflow's block.
|
|
await reconcileForkBlockPairs(
|
|
tx,
|
|
edge.childWorkspaceId,
|
|
sourceIsParent,
|
|
writtenItems.map((item) => item.sourceWorkflowId),
|
|
blockPairs
|
|
)
|
|
|
|
// Carry chat deployments for written targets that have NO chat row yet (typically
|
|
// create-mode targets): a fresh `{target-workspace}-{workflow}-{randomnum}` identifier with
|
|
// the source's config, live once this sync's deploy lands. Targets with any existing chat
|
|
// (live or archived) are left untouched - an earlier carry-over keeps its URL on every
|
|
// subsequent sync, and a deliberately archived chat is never resurrected. Targets whose
|
|
// redeploy this sync SKIPS (required dependents cleared) are excluded: their chat would
|
|
// squat a live identifier while nothing can serve - the next successful sync carries it.
|
|
const needsConfigurationTargetIds = new Set(needsConfiguration.map((entry) => entry.workflowId))
|
|
await copyForkChatDeployments({
|
|
tx,
|
|
pairs: writtenItems.flatMap((item) =>
|
|
needsConfigurationTargetIds.has(item.targetWorkflowId)
|
|
? []
|
|
: [
|
|
{
|
|
sourceWorkflowId: item.sourceWorkflowId,
|
|
targetWorkflowId: item.targetWorkflowId,
|
|
workflowName: item.sourceMeta.name,
|
|
},
|
|
]
|
|
),
|
|
targetWorkspaceName,
|
|
userId,
|
|
now,
|
|
resolveBlockId,
|
|
requestId,
|
|
})
|
|
|
|
// Mirror workflow-as-MCP-tool attachments onto MAPPED workflow-publishing servers for the
|
|
// written pairs: missing target attachments are created, drifted metadata refreshed, and a
|
|
// detached source's counterpart archived. The deployment outbox re-derives each affected
|
|
// tool's parameter schema when the target deploys below.
|
|
const mcpAttachmentResult = await reconcileForkWorkflowMcpAttachments({
|
|
tx,
|
|
childWorkspaceId: edge.childWorkspaceId,
|
|
sourceIsParent,
|
|
now,
|
|
writtenPairs: writtenItems.map((item) => ({
|
|
sourceWorkflowId: item.sourceWorkflowId,
|
|
targetWorkflowId: item.targetWorkflowId,
|
|
})),
|
|
})
|
|
|
|
// Persist / prune the stored dependent mapping. When the caller PROVIDED values, replace
|
|
// every written target's stored set (cleared/removed fields drop out so the store equals
|
|
// exactly what was applied) AND prune the archived targets' now-dead rows (their workflow
|
|
// no longer exists and has no FK to cascade). The TRANSLATED values are persisted - a
|
|
// source document id picked under a copy-resolved KB is stored as its copied counterpart,
|
|
// so the next sync (whose parent is then MAPPED via the persisted copy mapping) pre-fills
|
|
// a value that resolves in the target. Written CREATE targets persist too - they exist
|
|
// as of this sync, and their sent values (pre-configured in the mapping editor or the
|
|
// modal) must survive as the stored mapping for future syncs. Scope the inserted values
|
|
// to the delete's workflows so a value for a workflow skipped this pass (its source state
|
|
// vanished) can't be inserted without first clearing its old row and trip the unique
|
|
// constraint. When OMITTED, the store stays the source of truth (already applied above) -
|
|
// only prune archived targets, never touch the live targets' mapping.
|
|
const dependentTargetIds = new Set(writtenItems.map((item) => item.targetWorkflowId))
|
|
if (dependentValuesProvided) {
|
|
await reconcileForkDependentValues(
|
|
tx,
|
|
edge.childWorkspaceId,
|
|
[...dependentTargetIds, ...plan.archivedTargetIds],
|
|
appliedDependentValues.filter((entry) => dependentTargetIds.has(entry.targetWorkflowId))
|
|
)
|
|
} else if (plan.archivedTargetIds.length > 0) {
|
|
await reconcileForkDependentValues(tx, edge.childWorkspaceId, plan.archivedTargetIds, [])
|
|
}
|
|
|
|
const archivedNames =
|
|
plan.archivedTargetIds.length > 0
|
|
? (
|
|
await tx
|
|
.select({ name: workflow.name })
|
|
.from(workflow)
|
|
.where(inArray(workflow.id, plan.archivedTargetIds))
|
|
).map((row) => row.name)
|
|
: []
|
|
|
|
const undeployEventIds: string[] = []
|
|
const archivedSnapshots: PromoteRunWorkflowSnapshot[] = []
|
|
for (const targetWorkflowId of plan.archivedTargetIds) {
|
|
const priorVersion = priorVersionByTarget.get(targetWorkflowId) ?? null
|
|
archivedSnapshots.push({ workflowId: targetWorkflowId, priorVersion })
|
|
// Enqueue undeploy side-effects (webhook + MCP-tool cleanup) so an archived orphan
|
|
// doesn't leak its subscriptions/registrations - mirrors rollback's undeploy path.
|
|
await undeployWorkflow({
|
|
workflowId: targetWorkflowId,
|
|
tx,
|
|
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
|
|
if (deploymentVersionIds.length === 0) return
|
|
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
|
|
workflowId: targetWorkflowId,
|
|
deploymentVersionIds,
|
|
userId,
|
|
requestId,
|
|
})
|
|
undeployEventIds.push(eventId)
|
|
},
|
|
})
|
|
await tx
|
|
.update(workflow)
|
|
.set({ archivedAt: now, updatedAt: now })
|
|
.where(eq(workflow.id, targetWorkflowId))
|
|
}
|
|
// Archive the archived targets' chat deployments too (matching `archiveWorkflow`): a live
|
|
// chat row would keep squatting its unique identifier and serving a dead workflow. The
|
|
// undeploy side-effects above cover webhooks + MCP tools; chats have no undeploy hook.
|
|
if (plan.archivedTargetIds.length > 0) {
|
|
await tx
|
|
.update(chat)
|
|
.set({ archivedAt: now, isActive: false, updatedAt: now })
|
|
.where(and(inArray(chat.workflowId, plan.archivedTargetIds), isNull(chat.archivedAt)))
|
|
}
|
|
|
|
const identityEntries: ForkMappingUpsert[] = writtenItems.map((item) => ({
|
|
resourceType: 'workflow' as const,
|
|
parentResourceId: direction === 'pull' ? item.sourceWorkflowId : item.targetWorkflowId,
|
|
childResourceId: direction === 'pull' ? item.targetWorkflowId : item.sourceWorkflowId,
|
|
}))
|
|
// The identity upsert keys on the parent side, which on push is the TARGET. A
|
|
// source whose previously-mapped target was archived gets a freshly-generated
|
|
// target id here, so its old (stale-target) identity row wouldn't be overwritten
|
|
// and would leak a second mapping for the same source. Delete every prior identity
|
|
// row for these sources (by the source side) first so exactly one row per source
|
|
// remains - this also converges any pre-existing duplicates.
|
|
await deleteWorkflowIdentityByIds(
|
|
tx,
|
|
edge.childWorkspaceId,
|
|
direction === 'pull' ? 'parent' : 'child',
|
|
writtenItems.map((item) => item.sourceWorkflowId)
|
|
)
|
|
await upsertEdgeMappings(tx, edge.childWorkspaceId, userId, identityEntries)
|
|
|
|
await propagateCredentialAccess(tx, {
|
|
plan,
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
targetMembers,
|
|
now,
|
|
})
|
|
|
|
const promoteRunId = await upsertPromoteRun(tx, {
|
|
childWorkspaceId: edge.childWorkspaceId,
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
direction,
|
|
userId,
|
|
snapshot: {
|
|
updated: updatedSnapshots,
|
|
created: createdTargetIds,
|
|
archived: archivedSnapshots,
|
|
},
|
|
})
|
|
|
|
// A source whose active deployment vanished between plan and copy is skipped
|
|
// above, so report what was actually written - the plan totals would overstate.
|
|
const writtenSourceIds = new Set(writtenItems.map((item) => item.sourceWorkflowId))
|
|
const skippedItems = plan.items
|
|
.filter((item) => !writtenSourceIds.has(item.sourceWorkflowId))
|
|
.map((item) => ({ id: item.sourceWorkflowId, name: item.sourceMeta.name }))
|
|
if (skippedItems.length > 0) {
|
|
logger.warn(
|
|
`[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`,
|
|
{ sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length }
|
|
)
|
|
}
|
|
|
|
return {
|
|
blocked: null,
|
|
promoteRunId,
|
|
deployTargetIds: writtenItems.map((item) => item.targetWorkflowId),
|
|
updated: updatedSnapshots.length,
|
|
created: createdTargetIds.length,
|
|
archived: archivedSnapshots.length,
|
|
skippedItems,
|
|
writtenNames: Object.fromEntries(
|
|
writtenItems.map((item) => [item.targetWorkflowId, item.sourceMeta.name])
|
|
),
|
|
updatedNames: writtenItems
|
|
.filter((item) => item.mode === 'replace')
|
|
.map((item) => item.sourceMeta.name),
|
|
createdNames: writtenItems
|
|
.filter((item) => item.mode !== 'replace')
|
|
.map((item) => item.sourceMeta.name),
|
|
archivedNames,
|
|
undeployEventIds,
|
|
needsConfiguration,
|
|
clearedOptional,
|
|
copyContentPlan,
|
|
copyContentRefMaps,
|
|
copyContentBlobTasks,
|
|
mcpAttachmentServerIds: mcpAttachmentResult.affectedServerIds,
|
|
}
|
|
})
|
|
|
|
if (txResult.blocked !== null) {
|
|
return {
|
|
promoteRunId: '',
|
|
updated: 0,
|
|
created: 0,
|
|
archived: 0,
|
|
redeployed: 0,
|
|
deployFailed: 0,
|
|
unmappedRequired: txResult.blocked === 'unmapped' ? txResult.unmappedRequired : [],
|
|
blockers: txResult.blocked === 'cleared-refs' ? txResult.blockers : [],
|
|
blocked: txResult.blocked,
|
|
updatedNames: [],
|
|
createdNames: [],
|
|
archivedNames: [],
|
|
needsConfiguration: [],
|
|
clearedOptional: [],
|
|
}
|
|
}
|
|
|
|
// Process archived orphans' undeploy side-effects after commit (durably retried by the
|
|
// outbox cron if this dies first), so the locked transaction never held a network call.
|
|
for (const eventId of txResult.undeployEventIds) {
|
|
try {
|
|
await processWorkflowDeploymentOutboxEvent(eventId)
|
|
} catch (error) {
|
|
logger.warn(`[${requestId}] Deferred archive undeploy side-effect failed (will retry)`, {
|
|
eventId,
|
|
error: getErrorMessage(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Post-commit only: tell the affected workflow-publishing MCP servers their tool set changed
|
|
// (the deploy loop's outbox covers deployed targets; archived-attachment servers need this).
|
|
if (txResult.mcpAttachmentServerIds.length > 0) {
|
|
notifyMcpToolServers(txResult.mcpAttachmentServerIds.map((serverId) => ({ serverId })))
|
|
}
|
|
|
|
let redeployed = 0
|
|
const deployFailures: string[] = []
|
|
const deployWarnings: string[] = []
|
|
// Targets whose required dependents a parent change cleared: their draft holds the
|
|
// synced state, but we intentionally skip the redeploy so the prior deployed version
|
|
// keeps running instead of going live with an empty required field. The user re-picks
|
|
// the field (surfaced via `needsConfiguration`), then a redeploy/next sync deploys it.
|
|
const needsConfigTargetIds = new Set(txResult.needsConfiguration.map((n) => n.workflowId))
|
|
// Deploy in a deterministic (sorted) order so this UNLOCKED loop acquires workflow
|
|
// row locks in the same order as a concurrent rollback's atomic tx (and a sibling
|
|
// promote's deploy loop), avoiding deadlocks - see rollback.ts lock ordering.
|
|
const deployTargetIds = [...txResult.deployTargetIds].sort((a, b) => a.localeCompare(b))
|
|
for (const targetWorkflowId of deployTargetIds) {
|
|
// The transaction already force-replaced this target's draft state, so connected
|
|
// canvas clients must adopt it (mothership-edit semantics) whether or not the
|
|
// subsequent deploy succeeds - otherwise they keep, and may clobber, stale state.
|
|
void notifyForkWorkflowChanged(targetWorkflowId)
|
|
if (needsConfigTargetIds.has(targetWorkflowId)) continue
|
|
try {
|
|
const result = await performFullDeploy({ workflowId: targetWorkflowId, userId, requestId })
|
|
if (result.success) {
|
|
redeployed += 1
|
|
// A deploy can succeed but defer/queue some side-effects (trigger/schedule/MCP
|
|
// sync). Surface those instead of swallowing them into a clean success.
|
|
if (result.warnings?.length) {
|
|
const name = txResult.writtenNames[targetWorkflowId] ?? targetWorkflowId
|
|
for (const warning of result.warnings) deployWarnings.push(`${name}: ${warning}`)
|
|
}
|
|
} else {
|
|
deployFailures.push(targetWorkflowId)
|
|
logger.warn(`[${requestId}] Deploy after promote failed`, {
|
|
workflowId: targetWorkflowId,
|
|
error: result.error,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
deployFailures.push(targetWorkflowId)
|
|
logger.error(`[${requestId}] Deploy after promote threw`, {
|
|
workflowId: targetWorkflowId,
|
|
error: getErrorMessage(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fill the heavy content (table rows, KB documents + embeddings) of resources copied into the
|
|
// target this sync and rewrite copied skill bodies, off the request path. Scheduled AFTER the
|
|
// deploy loop so every deployed version this sync cut already EXISTS: a failed content fill's
|
|
// cleanup sweep unions `deployedTargetWorkflowIds`, so it must not race ahead of those versions.
|
|
// Mirrors fork: a durable status row + Trigger.dev task surfaces a crash as a failed Activity
|
|
// entry rather than a silently-empty placeholder (runDetached is the non-Trigger fallback); the
|
|
// runner also clears references + drops the placeholder for any resource whose fill fails.
|
|
const copyContentPlan = txResult.copyContentPlan
|
|
const copyBlobTasks = txResult.copyContentBlobTasks
|
|
const hasCopyContent =
|
|
copyContentPlan != null && hasForkContentToCopy(copyContentPlan, copyBlobTasks)
|
|
if (copyContentPlan && hasCopyContent) {
|
|
// Scope the durable record to the workspace whose Manage Forks -> Activity the user is
|
|
// viewing (the one the sync was initiated from), matching where the route records the sync.
|
|
const activityWorkspaceId = direction === 'push' ? sourceWorkspaceId : targetWorkspaceId
|
|
// The sync already committed; failing to record the tracking row must not turn it into a 500.
|
|
// The runner no-ops its status updates when statusId is absent, so the copy still runs.
|
|
let statusId: string | undefined
|
|
try {
|
|
statusId = await startBackgroundWork(db, {
|
|
workspaceId: activityWorkspaceId,
|
|
kind: 'fork_content_copy',
|
|
// Append-only: each sync's content fill is a distinct entry in the Activity history.
|
|
supersede: false,
|
|
message: 'Copying synced resources',
|
|
metadata: {
|
|
// The edge's other side, so the partner workspace's Activity surfaces this row too.
|
|
otherWorkspaceId: direction === 'push' ? targetWorkspaceId : sourceWorkspaceId,
|
|
// The content fill runs as a background worker with no session; the Activity
|
|
// actor is the user who initiated the sync, not "System".
|
|
actorName: params.actorName,
|
|
tables: copyContentPlan.tables.length,
|
|
knowledgeBases: copyContentPlan.knowledgeBases.length,
|
|
files: copyBlobTasks.length,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Failed to record sync content-copy status`, {
|
|
targetWorkspaceId,
|
|
error: getErrorMessage(error),
|
|
})
|
|
}
|
|
|
|
const payload: ForkContentCopyPayload = {
|
|
contentPlan: copyContentPlan,
|
|
blobTasks: copyBlobTasks,
|
|
contentRefMaps: txResult.copyContentRefMaps ?? undefined,
|
|
statusId,
|
|
// The targets this sync wrote and deployed above, so a failed content fill can sweep the
|
|
// dropped placeholder from their DEPLOYED version states too, not just drafts.
|
|
deployedTargetWorkflowIds: txResult.deployTargetIds,
|
|
requestId,
|
|
}
|
|
await scheduleForkContentCopy(payload, { detachedLabel: 'fork-sync-content-copy', requestId })
|
|
}
|
|
|
|
if (deployFailures.length > 0) {
|
|
logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, {
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
deployFailed: deployFailures.length,
|
|
deployFailures,
|
|
})
|
|
}
|
|
|
|
if (deployWarnings.length > 0) {
|
|
logger.warn(`[${requestId}] Promote deploys emitted warnings`, { deployWarnings })
|
|
}
|
|
if (txResult.skippedItems.length > 0) {
|
|
logger.warn(`[${requestId}] Promote skipped undeployed source workflows`, {
|
|
skipped: txResult.skippedItems.map((item) => item.name),
|
|
})
|
|
}
|
|
if (txResult.needsConfiguration.length > 0) {
|
|
logger.warn(`[${requestId}] Promote left required dependent fields needing configuration`, {
|
|
sourceWorkspaceId,
|
|
targetWorkspaceId,
|
|
needsConfiguration: txResult.needsConfiguration.map((n) => n.workflowName),
|
|
})
|
|
}
|
|
logger.info(`[${requestId}] Promoted ${sourceWorkspaceId} -> ${targetWorkspaceId}`, {
|
|
updated: txResult.updated,
|
|
created: txResult.created,
|
|
archived: txResult.archived,
|
|
redeployed,
|
|
deployFailed: deployFailures.length,
|
|
needsConfiguration: txResult.needsConfiguration.length,
|
|
})
|
|
|
|
return {
|
|
promoteRunId: txResult.promoteRunId,
|
|
updated: txResult.updated,
|
|
created: txResult.created,
|
|
archived: txResult.archived,
|
|
redeployed,
|
|
deployFailed: deployFailures.length,
|
|
unmappedRequired: [],
|
|
blockers: [],
|
|
blocked: null,
|
|
updatedNames: txResult.updatedNames,
|
|
createdNames: txResult.createdNames,
|
|
archivedNames: txResult.archivedNames,
|
|
needsConfiguration: txResult.needsConfiguration.map(({ workflowName, blocks }) => ({
|
|
workflowName,
|
|
blocks,
|
|
})),
|
|
clearedOptional: txResult.clearedOptional,
|
|
}
|
|
}
|