Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-w
import {
listForkExcludedDeployedWorkflows,
loadSourceDeployedStates,
loadTargetWebhookPathsByBlock,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
Expand All @@ -27,6 +28,10 @@ import {
collectForkClearedRefCandidates,
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
import {
buildForkTriggerPlan,
resolveForkTriggerPaths,
} from '@/ee/workspace-forking/lib/promote/trigger-urls'
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'

Expand Down Expand Up @@ -173,6 +178,36 @@ export const GET = withRouteHandler(
})
)

// Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request
// URL again" case, surfaced as an editable pairing before the overwrite instead of discovered
// after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the
// promote call, where the same plan is rebuilt and validated against them.
const triggerPlan = buildForkTriggerPlan({
items: plan.items,
sourceStates,
resolveBlockId,
targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds),
})
const { changes: triggerUrlChanges } = resolveForkTriggerPaths(triggerPlan)
// Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just
// the decisions, so the section reads as a standing statement of each URL rather than an alert.
//
// A trigger with neither is deliberately absent: whether a block will serve a URL at all is
// only knowable from its webhook row, and a schedule / chat / manual / poller trigger never
// gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative
// flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on
// 345 including `slack_oauth`, which routes by `routingKey` with a NULL path.
const triggerMappings = triggerPlan.slots
.filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0)
.map((slot) => ({
sourceBlockId: slot.sourceBlockId,
blockName: slot.blockName,
workflowName: slot.workflowName,
ownPath: slot.ownPath,
adoptablePaths: slot.adoptablePaths,
defaultAdoptPath: slot.defaultAdoptPath,
}))

const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
Expand Down Expand Up @@ -224,6 +259,8 @@ export const GET = withRouteHandler(
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
triggerUrlChanges,
triggerMappings,
})
}
)
19 changes: 17 additions & 2 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
const {
otherWorkspaceId,
direction,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
} = parsed.data.body

const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)

Expand All @@ -38,6 +45,8 @@ export const POST = withRouteHandler(
actorName: session.user.name ?? undefined,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
requestId,
})

Expand All @@ -52,6 +61,8 @@ export const POST = withRouteHandler(
blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences,
triggerUrlChanges: result.triggerUrlChanges,
}

if (result.blocked) {
Expand Down Expand Up @@ -91,7 +102,9 @@ export const POST = withRouteHandler(
status:
result.deployFailed > 0 ||
result.needsConfiguration.length > 0 ||
result.clearedOptional.length > 0
result.clearedOptional.length > 0 ||
result.droppedReferences.length > 0 ||
result.triggerUrlChanges.length > 0
? 'completed_with_warnings'
: 'completed',
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
Expand All @@ -110,6 +123,8 @@ export const POST = withRouteHandler(
archivedNames: result.archivedNames,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences.length,
triggerUrlChanges: result.triggerUrlChanges.length,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record sync activity`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,20 @@ export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
return { blockers, informational }
}

/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
const BLOCKER_KIND_LABEL: Record<string, string> = {
/**
* Human label per remap kind for the resolution copy (singular, lowercase mid-sentence). Shared
* with the Mappings section's source-deleted note so both phrase the same resolution identically.
* `credential` is reachable only from a mapping entry - credentials gate through the required
* check, never through the cleared-ref blockers.
*/
export const FORK_RESOURCE_KIND_LABEL: Record<string, string> = {
table: 'table',
'knowledge-base': 'knowledge base',
file: 'file',
'custom-tool': 'custom tool',
skill: 'skill',
'mcp-server': 'MCP server',
credential: 'credential',
}

/**
Expand All @@ -79,7 +85,7 @@ export function forkBlockerResolution(ref: ForkClearedRef): string | null {
case 'unmapped-copyable':
return 'map it to a target or select it for copy'
case 'source-deleted':
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
case 'workflow-missing':
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,38 +83,39 @@ export function forkParentResolution(
}

/**
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
* branches are mutually exclusive.
* Whether every required reference is satisfied - it has a mapping target, or its key is in
* `satisfiedKeys` (selected for copy, or acknowledged as a dropped source-deleted reference).
* The server accepts both as resolving a required ref, so the client gate must too. No
* double-count: a mapped copyable is excluded from the copy candidates, and a droppable reference
* is source-deleted, so it has no copy candidate either.
*/
export function isForkRequiredComplete(
entries: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
satisfiedKeys: ReadonlySet<string>
): boolean {
return entries.every(
(entry) =>
!entry.required ||
effectiveForkTarget(entry, targets) !== '' ||
copyingKeys.has(forkRefKey(entry))
satisfiedKeys.has(forkRefKey(entry))
)
}

/**
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
* Whether any reference in a kind is required AND still unmapped AND not satisfied another way -
* drives the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}.
*/
export function forkRequiredPending(
items: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
satisfiedKeys: ReadonlySet<string>
): boolean {
return items.some(
(entry) =>
entry.required &&
effectiveForkTarget(entry, targets) === '' &&
!copyingKeys.has(forkRefKey(entry))
!satisfiedKeys.has(forkRefKey(entry))
)
}

Expand Down
Loading
Loading