Skip to content

Commit 9aff331

Browse files
authored
fix(executor): fix dependency resolution, allow blocks with multiple inputs to execute (#598)
1 parent 901d20d commit 9aff331

4 files changed

Lines changed: 464 additions & 10 deletions

File tree

apps/sim/executor/index.test.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,4 +668,238 @@ describe('Executor', () => {
668668
expect(createContextSpy).toHaveBeenCalled()
669669
})
670670
})
671+
672+
/**
673+
* Dependency checking logic tests
674+
*/
675+
describe('dependency checking', () => {
676+
test('should handle multi-input blocks with inactive sources correctly', () => {
677+
// Create workflow with router -> multiple APIs -> single agent
678+
const routerWorkflow = {
679+
blocks: [
680+
{
681+
id: 'start',
682+
metadata: { id: 'starter', name: 'Start' },
683+
config: { params: {} },
684+
enabled: true,
685+
},
686+
{
687+
id: 'router',
688+
metadata: { id: 'router', name: 'Router' },
689+
config: { params: { prompt: 'test', model: 'gpt-4' } },
690+
enabled: true,
691+
},
692+
{
693+
id: 'api1',
694+
metadata: { id: 'api', name: 'API 1' },
695+
config: { params: { url: 'http://api1.com', method: 'GET' } },
696+
enabled: true,
697+
},
698+
{
699+
id: 'api2',
700+
metadata: { id: 'api', name: 'API 2' },
701+
config: { params: { url: 'http://api2.com', method: 'GET' } },
702+
enabled: true,
703+
},
704+
{
705+
id: 'agent',
706+
metadata: { id: 'agent', name: 'Agent' },
707+
config: { params: { model: 'gpt-4', userPrompt: 'test' } },
708+
enabled: true,
709+
},
710+
],
711+
connections: [
712+
{ source: 'start', target: 'router' },
713+
{ source: 'router', target: 'api1' },
714+
{ source: 'router', target: 'api2' },
715+
{ source: 'api1', target: 'agent' },
716+
{ source: 'api2', target: 'agent' },
717+
],
718+
loops: {},
719+
parallels: {},
720+
}
721+
722+
const executor = new Executor(routerWorkflow)
723+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
724+
725+
// Mock context simulating: router selected api1, api1 executed, api2 not in active path
726+
const mockContext = {
727+
blockStates: new Map(),
728+
decisions: {
729+
router: new Map([['router', 'api1']]),
730+
condition: new Map(),
731+
},
732+
activeExecutionPath: new Set(['start', 'router', 'api1', 'agent']),
733+
workflow: routerWorkflow,
734+
} as any
735+
736+
const executedBlocks = new Set(['start', 'router', 'api1'])
737+
738+
// Test agent's dependencies
739+
const agentConnections = [
740+
{ source: 'api1', target: 'agent', sourceHandle: 'source' },
741+
{ source: 'api2', target: 'agent', sourceHandle: 'source' },
742+
]
743+
744+
const dependenciesMet = checkDependencies(agentConnections, executedBlocks, mockContext)
745+
746+
// Both dependencies should be met:
747+
// - api1: in active path AND executed = met
748+
// - api2: NOT in active path = automatically met
749+
expect(dependenciesMet).toBe(true)
750+
})
751+
752+
test('should prioritize special connection types over active path check', () => {
753+
const workflow = createMinimalWorkflow()
754+
const executor = new Executor(workflow)
755+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
756+
757+
const mockContext = {
758+
blockStates: new Map(),
759+
decisions: { router: new Map(), condition: new Map() },
760+
activeExecutionPath: new Set(['block1']), // block2 not in active path
761+
completedLoops: new Set(),
762+
workflow: workflow,
763+
} as any
764+
765+
const executedBlocks = new Set(['block1'])
766+
767+
// Test error connection (should be handled before active path check)
768+
const errorConnections = [{ source: 'block2', target: 'block3', sourceHandle: 'error' }]
769+
770+
// Mock block2 with error state
771+
mockContext.blockStates.set('block2', {
772+
output: { error: 'test error' },
773+
})
774+
775+
// Even though block2 is not in active path, error connection should be handled specially
776+
const errorDepsResult = checkDependencies(errorConnections, new Set(['block2']), mockContext)
777+
expect(errorDepsResult).toBe(true) // source executed + has error = dependency met
778+
779+
// Test loop connection
780+
const loopConnections = [
781+
{ source: 'block2', target: 'block3', sourceHandle: 'loop-end-source' },
782+
]
783+
784+
mockContext.completedLoops.add('block2')
785+
const loopDepsResult = checkDependencies(loopConnections, new Set(['block2']), mockContext)
786+
expect(loopDepsResult).toBe(true) // loop completed = dependency met
787+
})
788+
789+
test('should handle router decisions correctly in dependency checking', () => {
790+
const workflow = createMinimalWorkflow()
791+
const executor = new Executor(workflow)
792+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
793+
794+
// Add router block to workflow
795+
workflow.blocks.push({
796+
id: 'router1',
797+
metadata: { id: 'router', name: 'Router' },
798+
config: { params: {} },
799+
enabled: true,
800+
})
801+
802+
const mockContext = {
803+
blockStates: new Map(),
804+
decisions: {
805+
router: new Map([['router1', 'target1']]), // router selected target1
806+
condition: new Map(),
807+
},
808+
activeExecutionPath: new Set(['router1', 'target1', 'target2']),
809+
workflow: workflow,
810+
} as any
811+
812+
const executedBlocks = new Set(['router1'])
813+
814+
// Test selected target
815+
const selectedConnections = [{ source: 'router1', target: 'target1', sourceHandle: 'source' }]
816+
const selectedResult = checkDependencies(selectedConnections, executedBlocks, mockContext)
817+
expect(selectedResult).toBe(true) // router executed + target selected = dependency met
818+
819+
// Test non-selected target
820+
const nonSelectedConnections = [
821+
{ source: 'router1', target: 'target2', sourceHandle: 'source' },
822+
]
823+
const nonSelectedResult = checkDependencies(
824+
nonSelectedConnections,
825+
executedBlocks,
826+
mockContext
827+
)
828+
expect(nonSelectedResult).toBe(true) // router executed + target NOT selected = dependency auto-met
829+
})
830+
831+
test('should handle condition decisions correctly in dependency checking', () => {
832+
const conditionWorkflow = createWorkflowWithCondition()
833+
const executor = new Executor(conditionWorkflow)
834+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
835+
836+
const mockContext = {
837+
blockStates: new Map(),
838+
decisions: {
839+
router: new Map(),
840+
condition: new Map([['condition1', 'true']]), // condition selected true path
841+
},
842+
activeExecutionPath: new Set(['condition1', 'trueTarget']),
843+
workflow: conditionWorkflow,
844+
} as any
845+
846+
const executedBlocks = new Set(['condition1'])
847+
848+
// Test selected condition path
849+
const trueConnections = [
850+
{ source: 'condition1', target: 'trueTarget', sourceHandle: 'condition-true' },
851+
]
852+
const trueResult = checkDependencies(trueConnections, executedBlocks, mockContext)
853+
expect(trueResult).toBe(true)
854+
855+
// Test non-selected condition path
856+
const falseConnections = [
857+
{ source: 'condition1', target: 'falseTarget', sourceHandle: 'condition-false' },
858+
]
859+
const falseResult = checkDependencies(falseConnections, executedBlocks, mockContext)
860+
expect(falseResult).toBe(true) // condition executed + path NOT selected = dependency auto-met
861+
})
862+
863+
test('should handle regular sequential dependencies correctly', () => {
864+
const workflow = createMinimalWorkflow()
865+
const executor = new Executor(workflow)
866+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
867+
868+
const mockContext = {
869+
blockStates: new Map(),
870+
decisions: { router: new Map(), condition: new Map() },
871+
activeExecutionPath: new Set(['block1', 'block2']),
872+
workflow: workflow,
873+
} as any
874+
875+
const executedBlocks = new Set(['block1'])
876+
877+
// Test normal sequential dependency
878+
const normalConnections = [{ source: 'block1', target: 'block2', sourceHandle: 'source' }]
879+
880+
// Without error
881+
const normalResult = checkDependencies(normalConnections, executedBlocks, mockContext)
882+
expect(normalResult).toBe(true) // source executed + no error = dependency met
883+
884+
// With error should fail regular connection
885+
mockContext.blockStates.set('block1', {
886+
output: { error: 'test error' },
887+
})
888+
const errorResult = checkDependencies(normalConnections, executedBlocks, mockContext)
889+
expect(errorResult).toBe(false) // source executed + has error = regular dependency not met
890+
})
891+
892+
test('should handle empty dependency list', () => {
893+
const workflow = createMinimalWorkflow()
894+
const executor = new Executor(workflow)
895+
const checkDependencies = (executor as any).checkDependencies.bind(executor)
896+
897+
const mockContext = createMockContext()
898+
const executedBlocks = new Set<string>()
899+
900+
// Empty connections should return true
901+
const result = checkDependencies([], executedBlocks, mockContext)
902+
expect(result).toBe(true)
903+
})
904+
})
671905
})

