Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/sim/executor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 2 additions & 6 deletions apps/sim/executor/execution/block-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)) {
Expand Down
12 changes: 2 additions & 10 deletions apps/sim/executor/handlers/trigger/trigger-handler.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
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'

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(
Expand Down
63 changes: 56 additions & 7 deletions apps/sim/executor/utils/block-data.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
}
Expand Down
73 changes: 65 additions & 8 deletions apps/sim/lib/workflows/comparison/compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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 },
],
},
},
Expand All @@ -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: {
Expand Down Expand Up @@ -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 },
],
},
},
Expand All @@ -1738,7 +1765,7 @@ describe('hasWorkflowChanged', () => {
id: 'field1',
name: 'Name',
type: 'string',
value: 'typed then cleared',
value: 'test',
collapsed: true,
},
],
Expand All @@ -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: {
Expand Down
9 changes: 5 additions & 4 deletions apps/sim/lib/workflows/comparison/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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',
Expand All @@ -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 },
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/workflows/comparison/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,18 @@ 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
*/
export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<string, unknown>[] {
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>
Expand Down