mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 22:55:16 -05:00
fix(input-format): resolution for blocks with input format fields (#3012)
* fix input format * fix tests * address bugbot comment
This commit is contained in:
committed by
GitHub
parent
3ccbee187d
commit
3cc9b1ae56
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user