Skip to content

Commit e290b7c

Browse files
committed
improvement(forking): make webhook url mapping clear
1 parent 35fd4ef commit e290b7c

32 files changed

Lines changed: 2243 additions & 159 deletions

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-w
1010
import {
1111
listForkExcludedDeployedWorkflows,
1212
loadSourceDeployedStates,
13+
loadTargetWebhookPathsByBlock,
1314
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
1415
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
1516
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
@@ -27,6 +28,10 @@ import {
2728
collectForkClearedRefCandidates,
2829
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
2930
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
31+
import {
32+
buildForkTriggerPlan,
33+
resolveForkTriggerPaths,
34+
} from '@/ee/workspace-forking/lib/promote/trigger-urls'
3035
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
3136
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
3237

@@ -173,6 +178,36 @@ export const GET = withRouteHandler(
173178
})
174179
)
175180

181+
// Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request
182+
// URL again" case, surfaced as an editable pairing before the overwrite instead of discovered
183+
// after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the
184+
// promote call, where the same plan is rebuilt and validated against them.
185+
const triggerPlan = buildForkTriggerPlan({
186+
items: plan.items,
187+
sourceStates,
188+
resolveBlockId,
189+
targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds),
190+
})
191+
const { changes: triggerUrlChanges } = resolveForkTriggerPaths(triggerPlan)
192+
// Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just
193+
// the decisions, so the section reads as a standing statement of each URL rather than an alert.
194+
//
195+
// A trigger with neither is deliberately absent: whether a block will serve a URL at all is
196+
// only knowable from its webhook row, and a schedule / chat / manual / poller trigger never
197+
// gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative
198+
// flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on
199+
// 345 including `slack_oauth`, which routes by `routingKey` with a NULL path.
200+
const triggerMappings = triggerPlan.slots
201+
.filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0)
202+
.map((slot) => ({
203+
sourceBlockId: slot.sourceBlockId,
204+
blockName: slot.blockName,
205+
workflowName: slot.workflowName,
206+
ownPath: slot.ownPath,
207+
adoptablePaths: slot.adoptablePaths,
208+
defaultAdoptPath: slot.defaultAdoptPath,
209+
}))
210+
176211
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
177212
kind: reference.kind,
178213
sourceId: reference.sourceId,
@@ -224,6 +259,8 @@ export const GET = withRouteHandler(
224259
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
225260
copyableUnmapped: plan.copyableUnmapped,
226261
clearedRefs,
262+
triggerUrlChanges,
263+
triggerMappings,
227264
})
228265
}
229266
)

apps/sim/app/api/workspaces/[id]/fork/promote/route.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ export const POST = withRouteHandler(
2525
const parsed = await parseRequest(promoteForkContract, req, context)
2626
if (!parsed.success) return parsed.response
2727
const { id } = parsed.data.params
28-
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
28+
const {
29+
otherWorkspaceId,
30+
direction,
31+
dependentValues,
32+
copyResources,
33+
dropReferences,
34+
triggerMappings,
35+
} = parsed.data.body
2936

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

@@ -38,6 +45,8 @@ export const POST = withRouteHandler(
3845
actorName: session.user.name ?? undefined,
3946
dependentValues,
4047
copyResources,
48+
dropReferences,
49+
triggerMappings,
4150
requestId,
4251
})
4352

@@ -52,6 +61,8 @@ export const POST = withRouteHandler(
5261
blockers: result.blockers,
5362
needsConfiguration: result.needsConfiguration,
5463
clearedOptional: result.clearedOptional,
64+
droppedReferences: result.droppedReferences,
65+
triggerUrlChanges: result.triggerUrlChanges,
5566
}
5667

5768
if (result.blocked) {
@@ -91,7 +102,9 @@ export const POST = withRouteHandler(
91102
status:
92103
result.deployFailed > 0 ||
93104
result.needsConfiguration.length > 0 ||
94-
result.clearedOptional.length > 0
105+
result.clearedOptional.length > 0 ||
106+
result.droppedReferences.length > 0 ||
107+
result.triggerUrlChanges.length > 0
95108
? 'completed_with_warnings'
96109
: 'completed',
97110
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
@@ -110,6 +123,8 @@ export const POST = withRouteHandler(
110123
archivedNames: result.archivedNames,
111124
needsConfiguration: result.needsConfiguration,
112125
clearedOptional: result.clearedOptional,
126+
droppedReferences: result.droppedReferences.length,
127+
triggerUrlChanges: result.triggerUrlChanges.length,
113128
},
114129
}).catch((error) =>
115130
logger.error(`[${requestId}] Failed to record sync activity`, {

apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,20 @@ export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
5858
return { blockers, informational }
5959
}
6060

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

7177
/**
@@ -79,7 +85,7 @@ export function forkBlockerResolution(ref: ForkClearedRef): string | null {
7985
case 'unmapped-copyable':
8086
return 'map it to a target or select it for copy'
8187
case 'source-deleted':
82-
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
88+
return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
8389
case 'workflow-missing':
8490
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
8591
}

apps/sim/ee/workspace-forking/components/fork-sync/copy-reconciliation.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,38 +83,39 @@ export function forkParentResolution(
8383
}
8484

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

104105
/**
105-
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
106-
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
106+
* Whether any reference in a kind is required AND still unmapped AND not satisfied another way -
107+
* drives the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}.
107108
*/
108109
export function forkRequiredPending(
109110
items: ForkMappingEntry[],
110111
targets: Record<string, string>,
111-
copyingKeys: ReadonlySet<string>
112+
satisfiedKeys: ReadonlySet<string>
112113
): boolean {
113114
return items.some(
114115
(entry) =>
115116
entry.required &&
116117
effectiveForkTarget(entry, targets) === '' &&
117-
!copyingKeys.has(forkRefKey(entry))
118+
!satisfiedKeys.has(forkRefKey(entry))
118119
)
119120
}
120121

0 commit comments

Comments
 (0)