From 73c029ffc759345a2ca043b0e2f8ad4bbed98e7d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 17 Jan 2026 12:11:33 -0800 Subject: [PATCH 1/2] fix(block-resolver): path lookup check --- .../variables/resolvers/block.test.ts | 58 +++++++++-- .../sim/executor/variables/resolvers/block.ts | 97 ++++++++++++++++++- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index 83e6cf1a73..dac00ee0b0 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -6,10 +6,14 @@ import type { ResolutionContext } from './reference' vi.mock('@sim/logger', () => loggerMock) -/** - * Creates a minimal workflow for testing. - */ -function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: string }> = []) { +function createTestWorkflow( + blocks: Array<{ + id: string + name?: string + type?: string + outputs?: Record + }> = [] +) { return { version: '1.0', blocks: blocks.map((b) => ({ @@ -17,7 +21,7 @@ function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: st position: { x: 0, y: 0 }, config: { tool: b.type ?? 'function', params: {} }, inputs: {}, - outputs: {}, + outputs: b.outputs ?? {}, metadata: { id: b.type ?? 'function', name: b.name ?? b.id }, enabled: true, })), @@ -126,7 +130,7 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBe(2) }) - it.concurrent('should return undefined for non-existent path', () => { + it.concurrent('should return undefined for non-existent path when no schema defined', () => { const workflow = createTestWorkflow([{ id: 'source' }]) const resolver = new BlockResolver(workflow) const ctx = createTestContext('current', { @@ -136,6 +140,48 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBeUndefined() }) + it.concurrent('should throw error for path not in output schema', () => { + const workflow = createTestWorkflow([ + { + id: 'source', + outputs: { + validField: { type: 'string', description: 'A valid field' }, + nested: { + child: { type: 'number', description: 'Nested child' }, + }, + }, + }, + ]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + source: { validField: 'value', nested: { child: 42 } }, + }) + + expect(() => resolver.resolve('', ctx)).toThrow( + /"invalidField" doesn't exist on block "source"/ + ) + expect(() => resolver.resolve('', ctx)).toThrow(/Available fields:/) + }) + + it.concurrent('should return undefined for path in schema but missing in data', () => { + const workflow = createTestWorkflow([ + { + id: 'source', + outputs: { + requiredField: { type: 'string', description: 'Always present' }, + optionalField: { type: 'string', description: 'Sometimes missing' }, + }, + }, + ]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + source: { requiredField: 'value' }, + }) + + expect(resolver.resolve('', ctx)).toBe('value') + expect(resolver.resolve('', ctx)).toBeUndefined() + }) + it.concurrent('should return undefined for non-existent block', () => { const workflow = createTestWorkflow([{ id: 'existing' }]) const resolver = new BlockResolver(workflow) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 4c46b0d299..49d837ed97 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -9,14 +9,92 @@ import { type ResolutionContext, type Resolver, } from '@/executor/variables/resolvers/reference' -import type { SerializedWorkflow } from '@/serializer/types' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +/** + * Check if a path exists in an output schema. + * Handles nested objects, arrays, and various schema formats. + * Numeric indices (array access) are skipped during validation. + */ +function isPathInOutputSchema( + outputs: Record | undefined, + pathParts: string[] +): boolean { + if (!outputs || pathParts.length === 0) { + return true // No schema or no path = allow (lenient) + } + + let current: any = outputs + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i] + + // Skip numeric indices (array access like items.0.name) + if (/^\d+$/.test(part)) { + continue + } + + if (current === null || current === undefined) { + return false + } + + // Check if the key exists directly + if (part in current) { + current = current[part] + continue + } + + // Check if current has 'properties' (object type with nested schema) + if (current.properties && part in current.properties) { + current = current.properties[part] + continue + } + + // Check if current is an array type with items + if (current.type === 'array' && current.items) { + // Array items can have properties or be a nested schema + 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 + } + } + + // Check if current has a 'type' field (it's a leaf with type definition) + // but we're trying to go deeper - this means the path doesn't exist + if ('type' in current && typeof current.type === 'string') { + // It's a typed field, can't go deeper unless it has properties + if (!current.properties && !current.items) { + return false + } + } + + // Path part not found in schema + return false + } + + return true +} + +/** + * Get available top-level field names from an output schema for error messages. + */ +function getSchemaFieldNames(outputs: Record | undefined): string[] { + if (!outputs) return [] + return Object.keys(outputs) +} export class BlockResolver implements Resolver { private nameToBlockId: Map + private blockById: Map constructor(private workflow: SerializedWorkflow) { this.nameToBlockId = new Map() + this.blockById = new Map() for (const block of workflow.blocks) { + this.blockById.set(block.id, block) if (block.metadata?.name) { this.nameToBlockId.set(normalizeName(block.metadata.name), block.id) } @@ -47,7 +125,9 @@ export class BlockResolver implements Resolver { return undefined } + const block = this.blockById.get(blockId) const output = this.getBlockOutput(blockId, context) + if (output === undefined) { return undefined } @@ -63,9 +143,6 @@ export class BlockResolver implements Resolver { return result } - // If failed, check if we should try backwards compatibility fallback - const block = this.workflow.blocks.find((b) => b.id === blockId) - // Response block backwards compatibility: // Old: -> New: // Only apply fallback if: @@ -108,6 +185,18 @@ export class BlockResolver implements Resolver { } } + // Path not found in data - check if it exists in the schema + // If path is NOT in schema, it's likely a typo - throw an error + // If path IS in schema but data is missing, it's an optional field - return undefined + const schemaFields = getSchemaFieldNames(block?.outputs) + if (schemaFields.length > 0 && !isPathInOutputSchema(block?.outputs, pathParts)) { + throw new Error( + `"${pathParts.join('.')}" doesn't exist on block "${blockName}". ` + + `Available fields: ${schemaFields.join(', ')}` + ) + } + + // Path exists in schema but data is missing - return undefined (optional field) return undefined } From 484eb365db9c5a8b4e6cd2b5497d9b26969a9ded Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 17 Jan 2026 12:14:16 -0800 Subject: [PATCH 2/2] remove comments --- .../sim/executor/variables/resolvers/block.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 49d837ed97..7786a27d6d 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -11,24 +11,18 @@ import { } from '@/executor/variables/resolvers/reference' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' -/** - * Check if a path exists in an output schema. - * Handles nested objects, arrays, and various schema formats. - * Numeric indices (array access) are skipped during validation. - */ function isPathInOutputSchema( outputs: Record | undefined, pathParts: string[] ): boolean { if (!outputs || pathParts.length === 0) { - return true // No schema or no path = allow (lenient) + return true } let current: any = outputs for (let i = 0; i < pathParts.length; i++) { const part = pathParts[i] - // Skip numeric indices (array access like items.0.name) if (/^\d+$/.test(part)) { continue } @@ -37,21 +31,17 @@ function isPathInOutputSchema( return false } - // Check if the key exists directly if (part in current) { current = current[part] continue } - // Check if current has 'properties' (object type with nested schema) if (current.properties && part in current.properties) { current = current.properties[part] continue } - // Check if current is an array type with items if (current.type === 'array' && current.items) { - // Array items can have properties or be a nested schema if (current.items.properties && part in current.items.properties) { current = current.items.properties[part] continue @@ -62,25 +52,18 @@ function isPathInOutputSchema( } } - // Check if current has a 'type' field (it's a leaf with type definition) - // but we're trying to go deeper - this means the path doesn't exist if ('type' in current && typeof current.type === 'string') { - // It's a typed field, can't go deeper unless it has properties if (!current.properties && !current.items) { return false } } - // Path part not found in schema return false } return true } -/** - * Get available top-level field names from an output schema for error messages. - */ function getSchemaFieldNames(outputs: Record | undefined): string[] { if (!outputs) return [] return Object.keys(outputs) @@ -185,9 +168,6 @@ export class BlockResolver implements Resolver { } } - // Path not found in data - check if it exists in the schema - // If path is NOT in schema, it's likely a typo - throw an error - // If path IS in schema but data is missing, it's an optional field - return undefined const schemaFields = getSchemaFieldNames(block?.outputs) if (schemaFields.length > 0 && !isPathInOutputSchema(block?.outputs, pathParts)) { throw new Error( @@ -196,7 +176,6 @@ export class BlockResolver implements Resolver { ) } - // Path exists in schema but data is missing - return undefined (optional field) return undefined }