Skip to content

Commit 4320fed

Browse files
feat(execution): callable execution service + structured error classifier
executeWorkflowService composes the same libs the v1 route holds inline (call-chain guard, execution-id claim, LoggingSession, preprocessing, deployed-state load + file-field processing, timeout-bound executeWorkflowCore, output hydration/compaction) for the deployed-state caller class — the seam the v2 execute route and in-process internal callers share, making the HTTP endpoint syntactic sugar. classifyExecutionError stops discarding the block context that buildBlockExecutionError already attaches at throw sites: failed runs now yield {message, code, blockId, blockName, blockType} with a stable append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/ INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/ OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class instead of substring-matching messages — the single place raw errors are interpreted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent 51b0cf1 commit 4320fed

3 files changed

Lines changed: 860 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ExecutionResult } from '@/executor/types'
6+
import {
7+
attachExecutionResult,
8+
buildBlockExecutionError,
9+
classifyExecutionError,
10+
} from '@/executor/utils/errors'
11+
12+
function failedResult(partial?: Partial<ExecutionResult>): ExecutionResult {
13+
return { success: false, output: {}, ...partial }
14+
}
15+
16+
describe('classifyExecutionError', () => {
17+
it('reads block context from the fields buildBlockExecutionError attaches and strips the name prefix', () => {
18+
const error = buildBlockExecutionError({
19+
block: { id: 'block-1', metadata: { name: 'Send Email', id: 'gmail' } } as never,
20+
error: new Error('Invalid credentials'),
21+
})
22+
23+
const classified = classifyExecutionError(error)
24+
25+
expect(classified).toMatchObject({
26+
message: 'Invalid credentials',
27+
code: 'BLOCK_EXECUTION_FAILED',
28+
blockId: 'block-1',
29+
blockName: 'Send Email',
30+
blockType: 'gmail',
31+
})
32+
})
33+
34+
it('falls back to the last failed, un-handled block log', () => {
35+
const result = failedResult({
36+
error: 'Agent: model refused',
37+
logs: [
38+
{
39+
blockId: 'b-ok',
40+
blockName: 'First',
41+
blockType: 'function',
42+
success: true,
43+
startedAt: '',
44+
endedAt: '',
45+
durationMs: 1,
46+
},
47+
{
48+
blockId: 'b-handled',
49+
blockName: 'Handled',
50+
blockType: 'api',
51+
success: false,
52+
errorHandled: true,
53+
error: 'handled upstream',
54+
startedAt: '',
55+
endedAt: '',
56+
durationMs: 1,
57+
},
58+
{
59+
blockId: 'b-fail',
60+
blockName: 'Agent',
61+
blockType: 'agent',
62+
success: false,
63+
error: 'model refused',
64+
startedAt: '',
65+
endedAt: '',
66+
durationMs: 1,
67+
},
68+
],
69+
})
70+
71+
const classified = classifyExecutionError(new Error('Agent: model refused'), result)
72+
73+
expect(classified).toMatchObject({
74+
message: 'model refused',
75+
code: 'BLOCK_EXECUTION_FAILED',
76+
blockId: 'b-fail',
77+
blockName: 'Agent',
78+
blockType: 'agent',
79+
})
80+
})
81+
82+
it('classifies child-workflow failures so parents can route on error class', () => {
83+
const result = failedResult({
84+
logs: [
85+
{
86+
blockId: 'wf-block',
87+
blockName: 'Enrich Lead',
88+
blockType: 'workflow_input',
89+
success: false,
90+
error: 'Child workflow failed',
91+
startedAt: '',
92+
endedAt: '',
93+
durationMs: 1,
94+
},
95+
],
96+
})
97+
98+
expect(classifyExecutionError(new Error('Child workflow failed'), result).code).toBe(
99+
'CHILD_WORKFLOW_FAILED'
100+
)
101+
})
102+
103+
it('maps the attached 4xx statusCode families', () => {
104+
const timeoutError = new Error('Execution exceeded the time limit')
105+
Object.assign(timeoutError, { statusCode: 408 })
106+
expect(classifyExecutionError(timeoutError).code).toBe('TIMEOUT')
107+
108+
const usageError = new Error('Usage limit exceeded for this billing period')
109+
Object.assign(usageError, { statusCode: 402 })
110+
expect(classifyExecutionError(usageError).code).toBe('USAGE_LIMIT_EXCEEDED')
111+
})
112+
113+
it('uses the attached executionResult when none is passed explicitly', () => {
114+
const error = new Error('Slack: channel not found')
115+
attachExecutionResult(
116+
error,
117+
failedResult({
118+
logs: [
119+
{
120+
blockId: 'slack-1',
121+
blockName: 'Slack',
122+
blockType: 'slack',
123+
success: false,
124+
error: 'channel not found',
125+
startedAt: '',
126+
endedAt: '',
127+
durationMs: 1,
128+
},
129+
],
130+
})
131+
)
132+
133+
expect(classifyExecutionError(error)).toMatchObject({
134+
code: 'BLOCK_EXECUTION_FAILED',
135+
blockId: 'slack-1',
136+
message: 'channel not found',
137+
})
138+
})
139+
140+
it('falls back to EXECUTION_FAILED with the raw message when nothing is classifiable', () => {
141+
expect(classifyExecutionError(new Error('something odd'))).toEqual({
142+
message: 'something odd',
143+
code: 'EXECUTION_FAILED',
144+
blockId: undefined,
145+
blockName: undefined,
146+
blockType: undefined,
147+
})
148+
})
149+
})

