Skip to content

Commit 363c320

Browse files
committed
improvement(subblocks): trust block registry over stored subblock type
1 parent e3f9deb commit 363c320

14 files changed

Lines changed: 318 additions & 16 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,12 @@ export async function copyWorkflowStateIntoTarget(
451451
clearUnmapped: true,
452452
canonicalModes: activeCanonicalModes,
453453
})
454-
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
454+
subBlocks = remapConditionIdsInSubBlocks(
455+
subBlocks,
456+
block.type,
457+
oldBlockId,
458+
newBlockId
459+
) as SubBlockRecord
455460

456461
// Apply the stored dependent values for this block (the modal's mapping). The reference
457462
// transform already cleared the source's dependent values when their parent was remapped,

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { describe, expect, it, vi } from 'vitest'
55
import {
6+
applyTriggerConfigToBlockSubblocks,
67
createBlockFromParams,
78
normalizeSubblockValue,
89
} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders'
@@ -33,14 +34,27 @@ const knowledgeBlockConfig = {
3334
],
3435
}
3536

37+
const slackBlockConfig = {
38+
type: 'slack',
39+
name: 'Slack',
40+
outputs: {},
41+
subBlocks: [{ id: 'channel', type: 'channel-selector' }],
42+
}
43+
3644
const blocksByType: Record<string, unknown> = {
3745
agent: agentBlockConfig,
3846
condition: conditionBlockConfig,
3947
knowledge: knowledgeBlockConfig,
48+
slack: slackBlockConfig,
4049
}
4150

4251
vi.mock('@/blocks/registry', () => ({
43-
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig, knowledgeBlockConfig],
52+
getAllBlocks: () => [
53+
agentBlockConfig,
54+
conditionBlockConfig,
55+
knowledgeBlockConfig,
56+
slackBlockConfig,
57+
],
4458
getBlock: (type: string) => blocksByType[type],
4559
}))
4660

@@ -168,3 +182,40 @@ describe('normalizeSubblockValue', () => {
168182
expect(JSON.parse(result as string)[0].id).not.toBe('filter-1')
169183
})
170184
})
185+
186+
describe('applyTriggerConfigToBlockSubblocks', () => {
187+
it('uses the registry type for declared keys and short-input only for undeclared keys', () => {
188+
const block = { id: 'b1', type: 'slack', subBlocks: {} as Record<string, unknown> }
189+
190+
applyTriggerConfigToBlockSubblocks(block, { channel: 'C123', customField: 'x' })
191+
192+
expect(block.subBlocks.channel).toEqual({
193+
id: 'channel',
194+
type: 'channel-selector',
195+
value: 'C123',
196+
})
197+
expect(block.subBlocks.customField).toEqual({
198+
id: 'customField',
199+
type: 'short-input',
200+
value: 'x',
201+
})
202+
})
203+
204+
it('keeps the existing entry metadata when the key already exists', () => {
205+
const block = {
206+
id: 'b1',
207+
type: 'slack',
208+
subBlocks: {
209+
channel: { id: 'channel', type: 'channel-selector', value: 'C-old' },
210+
} as Record<string, { id: string; type: string; value: unknown }>,
211+
}
212+
213+
applyTriggerConfigToBlockSubblocks(block, { channel: 'C-new' })
214+
215+
expect(block.subBlocks.channel).toEqual({
216+
id: 'channel',
217+
type: 'channel-selector',
218+
value: 'C-new',
219+
})
220+
})
221+
})

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
isCanonicalPair,
1010
} from '@/lib/workflows/subblocks/visibility'
1111
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
12-
import { getAllBlocks } from '@/blocks/registry'
12+
import { getAllBlocks, getBlock } from '@/blocks/registry'
1313
import type { BlockConfig } from '@/blocks/types'
1414
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
1515
import type { EditWorkflowOperation, SkippedItem, ValidationError } from './types'
@@ -624,9 +624,14 @@ export function applyTriggerConfigToBlockSubblocks(block: any, triggerConfig: Re
624624
value: configValue,
625625
}
626626
} else {
627+
// The registry type is authoritative for declared keys; `short-input` is
628+
// only the keep-alive default for dynamic trigger-config keys the block
629+
// config does not declare (an `unknown` type would be dropped on the next
630+
// sanitize pass, losing the value).
631+
const subBlockDef = getBlock(block.type)?.subBlocks.find((sb) => sb.id === configKey)
627632
block.subBlocks[configKey] = {
628633
id: configKey,
629-
type: 'short-input',
634+
type: subBlockDef?.type || 'short-input',
630635
value: configValue,
631636
}
632637
}

