Skip to content

Commit a9b167c

Browse files
icecrasher321claude
andcommitted
fix(forking): make the trigger URL preview reflect the user's actual picks
Two Cursor findings on #6272, both in the preview layer - the sync's write path was correct in each case, but the UI stated an outcome that did not match it. - The heads-up and overwrite confirm read `triggerUrlChanges` straight off the diff, which the server computes with its DEFAULT resolution before the user chooses anything. Selecting "Generate new URL" for a trigger that would have adopted a URL therefore killed that URL with no warning, in the one modal whose job is to state irreversible consequences (it also over-warned in the reverse case). The diff now returns the RAW retiring set and the client subtracts the live choices, so the rows, the heads-up and the confirm cannot disagree. - The picker let two triggers select the same retiring URL and showed both as keeping it. Two blocks cannot serve one path (`path_deployment_unique`) and the resolver awards it to the first slot, so the loser silently got a new URL. A path another row claimed is now disabled and named, and each row displays its RESOLVED outcome rather than its raw pick. The choice resolution is a pure module mirroring `resolveForkTriggerPaths` (offered-paths guard, first-claim-wins), so the preview and the server agree by construction rather than by two hand-kept implementations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6cc25f6 commit a9b167c

7 files changed

Lines changed: 257 additions & 23 deletions

File tree

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ import {
2828
collectForkClearedRefCandidates,
2929
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
3030
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'
31+
import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls'
3532
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
3633
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
3734

@@ -188,7 +185,13 @@ export const GET = withRouteHandler(
188185
resolveBlockId,
189186
targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds),
190187
})
191-
const { changes: triggerUrlChanges } = resolveForkTriggerPaths(triggerPlan)
188+
// The RAW retiring set, not the default resolution: the client derives which of these actually
189+
// stop being served from the picks the user is making right now, so the heads-up and the
190+
// overwrite confirm can never disagree with the Trigger URLs rows.
191+
const retiringTriggerUrls = triggerPlan.retiring.map((row) => ({
192+
workflowName: row.workflowName,
193+
path: row.path,
194+
}))
192195
// Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just
193196
// the decisions, so the section reads as a standing statement of each URL rather than an alert.
194197
//
@@ -259,7 +262,7 @@ export const GET = withRouteHandler(
259262
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
260263
copyableUnmapped: plan.copyableUnmapped,
261264
clearedRefs,
262-
triggerUrlChanges,
265+
retiringTriggerUrls,
263266
triggerMappings,
264267
})
265268
}

apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -598,10 +598,10 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) {
598598
// A trigger that already serves a URL keeps it, so the row states the URL and offers no
599599
// control. Only a trigger the sync would give a NEW URL has something to decide.
600600
const decidable = mapping.ownPath === null && mapping.adoptablePaths.length > 0
601-
const chosen =
602-
mapping.sourceBlockId in controller.triggerAdoptions
603-
? controller.triggerAdoptions[mapping.sourceBlockId]
604-
: (mapping.defaultAdoptPath ?? '')
601+
const pathOwners = controller.triggerPathOwnersFor(mapping.sourceBlockId)
602+
// The RESOLVED choice, not the raw pick: a path another row claimed first is awarded once, so
603+
// displaying the raw pick would promise a URL this row is not going to get.
604+
const chosen = controller.triggerChoiceFor(mapping.sourceBlockId)
605605
const resultingPath = mapping.ownPath ?? (chosen === '' ? null : chosen)
606606

607607
return (
@@ -625,13 +625,23 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) {
625625
// The full URL lives under the row and follows the selection, so an option only
626626
// has to name the CHOICE. Several retiring URLs is the one case that needs a
627627
// disambiguator, and the path tail is what distinguishes them.
628-
...mapping.adoptablePaths.map((path) => ({
629-
label:
628+
//
629+
// A URL another trigger already took is disabled and says who took it: two blocks
630+
// cannot serve one path, and the resolver awards it to the first slot - so
631+
// allowing the pick would leave this row reading "Keeps this URL" while the sync
632+
// silently minted it a new one.
633+
...mapping.adoptablePaths.map((path) => {
634+
const owner = pathOwners.get(path)
635+
const base =
630636
mapping.adoptablePaths.length === 1
631637
? 'Keep existing URL'
632-
: `Keep …${path.slice(-12)}`,
633-
value: path,
634-
})),
638+
: `Keep …${path.slice(-12)}`
639+
return {
640+
label: owner ? `${base} · taken by ${owner}` : base,
641+
value: path,
642+
disabled: owner !== undefined,
643+
}
644+
}),
635645
{ label: 'Generate new URL', value: NEW_TRIGGER_URL_VALUE },
636646
]}
637647
value={chosen === '' ? NEW_TRIGGER_URL_VALUE : chosen}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ForkTriggerMapping } from '@/lib/api/contracts/workspace-fork'
6+
import {
7+
forkDyingTriggerUrls,
8+
forkTriggerChoices,
9+
forkTriggerPathOwners,
10+
} from '@/ee/workspace-forking/components/fork-sync/trigger-choices'
11+
12+
function mapping(overrides: Partial<ForkTriggerMapping> = {}): ForkTriggerMapping {
13+
return {
14+
sourceBlockId: 'blk',
15+
blockName: 'Slack messages',
16+
workflowName: 'ITSM intake',
17+
ownPath: null,
18+
adoptablePaths: ['p1'],
19+
defaultAdoptPath: 'p1',
20+
...overrides,
21+
}
22+
}
23+
24+
describe('forkTriggerChoices', () => {
25+
it('takes the default when the user has not chosen', () => {
26+
expect(forkTriggerChoices([mapping()], {}).get('blk')).toBe('p1')
27+
})
28+
29+
it('honours an explicit pick over the default', () => {
30+
const mappings = [mapping({ adoptablePaths: ['p1', 'p2'], defaultAdoptPath: null })]
31+
expect(forkTriggerChoices(mappings, { blk: 'p2' }).get('blk')).toBe('p2')
32+
})
33+
34+
it("treats an explicit '' as minting a new URL, overriding the default", () => {
35+
expect(forkTriggerChoices([mapping()], { blk: '' }).get('blk')).toBe('')
36+
})
37+
38+
it('ignores a pick the slot never offered', () => {
39+
expect(forkTriggerChoices([mapping()], { blk: 'not-offered' }).get('blk')).toBe('')
40+
})
41+
42+
/**
43+
* Two blocks cannot serve one path (`path_deployment_unique`) and the server awards it to the
44+
* first slot, so the second row's real outcome is a NEW URL - not the path it asked for.
45+
*/
46+
it('awards a contested path to the first row only', () => {
47+
const mappings = [
48+
mapping({ sourceBlockId: 'a', blockName: 'Slack A', defaultAdoptPath: null }),
49+
mapping({ sourceBlockId: 'b', blockName: 'Slack B', defaultAdoptPath: null }),
50+
]
51+
const chosen = forkTriggerChoices(mappings, { a: 'p1', b: 'p1' })
52+
expect(chosen.get('a')).toBe('p1')
53+
expect(chosen.get('b')).toBe('')
54+
})
55+
})
56+
57+
describe('forkDyingTriggerUrls', () => {
58+
const retiring = [
59+
{ workflowName: 'ITSM intake', path: 'p1' },
60+
{ workflowName: 'ITSM intake', path: 'p2' },
61+
]
62+
63+
it('excludes a URL some row adopts', () => {
64+
const chosen = forkTriggerChoices([mapping()], {})
65+
expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p2'])
66+
})
67+
68+
/**
69+
* The bug this exists for: the server computes its warning from the DEFAULT resolution, so
70+
* choosing "Generate new URL" used to kill a URL the confirm never mentioned.
71+
*/
72+
it('re-lists a URL once the user opts into a new one instead', () => {
73+
const chosen = forkTriggerChoices([mapping()], { blk: '' })
74+
expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p1', 'p2'])
75+
})
76+
77+
it('drops a URL the user adopts where the default adopted nothing', () => {
78+
const mappings = [mapping({ adoptablePaths: ['p1', 'p2'], defaultAdoptPath: null })]
79+
const chosen = forkTriggerChoices(mappings, { blk: 'p2' })
80+
expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p1'])
81+
})
82+
83+
/** A contested path is still served by its winner, so it is not dying. */
84+
it('counts a contested path as adopted exactly once', () => {
85+
const mappings = [
86+
mapping({ sourceBlockId: 'a', defaultAdoptPath: null }),
87+
mapping({ sourceBlockId: 'b', defaultAdoptPath: null }),
88+
]
89+
const chosen = forkTriggerChoices(mappings, { a: 'p1', b: 'p1' })
90+
expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p2'])
91+
})
92+
})
93+
94+
describe('forkTriggerPathOwners', () => {
95+
const mappings = [
96+
mapping({ sourceBlockId: 'a', blockName: 'Slack A', defaultAdoptPath: null }),
97+
mapping({ sourceBlockId: 'b', blockName: 'Slack B', defaultAdoptPath: null }),
98+
]
99+
100+
it('names the row that claimed a path, from another row’s perspective', () => {
101+
const chosen = forkTriggerChoices(mappings, { a: 'p1' })
102+
expect(forkTriggerPathOwners(mappings, chosen, 'b').get('p1')).toBe('Slack A')
103+
})
104+
105+
it('never reports a row as the owner of its own claim', () => {
106+
const chosen = forkTriggerChoices(mappings, { a: 'p1' })
107+
expect(forkTriggerPathOwners(mappings, chosen, 'a').has('p1')).toBe(false)
108+
})
109+
110+
it('reports nothing while no row has claimed anything', () => {
111+
const chosen = forkTriggerChoices(mappings, {})
112+
expect(forkTriggerPathOwners(mappings, chosen, 'b').size).toBe(0)
113+
})
114+
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { ForkTriggerMapping, ForkTriggerUrlChange } from '@/lib/api/contracts/workspace-fork'
2+
3+
/**
4+
* Which retiring URL each arriving trigger currently takes, keyed by source block id. `''` means
5+
* "mint a new URL".
6+
*
7+
* Mirrors `resolveForkTriggerPaths` on the server, which is what makes the preview trustworthy:
8+
* an override counts only for a path the slot actually offered, and a path is awarded to the
9+
* FIRST row that claims it - two blocks cannot serve one path (`path_deployment_unique`), so a
10+
* later row claiming the same URL silently receives a new one instead.
11+
*/
12+
export function forkTriggerChoices(
13+
mappings: readonly ForkTriggerMapping[],
14+
adoptions: Readonly<Record<string, string>>
15+
): Map<string, string> {
16+
const chosen = new Map<string, string>()
17+
const claimed = new Set<string>()
18+
for (const mapping of mappings) {
19+
const picked =
20+
mapping.sourceBlockId in adoptions
21+
? adoptions[mapping.sourceBlockId]
22+
: (mapping.defaultAdoptPath ?? '')
23+
const honoured =
24+
picked !== '' && mapping.adoptablePaths.includes(picked) && !claimed.has(picked) ? picked : ''
25+
if (honoured !== '') claimed.add(honoured)
26+
chosen.set(mapping.sourceBlockId, honoured)
27+
}
28+
return chosen
29+
}
30+
31+
/**
32+
* The retiring URLs the CURRENT choices leave unserved.
33+
*
34+
* Derived from the raw retiring set rather than read off the diff: the server computes its own
35+
* default before the user picks anything, so a preview built from it would omit a URL the user
36+
* has just chosen to abandon - in the one modal that exists to state irreversible consequences.
37+
*/
38+
export function forkDyingTriggerUrls(
39+
retiring: readonly ForkTriggerUrlChange[],
40+
chosen: ReadonlyMap<string, string>
41+
): ForkTriggerUrlChange[] {
42+
const adopted = new Set(Array.from(chosen.values()).filter((path) => path !== ''))
43+
return retiring.filter((row) => !adopted.has(row.path))
44+
}
45+
46+
/**
47+
* The block name already claiming each path, from the perspective of one row - so its picker can
48+
* disable a URL another trigger took rather than letting the user select a choice the sync will
49+
* silently overrule.
50+
*/
51+
export function forkTriggerPathOwners(
52+
mappings: readonly ForkTriggerMapping[],
53+
chosen: ReadonlyMap<string, string>,
54+
forSourceBlockId: string
55+
): Map<string, string> {
56+
const owners = new Map<string, string>()
57+
for (const mapping of mappings) {
58+
if (mapping.sourceBlockId === forSourceBlockId) continue
59+
const pick = chosen.get(mapping.sourceBlockId)
60+
if (pick) owners.set(pick, mapping.blockName)
61+
}
62+
return owners
63+
}

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

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import {
3535
effectiveCopyDependentValue,
3636
effectiveDependentValue,
3737
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
38+
import {
39+
forkDyingTriggerUrls,
40+
forkTriggerChoices,
41+
forkTriggerPathOwners,
42+
} from '@/ee/workspace-forking/components/fork-sync/trigger-choices'
3843
import {
3944
type ForkDirection,
4045
useForkDiff,
@@ -180,7 +185,10 @@ export interface ForkSyncController {
180185
workflowChanges: ForkWorkflowChange[]
181186
/** Names of target workflows this sync archives, for the confirm modal. */
182187
archivedWorkflowNames: string[]
183-
/** Public trigger URLs this sync would stop serving in the target (warn before overwriting). */
188+
/**
189+
* Public trigger URLs the CURRENT picks leave unserved. Derived from the retiring set and the
190+
* live adoption choices, so the heads-up, the overwrite confirm and the rows always agree.
191+
*/
184192
triggerUrlChanges: ForkTriggerUrlChange[]
185193
/** Arriving triggers whose URL is a choice: keep a retiring one, or mint a new one. */
186194
triggerMappings: ForkTriggerMapping[]
@@ -190,6 +198,13 @@ export interface ForkSyncController {
190198
*/
191199
triggerAdoptions: Readonly<Record<string, string>>
192200
setTriggerAdoption: (sourceBlockId: string, path: string) => void
201+
/** Paths another trigger row has already claimed, so this row can disable them. */
202+
triggerPathOwnersFor: (sourceBlockId: string) => ReadonlyMap<string, string>
203+
/**
204+
* The path a row will actually serve, resolved the same way the server resolves it. Never
205+
* reports a path another row claimed first, so the row's displayed URL is its real outcome.
206+
*/
207+
triggerChoiceFor: (sourceBlockId: string) => string
193208
/** Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. */
194209
excludedSourceWorkflows: string[]
195210
/** Names of mapped TARGET workflows marked "Exclude from sync" - never replaced or archived. */
@@ -320,6 +335,10 @@ export function useForkSync(params: {
320335
() => diff.data?.triggerMappings ?? [],
321336
[diff.data?.triggerMappings]
322337
)
338+
const retiringTriggerUrls = useMemo(
339+
() => diff.data?.retiringTriggerUrls ?? [],
340+
[diff.data?.retiringTriggerUrls]
341+
)
323342

324343
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
325344
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
@@ -782,6 +801,24 @@ export function useForkSync(params: {
782801
setTriggerAdoptions((prev) => ({ ...prev, [sourceBlockId]: path }))
783802
}
784803

804+
/** Live choices, resolved exactly as the server will resolve them (first claim wins a path). */
805+
const chosenTriggerPaths = useMemo(
806+
() => forkTriggerChoices(triggerMappings, triggerAdoptions),
807+
[triggerMappings, triggerAdoptions]
808+
)
809+
810+
const triggerUrlChanges = useMemo(
811+
() => forkDyingTriggerUrls(retiringTriggerUrls, chosenTriggerPaths),
812+
[retiringTriggerUrls, chosenTriggerPaths]
813+
)
814+
815+
const triggerPathOwnersFor = (sourceBlockId: string): ReadonlyMap<string, string> =>
816+
forkTriggerPathOwners(triggerMappings, chosenTriggerPaths, sourceBlockId)
817+
818+
/** The path a row will actually serve, or '' for a new URL - never a claim another row won. */
819+
const triggerChoiceFor = (sourceBlockId: string): string =>
820+
chosenTriggerPaths.get(sourceBlockId) ?? ''
821+
785822
const discard = () => {
786823
setTargets({})
787824
setReconfig({})
@@ -965,10 +1002,12 @@ export function useForkSync(params: {
9651002
dependentClears,
9661003
workflowChanges,
9671004
archivedWorkflowNames,
968-
triggerUrlChanges: diff.data?.triggerUrlChanges ?? [],
1005+
triggerUrlChanges,
9691006
triggerMappings,
9701007
triggerAdoptions,
9711008
setTriggerAdoption,
1009+
triggerPathOwnersFor,
1010+
triggerChoiceFor,
9721011
excludedSourceWorkflows: diff.data?.excludedSourceWorkflows ?? [],
9731012
excludedTargetWorkflows: diff.data?.excludedTargetWorkflows ?? [],
9741013
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,

apps/sim/lib/api/contracts/workspace-fork.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe('getForkDiffContract response excluded-workflow lists', () => {
188188
const parsed = getForkDiffContract.response.schema.parse(baseDiffResponse)
189189
expect(parsed.excludedSourceWorkflows).toEqual([])
190190
expect(parsed.excludedTargetWorkflows).toEqual([])
191-
expect(parsed.triggerUrlChanges).toEqual([])
191+
expect(parsed.retiringTriggerUrls).toEqual([])
192192
expect(parsed.triggerMappings).toEqual([])
193193
})
194194

@@ -215,12 +215,12 @@ describe('getForkDiffContract response excluded-workflow lists', () => {
215215
defaultAdoptPath: 'live-slack-path',
216216
},
217217
],
218-
triggerUrlChanges: [{ workflowName: 'ITSM intake', path: 'dead-path' }],
218+
retiringTriggerUrls: [{ workflowName: 'ITSM intake', path: 'dead-path' }],
219219
})
220220
expect(parsed.triggerMappings[0].ownPath).toBe('prod-live-path')
221221
expect(parsed.triggerMappings[0].adoptablePaths).toEqual([])
222222
expect(parsed.triggerMappings[1].defaultAdoptPath).toBe('live-slack-path')
223-
expect(parsed.triggerUrlChanges[0].path).toBe('dead-path')
223+
expect(parsed.retiringTriggerUrls[0].path).toBe('dead-path')
224224
})
225225

226226
it('accepts a trigger mapping choice on the promote body, including "new URL"', () => {

apps/sim/lib/api/contracts/workspace-fork.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,10 +604,15 @@ export const getForkDiffContract = defineRouteContract({
604604
*/
605605
clearedRefs: z.array(forkClearedRefSchema),
606606
/**
607-
* Public trigger URLs this sync would stop serving in the target. Defaulted so a new client
608-
* tolerates an old server's response during rollout.
607+
* Every public trigger URL this sync retires in the target, BEFORE any adoption is applied.
608+
*
609+
* Deliberately pre-adoption: which of these actually stop being served depends on the
610+
* caller's live picks in `triggerMappings`, which only exist client-side until the promote
611+
* call. Returning the post-default set instead would freeze the preview at the server's
612+
* guess, so choosing "Generate new URL" would kill a URL the confirm never warned about.
613+
* Defaulted so a new client tolerates an old server's response during rollout.
609614
*/
610-
triggerUrlChanges: z.array(forkTriggerUrlChangeSchema).default([]),
615+
retiringTriggerUrls: z.array(forkTriggerUrlChangeSchema).default([]),
611616
/** Arriving trigger blocks whose URL this sync decides, with their adoptable alternatives. */
612617
triggerMappings: z.array(forkTriggerMappingSchema).default([]),
613618
}),

0 commit comments

Comments
 (0)