diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index ea020abaf..e0e7723bd 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -313,7 +313,7 @@ describe('Function Execute API Route', () => { 'block-2': 'world', }, blockNameMapping: { - validVar: 'block-1', + validvar: 'block-1', another_valid: 'block-2', }, }) @@ -539,7 +539,7 @@ describe('Function Execute API Route', () => { 'block-complex': complexData, }, blockNameMapping: { - complexData: 'block-complex', + complexdata: 'block-complex', }, }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 8868c2d40..0940054b2 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -6,11 +6,11 @@ import { executeInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' +import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { createEnvVarPattern, createWorkflowVariablePattern, } from '@/executor/utils/reference-validation' -import { navigatePath } from '@/executor/variables/resolvers/reference' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -470,14 +470,17 @@ function resolveEnvironmentVariables( function resolveTagVariables( code: string, - blockData: Record, + blockData: Record, blockNameMapping: Record, - contextVariables: Record + blockOutputSchemas: Record, + contextVariables: Record, + language = 'javascript' ): string { let resolvedCode = code + const undefinedLiteral = language === 'python' ? 'None' : 'undefined' const tagPattern = new RegExp( - `${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`, + `${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`, 'g' ) const tagMatches = resolvedCode.match(tagPattern) || [] @@ -486,41 +489,37 @@ function resolveTagVariables( const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim() const pathParts = tagName.split(REFERENCE.PATH_DELIMITER) const blockName = pathParts[0] + const fieldPath = pathParts.slice(1) - const blockId = blockNameMapping[blockName] - if (!blockId) { + const result = resolveBlockReference(blockName, fieldPath, { + blockNameMapping, + blockData, + blockOutputSchemas, + }) + + if (!result) { continue } - const blockOutput = blockData[blockId] - if (blockOutput === undefined) { - continue - } - - let tagValue: any - if (pathParts.length === 1) { - tagValue = blockOutput - } else { - tagValue = navigatePath(blockOutput, pathParts.slice(1)) - } + let tagValue = result.value if (tagValue === undefined) { + resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral) continue } - if ( - typeof tagValue === 'string' && - tagValue.length > 100 && - (tagValue.startsWith('{') || tagValue.startsWith('[')) - ) { - try { - tagValue = JSON.parse(tagValue) - } catch { - // Keep as-is + if (typeof tagValue === 'string') { + const trimmed = tagValue.trimStart() + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + tagValue = JSON.parse(tagValue) + } catch { + // Keep as string if not valid JSON + } } } - const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}` + const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}` contextVariables[safeVarName] = tagValue resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) } @@ -537,18 +536,27 @@ function resolveTagVariables( */ function resolveCodeVariables( code: string, - params: Record, + params: Record, envVars: Record = {}, - blockData: Record = {}, + blockData: Record = {}, blockNameMapping: Record = {}, - workflowVariables: Record = {} -): { resolvedCode: string; contextVariables: Record } { + blockOutputSchemas: Record = {}, + workflowVariables: Record = {}, + language = 'javascript' +): { resolvedCode: string; contextVariables: Record } { let resolvedCode = code - const contextVariables: Record = {} + const contextVariables: Record = {} resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables) resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables) - resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables) + resolvedCode = resolveTagVariables( + resolvedCode, + blockData, + blockNameMapping, + blockOutputSchemas, + contextVariables, + language + ) return { resolvedCode, contextVariables } } @@ -585,6 +593,7 @@ export async function POST(req: NextRequest) { envVars = {}, blockData = {}, blockNameMapping = {}, + blockOutputSchemas = {}, workflowVariables = {}, workflowId, isCustomTool = false, @@ -601,20 +610,21 @@ export async function POST(req: NextRequest) { isCustomTool, }) - // Resolve variables in the code with workflow environment variables + const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE + const codeResolution = resolveCodeVariables( code, executionParams, envVars, blockData, blockNameMapping, - workflowVariables + blockOutputSchemas, + workflowVariables, + lang ) resolvedCode = codeResolution.resolvedCode const contextVariables = codeResolution.contextVariables - const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE - let jsImports = '' let jsRemainingCode = resolvedCode let hasImports = false @@ -670,7 +680,11 @@ export async function POST(req: NextRequest) { prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n` prologueLineCount++ for (const [k, v] of Object.entries(contextVariables)) { - prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n` + if (v === undefined) { + prologue += `const ${k} = undefined;\n` + } else { + prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n` + } prologueLineCount++ } @@ -741,7 +755,11 @@ export async function POST(req: NextRequest) { prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n` prologueLineCount++ for (const [k, v] of Object.entries(contextVariables)) { - prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n` + if (v === undefined) { + prologue += `${k} = None\n` + } else { + prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n` + } prologueLineCount++ } const wrapped = [ diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 3240da897..9cbd6692a 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -305,7 +305,7 @@ export class AgentBlockHandler implements BlockHandler { base.executeFunction = async (callParams: Record) => { const mergedParams = mergeToolParameters(userProvidedParams, callParams) - const { blockData, blockNameMapping } = collectBlockData(ctx) + const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) const result = await executeTool( 'function_execute', @@ -317,6 +317,7 @@ export class AgentBlockHandler implements BlockHandler { workflowVariables: ctx.workflowVariables || {}, blockData, blockNameMapping, + blockOutputSchemas, isCustomTool: true, _context: { workflowId: ctx.workflowId, diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index f45046058..2954b06d8 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -26,7 +26,7 @@ export async function evaluateConditionExpression( const contextSetup = `const context = ${JSON.stringify(evalContext)};` const code = `${contextSetup}\nreturn Boolean(${conditionExpression})` - const { blockData, blockNameMapping } = collectBlockData(ctx) + const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) const result = await executeTool( 'function_execute', @@ -37,6 +37,7 @@ export async function evaluateConditionExpression( workflowVariables: ctx.workflowVariables || {}, blockData, blockNameMapping, + blockOutputSchemas, _context: { workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts index f04de4662..5426610c7 100644 --- a/apps/sim/executor/handlers/function/function-handler.test.ts +++ b/apps/sim/executor/handlers/function/function-handler.test.ts @@ -75,7 +75,12 @@ describe('FunctionBlockHandler', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, - _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId }, + blockOutputSchemas: {}, + _context: { + workflowId: mockContext.workflowId, + workspaceId: mockContext.workspaceId, + isDeployedContext: mockContext.isDeployedContext, + }, } const expectedOutput: any = { result: 'Success' } @@ -84,8 +89,8 @@ describe('FunctionBlockHandler', () => { expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', expectedToolParams, - false, // skipPostProcess - mockContext // execution context + false, + mockContext ) expect(result).toEqual(expectedOutput) }) @@ -107,7 +112,12 @@ describe('FunctionBlockHandler', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, - _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId }, + blockOutputSchemas: {}, + _context: { + workflowId: mockContext.workflowId, + workspaceId: mockContext.workspaceId, + isDeployedContext: mockContext.isDeployedContext, + }, } const expectedOutput: any = { result: 'Success' } @@ -116,8 +126,8 @@ describe('FunctionBlockHandler', () => { expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', expectedToolParams, - false, // skipPostProcess - mockContext // execution context + false, + mockContext ) expect(result).toEqual(expectedOutput) }) @@ -132,7 +142,12 @@ describe('FunctionBlockHandler', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, - _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId }, + blockOutputSchemas: {}, + _context: { + workflowId: mockContext.workflowId, + workspaceId: mockContext.workspaceId, + isDeployedContext: mockContext.isDeployedContext, + }, } await handler.execute(mockContext, mockBlock, inputs) diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index c7b9b0097..624a262d3 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -23,7 +23,7 @@ export class FunctionBlockHandler implements BlockHandler { ? inputs.code.map((c: { content: string }) => c.content).join('\n') : inputs.code - const { blockData, blockNameMapping } = collectBlockData(ctx) + const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) const result = await executeTool( 'function_execute', @@ -35,6 +35,7 @@ export class FunctionBlockHandler implements BlockHandler { workflowVariables: ctx.workflowVariables || {}, blockData, blockNameMapping, + blockOutputSchemas, _context: { workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/utils/block-data.ts b/apps/sim/executor/utils/block-data.ts index fc7b26ae3..694180374 100644 --- a/apps/sim/executor/utils/block-data.ts +++ b/apps/sim/executor/utils/block-data.ts @@ -1,24 +1,43 @@ +import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { normalizeName } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' +import type { OutputSchema } from '@/executor/utils/block-reference' export interface BlockDataCollection { - blockData: Record + blockData: Record blockNameMapping: Record + blockOutputSchemas: Record } export function collectBlockData(ctx: ExecutionContext): BlockDataCollection { - const blockData: Record = {} + const blockData: Record = {} const blockNameMapping: Record = {} + const blockOutputSchemas: Record = {} for (const [id, state] of ctx.blockStates.entries()) { if (state.output !== undefined) { blockData[id] = state.output - const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id) - if (workflowBlock?.metadata?.name) { - blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id + } + + const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id) + if (!workflowBlock) continue + + if (workflowBlock.metadata?.name) { + blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id + } + + const blockType = workflowBlock.metadata?.id + if (blockType) { + const params = workflowBlock.config?.params as Record | undefined + const subBlocks = params + ? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }])) + : undefined + const schema = getBlockOutputs(blockType, subBlocks) + if (schema && Object.keys(schema).length > 0) { + blockOutputSchemas[id] = schema } } } - return { blockData, blockNameMapping } + return { blockData, blockNameMapping, blockOutputSchemas } } diff --git a/apps/sim/executor/utils/block-reference.test.ts b/apps/sim/executor/utils/block-reference.test.ts new file mode 100644 index 000000000..6f110c2bc --- /dev/null +++ b/apps/sim/executor/utils/block-reference.test.ts @@ -0,0 +1,255 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type BlockReferenceContext, + InvalidFieldError, + resolveBlockReference, +} from './block-reference' + +describe('resolveBlockReference', () => { + const createContext = ( + overrides: Partial = {} + ): BlockReferenceContext => ({ + blockNameMapping: { start: 'block-1', agent: 'block-2' }, + blockData: {}, + blockOutputSchemas: {}, + ...overrides, + }) + + describe('block name resolution', () => { + it('should return undefined when block name does not exist', () => { + const ctx = createContext() + const result = resolveBlockReference('unknown', ['field'], ctx) + expect(result).toBeUndefined() + }) + + it('should normalize block name before lookup', () => { + const ctx = createContext({ + blockNameMapping: { myblock: 'block-1' }, + blockData: { 'block-1': { value: 'test' } }, + }) + + const result = resolveBlockReference('MyBlock', ['value'], ctx) + expect(result).toEqual({ value: 'test', blockId: 'block-1' }) + }) + + it('should handle block names with spaces', () => { + const ctx = createContext({ + blockNameMapping: { myblock: 'block-1' }, + blockData: { 'block-1': { value: 'test' } }, + }) + + const result = resolveBlockReference('My Block', ['value'], ctx) + expect(result).toEqual({ value: 'test', blockId: 'block-1' }) + }) + }) + + describe('field resolution', () => { + it('should return entire block output when no path specified', () => { + const ctx = createContext({ + blockData: { 'block-1': { input: 'hello', other: 'data' } }, + }) + + const result = resolveBlockReference('start', [], ctx) + expect(result).toEqual({ + value: { input: 'hello', other: 'data' }, + blockId: 'block-1', + }) + }) + + it('should resolve simple field path', () => { + const ctx = createContext({ + blockData: { 'block-1': { input: 'hello' } }, + }) + + const result = resolveBlockReference('start', ['input'], ctx) + expect(result).toEqual({ value: 'hello', blockId: 'block-1' }) + }) + + it('should resolve nested field path', () => { + const ctx = createContext({ + blockData: { 'block-1': { response: { data: { name: 'test' } } } }, + }) + + const result = resolveBlockReference('start', ['response', 'data', 'name'], ctx) + expect(result).toEqual({ value: 'test', blockId: 'block-1' }) + }) + + it('should resolve array index path', () => { + const ctx = createContext({ + blockData: { 'block-1': { items: ['a', 'b', 'c'] } }, + }) + + const result = resolveBlockReference('start', ['items', '1'], ctx) + expect(result).toEqual({ value: 'b', blockId: 'block-1' }) + }) + + it('should return undefined value when field exists but has no value', () => { + const ctx = createContext({ + blockData: { 'block-1': { input: undefined } }, + blockOutputSchemas: { + 'block-1': { input: { type: 'string' } }, + }, + }) + + const result = resolveBlockReference('start', ['input'], ctx) + expect(result).toEqual({ value: undefined, blockId: 'block-1' }) + }) + + it('should return null value when field has null', () => { + const ctx = createContext({ + blockData: { 'block-1': { input: null } }, + }) + + const result = resolveBlockReference('start', ['input'], ctx) + expect(result).toEqual({ value: null, blockId: 'block-1' }) + }) + }) + + describe('schema validation', () => { + it('should throw InvalidFieldError when field not in schema', () => { + const ctx = createContext({ + blockData: { 'block-1': { existing: 'value' } }, + blockOutputSchemas: { + 'block-1': { + input: { type: 'string' }, + conversationId: { type: 'string' }, + }, + }, + }) + + expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError) + expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow( + /"invalid" doesn't exist on block "start"/ + ) + }) + + it('should include available fields in error message', () => { + const ctx = createContext({ + blockData: { 'block-1': {} }, + blockOutputSchemas: { + 'block-1': { + input: { type: 'string' }, + conversationId: { type: 'string' }, + files: { type: 'files' }, + }, + }, + }) + + try { + resolveBlockReference('start', ['typo'], ctx) + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(InvalidFieldError) + const fieldError = error as InvalidFieldError + expect(fieldError.availableFields).toContain('input') + expect(fieldError.availableFields).toContain('conversationId') + expect(fieldError.availableFields).toContain('files') + } + }) + + it('should allow valid field even when value is undefined', () => { + const ctx = createContext({ + blockData: { 'block-1': {} }, + blockOutputSchemas: { + 'block-1': { input: { type: 'string' } }, + }, + }) + + const result = resolveBlockReference('start', ['input'], ctx) + expect(result).toEqual({ value: undefined, blockId: 'block-1' }) + }) + + it('should validate path when block has no output yet', () => { + const ctx = createContext({ + blockData: {}, + blockOutputSchemas: { + 'block-1': { input: { type: 'string' } }, + }, + }) + + expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError) + }) + + it('should return undefined for valid field when block has no output', () => { + const ctx = createContext({ + blockData: {}, + blockOutputSchemas: { + 'block-1': { input: { type: 'string' } }, + }, + }) + + const result = resolveBlockReference('start', ['input'], ctx) + expect(result).toEqual({ value: undefined, blockId: 'block-1' }) + }) + }) + + describe('without schema (pass-through mode)', () => { + it('should return undefined value without throwing when no schema', () => { + const ctx = createContext({ + blockData: { 'block-1': { existing: 'value' } }, + }) + + const result = resolveBlockReference('start', ['missing'], ctx) + expect(result).toEqual({ value: undefined, blockId: 'block-1' }) + }) + }) + + describe('file type handling', () => { + it('should allow file property access', () => { + const ctx = createContext({ + blockData: { + 'block-1': { + files: [{ name: 'test.txt', url: 'http://example.com/file' }], + }, + }, + blockOutputSchemas: { + 'block-1': { files: { type: 'files' } }, + }, + }) + + const result = resolveBlockReference('start', ['files', '0', 'name'], ctx) + expect(result).toEqual({ value: 'test.txt', blockId: 'block-1' }) + }) + + it('should validate file property names', () => { + const ctx = createContext({ + blockData: { 'block-1': { files: [] } }, + blockOutputSchemas: { + 'block-1': { files: { type: 'files' } }, + }, + }) + + expect(() => resolveBlockReference('start', ['files', '0', 'invalid'], ctx)).toThrow( + InvalidFieldError + ) + }) + }) +}) + +describe('InvalidFieldError', () => { + it('should have correct properties', () => { + const error = new InvalidFieldError('myBlock', 'invalid.path', ['field1', 'field2']) + + expect(error.blockName).toBe('myBlock') + expect(error.fieldPath).toBe('invalid.path') + expect(error.availableFields).toEqual(['field1', 'field2']) + expect(error.name).toBe('InvalidFieldError') + }) + + it('should format message correctly', () => { + const error = new InvalidFieldError('start', 'typo', ['input', 'files']) + + expect(error.message).toBe( + '"typo" doesn\'t exist on block "start". Available fields: input, files' + ) + }) + + it('should handle empty available fields', () => { + const error = new InvalidFieldError('start', 'field', []) + + expect(error.message).toBe('"field" doesn\'t exist on block "start". Available fields: none') + }) +}) diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts new file mode 100644 index 000000000..590e9d869 --- /dev/null +++ b/apps/sim/executor/utils/block-reference.ts @@ -0,0 +1,210 @@ +import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types' +import { normalizeName } from '@/executor/constants' +import { navigatePath } from '@/executor/variables/resolvers/reference' + +export type OutputSchema = Record + +export interface BlockReferenceContext { + blockNameMapping: Record + blockData: Record + blockOutputSchemas?: Record +} + +export interface BlockReferenceResult { + value: unknown + blockId: string +} + +export class InvalidFieldError extends Error { + constructor( + public readonly blockName: string, + public readonly fieldPath: string, + public readonly availableFields: string[] + ) { + super( + `"${fieldPath}" doesn't exist on block "${blockName}". ` + + `Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}` + ) + this.name = 'InvalidFieldError' + } +} + +function isFileType(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false + const typed = value as { type?: string } + return typed.type === 'file[]' || typed.type === 'files' +} + +function isArrayType(value: unknown): value is { type: 'array'; items?: unknown } { + if (typeof value !== 'object' || value === null) return false + return (value as { type?: string }).type === 'array' +} + +function getArrayItems(schema: unknown): unknown { + if (typeof schema !== 'object' || schema === null) return undefined + return (schema as { items?: unknown }).items +} + +function getProperties(schema: unknown): Record | undefined { + if (typeof schema !== 'object' || schema === null) return undefined + const props = (schema as { properties?: unknown }).properties + return typeof props === 'object' && props !== null + ? (props as Record) + : undefined +} + +function lookupField(schema: unknown, fieldName: string): unknown | undefined { + if (typeof schema !== 'object' || schema === null) return undefined + const typed = schema as Record + + if (fieldName in typed) { + return typed[fieldName] + } + + const props = getProperties(schema) + if (props && fieldName in props) { + return props[fieldName] + } + + return undefined +} + +function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean { + if (!schema || pathParts.length === 0) { + return true + } + + let current: unknown = schema + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i] + + if (current === null || current === undefined) { + return false + } + + if (/^\d+$/.test(part)) { + if (isFileType(current)) { + const nextPart = pathParts[i + 1] + return ( + !nextPart || + USER_FILE_ACCESSIBLE_PROPERTIES.includes( + nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number] + ) + ) + } + if (isArrayType(current)) { + current = getArrayItems(current) + } + continue + } + + const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) + if (arrayMatch) { + const [, prop] = arrayMatch + const fieldDef = lookupField(current, prop) + if (!fieldDef) return false + + if (isFileType(fieldDef)) { + const nextPart = pathParts[i + 1] + return ( + !nextPart || + USER_FILE_ACCESSIBLE_PROPERTIES.includes( + nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number] + ) + ) + } + + current = isArrayType(fieldDef) ? getArrayItems(fieldDef) : fieldDef + continue + } + + if ( + isFileType(current) && + USER_FILE_ACCESSIBLE_PROPERTIES.includes( + part as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number] + ) + ) { + return true + } + + const fieldDef = lookupField(current, part) + if (fieldDef !== undefined) { + if (isFileType(fieldDef)) { + const nextPart = pathParts[i + 1] + if (!nextPart) return true + if (/^\d+$/.test(nextPart)) { + const afterIndex = pathParts[i + 2] + return ( + !afterIndex || + USER_FILE_ACCESSIBLE_PROPERTIES.includes( + afterIndex as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number] + ) + ) + } + return USER_FILE_ACCESSIBLE_PROPERTIES.includes( + nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number] + ) + } + current = fieldDef + continue + } + + if (isArrayType(current)) { + const items = getArrayItems(current) + const itemField = lookupField(items, part) + if (itemField !== undefined) { + current = itemField + continue + } + } + + return false + } + + return true +} + +function getSchemaFieldNames(schema: OutputSchema | undefined): string[] { + if (!schema) return [] + return Object.keys(schema) +} + +export function resolveBlockReference( + blockName: string, + pathParts: string[], + context: BlockReferenceContext +): BlockReferenceResult | undefined { + const normalizedName = normalizeName(blockName) + const blockId = context.blockNameMapping[normalizedName] + + if (!blockId) { + return undefined + } + + const blockOutput = context.blockData[blockId] + const schema = context.blockOutputSchemas?.[blockId] + + if (blockOutput === undefined) { + if (schema && pathParts.length > 0) { + if (!isPathInSchema(schema, pathParts)) { + throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema)) + } + } + return { value: undefined, blockId } + } + + if (pathParts.length === 0) { + return { value: blockOutput, blockId } + } + + const value = navigatePath(blockOutput, pathParts) + + if (value === undefined && schema) { + if (!isPathInSchema(schema, pathParts)) { + throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema)) + } + } + + return { value, blockId } +} diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 2bdee595b..a29339b2b 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -1,11 +1,15 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' -import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types' import { isReference, normalizeName, parseReferencePath, SPECIAL_REFERENCE_PREFIXES, } from '@/executor/constants' +import { + InvalidFieldError, + type OutputSchema, + resolveBlockReference, +} from '@/executor/utils/block-reference' import { navigatePath, type ResolutionContext, @@ -14,123 +18,6 @@ import { import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { getTool } from '@/tools/utils' -function isPathInOutputSchema( - outputs: Record | undefined, - pathParts: string[] -): boolean { - if (!outputs || pathParts.length === 0) { - return true - } - - const isFileArrayType = (value: any): boolean => - value?.type === 'file[]' || value?.type === 'files' - - let current: any = outputs - for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i] - - const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) - if (arrayMatch) { - const [, prop] = arrayMatch - let fieldDef: any - - if (prop in current) { - fieldDef = current[prop] - } else if (current.properties && prop in current.properties) { - fieldDef = current.properties[prop] - } else if (current.type === 'array' && current.items) { - if (current.items.properties && prop in current.items.properties) { - fieldDef = current.items.properties[prop] - } else if (prop in current.items) { - fieldDef = current.items[prop] - } - } - - if (!fieldDef) { - return false - } - - if (isFileArrayType(fieldDef)) { - if (i + 1 < pathParts.length) { - return USER_FILE_ACCESSIBLE_PROPERTIES.includes(pathParts[i + 1] as any) - } - return true - } - - if (fieldDef.type === 'array' && fieldDef.items) { - current = fieldDef.items - continue - } - - current = fieldDef - continue - } - - if (/^\d+$/.test(part)) { - if (isFileArrayType(current)) { - if (i + 1 < pathParts.length) { - const nextPart = pathParts[i + 1] - return USER_FILE_ACCESSIBLE_PROPERTIES.includes(nextPart as any) - } - return true - } - continue - } - - if (current === null || current === undefined) { - return false - } - - if (part in current) { - const nextCurrent = current[part] - if (nextCurrent?.type === 'file[]' && i + 1 < pathParts.length) { - const nextPart = pathParts[i + 1] - if (/^\d+$/.test(nextPart) && i + 2 < pathParts.length) { - const propertyPart = pathParts[i + 2] - return USER_FILE_ACCESSIBLE_PROPERTIES.includes(propertyPart as any) - } - } - current = nextCurrent - continue - } - - if (current.properties && part in current.properties) { - current = current.properties[part] - continue - } - - if (current.type === 'array' && current.items) { - if (current.items.properties && part in current.items.properties) { - current = current.items.properties[part] - continue - } - if (part in current.items) { - current = current.items[part] - continue - } - } - - if (isFileArrayType(current) && USER_FILE_ACCESSIBLE_PROPERTIES.includes(part as any)) { - return true - } - - if ('type' in current && typeof current.type === 'string') { - if (!current.properties && !current.items) { - return false - } - } - - return false - } - - return true -} - -function getSchemaFieldNames(outputs: Record | undefined): string[] { - if (!outputs) return [] - return Object.keys(outputs) -} - export class BlockResolver implements Resolver { private nameToBlockId: Map private blockById: Map @@ -170,83 +57,94 @@ export class BlockResolver implements Resolver { return undefined } - const block = this.blockById.get(blockId) + const block = this.blockById.get(blockId)! const output = this.getBlockOutput(blockId, context) - if (output === undefined) { + const blockData: Record = {} + const blockOutputSchemas: Record = {} + + if (output !== undefined) { + blockData[blockId] = output + } + + const blockType = block.metadata?.id + const params = block.config?.params as Record | undefined + const subBlocks = params + ? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }])) + : undefined + const toolId = block.config?.tool + const toolConfig = toolId ? getTool(toolId) : undefined + const outputSchema = + toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block.outputs) + + if (outputSchema && Object.keys(outputSchema).length > 0) { + blockOutputSchemas[blockId] = outputSchema + } + + try { + const result = resolveBlockReference(blockName, pathParts, { + blockNameMapping: Object.fromEntries(this.nameToBlockId), + blockData, + blockOutputSchemas, + })! + + if (result.value !== undefined) { + return result.value + } + + return this.handleBackwardsCompat(block, output, pathParts) + } catch (error) { + if (error instanceof InvalidFieldError) { + const fallback = this.handleBackwardsCompat(block, output, pathParts) + if (fallback !== undefined) { + return fallback + } + throw new Error(error.message) + } + throw error + } + } + + private handleBackwardsCompat( + block: SerializedBlock, + output: unknown, + pathParts: string[] + ): unknown { + if (output === undefined || pathParts.length === 0) { return undefined } - if (pathParts.length === 0) { - return output - } - // Try the original path first - let result = navigatePath(output, pathParts) - - // If successful, return it immediately - if (result !== undefined) { - return result - } - - // Response block backwards compatibility: - // Old: -> New: - // Only apply fallback if: - // 1. Block type is 'response' - // 2. Path starts with 'response.' - // 3. Output doesn't have a 'response' key (confirming it's the new format) if ( - block?.metadata?.id === 'response' && + block.metadata?.id === 'response' && pathParts[0] === 'response' && - output?.response === undefined + (output as Record)?.response === undefined ) { const adjustedPathParts = pathParts.slice(1) if (adjustedPathParts.length === 0) { return output } - result = navigatePath(output, adjustedPathParts) - if (result !== undefined) { - return result + const fallbackResult = navigatePath(output, adjustedPathParts) + if (fallbackResult !== undefined) { + return fallbackResult } } - // Workflow block backwards compatibility: - // Old: -> New: - // Only apply fallback if: - // 1. Block type is 'workflow' or 'workflow_input' - // 2. Path starts with 'result.response.' - // 3. output.result.response doesn't exist (confirming child used new format) const isWorkflowBlock = - block?.metadata?.id === 'workflow' || block?.metadata?.id === 'workflow_input' + block.metadata?.id === 'workflow' || block.metadata?.id === 'workflow_input' + const outputRecord = output as Record | undefined> if ( isWorkflowBlock && pathParts[0] === 'result' && pathParts[1] === 'response' && - output?.result?.response === undefined + outputRecord?.result?.response === undefined ) { const adjustedPathParts = ['result', ...pathParts.slice(2)] - result = navigatePath(output, adjustedPathParts) - if (result !== undefined) { - return result + const fallbackResult = navigatePath(output, adjustedPathParts) + if (fallbackResult !== undefined) { + return fallbackResult } } - const blockType = block?.metadata?.id - const params = block?.config?.params as Record | undefined - const subBlocks = params - ? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }])) - : undefined - const toolId = block?.config?.tool - const toolConfig = toolId ? getTool(toolId) : undefined - const outputSchema = - toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block?.outputs) - const schemaFields = getSchemaFieldNames(outputSchema) - if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) { - throw new Error( - `"${pathParts.join('.')}" doesn't exist on block "${blockName}". ` + - `Available fields: ${schemaFields.join(', ')}` - ) - } - return undefined } diff --git a/apps/sim/executor/variables/resolvers/reference.ts b/apps/sim/executor/variables/resolvers/reference.ts index 986ee2ab6..9f4b69eec 100644 --- a/apps/sim/executor/variables/resolvers/reference.ts +++ b/apps/sim/executor/variables/resolvers/reference.ts @@ -27,23 +27,28 @@ export function navigatePath(obj: any, path: string[]): any { return undefined } - // Handle array indexing like "items[0]" or just numeric indices - const arrayMatch = part.match(/^([^[]+)\[(\d+)\](.*)$/) + const arrayMatch = part.match(/^([^[]+)(\[.+)$/) if (arrayMatch) { - // Handle complex array access like "items[0]" - const [, prop, index] = arrayMatch + const [, prop, bracketsPart] = arrayMatch current = current[prop] if (current === undefined || current === null) { return undefined } - const idx = Number.parseInt(index, 10) - current = Array.isArray(current) ? current[idx] : undefined + + const indices = bracketsPart.match(/\[(\d+)\]/g) + if (indices) { + for (const indexMatch of indices) { + if (current === null || current === undefined) { + return undefined + } + const idx = Number.parseInt(indexMatch.slice(1, -1), 10) + current = Array.isArray(current) ? current[idx] : undefined + } + } } else if (/^\d+$/.test(part)) { - // Handle plain numeric index const index = Number.parseInt(part, 10) current = Array.isArray(current) ? current[index] : undefined } else { - // Handle regular property access current = current[part] } } diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index 53aa5b6fc..f6c587a15 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -130,7 +130,11 @@ async function executeCode(request) { await jail.set('environmentVariables', new ivm.ExternalCopy(envVars).copyInto()) for (const [key, value] of Object.entries(contextVariables)) { - await jail.set(key, new ivm.ExternalCopy(value).copyInto()) + if (value === undefined) { + await jail.set(key, undefined) + } else { + await jail.set(key, new ivm.ExternalCopy(value).copyInto()) + } } const fetchCallback = new ivm.Reference(async (url, optionsJson) => { diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index c5ab2147c..dc1b6eb20 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -56,6 +56,7 @@ describe('Function Execute Tool', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, + blockOutputSchemas: {}, isCustomTool: false, language: 'javascript', timeout: 5000, @@ -83,6 +84,7 @@ describe('Function Execute Tool', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, + blockOutputSchemas: {}, isCustomTool: false, language: 'javascript', workflowId: undefined, @@ -101,6 +103,7 @@ describe('Function Execute Tool', () => { workflowVariables: {}, blockData: {}, blockNameMapping: {}, + blockOutputSchemas: {}, isCustomTool: false, language: 'javascript', workflowId: undefined, diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 516c70127..d7f59daa8 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -53,6 +53,13 @@ export const functionExecuteTool: ToolConfig blockData?: Record blockNameMapping?: Record + blockOutputSchemas?: Record> _context?: { workflowId?: string }