fix(input-format): resolution for blocks with input format fields (#3012)

* fix input format

* fix tests

* address bugbot comment
This commit is contained in:
Vikhyath Mondreti
2026-01-26 16:04:19 -08:00
committed by GitHub
parent 3ccbee187d
commit 3cc9b1ae56
7 changed files with 153 additions and 38 deletions

View File

@@ -275,6 +275,26 @@ export function isTriggerBlockType(blockType: string | undefined): boolean {
return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType)
}
/**
* Determines if a block behaves as a trigger based on its metadata and config.
* This is used for execution flow decisions where trigger-like behavior matters.
*
* A block is considered trigger-like if:
* - Its category is 'triggers'
* - It has triggerMode enabled
* - It's a starter block (legacy entry point)
*/
export function isTriggerBehavior(block: {
metadata?: { category?: string; id?: string }
config?: { params?: { triggerMode?: boolean } }
}): boolean {
return (
block.metadata?.category === 'triggers' ||
block.config?.params?.triggerMode === true ||
block.metadata?.id === BlockType.STARTER
)
}
export function isMetadataOnlyBlockType(blockType: string | undefined): boolean {
return (
blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType)

View File

@@ -11,6 +11,7 @@ import {
DEFAULTS,
EDGE,
isSentinelBlockType,
isTriggerBehavior,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
@@ -346,12 +347,7 @@ export class BlockExecutor {
return filtered
}
const isTrigger =
block.metadata?.category === 'triggers' ||
block.config?.params?.triggerMode === true ||
block.metadata?.id === BlockType.STARTER
if (isTrigger) {
if (isTriggerBehavior(block)) {
const filtered: NormalizedBlockOutput = {}
const internalKeys = ['webhook', 'workflowId']
for (const [key, value] of Object.entries(output)) {

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { BlockType } from '@/executor/constants'
import { BlockType, isTriggerBehavior } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
@@ -7,15 +7,7 @@ const logger = createLogger('TriggerBlockHandler')
export class TriggerBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
if (block.metadata?.id === BlockType.STARTER) {
return true
}
const isTriggerCategory = block.metadata?.category === 'triggers'
const hasTriggerMode = block.config?.params?.triggerMode === true
return isTriggerCategory || hasTriggerMode
return isTriggerBehavior(block)
}
async execute(

View File

@@ -1,4 +1,5 @@
import { normalizeName } from '@/executor/constants'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
import type { SerializedBlock } from '@/serializer/types'
@@ -11,25 +12,73 @@ export interface BlockDataCollection {
blockOutputSchemas: Record<string, OutputSchema>
}
/**
* Block types where inputFormat fields should be merged into outputs schema.
* These are blocks where users define custom fields via inputFormat that become
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
*
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
* have category 'blocks' but still need their inputFormat exposed as outputs.
*/
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
'start_trigger',
'starter',
'api_trigger',
'input_trigger',
'generic_webhook',
'human_in_the_loop',
] as const
function getInputFormatFields(block: SerializedBlock): OutputSchema {
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
if (inputFormat.length === 0) {
return {}
}
const schema: OutputSchema = {}
for (const field of inputFormat) {
if (!field.name) continue
schema[field.name] = {
type: (field.type || 'any') as 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any',
}
}
return schema
}
export function getBlockSchema(
block: SerializedBlock,
toolConfig?: ToolConfig
): OutputSchema | undefined {
const isTrigger =
block.metadata?.category === 'triggers' ||
(block.config?.params as Record<string, unknown> | undefined)?.triggerMode === true
const blockType = block.metadata?.id
// For blocks that expose inputFormat as outputs, always merge them
// This includes both triggers (start_trigger, generic_webhook) and
// non-triggers (starter, human_in_the_loop) that have inputFormat
if (
blockType &&
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
)
) {
const baseOutputs = (block.outputs as OutputSchema) || {}
const inputFormatFields = getInputFormatFields(block)
const merged = { ...baseOutputs, ...inputFormatFields }
if (Object.keys(merged).length > 0) {
return merged
}
}
const isTrigger = isTriggerBehavior(block)
// Triggers use saved outputs (defines the trigger payload schema)
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
// When a tool is selected, tool outputs are the source of truth
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
return toolConfig.outputs as OutputSchema
}
// Fallback to saved outputs for blocks without tools
if (block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}

View File

@@ -557,7 +557,8 @@ describe('hasWorkflowChanged', () => {
})
describe('InputFormat SubBlock Special Handling', () => {
it.concurrent('should ignore value and collapsed fields in inputFormat', () => {
it.concurrent('should ignore collapsed field but detect value changes in inputFormat', () => {
// Only collapsed changes - should NOT detect as change
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -578,8 +579,8 @@ describe('hasWorkflowChanged', () => {
subBlocks: {
inputFormat: {
value: [
{ id: 'input1', name: 'Name', value: 'Jane', collapsed: false },
{ id: 'input2', name: 'Age', value: 30, collapsed: true },
{ id: 'input1', name: 'Name', value: 'John', collapsed: false },
{ id: 'input2', name: 'Age', value: 25, collapsed: true },
],
},
},
@@ -589,6 +590,32 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
it.concurrent('should detect value changes in inputFormat', () => {
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'input1', name: 'Name', value: 'John' }],
},
},
}),
},
})
const state2 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'input1', name: 'Name', value: 'Jane' }],
},
},
}),
},
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should detect actual inputFormat changes', () => {
const state1 = createWorkflowState({
blocks: {
@@ -1712,15 +1739,15 @@ describe('hasWorkflowChanged', () => {
})
describe('Input Format Field Scenarios', () => {
it.concurrent('should not detect change when inputFormat value is typed and cleared', () => {
// The "value" field in inputFormat is UI-only and should be ignored
it.concurrent('should not detect change when only inputFormat collapsed changes', () => {
// The "collapsed" field in inputFormat is UI-only and should be ignored
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [
{ id: 'field1', name: 'Name', type: 'string', value: '', collapsed: false },
{ id: 'field1', name: 'Name', type: 'string', value: 'test', collapsed: false },
],
},
},
@@ -1738,7 +1765,7 @@ describe('hasWorkflowChanged', () => {
id: 'field1',
name: 'Name',
type: 'string',
value: 'typed then cleared',
value: 'test',
collapsed: true,
},
],
@@ -1748,10 +1775,40 @@ describe('hasWorkflowChanged', () => {
},
})
// value and collapsed are UI-only fields - should NOT detect as change
// collapsed is UI-only field - should NOT detect as change
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should detect change when inputFormat value changes', () => {
// The "value" field in inputFormat is meaningful and should trigger change detection
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'field1', name: 'Name', type: 'string', value: '' }],
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'field1', name: 'Name', type: 'string', value: 'new value' }],
},
},
}),
},
})
// value changes should be detected
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
})
it.concurrent('should detect change when inputFormat field name changes', () => {
const deployedState = createWorkflowState({
blocks: {

View File

@@ -370,7 +370,7 @@ describe('Workflow Normalization Utilities', () => {
expect(sanitizeInputFormat({} as any)).toEqual([])
})
it.concurrent('should remove value and collapsed fields', () => {
it.concurrent('should remove collapsed field but keep value', () => {
const inputFormat = [
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
@@ -379,13 +379,13 @@ describe('Workflow Normalization Utilities', () => {
const result = sanitizeInputFormat(inputFormat)
expect(result).toEqual([
{ id: 'input1', name: 'Name' },
{ id: 'input2', name: 'Age' },
{ id: 'input1', name: 'Name', value: 'John' },
{ id: 'input2', name: 'Age', value: 25 },
{ id: 'input3', name: 'Email' },
])
})
it.concurrent('should preserve all other fields', () => {
it.concurrent('should preserve all other fields including value', () => {
const inputFormat = [
{
id: 'input1',
@@ -402,6 +402,7 @@ describe('Workflow Normalization Utilities', () => {
expect(result[0]).toEqual({
id: 'input1',
name: 'Complex Input',
value: 'test-value',
type: 'string',
required: true,
validation: { min: 0, max: 100 },

View File

@@ -156,10 +156,10 @@ export function normalizeVariables(variables: unknown): Record<string, Variable>
}
/** Input format item with optional UI-only fields */
type InputFormatItem = Record<string, unknown> & { value?: unknown; collapsed?: boolean }
type InputFormatItem = Record<string, unknown> & { collapsed?: boolean }
/**
* Sanitizes inputFormat array by removing UI-only fields like value and collapsed
* Sanitizes inputFormat array by removing UI-only fields like collapsed
* @param inputFormat - Array of input format configurations
* @returns Sanitized input format array
*/
@@ -167,7 +167,7 @@ export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<
if (!Array.isArray(inputFormat)) return []
return inputFormat.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const { value, collapsed, ...rest } = item as InputFormatItem
const { collapsed, ...rest } = item as InputFormatItem
return rest
}
return item as Record<string, unknown>