Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion apps/sim/executor/execution/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { EDGE } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type { ExecutionContext } from '@/executor/types'
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
import { ExecutionEngine } from './engine'
Expand Down Expand Up @@ -275,6 +275,39 @@ describe('ExecutionEngine', () => {
expect(provenance?.entries).toEqual([{ name: 'TOKEN', encryptedValue: 'ciphertext' }])
})

/**
* The crossing at the copilot boundary reads the absence of an attached result as "no block
* ran", so the attach has to be total. A block failure is normalized on the way in, so only
* a non-Error raised by `run`'s own work — here the cancellation subscribe it awaits before
* the queue — reaches the catch untouched and exercises the guarantee.
*/
it('attaches the execution result to a non-Error thrown by its own work', async () => {
const node = createMockNode('function-1', 'function')
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' },
])
registry.recordResolved('TOKEN', 'secret-value-1234')
const context = createMockContext({
decisions: { router: new Map(), condition: new Map() },
resolvedSecretTraceRegistry: registry,
})
mockIsExecutionCancelled.mockRejectedValueOnce('cancellation lookup exploded')

const engine = new ExecutionEngine(
context,
createMockDAG([node]),
createMockEdgeManager(),
createMockNodeOrchestrator()
)

const thrown = await engine.run(node.id).catch((error: unknown) => error)

expect(thrown).toBeInstanceOf(Error)
const attached = (thrown as Error & { executionResult?: ExecutionResult }).executionResult
expect(attached).toBeDefined()
expect(attached?.executionState?.resolvedSecretTraceProvenance).toBeDefined()
})

