Skip to content

Commit cac152f

Browse files
committed
improvment(executor): reserved keyword errors
1 parent 690b7ab commit cac152f

3 files changed

Lines changed: 213 additions & 9 deletions

File tree

apps/sim/executor/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ export interface NormalizedBlockOutput {
213213
_pauseMetadata?: PauseMetadata
214214
}
215215

216+
export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [
217+
'error',
218+
'selectedOption',
219+
'selectedRoute',
220+
'_pauseMetadata',
221+
] as const
222+
223+
export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number]
224+
216225
export interface BlockLog {
217226
blockId: string
218227
blockName?: string

apps/sim/executor/utils/start-block.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,102 @@ describe('start-block utilities', () => {
119119
expect(output.files).toEqual(files)
120120
})
121121

122+
it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => {
123+
const block = createBlock('start_trigger', 'start', {
124+
subBlocks: {
125+
inputFormat: {
126+
value: [
127+
{ name: 'error', type: 'string' },
128+
{ name: 'error', type: 'string' },
129+
{ name: ' selectedOption ', type: 'string' },
130+
{ name: 'selectedRoute', type: 'string' },
131+
{ name: '_pauseMetadata', type: 'object' },
132+
],
133+
},
134+
},
135+
})
136+
137+
const resolution = {
138+
blockId: 'start',
139+
block,
140+
path: StartBlockPath.UNIFIED,
141+
} as const
142+
143+
expect(() =>
144+
buildStartBlockOutput({
145+
resolution,
146+
workflowInput: { error: false, selectedRoute: 'source' },
147+
})
148+
).toThrow(
149+
'Start block "block-start_trigger" cannot use reserved input format field name(s): error, selectedOption, selectedRoute, _pauseMetadata'
150+
)
151+
})
152+
153+
it.concurrent(
154+
'rejects reserved top-level runtime input keys copied to unified Start output',
155+
() => {
156+
const block = createBlock('start_trigger', 'start')
157+
const resolution = {
158+
blockId: 'start',
159+
block,
160+
path: StartBlockPath.UNIFIED,
161+
} as const
162+
163+
expect(() =>
164+
buildStartBlockOutput({
165+
resolution,
166+
workflowInput: { error: 'false', payload: 'value' },
167+
})
168+
).toThrow(
169+
'Start block "block-start_trigger" cannot use reserved runtime input field name(s): error'
170+
)
171+
}
172+
)
173+
174+
it.concurrent('rejects reserved nested API input keys copied to trigger output', () => {
175+
const block = createBlock('api_trigger', 'api')
176+
const resolution = {
177+
blockId: 'api',
178+
block,
179+
path: StartBlockPath.SPLIT_API,
180+
} as const
181+
182+
expect(() =>
183+
buildStartBlockOutput({
184+
resolution,
185+
workflowInput: { input: { selectedRoute: 'route-1', payload: 'value' } },
186+
})
187+
).toThrow(
188+
'Start block "block-api_trigger" cannot use reserved runtime input field name(s): selectedRoute'
189+
)
190+
})
191+
192+
it.concurrent('ignores malformed non-string inputFormat field names', () => {
193+
const block = createBlock('start_trigger', 'start', {
194+
subBlocks: {
195+
inputFormat: {
196+
value: [
197+
{ name: 123, type: 'string', value: 'ignored' },
198+
{ name: 'customField', type: 'string' },
199+
],
200+
},
201+
},
202+
})
203+
const resolution = {
204+
blockId: 'start',
205+
block,
206+
path: StartBlockPath.UNIFIED,
207+
} as const
208+
209+
const output = buildStartBlockOutput({
210+
resolution,
211+
workflowInput: { customField: 'value' },
212+
})
213+
214+
expect(output.customField).toBe('value')
215+
expect(output[123]).toBeUndefined()
216+
})
217+
122218
describe('inputFormat default values', () => {
123219
it.concurrent('uses default value when runtime does not provide the field', () => {
124220
const block = createBlock('start_trigger', 'start', {
@@ -294,6 +390,24 @@ describe('start-block utilities', () => {
294390
})
295391

296392
describe('EXTERNAL_TRIGGER path', () => {
393+
it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => {
394+
const block = createBlock('webhook', 'start')
395+
const resolution = {
396+
blockId: 'start',
397+
block,
398+
path: StartBlockPath.EXTERNAL_TRIGGER,
399+
} as const
400+
401+
expect(() =>
402+
buildStartBlockOutput({
403+
resolution,
404+
workflowInput: { _pauseMetadata: { contextId: 'fake-pause' }, payload: 'value' },
405+
})
406+
).toThrow(
407+
'Start block "block-webhook" cannot use reserved runtime input field name(s): _pauseMetadata'
408+
)
409+
})
410+
297411
it.concurrent('preserves coerced types for integration trigger payload', () => {
298412
const block = createBlock('webhook', 'start', {
299413
subBlocks: {

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

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ import {
66
StartBlockPath,
77
} from '@/lib/workflows/triggers/triggers'
88
import type { InputFormatField } from '@/lib/workflows/types'
9-
import type { NormalizedBlockOutput, UserFile } from '@/executor/types'
9+
import {
10+
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES,
11+
type NormalizedBlockOutput,
12+
type UserFile,
13+
} from '@/executor/types'
1014
import type { SerializedBlock } from '@/serializer/types'
1115
import { safeAssign } from '@/tools/safe-assign'
1216

1317
type ExecutionKind = 'chat' | 'manual' | 'api' | 'external'
1418

19+
const EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET = new Set<string>(
20+
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES
21+
)
22+
1523
export interface ExecutorStartResolution {
1624
blockId: string
1725
block: SerializedBlock
@@ -133,6 +141,66 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] {
133141
.map((field) => field)
134142
}
135143

144+
function readInputFormatFieldName(field: InputFormatField): string | undefined {
145+
return typeof field.name === 'string' ? field.name.trim() : undefined
146+
}
147+
148+
function collectExecutionControlFieldNames(fieldNames: Iterable<string | undefined>): string[] {
149+
const reservedFieldNames: string[] = []
150+
151+
for (const fieldName of fieldNames) {
152+
if (!fieldName || !EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) {
153+
continue
154+
}
155+
156+
if (!reservedFieldNames.includes(fieldName)) {
157+
reservedFieldNames.push(fieldName)
158+
}
159+
}
160+
161+
return reservedFieldNames
162+
}
163+
164+
function throwReservedStartOutputFieldsError(
165+
block: SerializedBlock,
166+
reservedFieldNames: string[],
167+
source: 'input format' | 'runtime input'
168+
): never {
169+
const blockName = block.metadata?.name ?? block.id
170+
171+
throw new Error(
172+
`Start block "${blockName}" cannot use reserved ${source} field name(s): ${reservedFieldNames.join(', ')}. These names control workflow execution and cannot be used as Start outputs. Rename these fields before running the workflow. Reserved names are: ${EXECUTION_CONTROL_OUTPUT_FIELD_NAMES.join(', ')}.`
173+
)
174+
}
175+
176+
function assertNoReservedInputFormatFields(
177+
inputFormat: InputFormatField[],
178+
block: SerializedBlock
179+
): void {
180+
const reservedFieldNames = collectExecutionControlFieldNames(
181+
inputFormat.map(readInputFormatFieldName)
182+
)
183+
184+
if (reservedFieldNames.length === 0) {
185+
return
186+
}
187+
188+
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'input format')
189+
}
190+
191+
function assertNoReservedStartOutputFields(
192+
output: NormalizedBlockOutput,
193+
block: SerializedBlock
194+
): void {
195+
const reservedFieldNames = collectExecutionControlFieldNames(Object.keys(output))
196+
197+
if (reservedFieldNames.length === 0) {
198+
return
199+
}
200+
201+
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input')
202+
}
203+
136204
export function coerceValue(type: string | null | undefined, value: unknown): unknown {
137205
if (value === undefined || value === null) {
138206
return value
@@ -190,7 +258,7 @@ function deriveInputFromFormat(
190258
}
191259

192260
for (const field of inputFormat) {
193-
const fieldName = field.name?.trim()
261+
const fieldName = readInputFormatFieldName(field)
194262
if (!fieldName) continue
195263

196264
let fieldValue: unknown
@@ -436,35 +504,48 @@ export interface StartBlockOutputOptions {
436504
export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput {
437505
const { resolution, workflowInput } = options
438506
const inputFormat = extractInputFormat(resolution.block)
507+
assertNoReservedInputFormatFields(inputFormat, resolution.block)
439508
const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat(
440509
inputFormat,
441510
workflowInput
442511
)
443512

513+
let output: NormalizedBlockOutput
514+
444515
switch (resolution.path) {
445516
case StartBlockPath.UNIFIED:
446-
return buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
517+
output = buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
518+
break
447519

448520
case StartBlockPath.SPLIT_API:
449521
case StartBlockPath.SPLIT_INPUT:
450-
return buildApiOrInputOutput(finalInput, workflowInput)
522+
output = buildApiOrInputOutput(finalInput, workflowInput)
523+
break
451524

452525
case StartBlockPath.SPLIT_CHAT:
453-
return buildChatOutput(workflowInput)
526+
output = buildChatOutput(workflowInput)
527+
break
454528

455529
case StartBlockPath.SPLIT_MANUAL:
456-
return buildManualTriggerOutput(finalInput, workflowInput)
530+
output = buildManualTriggerOutput(finalInput, workflowInput)
531+
break
457532

458533
case StartBlockPath.EXTERNAL_TRIGGER:
459-
return buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
534+
output = buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
535+
break
460536

461537
case StartBlockPath.LEGACY_STARTER:
462-
return buildLegacyStarterOutput(
538+
output = buildLegacyStarterOutput(
463539
finalInput,
464540
workflowInput,
465541
getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) })
466542
)
543+
break
544+
467545
default:
468-
return buildManualTriggerOutput(finalInput, workflowInput)
546+
output = buildManualTriggerOutput(finalInput, workflowInput)
469547
}
548+
549+
assertNoReservedStartOutputFields(output, resolution.block)
550+
return output
470551
}

0 commit comments

Comments
 (0)