apps/sim/executor/utils/errors.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,120 @@ export function normalizeError(error: unknown): string {
117117
}
118118
return String(error)
119119
}
120+
121+
/**
122+
* Stable, append-only error classes for failed workflow executions. Callers
123+
* (v2 API consumers, parent workflows, MCP clients) route on these instead of
124+
* substring-matching messages; this module is the single place raw errors are
125+
* interpreted, so the executor can later attach codes natively at throw sites
126+
* without a wire change.
127+
*/
128+
export type WorkflowExecutionErrorCode =
129+
| 'TIMEOUT'
130+
| 'CANCELLED'
131+
| 'USAGE_LIMIT_EXCEEDED'
132+
| 'INVALID_INPUT'
133+
| 'BLOCK_EXECUTION_FAILED'
134+
| 'CHILD_WORKFLOW_FAILED'
135+
| 'OUTPUT_TOO_LARGE'
136+
| 'EXECUTION_FAILED'
137+
138+
export interface StructuredExecutionError {
139+
message: string
140+
code: WorkflowExecutionErrorCode
141+
blockId?: string
142+
blockName?: string
143+
blockType?: string
144+
}
145+
146+
interface AttachedBlockContext {
147+
blockId?: unknown
148+
blockName?: unknown
149+
blockType?: unknown
150+
}
151+
152+
function readAttachedBlockContext(error: unknown): {
153+
blockId?: string
154+
blockName?: string
155+
blockType?: string
156+
} {
157+
if (!(error instanceof Error)) return {}
158+
const attached = error as unknown as AttachedBlockContext
159+
return {
160+
blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined,
161+
blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined,
162+
blockType: typeof attached.blockType === 'string' ? attached.blockType : undefined,
163+
}
164+
}
165+
166+
function lastFailedBlockLog(result: ExecutionResult | undefined): {
167+
blockId?: string
168+
blockName?: string
169+
blockType?: string
170+
error?: string
171+
} {
172+
const logs = result?.logs
173+
if (!logs?.length) return {}
174+
for (let i = logs.length - 1; i >= 0; i--) {
175+
const log = logs[i]
176+
if (!log.success && log.errorHandled !== true) {
177+
return {
178+
blockId: log.blockId,
179+
blockName: log.blockName,
180+
blockType: log.blockType,
181+
error: log.error,
182+
}
183+
}
184+
}
185+
return {}
186+
}
187+
188+
const CHILD_WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input'])
189+
190+
/**
191+
* Classifies a failed execution into {@link StructuredExecutionError}.
192+
* Block context comes from the fields {@link buildBlockExecutionError} already
193+
* attaches at the throw site, falling back to the last failed, un-handled
194+
* `BlockLog`. The message drops the historical `"BlockName: "` prefix once
195+
* `blockName` is carried as its own field.
196+
*/
197+
export function classifyExecutionError(
198+
error: unknown,
199+
result?: ExecutionResult
200+
): StructuredExecutionError {
201+
const executionResult = result ?? (hasExecutionResult(error) ? error.executionResult : undefined)
202+
const attached = readAttachedBlockContext(error)
203+
const fromLog = lastFailedBlockLog(executionResult)
204+
const blockId = attached.blockId ?? fromLog.blockId
205+
const blockName = attached.blockName ?? fromLog.blockName
206+
const blockType = attached.blockType ?? fromLog.blockType
207+
208+
let message =
209+
(error instanceof Error ? error.message : undefined) ??
210+
executionResult?.error ??
211+
fromLog.error ??
212+
'Execution failed'
213+
if (blockName && message.startsWith(`${blockName}: `)) {
214+
message = message.slice(blockName.length + 2)
215+
}
216+
217+
const statusCode = error instanceof Error ? getExecutionErrorStatus(error) : undefined
218+
let code: WorkflowExecutionErrorCode
219+
if (statusCode === 408 || /\btimed? ?out\b/i.test(message)) {
220+
code = 'TIMEOUT'
221+
} else if (statusCode === 402 || /usage limit/i.test(message)) {
222+
code = 'USAGE_LIMIT_EXCEEDED'
223+
} else if (executionResult?.status === 'cancelled' || /\bcancelled\b/i.test(message)) {
224+
code = 'CANCELLED'
225+
} else if (/invalid input format/i.test(message)) {
226+
code = 'INVALID_INPUT'
227+
} else if (blockType && CHILD_WORKFLOW_BLOCK_TYPES.has(blockType)) {
228+
code = 'CHILD_WORKFLOW_FAILED'
229+
} else if (blockId) {
230+
code = 'BLOCK_EXECUTION_FAILED'
231+
} else {
232+
code = 'EXECUTION_FAILED'
233+
}
234+
235+
return { message, code, blockId, blockName, blockType }
236+
}

0 commit comments

Comments
 (0)