/** Deriving must not weaken the guarantee: a latched registry still exports incomplete. */
it('keeps the final output envelope incomplete when the registry latched', async () => {
const node = createMockNode('loop-1', 'loop')
Expand Down
15 changes: 11 additions & 4 deletions apps/sim/executor/execution/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,17 @@ export class ExecutionEngine {
metadata: this.context.metadata,
}

if (error instanceof Error) {
attachExecutionResult(error, executionResult)
}
throw error
/**
* Normalized first so the attach is total rather than conditional on the throw already
* being an `Error`. A block failure is normalized on the way in, so the old guard held in
* practice; what it did not give was a guarantee. The copilot crossing reads a missing
* result as proof that no block ran, and that inference has to hold for every throw out of
* here, including a non-`Error` raised by this file's own synchronous work. `toError`
* returns an `Error` unchanged, so ordinary failures keep their identity and their type.
*/
const thrown = toError(error)
attachExecutionResult(thrown, executionResult)
throw thrown
} finally {
this.cleanup()
}
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/executor/utils/resolved-secret-trace-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ export interface ResolvedSecretIncompletenessDiagnostics {
export const ANONYMOUS_SECRET_TRACE_REPLACEMENT = OPAQUE_RESOLVED_SECRET_REPLACEMENT
export const RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION = 1

/**
* The envelope for content no secret ever reached: vouched for, naming nothing.
*
* Distinct from an incomplete envelope, which says the opposite — that something may be carried
* and cannot be named. A boundary that knows nothing was resolved should say so with this rather
* than latch, since latching is the claim that redaction is impossible. Returned fresh so no
* caller shares a value it may serialize or extend.
*/
export function emptyResolvedSecretTraceProvenance(): ResolvedSecretTraceProvenanceV1 {
return { version: 1, complete: true, entries: [] }
}

const MAX_PROVENANCE_ENTRIES = PROVENANCE_MAX_ENTRIES
const MAX_SERIALIZED_PROVENANCE_BYTES = PROVENANCE_MAX_SERIALIZED_BYTES
const MAX_TRACE_CATALOG_ENTRIES = PROVENANCE_MAX_ENTRIES
Expand Down
5 changes: 1 addition & 4 deletions apps/sim/lib/logs/execution/logging-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type { SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog } from '@/executor/types'
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
import {
emptyResolvedSecretTraceProvenance,
isResolvedSecretTraceProvenanceV1,
RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION,
type ResolvedSecretTraceProvenanceV1,
Expand Down Expand Up @@ -124,10 +125,6 @@ function getActiveBlockDisplayProvenance(

const logger = createLogger('LoggingSession')

function emptyResolvedSecretTraceProvenance(): ResolvedSecretTraceProvenanceV1 {
return { version: 1, complete: true, entries: [] }
}

type CompletionAttempt = 'complete' | 'error' | 'cancelled' | 'paused'

export interface SecretSafeDisplayContent {
Expand Down
129 changes: 129 additions & 0 deletions apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,4 +391,133 @@ describe('Copilot workflow run application commands', () => {
expect(readAttemptedExecutionId(error)).toBeUndefined()
})
})

describe('failed-run provenance crossing', () => {
function trackingLifecycle() {
const importCrossingProvenance = vi.fn().mockResolvedValue(true)
return {
importCrossingProvenance,
lifecycle: {
resolvedSecretTraceRegistry: {
exportProvenanceForValue: vi.fn(() => undefined),
beginPendingActivation: vi.fn(() => vi.fn()),
importCrossingProvenance,
},
},
}
}

async function runExpectingFailure(input: { lifecycle: unknown }) {
await expect(
runWorkflowFromCopilot.execute({
principal,
input: {
workflowId: 'workflow-1',
useDraftState: true,
lifecycle: input.lifecycle,
hasWorkflowInput: false,
useMockPayload: true,
},
})
).rejects.toThrow()
}

/**
* The executor attaches its result to every throw, so a failure without one never reached a
* block. Nothing crossed, and saying so keeps the caller's tool result — and the reason its
* run could not start — instead of reducing it to "result unavailable".
*/
it('vouches for a failure that never reached the engine', async () => {
const { importCrossingProvenance, lifecycle: tracked } = trackingLifecycle()
mocks.executeWorkflow.mockRejectedValueOnce(new Error('workflow is not deployed'))

await runExpectingFailure({ lifecycle: tracked })

expect(importCrossingProvenance).toHaveBeenCalledWith(
{ version: 1, complete: true, entries: [] },
expect.objectContaining({ thrownMessage: 'workflow is not deployed' }),
expect.objectContaining({ origin: 'copilotWorkflowMutation.failedRunCrossing' })
)
})

/**
* The post-run crossing is inside the same try, so its failure reaches the catch with no
* execution result — the same evidence a never-started run leaves. An execution exists and
* its provenance was never imported, so this must not be vouched for.
*/
it('does not vouch when the crossing threw after the run returned', async () => {
const importCrossingProvenance = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('crossing import failed')
})
.mockResolvedValue(true)

await runExpectingFailure({
lifecycle: {
resolvedSecretTraceRegistry: {
exportProvenanceForValue: vi.fn(() => undefined),
beginPendingActivation: vi.fn(() => vi.fn()),
importCrossingProvenance,
},
},
})

expect(importCrossingProvenance).toHaveBeenNthCalledWith(
2,
undefined,
expect.objectContaining({ thrownMessage: 'crossing import failed' }),
expect.objectContaining({ origin: 'copilotWorkflowMutation.failedRunCrossing' })
)
})

/**
* The executor's post-execution work can throw after a run has already produced a result.
* `executeWorkflow` carries it on that throw, so this reaches the catch with a result and
* must not be claimed as never-started.
*/
it('does not vouch when post-execution work threw after the engine ran', async () => {
const { importCrossingProvenance, lifecycle: tracked } = trackingLifecycle()
const incomplete = { version: 1 as const, complete: false, entries: [] }
mocks.executeWorkflow.mockRejectedValueOnce(
Object.assign(new Error('post-execution persistence failed'), {
executionResult: {
success: true,
output: { ran: true },
executionState: { resolvedSecretTraceProvenance: incomplete },
},
})
)

await runExpectingFailure({ lifecycle: tracked })

expect(importCrossingProvenance).toHaveBeenCalledWith(
incomplete,
expect.objectContaining({ output: { ran: true } }),
expect.objectContaining({ origin: 'copilotWorkflowMutation.failedRunCrossing' })
)
})

/** A run that did execute and could not vouch still hands back its incomplete envelope. */
it('passes through an incomplete envelope from a run that did execute', async () => {
const { importCrossingProvenance, lifecycle: tracked } = trackingLifecycle()
const incomplete = { version: 1 as const, complete: false, entries: [] }
const failure = Object.assign(new Error('block failed'), {
executionResult: {
success: false,
output: { partial: true },
executionState: { resolvedSecretTraceProvenance: incomplete },
},
})
mocks.executeWorkflow.mockRejectedValueOnce(failure)

await runExpectingFailure({ lifecycle: tracked })

expect(importCrossingProvenance).toHaveBeenCalledWith(
incomplete,
expect.objectContaining({ output: { partial: true } }),
expect.objectContaining({ origin: 'copilotWorkflowMutation.failedRunCrossing' })
)
})
})
})
38 changes: 28 additions & 10 deletions apps/sim/lib/workflows/application/run-workflow-from-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ import {
} from '@/lib/workflows/triggers/run-options'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import { attachAttemptedExecutionId } from '@/executor/utils/errors'
import { attachAttemptedExecutionId, hasExecutionResult } from '@/executor/utils/errors'

