Skip to content

Commit 3cc9b1a

Browse files
fix(input-format): resolution for blocks with input format fields (#3012)
* fix input format * fix tests * address bugbot comment
1 parent 3ccbee1 commit 3cc9b1a

File tree

7 files changed

+153
-38
lines changed

7 files changed

+153
-38
lines changed

apps/sim/executor/constants.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,26 @@ export function isTriggerBlockType(blockType: string | undefined): boolean {
275275
return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType)
276276
}
277277

278+
/**
279+
* Determines if a block behaves as a trigger based on its metadata and config.
280+
* This is used for execution flow decisions where trigger-like behavior matters.
281+
*
282+
* A block is considered trigger-like if:
283+
* - Its category is 'triggers'
284+
* - It has triggerMode enabled
285+
* - It's a starter block (legacy entry point)
286+
*/
287+
export function isTriggerBehavior(block: {
288+
metadata?: { category?: string; id?: string }
289+
config?: { params?: { triggerMode?: boolean } }
290+
}): boolean {
291+
return (
292+
block.metadata?.category === 'triggers' ||
293+
block.config?.params?.triggerMode === true ||
294+
block.metadata?.id === BlockType.STARTER
295+
)
296+
}
297+
278298
export function isMetadataOnlyBlockType(blockType: string | undefined): boolean {
279299
return (
280300
blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType)

apps/sim/executor/execution/block-executor.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
DEFAULTS,
1212
EDGE,
1313
isSentinelBlockType,
14+
isTriggerBehavior,
1415
} from '@/executor/constants'
1516
import type { DAGNode } from '@/executor/dag/builder'
1617
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
@@ -346,12 +347,7 @@ export class BlockExecutor {
346347
return filtered
347348
}
348349

