From cac152fe5ee5edef461cc7eb69a34a387f599e6e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 6 May 2026 15:18:02 -0700 Subject: [PATCH 1/2] improvment(executor): reserved keyword errors --- apps/sim/executor/types.ts | 9 ++ apps/sim/executor/utils/start-block.test.ts | 114 ++++++++++++++++++++ apps/sim/executor/utils/start-block.ts | 99 +++++++++++++++-- 3 files changed, 213 insertions(+), 9 deletions(-) diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 8195f385a77..151a9c96693 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -213,6 +213,15 @@ export interface NormalizedBlockOutput { _pauseMetadata?: PauseMetadata } +export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [ + 'error', + 'selectedOption', + 'selectedRoute', + '_pauseMetadata', +] as const + +export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number] + export interface BlockLog { blockId: string blockName?: string diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 9a3942cbeeb..4cdb0381115 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -119,6 +119,102 @@ describe('start-block utilities', () => { expect(output.files).toEqual(files) }) + it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => { + const block = createBlock('start_trigger', 'start', { + subBlocks: { + inputFormat: { + value: [ + { name: 'error', type: 'string' }, + { name: 'error', type: 'string' }, + { name: ' selectedOption ', type: 'string' }, + { name: 'selectedRoute', type: 'string' }, + { name: '_pauseMetadata', type: 'object' }, + ], + }, + }, + }) + + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: { error: false, selectedRoute: 'source' }, + }) + ).toThrow( + 'Start block "block-start_trigger" cannot use reserved input format field name(s): error, selectedOption, selectedRoute, _pauseMetadata' + ) + }) + + it.concurrent( + 'rejects reserved top-level runtime input keys copied to unified Start output', + () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: { error: 'false', payload: 'value' }, + }) + ).toThrow( + 'Start block "block-start_trigger" cannot use reserved runtime input field name(s): error' + ) + } + ) + + it.concurrent('rejects reserved nested API input keys copied to trigger output', () => { + const block = createBlock('api_trigger', 'api') + const resolution = { + blockId: 'api', + block, + path: StartBlockPath.SPLIT_API, + } as const + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: { input: { selectedRoute: 'route-1', payload: 'value' } }, + }) + ).toThrow( + 'Start block "block-api_trigger" cannot use reserved runtime input field name(s): selectedRoute' + ) + }) + + it.concurrent('ignores malformed non-string inputFormat field names', () => { + const block = createBlock('start_trigger', 'start', { + subBlocks: { + inputFormat: { + value: [ + { name: 123, type: 'string', value: 'ignored' }, + { name: 'customField', type: 'string' }, + ], + }, + }, + }) + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { customField: 'value' }, + }) + + expect(output.customField).toBe('value') + expect(output[123]).toBeUndefined() + }) + describe('inputFormat default values', () => { it.concurrent('uses default value when runtime does not provide the field', () => { const block = createBlock('start_trigger', 'start', { @@ -294,6 +390,24 @@ describe('start-block utilities', () => { }) describe('EXTERNAL_TRIGGER path', () => { + it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => { + const block = createBlock('webhook', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.EXTERNAL_TRIGGER, + } as const + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: { _pauseMetadata: { contextId: 'fake-pause' }, payload: 'value' }, + }) + ).toThrow( + 'Start block "block-webhook" cannot use reserved runtime input field name(s): _pauseMetadata' + ) + }) + it.concurrent('preserves coerced types for integration trigger payload', () => { const block = createBlock('webhook', 'start', { subBlocks: { diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index de674f0445a..fa6a88fb59a 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -6,12 +6,20 @@ import { StartBlockPath, } from '@/lib/workflows/triggers/triggers' import type { InputFormatField } from '@/lib/workflows/types' -import type { NormalizedBlockOutput, UserFile } from '@/executor/types' +import { + EXECUTION_CONTROL_OUTPUT_FIELD_NAMES, + type NormalizedBlockOutput, + type UserFile, +} from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' import { safeAssign } from '@/tools/safe-assign' type ExecutionKind = 'chat' | 'manual' | 'api' | 'external' +const EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET = new Set( + EXECUTION_CONTROL_OUTPUT_FIELD_NAMES +) + export interface ExecutorStartResolution { blockId: string block: SerializedBlock @@ -133,6 +141,66 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] { .map((field) => field) } +function readInputFormatFieldName(field: InputFormatField): string | undefined { + return typeof field.name === 'string' ? field.name.trim() : undefined +} + +function collectExecutionControlFieldNames(fieldNames: Iterable): string[] { + const reservedFieldNames: string[] = [] + + for (const fieldName of fieldNames) { + if (!fieldName || !EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) { + continue + } + + if (!reservedFieldNames.includes(fieldName)) { + reservedFieldNames.push(fieldName) + } + } + + return reservedFieldNames +} + +function throwReservedStartOutputFieldsError( + block: SerializedBlock, + reservedFieldNames: string[], + source: 'input format' | 'runtime input' +): never { + const blockName = block.metadata?.name ?? block.id + + throw new Error( + `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(', ')}.` + ) +} + +function assertNoReservedInputFormatFields( + inputFormat: InputFormatField[], + block: SerializedBlock +): void { + const reservedFieldNames = collectExecutionControlFieldNames( + inputFormat.map(readInputFormatFieldName) + ) + + if (reservedFieldNames.length === 0) { + return + } + + throwReservedStartOutputFieldsError(block, reservedFieldNames, 'input format') +} + +function assertNoReservedStartOutputFields( + output: NormalizedBlockOutput, + block: SerializedBlock +): void { + const reservedFieldNames = collectExecutionControlFieldNames(Object.keys(output)) + + if (reservedFieldNames.length === 0) { + return + } + + throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input') +} + export function coerceValue(type: string | null | undefined, value: unknown): unknown { if (value === undefined || value === null) { return value @@ -190,7 +258,7 @@ function deriveInputFromFormat( } for (const field of inputFormat) { - const fieldName = field.name?.trim() + const fieldName = readInputFormatFieldName(field) if (!fieldName) continue let fieldValue: unknown @@ -436,35 +504,48 @@ export interface StartBlockOutputOptions { export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput { const { resolution, workflowInput } = options const inputFormat = extractInputFormat(resolution.block) + assertNoReservedInputFormatFields(inputFormat, resolution.block) const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat( inputFormat, workflowInput ) + let output: NormalizedBlockOutput + switch (resolution.path) { case StartBlockPath.UNIFIED: - return buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured) + output = buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured) + break case StartBlockPath.SPLIT_API: case StartBlockPath.SPLIT_INPUT: - return buildApiOrInputOutput(finalInput, workflowInput) + output = buildApiOrInputOutput(finalInput, workflowInput) + break case StartBlockPath.SPLIT_CHAT: - return buildChatOutput(workflowInput) + output = buildChatOutput(workflowInput) + break case StartBlockPath.SPLIT_MANUAL: - return buildManualTriggerOutput(finalInput, workflowInput) + output = buildManualTriggerOutput(finalInput, workflowInput) + break case StartBlockPath.EXTERNAL_TRIGGER: - return buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured) + output = buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured) + break case StartBlockPath.LEGACY_STARTER: - return buildLegacyStarterOutput( + output = buildLegacyStarterOutput( finalInput, workflowInput, getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) }) ) + break + default: - return buildManualTriggerOutput(finalInput, workflowInput) + output = buildManualTriggerOutput(finalInput, workflowInput) } + + assertNoReservedStartOutputFields(output, resolution.block) + return output } From a1a09bfe1de8f8d91ca6a7291bdd2c4a487ba916 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 6 May 2026 15:39:15 -0700 Subject: [PATCH 2/2] address comments and make error messages for func execute make sense block ref accs --- .../app/api/function/execute/route.test.ts | 30 +++++++++ apps/sim/app/api/function/execute/route.ts | 57 ++++++++++++---- .../function/function-handler.test.ts | 19 ++++++ .../handlers/function/function-handler.ts | 65 ++++++++++++------- apps/sim/executor/utils/start-block.test.ts | 65 +++++++++++++++++++ apps/sim/executor/utils/start-block.ts | 59 ++++++++++++----- apps/sim/lib/api/contracts/hotspots.ts | 1 + apps/sim/tools/function/execute.ts | 1 + apps/sim/tools/function/types.ts | 2 + 9 files changed, 247 insertions(+), 52 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 8cdf1ca8d98..9f4c74df5da 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -538,6 +538,36 @@ describe('Function Execute API Route', () => { expect(data.error).toContain('undefinedVariable is not defined') }) + it('should show original source code when resolved block references cause syntax errors', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { + message: 'Unexpected identifier "globalThis"', + name: 'SyntaxError', + line: 1, + column: 7, + lineContent: 'retur globalThis["__blockRef_0"]', + }, + }) + + const req = createMockRequest('POST', { + code: 'retur globalThis["__blockRef_0"]', + sourceCode: 'retur ', + contextVariables: { __blockRef_0: 'value' }, + timeout: 5000, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + expect(data.error).toContain('Line 1: `retur `') + expect(data.error).not.toContain('globalThis') + expect(data.debug.lineContent).toBe('retur ') + }) + it('should handle thrown errors gracefully', async () => { const req = createMockRequest('POST', { code: 'throw new Error("Custom error message");', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index c45a4bef7b7..fcfda730c4b 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -410,6 +410,30 @@ function createUserFriendlyErrorMessage( return errorMessage } +function getErrorDisplayCode(sourceCode: string | undefined, resolvedCode: string): string { + return sourceCode && sourceCode.length > 0 ? sourceCode : resolvedCode +} + +function getLineContent(code: string, line: number | undefined): string | undefined { + if (line === undefined || line < 1) { + return undefined + } + + return code.split('\n')[line - 1]?.trim() +} + +function getErrorDisplayMessage( + message: string, + sourceCode: string | undefined, + resolvedCode: string +): string { + if (!sourceCode || sourceCode === resolvedCode || !resolvedCode.includes('__blockRef_')) { + return message + } + + return message.replace(/\s+["']globalThis["']/g, '') +} + function resolveWorkflowVariables( code: string, workflowVariables: Record, @@ -767,6 +791,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let stdout = '' let userCodeStartLine = 3 // Default value for error reporting let resolvedCode = '' // Store resolved code for error reporting + let sourceCodeForErrors: string | undefined try { const auth = await checkInternalAuth(req) @@ -783,6 +808,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { code, + sourceCode, params = {}, timeout = DEFAULT_EXECUTION_TIMEOUT_MS, language = DEFAULT_CODE_LANGUAGE, @@ -801,6 +827,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { isCustomTool = false, _sandboxFiles, } = body + sourceCodeForErrors = sourceCode const executionParams = { ...params } executionParams._context = undefined @@ -1019,11 +1046,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (e2bError) { + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) const { formattedError, cleanedOutput } = formatE2BError( - e2bError, + getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode), e2bStdout, lang, - resolvedCode, + errorDisplayCode, prologueLineCount + importLineCount ) return NextResponse.json( @@ -1101,11 +1129,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (e2bError) { + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) const { formattedError, cleanedOutput } = formatE2BError( - e2bError, + getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode), e2bStdout, lang, - resolvedCode, + errorDisplayCode, prologueLineCount ) return NextResponse.json( @@ -1192,13 +1221,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let adjustedLineContent = ivmError.lineContent if (prependedLineCount > 0 && ivmError.line !== undefined) { adjustedLine = Math.max(1, ivmError.line - prependedLineCount) - const codeLines = resolvedCode.split('\n') - if (adjustedLine <= codeLines.length) { - adjustedLineContent = codeLines[adjustedLine - 1]?.trim() - } } + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) + const displayMessage = getErrorDisplayMessage( + ivmError.message, + sourceCodeForErrors, + resolvedCode + ) + adjustedLineContent = getLineContent(errorDisplayCode, adjustedLine) ?? adjustedLineContent const enhancedError: EnhancedError = { - message: ivmError.message, + message: displayMessage, name: ivmError.name, stack: ivmError.stack, originalError: ivmError, @@ -1210,7 +1242,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const userFriendlyErrorMessage = createUserFriendlyErrorMessage( enhancedError, requestId, - resolvedCode + errorDisplayCode ) const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) @@ -1261,11 +1293,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { executionTime, }) - const enhancedError = extractEnhancedError(error, userCodeStartLine, resolvedCode) + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) + const enhancedError = extractEnhancedError(error, userCodeStartLine, errorDisplayCode) const userFriendlyErrorMessage = createUserFriendlyErrorMessage( enhancedError, requestId, - resolvedCode + errorDisplayCode ) logger.error(`[${requestId}] Enhanced error details`, { diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts index d7a4e19929f..f6facf69cfe 100644 --- a/apps/sim/executor/handlers/function/function-handler.test.ts +++ b/apps/sim/executor/handlers/function/function-handler.test.ts @@ -196,6 +196,25 @@ describe('FunctionBlockHandler', () => { ) }) + it('should pass original function code for error display after reference resolution', async () => { + mockBlock.config.params = { code: 'retur ' } + + await handler.execute(mockContext, mockBlock, { + code: 'retur globalThis["__blockRef_0"]', + [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' }, + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + code: 'retur globalThis["__blockRef_0"]', + sourceCode: 'retur ', + }), + false, + mockContext + ) + }) + it('should normalize malformed execution context records before calling function_execute', async () => { const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' } mockContext.workflowVariables = [legacyVariable] as unknown as Record diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 5cd01e1aa1f..c5dc9c47dcd 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -12,6 +12,22 @@ import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' +function readCodeContent(value: unknown): string | undefined { + if (typeof value === 'string') { + return value + } + + if (Array.isArray(value)) { + return value + .map((entry) => + entry && typeof entry === 'object' && typeof entry.content === 'string' ? entry.content : '' + ) + .join('\n') + } + + return undefined +} + /** * Handler for Function blocks that execute custom code. */ @@ -25,37 +41,36 @@ export class FunctionBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { - const codeContent = Array.isArray(inputs.code) - ? inputs.code.map((c: { content: string }) => c.content).join('\n') - : inputs.code + const codeContent = readCodeContent(inputs.code) ?? inputs.code + const sourceCode = readCodeContent( + (block.config?.params as Record | undefined)?.code + ) const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY]) - const result = await executeTool( - 'function_execute', - { - code: codeContent, - language: inputs.language || DEFAULT_CODE_LANGUAGE, - timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, - envVars: normalizeStringRecord(ctx.environmentVariables), - workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables), - blockData, - blockNameMapping, - blockOutputSchemas, - contextVariables, - _context: { - workflowId: ctx.workflowId, - workspaceId: ctx.workspaceId, - userId: ctx.userId, - isDeployedContext: ctx.isDeployedContext, - enforceCredentialAccess: ctx.enforceCredentialAccess, - }, + const toolParams = { + code: codeContent, + ...(sourceCode ? { sourceCode } : {}), + language: inputs.language || DEFAULT_CODE_LANGUAGE, + timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, + envVars: normalizeStringRecord(ctx.environmentVariables), + workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables), + blockData, + blockNameMapping, + blockOutputSchemas, + contextVariables, + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, }, - false, - ctx - ) + } + + const result = await executeTool('function_execute', toolParams, false, ctx) if (!result.success) { throw new Error(result.error || 'Function execution failed') diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 4cdb0381115..f50c5a58da5 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -189,6 +189,71 @@ describe('start-block utilities', () => { ) }) + it.concurrent('allows reserved inputFormat field names on split chat trigger output', () => { + const block = createBlock('chat_trigger', 'chat', { + subBlocks: { + inputFormat: { + value: [{ name: 'error', type: 'string' }], + }, + }, + }) + const resolution = { + blockId: 'chat', + block, + path: StartBlockPath.SPLIT_CHAT, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { input: 'hello', conversationId: 'conversation-1' }, + }) + + expect(output).toEqual({ input: 'hello', conversationId: 'conversation-1' }) + }) + + it.concurrent('allows reserved inputFormat field names on legacy chat starter output', () => { + const block = createBlock('starter', 'starter', { + subBlocks: { + startWorkflow: { value: 'chat' }, + inputFormat: { + value: [{ name: 'error', type: 'string' }], + }, + }, + }) + const resolution = { + blockId: 'starter', + block, + path: StartBlockPath.LEGACY_STARTER, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { input: 'hello' }, + }) + + expect(output).toEqual({ input: 'hello' }) + }) + + it.concurrent('allows reserved inputFormat field names on serialized legacy chat starter', () => { + const block = createBlock('starter', 'starter') + block.config.params = { + startWorkflow: 'chat', + inputFormat: [{ name: 'error', type: 'string' }], + } + const resolution = { + blockId: 'starter', + block, + path: StartBlockPath.LEGACY_STARTER, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { input: 'hello' }, + }) + + expect(output).toEqual({ input: 'hello' }) + }) + it.concurrent('ignores malformed non-string inputFormat field names', () => { const block = createBlock('start_trigger', 'start', { subBlocks: { diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index fa6a88fb59a..8d0a4afcb0e 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -1,7 +1,6 @@ import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { classifyStartBlockType, - getLegacyStarterMode, resolveStartCandidates, StartBlockPath, } from '@/lib/workflows/triggers/triggers' @@ -141,24 +140,36 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] { .map((field) => field) } +function normalizeLegacyStarterMode(modeValue: unknown): 'manual' | 'api' | 'chat' | null { + if (modeValue === 'chat') return 'chat' + if (modeValue === 'api' || modeValue === 'run') return 'api' + if (modeValue === undefined || modeValue === 'manual') return 'manual' + return null +} + +function getSerializedLegacyStarterMode(block: SerializedBlock): 'manual' | 'api' | 'chat' | null { + const fromMetadata = readMetadataSubBlockValue(block, 'startWorkflow') + if (fromMetadata !== undefined) { + return normalizeLegacyStarterMode(fromMetadata) + } + + return normalizeLegacyStarterMode(block.config?.params?.startWorkflow) +} + function readInputFormatFieldName(field: InputFormatField): string | undefined { return typeof field.name === 'string' ? field.name.trim() : undefined } function collectExecutionControlFieldNames(fieldNames: Iterable): string[] { - const reservedFieldNames: string[] = [] + const reservedFieldNames = new Set() for (const fieldName of fieldNames) { - if (!fieldName || !EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) { - continue - } - - if (!reservedFieldNames.includes(fieldName)) { - reservedFieldNames.push(fieldName) + if (fieldName && EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) { + reservedFieldNames.add(fieldName) } } - return reservedFieldNames + return Array.from(reservedFieldNames) } function throwReservedStartOutputFieldsError( @@ -201,6 +212,20 @@ function assertNoReservedStartOutputFields( throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input') } +function pathConsumesInputFormat( + path: StartBlockPath, + legacyStarterMode: 'manual' | 'api' | 'chat' | null +): boolean { + switch (path) { + case StartBlockPath.SPLIT_CHAT: + return false + case StartBlockPath.LEGACY_STARTER: + return legacyStarterMode !== 'chat' + default: + return true + } +} + export function coerceValue(type: string | null | undefined, value: unknown): unknown { if (value === undefined || value === null) { return value @@ -504,7 +529,15 @@ export interface StartBlockOutputOptions { export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput { const { resolution, workflowInput } = options const inputFormat = extractInputFormat(resolution.block) - assertNoReservedInputFormatFields(inputFormat, resolution.block) + const legacyStarterMode = + resolution.path === StartBlockPath.LEGACY_STARTER + ? getSerializedLegacyStarterMode(resolution.block) + : null + + if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) { + assertNoReservedInputFormatFields(inputFormat, resolution.block) + } + const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat( inputFormat, workflowInput @@ -535,11 +568,7 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali break case StartBlockPath.LEGACY_STARTER: - output = buildLegacyStarterOutput( - finalInput, - workflowInput, - getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) }) - ) + output = buildLegacyStarterOutput(finalInput, workflowInput, legacyStarterMode) break default: diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index c4e7a277303..099170bc8be 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -86,6 +86,7 @@ export const functionExecuteContract = defineRouteContract({ path: '/api/function/execute', body: z.object({ code: z.string().min(1, 'Code is required'), + sourceCode: z.string().optional(), params: unknownRecordSchema.optional().default({}), timeout: z.coerce.number().int().positive().optional(), language: z.string().optional().default(DEFAULT_CODE_LANGUAGE), diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 7ee26f5c4b3..4d096ce7cf4 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -122,6 +122,7 @@ export const functionExecuteTool: ToolConfig = { code: codeContent, + sourceCode: params.sourceCode, language: params.language || DEFAULT_CODE_LANGUAGE, timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, outputPath: params.outputPath, diff --git a/apps/sim/tools/function/types.ts b/apps/sim/tools/function/types.ts index b46aee1561b..3ad1df77ece 100644 --- a/apps/sim/tools/function/types.ts +++ b/apps/sim/tools/function/types.ts @@ -3,6 +3,8 @@ import type { ToolResponse } from '@/tools/types' export interface CodeExecutionInput { code: Array<{ content: string; id: string }> | string + /** Original user-authored code used for error display after execution-time reference resolution. */ + sourceCode?: string language?: CodeLanguage useLocalVM?: boolean timeout?: number