apps/sim/lib/workflows/persistence/duplicate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ export async function duplicateWorkflow(
326326
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
327327
updatedSubBlocks = remapConditionIdsInSubBlocks(
328328
updatedSubBlocks as Record<string, any>,
329+
block.type,
329330
block.id,
330331
newBlockId
331332
)

apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
56
import {
67
coerceObjectArray,
8+
remapConditionIdsInSubBlocks,
79
remapWorkflowReferencesInSubBlocks,
810
type SubBlockRecord,
911
} from '@/lib/workflows/persistence/remap-internal-ids'
@@ -378,6 +380,77 @@ describe('remapWorkflowReferencesInSubBlocks', () => {
378380
})
379381
})
380382

383+
describe('remapConditionIdsInSubBlocks', () => {
384+
const OLD_ID = 'old-block'
385+
const NEW_ID = 'new-block'
386+
const conditionsValue = JSON.stringify([
387+
{ id: `${OLD_ID}-if`, title: 'if', value: '<a.b> > 1' },
388+
{ id: `${OLD_ID}-else`, title: 'else', value: '' },
389+
])
390+
391+
it('remaps condition row ids on a condition block', () => {
392+
const subBlocks: SubBlockRecord = {
393+
conditions: { id: 'conditions', type: 'condition-input', value: conditionsValue },
394+
}
395+
const result = remapConditionIdsInSubBlocks(subBlocks, 'condition', OLD_ID, NEW_ID)
396+
const rows = JSON.parse(result.conditions.value as string)
397+
expect(rows.map((row: { id: string }) => row.id)).toEqual([`${NEW_ID}-if`, `${NEW_ID}-else`])
398+
})
399+
400+
/**
401+
* Regression: a fallback writer stamped the conditions subblock `short-input`.
402+
* The remap must key on block type + subblock key, not the drifted stored type,
403+
* so the row ids and the edge handle move together (previously the ids stayed
404+
* stale while the handle remapped, orphaning every edge out of the block).
405+
*/
406+
it('remaps condition row ids even when the stored subblock type drifted', () => {
407+
const subBlocks: SubBlockRecord = {
408+
conditions: { id: 'conditions', type: 'short-input', value: conditionsValue },
409+
}
410+
const result = remapConditionIdsInSubBlocks(subBlocks, 'condition', OLD_ID, NEW_ID)
411+
const rows = JSON.parse(result.conditions.value as string)
412+
const handle = remapConditionEdgeHandle(`condition-${OLD_ID}-else`, OLD_ID, NEW_ID)
413+
expect(handle).toBe(`condition-${NEW_ID}-else`)
414+
expect(rows.map((row: { id: string }) => row.id)).toContain(`${NEW_ID}-else`)
415+
})
416+
417+
it('remaps route ids on a router_v2 block', () => {
418+
const subBlocks: SubBlockRecord = {
419+
routes: {
420+
id: 'routes',
421+
type: 'router-input',
422+
value: JSON.stringify([{ id: `${OLD_ID}-route1`, title: 'Route 1', value: 'desc' }]),
423+
},
424+
}
425+
const result = remapConditionIdsInSubBlocks(subBlocks, 'router_v2', OLD_ID, NEW_ID)
426+
const rows = JSON.parse(result.routes.value as string)
427+
expect(rows[0].id).toBe(`${NEW_ID}-route1`)
428+
})
429+
430+
it('leaves rows with a foreign block-id prefix untouched (matches the edge-handle remap)', () => {
431+
const subBlocks: SubBlockRecord = {
432+
conditions: {
433+
id: 'conditions',
434+
type: 'condition-input',
435+
value: JSON.stringify([{ id: 'foreign-block-if', title: 'if', value: '' }]),
436+
},
437+
}
438+
const result = remapConditionIdsInSubBlocks(subBlocks, 'condition', OLD_ID, NEW_ID)
439+
expect(result.conditions).toBe(subBlocks.conditions)
440+
expect(remapConditionEdgeHandle('condition-foreign-block-if', OLD_ID, NEW_ID)).toBe(
441+
'condition-foreign-block-if'
442+
)
443+
})
444+
445+
it('does not touch subblocks on non-dynamic-handle block types', () => {
446+
const subBlocks: SubBlockRecord = {
447+
conditions: { id: 'conditions', type: 'condition-input', value: conditionsValue },
448+
}
449+
const result = remapConditionIdsInSubBlocks(subBlocks, 'function', OLD_ID, NEW_ID)
450+
expect(result.conditions).toBe(subBlocks.conditions)
451+
})
452+
})
453+
381454
describe('coerceObjectArray', () => {
382455
it('returns arrays directly', () => {
383456
expect(coerceObjectArray([{ a: 1 }])).toEqual({ array: [{ a: 1 }], wasString: false })

apps/sim/lib/workflows/persistence/remap-internal-ids.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
3+
import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology'
34
import {
45
type CanonicalModeOverrides,
56
resolveCanonicalMode,
@@ -321,9 +322,16 @@ function remapWorkflowInputTools(
321322
/**
322323
* Remap condition/router block IDs within subBlocks when a block is copied with
323324
* a new ID. Returns a new object without mutating the input.
325+
*
326+
* Gated on the BLOCK type + canonical subblock key (`conditions`/`routes`), not
327+
* the stored subblock `type`: edge handles are remapped by string prefix with no
328+
* type gate, so keying this side on mutable stored metadata lets a drifted type
329+
* (e.g. a `conditions` entry stamped `short-input` by a fallback writer) skip the
330+
* id remap while the handles still move — orphaning every edge out of the block.
324331
*/
325332
export function remapConditionIdsInSubBlocks(
326333
subBlocks: SubBlockRecord,
334+
blockType: string | undefined,
327335
oldBlockId: string,
328336
newBlockId: string
329337
): SubBlockRecord {
@@ -332,7 +340,7 @@ export function remapConditionIdsInSubBlocks(
332340
if (
333341
subBlock &&
334342
typeof subBlock === 'object' &&
335-
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
343+
isDynamicHandleSubblock(blockType, key) &&
336344
typeof subBlock.value === 'string'
337345
) {
338346
try {

apps/sim/lib/workflows/persistence/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { LRUCache } from 'lru-cache'
2323
import type { Edge } from 'reactflow'
2424
import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims'
2525
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
26+
import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology'
2627
import {
2728
backfillCanonicalModes,
2829
migrateSubblockIds,
@@ -717,7 +718,7 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
717718
}
718719

719720
if (
720-
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
721+
isDynamicHandleSubblock(block.type, subId) &&
721722
typeof updatedSubBlock.value === 'string'
722723
) {
723724
try {

apps/sim/lib/workflows/sanitization/subblocks.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,59 @@ describe('sanitizeMalformedSubBlocks', () => {
133133
expect(changed).toBe(true)
134134
expect(subBlocks.code).toEqual({ id: 'code', type: 'code', value: 'return 1' })
135135
})
136+
137+
it('repairs a stored type that contradicts the configured type', () => {
138+
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
139+
id: 'block-1',
140+
type: 'function',
141+
subBlocks: {
142+
code: { id: 'code', type: 'short-input', value: 'return 1' },
143+
},
144+
})
145+
146+
expect(changed).toBe(true)
147+
expect(subBlocks.code).toEqual({ id: 'code', type: 'code', value: 'return 1' })
148+
})
149+
150+
/**
151+
* Regression: a fallback writer stamped a condition block's `conditions`
152+
* subblock `short-input`. Copy-time id remapping used to gate on that stored
153+
* type, skipping the conditions array while edge handles still remapped —
154+
* orphaning every edge out of the block on fork/duplicate/import.
155+
*/
156+
it('repairs a condition block conditions subblock stamped short-input by a fallback writer', () => {
157+
const conditionsValue = JSON.stringify([
158+
{ id: 'block-1-if', title: 'if', value: '<a.b>' },
159+
{ id: 'block-1-else', title: 'else', value: '' },
160+
])
161+
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
162+
id: 'block-1',
163+
type: 'condition',
164+
subBlocks: {
165+
conditions: { id: 'conditions', type: 'short-input', value: conditionsValue },
166+
},
167+
})
168+
169+
expect(changed).toBe(true)
170+
expect(subBlocks.conditions).toEqual({
171+
id: 'conditions',
172+
type: 'condition-input',
173+
value: conditionsValue,
174+
})
175+
})
176+
177+
it('preserves a valid stored type for keys the config does not declare (runtime values)', () => {
178+
const input = {
179+
webhookId: { id: 'webhookId', type: 'short-input', value: 'wh-1' },
180+
}
181+
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
182+
id: 'block-1',
183+
type: 'function',
184+
subBlocks: input,
185+
})
186+
187+
expect(changed).toBe(false)
188+
expect(subBlocks).toBe(input)
189+
})
136190
})
137191
})

apps/sim/lib/workflows/sanitization/subblocks.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ interface SanitizableBlock {
2121
* Repairs legacy subBlock metadata when the map key identifies a real field,
2222
* and drops entries that cannot be associated with a stable subBlock.
2323
*
24+
* For keys the block registry declares, the CONFIGURED type is authoritative
25+
* and overwrites a contradicting stored type. Stored types drift in two ways:
26+
* fallback writers stamp a plausible-but-wrong default (`short-input`) when
27+
* they synthesize a missing structure entry, and block configs evolve their
28+
* declared types over time while persisted rows keep the old one. A wrong
29+
* stored type silently disables type-gated logic downstream — most damaging
30+
* for `condition-input`/`router-input`, where copy-time id remapping skips
31+
* the conditions array while edge handles still remap, orphaning the edges.
32+
* Draft loads persist this repair via `persistMigratedBlocks`, so stored
33+
* state converges back to the registry.
34+
*
2435
* Custom blocks are schema-agnostic here: their server-side config never
2536
* declares the per-field input sub-blocks (the execution overlay passes bare
2637
* wiring rows, and this may run with no overlay at all), so "not in config"
@@ -95,10 +106,11 @@ export function sanitizeMalformedSubBlocks(
95106
continue
96107
}
97108

98-
const type =
109+
const storedType =
99110
typeof subBlock.type === 'string' && subBlock.type.length > 0 && subBlock.type !== 'unknown'
100111
? subBlock.type
101-
: typeFromConfig || DEFAULT_SUBBLOCK_TYPE
112+
: null
113+
const type = typeFromConfig ?? storedType ?? DEFAULT_SUBBLOCK_TYPE
102114
const hasValue = Object.hasOwn(subBlock, 'value')
103115
const value =
104116
options.convertEmptyStringToNull && subBlock.value === ''
@@ -111,7 +123,12 @@ export function sanitizeMalformedSubBlocks(
111123
const normalizedValue = hasValue && value !== subBlock.value
112124

113125
if (repairedMetadata) {
114-
logger.warn('Repairing malformed subBlock metadata', { blockId: block.id, subBlockId })
126+
logger.warn('Repairing malformed subBlock metadata', {
127+
blockId: block.id,
128+
subBlockId,
129+
storedType: subBlock.type,
130+
repairedType: type,
131+
})
115132
changed = true
116133
} else if (normalizedValue) {
117134
logger.warn('Normalizing malformed subBlock value', { blockId: block.id, subBlockId })

0 commit comments

Comments
 (0)