Skip to content

Commit 3367369

Browse files
committed
fix(provenance): normalize a post-execution failure so it can carry the result
Round 3, cubic's finding accepted. The guard added last round required the caught value to already be an `Error`, so a non-Error raised by post-execution work skipped the attach and was rethrown bare — the same hole this branch closed in the executor, left open one layer up by my own change. A Copilot run would have reported an executed workflow as never started and vouched for content it cannot describe. Normalize once at the top of the catch and use that value throughout, including the rethrow, matching what the executor does. `toError` returns an `Error` unchanged, so a custom error class keeps its identity and every ordinary failure is untouched — the existing identity assertion on the rejection path still holds. Two tests: the result reaches an ordinary post-execution failure, and a non-Error one is normalized so it can carry the result too. The second fails against the previous guard.
1 parent 4260004 commit 3367369

2 files changed

Lines changed: 51 additions & 5 deletions

File tree

apps/sim/lib/workflows/executor/execute-workflow.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
5656
}))
5757

5858
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
59+
import { hasExecutionResult } from '@/executor/utils/errors'
5960

6061
const workflowExecutionLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
6162
([name]) => name === 'WorkflowExecution'
@@ -296,6 +297,44 @@ describe('executeWorkflow', () => {
296297
expect(executionSettled).toBe(true)
297298
})
298299

300+
/**
301+
* Post-execution work runs after the core has produced a result and the executor never sees
302+
* its failure, so this layer is the only one that can carry the result onto it. Callers read a
303+
* missing result as proof that no block ran — a Copilot run would report an executed workflow
304+
* as never started and vouch for content it cannot describe.
305+
*/
306+
it('carries the execution result onto a post-execution failure', async () => {
307+
const result = { success: true, output: { ran: true }, logs: [] }
308+
executeWorkflowCoreMock.mockResolvedValueOnce(result)
309+
handlePostExecutionPauseStateMock.mockRejectedValueOnce(new Error('pause persistence failed'))
310+
311+
const thrown = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
312+
enabled: true,
313+
principal,
314+
billingAttribution,
315+
}).catch((error: unknown) => error)
316+
317+
expect(hasExecutionResult(thrown)).toBe(true)
318+
expect((thrown as { executionResult?: unknown }).executionResult).toBe(result)
319+
})
320+
321+
/** A non-Error cannot carry the result, so it is normalized before anything reads it. */
322+
it('normalizes a non-Error post-execution failure so it can carry the result', async () => {
323+
const result = { success: true, output: { ran: true }, logs: [] }
324+
executeWorkflowCoreMock.mockResolvedValueOnce(result)
325+
handlePostExecutionPauseStateMock.mockRejectedValueOnce('pause persistence exploded')
326+
327+
const thrown = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
328+
enabled: true,
329+
principal,
330+
billingAttribution,
331+
}).catch((error: unknown) => error)
332+
333+
expect(thrown).toBeInstanceOf(Error)
334+
expect(hasExecutionResult(thrown)).toBe(true)
335+
expect((thrown as { executionResult?: unknown }).executionResult).toBe(result)
336+
})
337+
299338
it('transfers post-execution ownership with successful streaming metadata', async () => {
300339
const result = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
301340
enabled: true,

apps/sim/lib/workflows/executor/execute-workflow.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { WorkflowExecutionPrincipal } from '@sim/auth/principal'
22
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
34
import { generateId } from '@sim/utils/id'
45
import {
56
assertBillingAttributionSnapshot,
@@ -247,14 +248,20 @@ export async function executeWorkflow(
247248
}
248249

249250
return result
250-
} catch (error: unknown) {
251+
} catch (caught: unknown) {
252+
/**
253+
* Normalized before anything reads it, for the reason the executor normalizes its own throw:
254+
* a value that cannot carry the result would otherwise reach callers bare, and they read a
255+
* missing result as proof that no block ran. `toError` returns an `Error` unchanged, so a
256+
* custom error class keeps its identity and every ordinary failure is untouched.
257+
*/
258+
const error = toError(caught)
251259
/**
252260
* Carries the run's result on a failure raised after it produced one — the post-execution
253-
* work below the executor call can throw, and callers read a missing result as proof that no
254-
* block ran. Skipped when the executor already attached its own, which is the more specific
255-
* record, and when the throw is not an object to carry it.
261+
* work below the executor call can throw, and the executor never saw it. Skipped when the
262+
* executor already attached its own, which is the more specific record.
256263
*/
257-
if (executionResult && error instanceof Error && !hasExecutionResult(error)) {
264+
if (executionResult && !hasExecutionResult(error)) {
258265
attachExecutionResult(error, executionResult)
259266
}
260267
const errorDiagnostic = loggingSession.projectDiagnosticError(error)

0 commit comments

Comments
 (0)