349-
const isTrigger =
350-
block.metadata?.category === 'triggers' ||
351-
block.config?.params?.triggerMode === true ||
352-
block.metadata?.id === BlockType.STARTER
353-
354-
if (isTrigger) {
350+
if (isTriggerBehavior(block)) {
355351
const filtered: NormalizedBlockOutput = {}
356352
const internalKeys = ['webhook', 'workflowId']
357353
for (const [key, value] of Object.entries(output)) {

apps/sim/executor/handlers/trigger/trigger-handler.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import { createLogger } from '@sim/logger'
2-
import { BlockType } from '@/executor/constants'
2+
import { BlockType, isTriggerBehavior } from '@/executor/constants'
33
import type { BlockHandler, ExecutionContext } from '@/executor/types'
44
import type { SerializedBlock } from '@/serializer/types'
55

66
const logger = createLogger('TriggerBlockHandler')
77

88
export class TriggerBlockHandler implements BlockHandler {
99
canHandle(block: SerializedBlock): boolean {
10-
if (block.metadata?.id === BlockType.STARTER) {
11-
return true
12-
}
13-
14-
const isTriggerCategory = block.metadata?.category === 'triggers'
15-
16-
const hasTriggerMode = block.config?.params?.triggerMode === true
17-
18-
return isTriggerCategory || hasTriggerMode
10+
return isTriggerBehavior(block)
1911
}
2012

2113
async execute(

apps/sim/executor/utils/block-data.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { normalizeName } from '@/executor/constants'
1+
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
2+
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
23
import type { ExecutionContext } from '@/executor/types'
34
import type { OutputSchema } from '@/executor/utils/block-reference'
45
import type { SerializedBlock } from '@/serializer/types'
@@ -11,25 +12,73 @@ export interface BlockDataCollection {
1112
blockOutputSchemas: Record<string, OutputSchema>
1213
}
1314

15+
/**
16+
* Block types where inputFormat fields should be merged into outputs schema.
17+
* These are blocks where users define custom fields via inputFormat that become
18+
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
19+
*
20+
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
21+
* have category 'blocks' but still need their inputFormat exposed as outputs.
22+
*/
23+
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
24+
'start_trigger',
25+
'starter',
26+
'api_trigger',
27+
'input_trigger',
28+
'generic_webhook',
29+
'human_in_the_loop',
30+
] as const
31+
32+
function getInputFormatFields(block: SerializedBlock): OutputSchema {
33+
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
34+
if (inputFormat.length === 0) {
35+
return {}
36+
}
37+
38+
const schema: OutputSchema = {}
39+
for (const field of inputFormat) {
40+
if (!field.name) continue
41+
schema[field.name] = {
42+
type: (field.type || 'any') as 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any',
43+
}
44+
}
45+
46+
return schema
47+
}
48+
1449
export function getBlockSchema(
1550
block: SerializedBlock,
1651
toolConfig?: ToolConfig
1752
): OutputSchema | undefined {
18-
const isTrigger =
19-
block.metadata?.category === 'triggers' ||
20-
(block.config?.params as Record<string, unknown> | undefined)?.triggerMode === true
53+
const blockType = block.metadata?.id
54+
55+
// For blocks that expose inputFormat as outputs, always merge them
56+
// This includes both triggers (start_trigger, generic_webhook) and
57+
// non-triggers (starter, human_in_the_loop) that have inputFormat
58+
if (
59+
blockType &&
60+
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
61+
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
62+
)
63+
) {
64+
const baseOutputs = (block.outputs as OutputSchema) || {}
65+
const inputFormatFields = getInputFormatFields(block)
66+
const merged = { ...baseOutputs, ...inputFormatFields }
67+
if (Object.keys(merged).length > 0) {
68+
return merged
69+
}
70+
}
71+
72+
const isTrigger = isTriggerBehavior(block)
2173

22-
// Triggers use saved outputs (defines the trigger payload schema)
2374
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
2475
return block.outputs as OutputSchema
2576
}
2677

27-
// When a tool is selected, tool outputs are the source of truth
2878
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
2979
return toolConfig.outputs as OutputSchema
3080
}
3181

32-
// Fallback to saved outputs for blocks without tools
3382
if (block.outputs && Object.keys(block.outputs).length > 0) {
3483
return block.outputs as OutputSchema
3584
}

apps/sim/lib/workflows/comparison/compare.test.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,8 @@ describe('hasWorkflowChanged', () => {
557557
})
558558

559559
describe('InputFormat SubBlock Special Handling', () => {
560-
it.concurrent('should ignore value and collapsed fields in inputFormat', () => {
560+
it.concurrent('should ignore collapsed field but detect value changes in inputFormat', () => {
561+
// Only collapsed changes - should NOT detect as change
561562
const state1 = createWorkflowState({
562563
blocks: {
563564
block1: createBlock('block1', {
@@ -578,8 +579,8 @@ describe('hasWorkflowChanged', () => {
578579
subBlocks: {
579580
inputFormat: {
580581
value: [
581-
{ id: 'input1', name: 'Name', value: 'Jane', collapsed: false },
582-
{ id: 'input2', name: 'Age', value: 30, collapsed: true },
582+
{ id: 'input1', name: 'Name', value: 'John', collapsed: false },
583+
{ id: 'input2', name: 'Age', value: 25, collapsed: true },
583584
],
584585
},
585586
},
@@ -589,6 +590,32 @@ describe('hasWorkflowChanged', () => {
589590
expect(hasWorkflowChanged(state1, state2)).toBe(false)
590591
})
591592

593+
it.concurrent('should detect value changes in inputFormat', () => {
594+
const state1 = createWorkflowState({
595+
blocks: {
596+
block1: createBlock('block1', {
597+
subBlocks: {
598+
inputFormat: {
599+
value: [{ id: 'input1', name: 'Name', value: 'John' }],
600+
},
601+
},
602+
}),
603+
},
604+
})
605+
const state2 = createWorkflowState({
606+
blocks: {
607+
block1: createBlock('block1', {
608+
subBlocks: {
609+
inputFormat: {
610+
value: [{ id: 'input1', name: 'Name', value: 'Jane' }],
611+
},
612+
},
613+
}),
614+
},
615+
})
616+
expect(hasWorkflowChanged(state1, state2)).toBe(true)
617+
})
618+
592619
it.concurrent('should detect actual inputFormat changes', () => {
593620
const state1 = createWorkflowState({
594621
blocks: {
@@ -1712,15 +1739,15 @@ describe('hasWorkflowChanged', () => {
17121739
})
17131740

17141741
describe('Input Format Field Scenarios', () => {
1715-
it.concurrent('should not detect change when inputFormat value is typed and cleared', () => {
1716-
// The "value" field in inputFormat is UI-only and should be ignored
1742+
it.concurrent('should not detect change when only inputFormat collapsed changes', () => {
1743+
// The "collapsed" field in inputFormat is UI-only and should be ignored
17171744
const deployedState = createWorkflowState({
17181745
blocks: {
17191746
block1: createBlock('block1', {
17201747
subBlocks: {
17211748
inputFormat: {
17221749
value: [
1723-
{ id: 'field1', name: 'Name', type: 'string', value: '', collapsed: false },
1750+
{ id: 'field1', name: 'Name', type: 'string', value: 'test', collapsed: false },
17241751
],
17251752
},
17261753
},
@@ -1738,7 +1765,7 @@ describe('hasWorkflowChanged', () => {
17381765
id: 'field1',
17391766
name: 'Name',
17401767
type: 'string',
1741-
value: 'typed then cleared',
1768+
value: 'test',
17421769
collapsed: true,
17431770
},
17441771
],
@@ -1748,10 +1775,40 @@ describe('hasWorkflowChanged', () => {
17481775
},
17491776
})
17501777

1751-
// value and collapsed are UI-only fields - should NOT detect as change
1778+
// collapsed is UI-only field - should NOT detect as change
17521779
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
17531780
})
17541781

1782+
it.concurrent('should detect change when inputFormat value changes', () => {
1783+
// The "value" field in inputFormat is meaningful and should trigger change detection
1784+
const deployedState = createWorkflowState({
1785+
blocks: {
1786+
block1: createBlock('block1', {
1787+
subBlocks: {
1788+
inputFormat: {
1789+
value: [{ id: 'field1', name: 'Name', type: 'string', value: '' }],
1790+
},
1791+
},
1792+
}),
1793+
},
1794+
})
1795+
1796+
const currentState = createWorkflowState({
1797+
blocks: {
1798+
block1: createBlock('block1', {
1799+
subBlocks: {
1800+
inputFormat: {
1801+
value: [{ id: 'field1', name: 'Name', type: 'string', value: 'new value' }],
1802+
},
1803+
},
1804+
}),
1805+
},
1806+
})
1807+
1808+
// value changes should be detected
1809+
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
1810+
})
1811+
17551812
it.concurrent('should detect change when inputFormat field name changes', () => {
17561813
const deployedState = createWorkflowState({
17571814
blocks: {

apps/sim/lib/workflows/comparison/normalize.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ describe('Workflow Normalization Utilities', () => {
370370
expect(sanitizeInputFormat({} as any)).toEqual([])
371371
})
372372

373-
it.concurrent('should remove value and collapsed fields', () => {
373+
it.concurrent('should remove collapsed field but keep value', () => {
374374
const inputFormat = [
375375
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
376376
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
@@ -379,13 +379,13 @@ describe('Workflow Normalization Utilities', () => {
379379
const result = sanitizeInputFormat(inputFormat)
380380

381381
expect(result).toEqual([
382-
{ id: 'input1', name: 'Name' },
383-
{ id: 'input2', name: 'Age' },
382+
{ id: 'input1', name: 'Name', value: 'John' },
383+
{ id: 'input2', name: 'Age', value: 25 },
384384
{ id: 'input3', name: 'Email' },
385385
])
386386
})
387387

388-
it.concurrent('should preserve all other fields', () => {
388+
it.concurrent('should preserve all other fields including value', () => {
389389
const inputFormat = [
390390
{
391391
id: 'input1',
@@ -402,6 +402,7 @@ describe('Workflow Normalization Utilities', () => {
402402
expect(result[0]).toEqual({
403403
id: 'input1',
404404
name: 'Complex Input',
405+
value: 'test-value',
405406
type: 'string',
406407
required: true,
407408
validation: { min: 0, max: 100 },

apps/sim/lib/workflows/comparison/normalize.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,18 @@ export function normalizeVariables(variables: unknown): Record<string, Variable>
156156
}
157157

158158
/** Input format item with optional UI-only fields */
159-
type InputFormatItem = Record<string, unknown> & { value?: unknown; collapsed?: boolean }
159+
type InputFormatItem = Record<string, unknown> & { collapsed?: boolean }
160160

161161
/**
162-
* Sanitizes inputFormat array by removing UI-only fields like value and collapsed
162+
* Sanitizes inputFormat array by removing UI-only fields like collapsed
163163
* @param inputFormat - Array of input format configurations
164164
* @returns Sanitized input format array
165165
*/
166166
export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<string, unknown>[] {
167167
if (!Array.isArray(inputFormat)) return []
168168
return inputFormat.map((item) => {
169169
if (item && typeof item === 'object' && !Array.isArray(item)) {
170-
const { value, collapsed, ...rest } = item as InputFormatItem
170+
const { collapsed, ...rest } = item as InputFormatItem
171171
return rest
172172
}
173173
return item as Record<string, unknown>

0 commit comments

Comments
 (0)