apps/sim/executor/index.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,9 @@ export class Executor {
877877
insideParallel?: string,
878878
iterationIndex?: number
879879
): boolean {
880+
if (incomingConnections.length === 0) {
881+
return true
882+
}
880883
// Check if this is a loop block
881884
const isLoopBlock = incomingConnections.some((conn) => {
882885
const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source)
@@ -994,6 +997,12 @@ export class Executor {
994997
return sourceExecuted && conn.target === selectedTarget
995998
}
996999

1000+
// If source is not in active path, consider this dependency met
1001+
// This allows blocks with multiple inputs to execute even if some inputs are from inactive paths
1002+
if (!context.activeExecutionPath.has(conn.source)) {
1003+
return true
1004+
}
1005+
9971006
// For error connections, check if the source had an error
9981007
if (conn.sourceHandle === 'error') {
9991008
return sourceExecuted && hasSourceError
@@ -1004,12 +1013,6 @@ export class Executor {
10041013
return sourceExecuted && !hasSourceError
10051014
}
10061015

1007-
// If source is not in active path, consider this dependency met
1008-
// This allows blocks with multiple inputs to execute even if some inputs are from inactive paths
1009-
if (!context.activeExecutionPath.has(conn.source)) {
1010-
return true
1011-
}
1012-
10131016
// For regular blocks, dependency is met if source is executed
10141017
return sourceExecuted
10151018
})

0 commit comments

Comments
 (0)