Skip to content

Commit da73fd0

Browse files
committed
fix(workspace-forking): scope the canonical gates to the block's active surface
`createCanonicalModeGates` indexed a block's whole `subBlocks` array, so on a mixed action/trigger block a trigger field sharing a `canonicalParamId` with an action pair was read as a member of THAT pair. Being neither its `basicId` nor in its `advancedIds`, `isDormantMember` answered true the moment the shared mode resolved to advanced — and a fork acts on that by clearing the value, so a configured trigger field was silently wiped on fork/sync. Reachable without any explicit toggle: a block configured as an action with a manual id and then switched to trigger mode leaves the pair's value heuristic resolving to advanced on its own. - `createCanonicalModeGates` takes the surface and scopes its index - thread `triggerMode` through `RemapForkContext`, `SubBlockTransform`, `clearDependentsOnRemap`, `collectClearedDependents`, the reference scanners, and the promote cleared-ref collectors - nested tool params and the dependent scan are unchanged: a tool is always the action surface, and the dependent scan already narrows its configs
1 parent 2ff39d5 commit da73fd0

6 files changed

Lines changed: 150 additions & 22 deletions

File tree

apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,21 @@ export async function copyWorkflowStateIntoTarget(
520520
let activeCanonicalModes: CanonicalModeOverrides | undefined = (
521521
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
522522
)?.canonicalModes
523+
// A mixed action/trigger block shares one `canonicalModes` key across both surfaces, so the
524+
// remap has to know which surface is live: without it a trigger field reads as a dormant
525+
// member of the action pair and the remap clears the value.
526+
const blockTriggerMode = block.triggerMode === true
523527
if (transformSubBlocks) {
524-
subBlocks = transformSubBlocks(subBlocks, block.type, activeCanonicalModes, (next) => {
525-
activeCanonicalModes = next
526-
updatedData = { ...updatedData, canonicalModes: next } as BlockData
527-
})
528+
subBlocks = transformSubBlocks(
529+
subBlocks,
530+
block.type,
531+
activeCanonicalModes,
532+
(next) => {
533+
activeCanonicalModes = next
534+
updatedData = { ...updatedData, canonicalModes: next } as BlockData
535+
},
536+
blockTriggerMode
537+
)
528538
}
529539
if (varIdMapping.size > 0) {
530540
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
@@ -565,7 +575,8 @@ export async function copyWorkflowStateIntoTarget(
565575
block.name,
566576
targetCurrent.subBlocks,
567577
subBlocks,
568-
activeCanonicalModes
578+
activeCanonicalModes,
579+
blockTriggerMode
569580
)
570581
)
571582
}

apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ function baseSubBlockId(key: string): string {
8989
function collectForkWorkflowReferences(
9090
subBlocks: SubBlockRecord,
9191
config: ReturnType<typeof getBlock>,
92-
canonicalModes: CanonicalModeOverrides | undefined
92+
canonicalModes: CanonicalModeOverrides | undefined,
93+
triggerMode: boolean
9394
): Array<{ workflowId: string; subBlockKey: string }> {
9495
const out: Array<{ workflowId: string; subBlockKey: string }> = []
9596
// Collapse each canonical pair to its ACTIVE member and skip condition-hidden fields: only a
@@ -103,7 +104,8 @@ function collectForkWorkflowReferences(
103104
const gates = createCanonicalModeGates(
104105
config?.subBlocks,
105106
buildSubBlockValues(subBlocks),
106-
canonicalModes
107+
canonicalModes,
108+
triggerMode
107109
)
108110
const detectionSkipped = (key: string) =>
109111
gates.isDormantMember(key) || gates.isConditionHidden(key)
@@ -199,6 +201,7 @@ export function collectForkClearedRefCandidates(
199201
blockName: blockLabel,
200202
blockType: block.type,
201203
canonicalModes: block.data?.canonicalModes,
204+
triggerMode: block.triggerMode === true,
202205
})
203206
for (const ref of scan.unmapped) {
204207
if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue
@@ -245,7 +248,8 @@ export function collectForkClearedRefCandidates(
245248
for (const wfRef of collectForkWorkflowReferences(
246249
subBlocks,
247250
config,
248-
block.data?.canonicalModes
251+
block.data?.canonicalModes,
252+
block.triggerMode === true
249253
)) {
250254
if (workflowIdMap.has(wfRef.workflowId)) continue
251255
out.push({
@@ -397,7 +401,8 @@ function hasForkSyncBlockerCandidates(
397401
const workflowRefs = collectForkWorkflowReferences(
398402
subBlocks,
399403
getBlock(block.type),
400-
block.data?.canonicalModes
404+
block.data?.canonicalModes,
405+
block.triggerMode === true
401406
)
402407
if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true
403408
}

apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string
2222
* the child defines the key).
2323
*/
2424
export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform {
25-
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => {
25+
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => {
2626
// Every resolution at fork-create IS a copy (the resolver is the copy id map), so all
2727
// remapped keys carry copy provenance - copy-faithful dependents (column picks) survive.
2828
// `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active
2929
// advanced (manual) passes through with its dependents, dormant members clear.
3030
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', {
3131
blockType,
3232
canonicalModes,
33+
triggerMode,
3334
isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null,
3435
})
3536
if (result.canonicalModes) onCanonicalModesChanged?.(result.canonicalModes)
@@ -38,7 +39,8 @@ export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): S
3839
blockType,
3940
result.remappedKeys,
4041
result.canonicalModes ?? canonicalModes,
41-
result.copyRemappedKeys
42+
result.copyRemappedKeys,
43+
triggerMode
4244
)
4345
}
4446
}

apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface ScannerBlock {
1212
type: string
1313
subBlocks: unknown
1414
canonicalModes?: CanonicalModeOverrides
15+
triggerMode?: boolean
1516
}
1617

1718
/**
@@ -45,6 +46,7 @@ export function toScannerBlocks(state: WorkflowState): ScannerBlock[] {
4546
type: block.type,
4647
subBlocks: block.subBlocks as unknown,
4748
canonicalModes: block.data?.canonicalModes,
49+
triggerMode: block.triggerMode,
4850
}))
4951
}
5052

apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
applyDependentOverrides,
3636
clearDependentsOnRemap,
3737
collectClearedDependents,
38+
createCanonicalModeGates,
3839
createForkSubBlockTransform,
3940
type ForkReferenceResolver,
4041
parseNestedDependentKey,
@@ -777,6 +778,78 @@ describe('clearDependentsOnRemap canonical-pair gating', () => {
777778
})
778779
})
779780

781+
describe('canonical-mode gates on a mixed action/trigger block', () => {
782+
/**
783+
* Webflow's shape: an action pair plus a trigger alias sharing one `canonicalParamId` under a
784+
* DIFFERENT id. Both surfaces live in one `subBlocks` array and share one `canonicalModes` key.
785+
*/
786+
const mixedSurfaceBlock = () =>
787+
blockWith([
788+
{
789+
id: 'siteSelector',
790+
title: 'Site',
791+
type: 'project-selector',
792+
canonicalParamId: 'siteId',
793+
mode: 'basic',
794+
},
795+
{
796+
id: 'manualSiteId',
797+
title: 'Site ID',
798+
type: 'short-input',
799+
canonicalParamId: 'siteId',
800+
mode: 'advanced',
801+
},
802+
{
803+
id: 'triggerSiteId',
804+
title: 'Site',
805+
type: 'dropdown',
806+
canonicalParamId: 'siteId',
807+
mode: 'trigger',
808+
},
809+
])
810+
811+
const values = {
812+
siteSelector: '',
813+
manualSiteId: 'stale-manual-site',
814+
triggerSiteId: 'site-live',
815+
}
816+
817+
it('does not call a live trigger field dormant when the shared mode is advanced', () => {
818+
vi.mocked(getBlock).mockReturnValue(mixedSurfaceBlock())
819+
const config = getBlock('webflow') as BlockConfig
820+
// Configured as an action with the manual Site ID, then switched to trigger mode. The mode key
821+
// is shared, so unscoped the trigger field reads as a dormant member of the action pair — and
822+
// a fork CLEARS dormant members, silently wiping the trigger's configured site.
823+
const gates = createCanonicalModeGates(config.subBlocks, values, { siteId: 'advanced' }, true)
824+
expect(gates.isDormantMember('triggerSiteId')).toBe(false)
825+
expect(gates.isActiveManualMember('triggerSiteId')).toBe(false)
826+
})
827+
828+
it('still gates the action surface normally', () => {
829+
vi.mocked(getBlock).mockReturnValue(mixedSurfaceBlock())
830+
const config = getBlock('webflow') as BlockConfig
831+
const gates = createCanonicalModeGates(config.subBlocks, values, { siteId: 'advanced' }, false)
832+
// Basic is dormant while advanced is active; the manual member is the live one.
833+
expect(gates.isDormantMember('siteSelector')).toBe(true)
834+
expect(gates.isActiveManualMember('manualSiteId')).toBe(true)
835+
})
836+
837+
it("keeps a trigger-mode block's live field through the fork remap", () => {
838+
vi.mocked(getBlock).mockReturnValue(mixedSurfaceBlock())
839+
const subBlocks: SubBlockRecord = {
840+
siteSelector: { type: 'project-selector', value: '' },
841+
manualSiteId: { type: 'short-input', value: 'stale-manual-site' },
842+
triggerSiteId: { type: 'dropdown', value: 'site-live' },
843+
}
844+
const result = remapForkSubBlocks(subBlocks, () => null, 'create', {
845+
blockType: 'webflow',
846+
canonicalModes: { siteId: 'advanced' },
847+
triggerMode: true,
848+
})
849+
expect(result.subBlocks.triggerSiteId.value).toBe('site-live')
850+
})
851+
})
852+
780853
describe('scanWorkflowReferences canonical-pair detection', () => {
781854
const credBlock = () =>
782855
blockWith([

apps/sim/ee/workspace-forking/lib/remap/remap-references.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
buildSubBlockValues,
2727
type CanonicalModeOverrides,
2828
evaluateSubBlockCondition,
29+
getCanonicalSubBlocksForSurface,
2930
isCanonicalPair,
3031
isNonEmptyValue,
3132
reindexCanonicalModesByPosition,
@@ -228,7 +229,9 @@ export type SubBlockTransform = (
228229
subBlocks: SubBlockRecord,
229230
blockType: string,
230231
canonicalModes?: CanonicalModeOverrides,
231-
onCanonicalModesChanged?: (next: CanonicalModeOverrides) => void
232+
onCanonicalModesChanged?: (next: CanonicalModeOverrides) => void,
233+
/** The block's trigger mode, scoping the canonical index (see {@link createCanonicalModeGates}). */
234+
triggerMode?: boolean
232235
) => SubBlockRecord
233236

234237
/**
@@ -451,16 +454,26 @@ const NO_GATES: CanonicalModeGates = {
451454
* augmented with each pair's ACTIVE value under its canonical id, mirroring how the serializer
452455
* exposes params to conditions. With no configs (unknown block type) every gate is a no-op:
453456
* everything is detected and nothing passes through, the conservative default.
457+
*
458+
* `triggerSurface` scopes the index to the block's active surface. Without it, a trigger field
459+
* sharing a `canonicalParamId` with an action pair (`triggerSiteId` under `siteId`,
460+
* `triggerCredentials` under `oauthCredential`) is read as a member of THAT pair, and since it is
461+
* neither its `basicId` nor in its `advancedIds`, `isDormantMember` answers `true` the moment the
462+
* shared mode resolves to advanced — which a fork acts on by CLEARING the value. Pass a caller
463+
* that has already narrowed its configs (the dependent scan) `false`; scoping twice is harmless
464+
* but the flag should describe what the caller actually did.
454465
*/
455466
export function createCanonicalModeGates(
456467
configSubBlocks: SubBlockConfig[] | undefined,
457468
values: Record<string, unknown>,
458-
canonicalModes?: CanonicalModeOverrides
469+
canonicalModes?: CanonicalModeOverrides,
470+
triggerSurface = false
459471
): CanonicalModeGates {
460472
if (!configSubBlocks || configSubBlocks.length === 0) return NO_GATES
461-
const canonicalIndex = buildCanonicalIndex(configSubBlocks)
473+
const surfaceSubBlocks = getCanonicalSubBlocksForSurface(configSubBlocks, triggerSurface)
474+
const canonicalIndex = buildCanonicalIndex(surfaceSubBlocks)
462475
const configByBaseKey = new Map(
463-
configSubBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])
476+
surfaceSubBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])
464477
)
465478
const conditionValues = { ...values }
466479
for (const [canonicalId, group] of Object.entries(canonicalIndex.groupsById)) {
@@ -521,6 +534,12 @@ export interface RemapForkContext {
521534
blockType?: string
522535
/** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */
523536
canonicalModes?: CanonicalModeOverrides
537+
/**
538+
* Whether the block is in TRIGGER mode, scoping the canonical index to that surface. A mixed
539+
* action/trigger block shares one mode key across both surfaces, so without this a trigger
540+
* field reads as a dormant member of the action pair and its value is cleared.
541+
*/
542+
triggerMode?: boolean
524543
/** Target MCP server row lookup for rewriting remapped tool-input entries' server metadata. */
525544
resolveMcpServerMeta?: ForkMcpServerMetaResolver
526545
/**
@@ -1047,7 +1066,8 @@ export function remapForkSubBlocks(
10471066
const gates = createCanonicalModeGates(
10481067
context?.blockType ? getBlock(context.blockType)?.subBlocks : undefined,
10491068
buildSubBlockValues(subBlocks),
1050-
context?.canonicalModes
1069+
context?.canonicalModes,
1070+
context?.triggerMode === true
10511071
)
10521072

10531073
for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) {
@@ -1276,7 +1296,9 @@ export function clearDependentsOnRemap(
12761296
remappedKeys: ReadonlySet<string>,
12771297
canonicalModes?: CanonicalModeOverrides,
12781298
/** Keys remapped via a COPY (see {@link RemapSubBlocksResult.copyRemappedKeys}). */
1279-
copyRemappedKeys?: ReadonlySet<string>
1299+
copyRemappedKeys?: ReadonlySet<string>,
1300+
/** The block's trigger mode, scoping the canonical index (see {@link createCanonicalModeGates}). */
1301+
triggerMode?: boolean
12801302
): SubBlockRecord {
12811303
if (remappedKeys.size === 0) return subBlocks
12821304
const config = getBlock(blockType)
@@ -1290,7 +1312,8 @@ export function clearDependentsOnRemap(
12901312
const gates = createCanonicalModeGates(
12911313
config.subBlocks,
12921314
buildSubBlockValues(subBlocks),
1293-
canonicalModes
1315+
canonicalModes,
1316+
triggerMode === true
12941317
)
12951318

12961319
// The exemption's parent test: an mcp-server selector whose POST-remap value is non-empty was
@@ -1498,15 +1521,22 @@ export function collectClearedDependents(
14981521
blockName: string,
14991522
targetCurrentSubBlocks: SubBlockRecord,
15001523
mergedSubBlocks: SubBlockRecord,
1501-
canonicalModes?: CanonicalModeOverrides
1524+
canonicalModes?: CanonicalModeOverrides,
1525+
/** The block's trigger mode, scoping the canonical index (see {@link createCanonicalModeGates}). */
1526+
triggerMode?: boolean
15021527
): NeedsConfigurationField[] {
15031528
const config = getBlock(blockType)
15041529
if (!config) return []
15051530
const targetValues = buildSubBlockValues(targetCurrentSubBlocks)
15061531
const mergedValues = buildSubBlockValues(mergedSubBlocks)
15071532
// A DORMANT canonical member the merge cleared is not a lost configuration - only the pair's
15081533
// active member executes, so an inactive slot must never demand a re-pick.
1509-
const gates = createCanonicalModeGates(config.subBlocks, mergedValues, canonicalModes)
1534+
const gates = createCanonicalModeGates(
1535+
config.subBlocks,
1536+
mergedValues,
1537+
canonicalModes,
1538+
triggerMode === true
1539+
)
15101540
const fields: NeedsConfigurationField[] = []
15111541
for (const cfg of config.subBlocks) {
15121542
if (!cfg.id) continue
@@ -1755,10 +1785,11 @@ export function createForkSubBlockTransform(
17551785
isCopiedTarget?: (kind: ForkRemapKind, sourceId: string) => boolean
17561786
}
17571787
): SubBlockTransform {
1758-
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => {
1788+
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => {
17591789
const result = remapSubBlocks(subBlocks, resolve, {
17601790
blockType,
17611791
canonicalModes,
1792+
triggerMode,
17621793
resolveMcpServerMeta: options?.resolveMcpServerMeta,
17631794
isCopiedTarget: options?.isCopiedTarget,
17641795
})
@@ -1768,7 +1799,8 @@ export function createForkSubBlockTransform(
17681799
blockType,
17691800
result.remappedKeys,
17701801
result.canonicalModes ?? canonicalModes,
1771-
result.copyRemappedKeys
1802+
result.copyRemappedKeys,
1803+
triggerMode
17721804
)
17731805
}
17741806
}
@@ -1792,6 +1824,8 @@ export function scanWorkflowReferences(
17921824
subBlocks: unknown
17931825
/** `block.data.canonicalModes`, picking the active member per canonical pair for detection. */
17941826
canonicalModes?: CanonicalModeOverrides
1827+
/** The block's trigger mode, scoping the canonical index (see {@link createCanonicalModeGates}). */
1828+
triggerMode?: boolean
17951829
}>,
17961830
resolve: ForkReferenceResolver
17971831
): WorkflowReferenceScan {
@@ -1822,6 +1856,7 @@ export function scanWorkflowReferences(
18221856
blockName: block.name,
18231857
blockType: block.type,
18241858
canonicalModes: block.canonicalModes,
1859+
triggerMode: block.triggerMode,
18251860
})
18261861
for (const reference of blockResult.references) {
18271862
const key = `${reference.kind}:${reference.sourceId}`

0 commit comments

Comments
 (0)