Skip to content

Commit e1b58c1

Browse files
committed
fix(executor): stop condition/router erroring when downstream blocks are disabled
Manual editor runs built the executor payload from blocks-minus-disabled but sent every edge, so the server received edges pointing at blocks absent from workflow.blocks. Condition and router are the only handlers that resolve their target out of workflow.blocks, so only they threw "Target block <id> not found"; every other block type ignores a dangling edge. Deployed, scheduled, webhook and run-from-block executions always sent full state, so the editor was the only outlier — the DAG builder already excludes disabled blocks and their edges. - keep disabled blocks in the payload; filter only for trigger resolution - condition: a disabled or missing target dead-ends the branch instead of throwing - router: exclude disabled/missing targets from the candidate list, and dead-end without calling the model when none remain (previously these runs succeeded after picking a disabled block, so throwing would have failed live workflows) - serializer: drop connections that reference a block that does not exist
1 parent 1a23438 commit e1b58c1

8 files changed

Lines changed: 270 additions & 46 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ const {
3434
enabled: true,
3535
subBlocks: {},
3636
},
37+
condition1: {
38+
id: 'condition1',
39+
type: 'condition',
40+
name: 'Check',
41+
enabled: true,
42+
subBlocks: {},
43+
},
44+
disabledBranch: {
45+
id: 'disabledBranch',
46+
type: 'slack',
47+
name: 'Send Empty',
48+
enabled: false,
49+
subBlocks: {},
50+
},
3751
}
3852
const idleExecution = {
3953
status: 'idle',
@@ -73,12 +87,20 @@ const {
7387
finishRunningEntries: vi.fn(),
7488
clearExecutionEntries: vi.fn(),
7589
}
90+
const workflowEdges = [
91+
{
92+
id: 'edge-1',
93+
source: 'condition1',
94+
target: 'disabledBranch',
95+
sourceHandle: 'condition-else1',
96+
},
97+
]
7698
const workflowStoreState = {
7799
blocks: workflowBlocks,
78-
edges: [],
100+
edges: workflowEdges,
79101
getWorkflowState: vi.fn(() => ({
80102
blocks: workflowBlocks,
81-
edges: [],
103+
edges: workflowEdges,
82104
loops: {},
83105
parallels: {},
84106
})),
@@ -471,3 +493,39 @@ describe('useWorkflowExecution attachment uploads', () => {
471493
unmount()
472494
})
473495
})
496+
497+
describe('useWorkflowExecution workflow state override', () => {
498+
beforeEach(() => {
499+
vi.clearAllMocks()
500+
vi.stubGlobal('fetch', mockFetch)
501+
mockExecute.mockResolvedValue(undefined)
502+
})
503+
504+
afterEach(() => {
505+
vi.unstubAllGlobals()
506+
})
507+
508+
it('sends disabled blocks so no edge points at a block the executor cannot see', async () => {
509+
const { result, unmount } = renderWorkflowExecutionHook()
510+
511+
await act(async () => {
512+
const runResult = await result().handleRunWorkflow({
513+
input: 'go',
514+
conversationId: 'conversation-1',
515+
})
516+
await drainStream(runResult)
517+
})
518+
519+
expect(mockExecute).toHaveBeenCalledTimes(1)
520+
const { workflowStateOverride } = mockExecute.mock.calls[0][0]
521+
const sentBlockIds = new Set(Object.keys(workflowStateOverride.blocks))
522+
523+
expect(sentBlockIds.has('disabledBranch')).toBe(true)
524+
for (const edge of workflowStateOverride.edges) {
525+
expect(sentBlockIds.has(edge.source)).toBe(true)
526+
expect(sentBlockIds.has(edge.target)).toBe(true)
527+
}
528+
529+
unmount()
530+
})
531+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,10 +1009,13 @@ export function useWorkflowExecution() {
10091009
const workflowEdges = (executionWorkflowState?.edges ??
10101010
latestWorkflowState.edges) as typeof currentWorkflow.edges
10111011

1012-
// Filter out blocks without type (these are layout-only blocks) and disabled blocks
1012+
// Filter out blocks without type (these are layout-only blocks). Disabled blocks are
1013+
// deliberately kept: the server DAG excludes them (and their edges) on its own, and
1014+
// dropping them here while sending every edge leaves edges pointing at blocks that no
1015+
// longer exist — which makes condition/router target resolution fail at runtime.
10131016
const validBlocks = Object.entries(workflowBlocks).reduce(
10141017
(acc, [blockId, block]) => {
1015-
if (block?.type && block.enabled !== false) {
1018+
if (block?.type) {
10161019
acc[blockId] = block
10171020
}
10181021
return acc
@@ -1050,24 +1053,31 @@ export function useWorkflowExecution() {
10501053
}
10511054
})
10521055

1053-
// Filter out blocks without type and disabled blocks
1056+
// Filter out blocks without type. Disabled blocks stay in the payload so blocks and
1057+
// edges remain consistent; the executor's DAG builder drops them and their edges.
10541058
const filteredStates = Object.entries(mergedStates).reduce(
10551059
(acc, [id, block]) => {
10561060
if (!block || !block.type) {
10571061
logger.warn(`Skipping block with undefined type: ${id}`, block)
10581062
return acc
10591063
}
1060-
// Skip disabled blocks to prevent them from being passed to executor
1061-
if (block.enabled === false) {
1062-
logger.warn(`Skipping disabled block: ${id}`)
1063-
return acc
1064-
}
10651064
acc[id] = block
10661065
return acc
10671066
},
10681067
{} as typeof mergedStates
10691068
)
10701069

1070+
/** Trigger resolution must never select a disabled trigger. */
1071+
const enabledStates = Object.entries(filteredStates).reduce(
1072+
(acc, [id, block]) => {
1073+
if (block.enabled !== false) {
1074+
acc[id] = block
1075+
}
1076+
return acc
1077+
},
1078+
{} as typeof filteredStates
1079+
)
1080+
10711081
// If this is a chat execution, get the selected outputs
10721082
let selectedOutputs: string[] | undefined
10731083
if (isExecutingFromChat && activeWorkflowId) {
@@ -1082,7 +1092,7 @@ export function useWorkflowExecution() {
10821092

10831093
if (isExecutingFromChat) {
10841094
// For chat execution, find the appropriate chat trigger
1085-
const startBlock = TriggerUtils.findStartBlock(filteredStates, 'chat')
1095+
const startBlock = TriggerUtils.findStartBlock(enabledStates, 'chat')
10861096

10871097
if (!startBlock) {
10881098
throw new WorkflowValidationError(
@@ -1096,7 +1106,7 @@ export function useWorkflowExecution() {
10961106
startBlockId = startBlock.blockId
10971107
} else {
10981108
// Manual execution: detect and group triggers by paths
1099-
const candidates = resolveStartCandidates(filteredStates, {
1109+
const candidates = resolveStartCandidates(enabledStates, {
11001110
execution: 'manual',
11011111
})
11021112

@@ -1108,7 +1118,7 @@ export function useWorkflowExecution() {
11081118
'Workflow Validation'
11091119
)
11101120
logger.error('No trigger blocks found for manual run', {
1111-
allBlockTypes: Object.values(filteredStates).map((b) => b.type),
1121+
allBlockTypes: Object.values(enabledStates).map((b) => b.type),
11121122
})
11131123
if (activeWorkflowId) setIsExecuting(activeWorkflowId, false)
11141124
throw error

apps/sim/executor/handlers/condition/condition-handler.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,17 +288,35 @@ describe('ConditionBlockHandler', () => {
288288
expect(result).toHaveProperty('selectedOption', 'cond1')
289289
})
290290

291-
it('should throw error if target block is missing', async () => {
291+
it('dead-ends the branch when the target block is missing', async () => {
292292
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
293293

294294
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
295295
const inputs = { conditions: JSON.stringify(conditions) }
296296

297297
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
298298

299-
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
300-
`Target block ${mockTargetBlock1.id} not found`
301-
)
299+
const result = await handler.execute(mockContext, mockBlock, inputs)
300+
301+
expect(result).toHaveProperty('conditionResult', true)
302+
expect((result as any).selectedOption).toBe('cond1')
303+
expect((result as any).selectedPath).toBeNull()
304+
})
305+
306+
it('dead-ends the branch when the target block is disabled', async () => {
307+
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
308+
309+
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
310+
const inputs = { conditions: JSON.stringify(conditions) }
311+
312+
mockTargetBlock1.enabled = false
313+
314+
const result = await handler.execute(mockContext, mockBlock, inputs)
315+
316+
expect(result).toHaveProperty('conditionResult', true)
317+
expect((result as any).selectedOption).toBe('cond1')
318+
expect((result as any).selectedPath).toBeNull()
319+
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
302320
})
303321

304322
it('should return no-match result if no condition matches and no else exists', async () => {

apps/sim/executor/handlers/condition/condition-handler.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,32 @@ export class ConditionBlockHandler implements BlockHandler {
143143
}
144144
}
145145

146-
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection?.target)
147-
if (!targetBlock) {
148-
throw new Error(`Target block ${selectedConnection?.target} not found`)
149-
}
146+
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection.target)
150147

151148
const decisionKey = ctx.currentVirtualBlockId || block.id
152149
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
153150

151+
/**
152+
* A branch whose target is disabled (or no longer exists) is a dead end, not a
153+
* failure: the condition still resolves, and the DAG — which excludes disabled
154+
* blocks and their edges — simply has nothing left to activate on this path.
155+
*/
156+
if (!targetBlock || targetBlock.enabled === false) {
157+
logger.info('Condition branch target is not executable; path ends here', {
158+
blockId: block.id,
159+
conditionId: selectedCondition.id,
160+
targetBlockId: selectedConnection.target,
161+
reason: targetBlock ? 'disabled' : 'missing',
162+
})
163+
164+
return {
165+
...((sourceOutput as any) || {}),
166+
conditionResult: true,
167+
selectedPath: null,
168+
selectedOption: selectedCondition.id,
169+
}
170+
}
171+
154172
return {
155173
...((sourceOutput as any) || {}),
156174
conditionResult: true,

apps/sim/executor/handlers/router/router-handler.test.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,55 @@ describe('RouterBlockHandler', () => {
232232
})
233233
})
234234

235-
it('should throw error if target block is missing', async () => {
236-
const inputs = { prompt: 'Test' }
235+
it('excludes a missing target block from the routing candidates', async () => {
237236
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
238237

239-
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
240-
'Target block target-block-1 not found'
238+
mockFetch.mockImplementationOnce(() =>
239+
Promise.resolve({
240+
ok: true,
241+
json: () => Promise.resolve({ content: 'target-block-2', model: 'mock-model' }),
242+
})
243+
)
244+
245+
const result = await handler.execute(mockContext, mockBlock, { prompt: 'Test' })
246+
247+
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('Test', [
248+
expect.objectContaining({ id: 'target-block-2' }),
249+
])
250+
expect((result as { selectedRoute: string }).selectedRoute).toBe('target-block-2')
251+
})
252+
253+
it('excludes a disabled target block from the routing candidates', async () => {
254+
mockTargetBlock1.enabled = false
255+
256+
mockFetch.mockImplementationOnce(() =>
257+
Promise.resolve({
258+
ok: true,
259+
json: () => Promise.resolve({ content: 'target-block-2', model: 'mock-model' }),
260+
})
241261
)
262+
263+
const result = await handler.execute(mockContext, mockBlock, { prompt: 'Test' })
264+
265+
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('Test', [
266+
expect.objectContaining({ id: 'target-block-2' }),
267+
])
268+
expect((result as { selectedRoute: string }).selectedRoute).toBe('target-block-2')
269+
})
270+
271+
it('dead-ends without calling the model when every target block is disabled', async () => {
272+
mockTargetBlock1.enabled = false
273+
mockTargetBlock2.enabled = false
274+
275+
const result = (await handler.execute(mockContext, mockBlock, { prompt: 'Test' })) as {
276+
selectedRoute: string | null
277+
selectedPath: unknown
278+
cost: { total: number }
279+
}
280+
281+
expect(result.selectedRoute).toBeNull()
282+
expect(result.selectedPath).toBeNull()
283+
expect(result.cost.total).toBe(0)
242284
expect(mockFetch).not.toHaveBeenCalled()
243285
})
244286

apps/sim/executor/handlers/router/router-handler.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@ export class RouterBlockHandler implements BlockHandler {
6060
): Promise<BlockOutput> {
6161
const targetBlocks = this.getTargetBlocks(ctx, block)
6262

63+
/**
64+
* With nothing executable to route to there is no decision to make, so the path ends
65+
* here rather than erroring — the same dead end a condition branch reaches when its
66+
* target is disabled. Asking the model to choose from an empty list would only burn a
67+
* call to produce an unusable answer.
68+
*/
69+
if (!targetBlocks || targetBlocks.length === 0) {
70+
logger.warn('Router has no executable target blocks; path ends here', {
71+
blockId: block.id,
72+
})
73+
74+
return {
75+
prompt: inputs.prompt,
76+
model: inputs.model || ROUTER.DEFAULT_MODEL,
77+
tokens: {
78+
input: DEFAULTS.TOKENS.PROMPT,
79+
output: DEFAULTS.TOKENS.COMPLETION,
80+
total: DEFAULTS.TOKENS.TOTAL,
81+
},
82+
cost: { input: 0, output: 0, total: 0 },
83+
selectedPath: null,
84+
selectedRoute: null,
85+
} as BlockOutput
86+
}
87+
6388
const routerConfig = {
6489
prompt: inputs.prompt,
6590
model: inputs.model || ROUTER.DEFAULT_MODEL,
@@ -380,13 +405,22 @@ export class RouterBlockHandler implements BlockHandler {
380405
}
381406
}
382407

408+
/**
409+
* Candidate blocks the router may route to. Disabled (and missing) targets are
410+
* excluded so the model is never offered a route the DAG cannot execute.
411+
*/
383412
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
384413
return ctx.workflow?.connections
385414
.filter((conn) => conn.source === block.id)
386-
.map((conn) => {
415+
.flatMap((conn) => {
387416
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target)
388-
if (!targetBlock) {
389-
throw new Error(`Target block ${conn.target} not found`)
417+
if (!targetBlock || targetBlock.enabled === false) {
418+
logger.info('Skipping router target that is not executable', {
419+
blockId: block.id,
420+
targetBlockId: conn.target,
421+
reason: targetBlock ? 'disabled' : 'missing',
422+
})
423+
return []
390424
}
391425

392426
let systemPrompt = ''
@@ -399,17 +433,19 @@ export class RouterBlockHandler implements BlockHandler {
399433
''
400434
}
401435

402-
return {
403-
id: targetBlock.id,
404-
type: targetBlock.metadata?.id,
405-
title: targetBlock.metadata?.name,
406-
description: targetBlock.metadata?.description,
407-
subBlocks: {
408-
...targetBlock.config.params,
409-
systemPrompt: systemPrompt,
436+
return [
437+
{
438+
id: targetBlock.id,
439+
type: targetBlock.metadata?.id,
440+
title: targetBlock.metadata?.name,
441+
description: targetBlock.metadata?.description,
442+
subBlocks: {
443+
...targetBlock.config.params,
444+
systemPrompt: systemPrompt,
445+
},
446+
currentState: ctx.blockStates.get(targetBlock.id)?.output,
410447
},
411-
currentState: ctx.blockStates.get(targetBlock.id)?.output,
412-
}
448+
]
413449
})
414450
}
415451
}

0 commit comments

Comments
 (0)