Compare commits

...

2 Commits

Author SHA1 Message Date
Vikhyath Mondreti
484eb365db remove comments 2026-01-17 12:14:16 -08:00
Vikhyath Mondreti
73c029ffc7 fix(block-resolver): path lookup check 2026-01-17 12:11:33 -08:00
2 changed files with 124 additions and 10 deletions

View File

@@ -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<string, any>
}> = []
) {
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('<source.items.1.id>', 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('<source.nonexistent>', 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('<source.invalidField>', ctx)).toThrow(
/"invalidField" doesn't exist on block "source"/
)
expect(() => resolver.resolve('<source.invalidField>', 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('<source.requiredField>', ctx)).toBe('value')
expect(resolver.resolve('<source.optionalField>', ctx)).toBeUndefined()
})
it.concurrent('should return undefined for non-existent block', () => {
const workflow = createTestWorkflow([{ id: 'existing' }])
const resolver = new BlockResolver(workflow)

View File

@@ -9,14 +9,75 @@ import {
type ResolutionContext,
type Resolver,
} from '@/executor/variables/resolvers/reference'
import type { SerializedWorkflow } from '@/serializer/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function isPathInOutputSchema(
outputs: Record<string, any> | undefined,
pathParts: string[]
): boolean {
if (!outputs || pathParts.length === 0) {
return true
}
let current: any = outputs
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]
if (/^\d+$/.test(part)) {
continue
}
if (current === null || current === undefined) {
return false
}
if (part in current) {
current = current[part]
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 ('type' in current && typeof current.type === 'string') {
if (!current.properties && !current.items) {
return false
}
}
return false
}
return true
}
function getSchemaFieldNames(outputs: Record<string, any> | undefined): string[] {
if (!outputs) return []
return Object.keys(outputs)
}
export class BlockResolver implements Resolver {
private nameToBlockId: Map<string, string>
private blockById: Map<string, SerializedBlock>
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 +108,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 +126,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: <responseBlock.response.data> -> New: <responseBlock.data>
// Only apply fallback if:
@@ -108,6 +168,14 @@ export class BlockResolver implements Resolver {
}
}
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(', ')}`
)
}
return undefined
}