const logger = createLogger('CopilotWorkflowRun')

import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import {
emptyResolvedSecretTraceProvenance,
type ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'

export interface CopilotWorkflowRunLifecycle {
billingAttribution?: BillingAttributionSnapshot
Expand Down Expand Up @@ -250,6 +253,13 @@ async function executeCopilotRun(params: {
params.executionInput
)
const completePendingActivation = registry?.beginPendingActivation()
/**
* The run's own result, once the executor returns it. The post-run crossing below is inside the
* same `try`, so its failure reaches the catch carrying nothing — and on that evidence alone it
* is indistinguishable from a run that never started. Holding the result here keeps the real
* envelope available to describe content that certainly exists.
*/
let runResult: ExecutionResult | undefined
/**
* The executor call is the first statement of this `try`, so everything caught below is
* post-dispatch by construction, while authorization, admission and provenance export all
Expand Down Expand Up @@ -302,6 +312,7 @@ async function executeCopilotRun(params: {
},
childExecutionId
)
runResult = result
if (registry) {
await registry.importCrossingProvenance(
result.executionState?.resolvedSecretTraceProvenance,
Expand All @@ -325,16 +336,23 @@ async function executeCopilotRun(params: {
* as never started and invite the duplicate this id exists to prevent.
*/
if (registry) {
const executionResult =
typeof error === 'object' &&
error !== null &&
'executionResult' in error &&
typeof error.executionResult === 'object'
? (error.executionResult as ExecutionResult)
: undefined
/**
* Either source counts as proof a run exists: the error carries the result when the run or
* its post-execution work threw, and `runResult` holds it when the failure came later still
* — from the crossing below, after the executor had already returned.
*/
const executionResult = hasExecutionResult(error) ? error.executionResult : runResult
try {
/**
* Only a failure with no result from either source can claim nothing ran, and saying so
* keeps the caller's failure reason instead of reducing the tool result to "result
* unavailable" for a message that named no secret because none had been resolved yet.
* Every other failure hands back the envelope it has, and an incomplete one still latches.
*/
await registry.importCrossingProvenance(
executionResult?.executionState?.resolvedSecretTraceProvenance,
executionResult
? executionResult.executionState?.resolvedSecretTraceProvenance
: emptyResolvedSecretTraceProvenance(),
{
output: executionResult?.output,
logs: executionResult?.logs,
Expand Down
39 changes: 39 additions & 0 deletions apps/sim/lib/workflows/executor/execute-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
}))

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

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

/**
* Post-execution work runs after the core has produced a result and the executor never sees
* its failure, so this layer is the only one that can carry the result onto it. Callers read a
* missing result as proof that no block ran — a Copilot run would report an executed workflow
* as never started and vouch for content it cannot describe.
*/
it('carries the execution result onto a post-execution failure', async () => {
const result = { success: true, output: { ran: true }, logs: [] }
executeWorkflowCoreMock.mockResolvedValueOnce(result)
handlePostExecutionPauseStateMock.mockRejectedValueOnce(new Error('pause persistence failed'))

const thrown = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
enabled: true,
principal,
billingAttribution,
}).catch((error: unknown) => error)

expect(hasExecutionResult(thrown)).toBe(true)
expect((thrown as { executionResult?: unknown }).executionResult).toBe(result)
})

/** A non-Error cannot carry the result, so it is normalized before anything reads it. */
it('normalizes a non-Error post-execution failure so it can carry the result', async () => {
const result = { success: true, output: { ran: true }, logs: [] }
executeWorkflowCoreMock.mockResolvedValueOnce(result)
handlePostExecutionPauseStateMock.mockRejectedValueOnce('pause persistence exploded')

const thrown = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
enabled: true,
principal,
billingAttribution,
}).catch((error: unknown) => error)

expect(thrown).toBeInstanceOf(Error)
expect(hasExecutionResult(thrown)).toBe(true)
expect((thrown as { executionResult?: unknown }).executionResult).toBe(result)
})

it('transfers post-execution ownership with successful streaming metadata', async () => {
const result = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
enabled: true,
Expand Down
Loading
Loading