Skip to content

Commit 671f88e

Browse files
icecrasher321claude
andcommitted
fix(copilot): put the dispatch boundary at the logging session, not the core
Review was right that entering the execution core is too early. The core loads custom blocks, workflow state, and the environment before `safeStart` writes a row, so a setup failure — which ran nothing and is safely retryable — still reported a dispatched run and sent the caller looking for it. Move the marker to `loggingStarted`, read before the catch's own recovery `safeStart` writes a row for the failure itself. That is the first point blocks may have executed, so it is the honest line, and it lets executeWorkflow go back to a plain rethrow. The thrown value stays exactly as received, including a non-Error one: the core's finalization guard identifies it, and three existing tests pin that. A thrown primitive therefore carries no id, which costs nothing today because every throw site past `safeStart` raises an Error — noted in the code rather than papered over. Also read the marker with `Object.hasOwn` rather than `in`, so an id reached through a prototype chain can never disclose an unrelated run, and cover the reserved-key branch of the disclosure guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1924f67 commit 671f88e

5 files changed

Lines changed: 38 additions & 22 deletions

File tree

apps/sim/executor/utils/errors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@ const ATTEMPTED_EXECUTION_ID = 'attemptedExecutionId'
5252
*/
5353
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
5454
if (!isAttachableThrown(error) || !executionId) return
55-
if (ATTEMPTED_EXECUTION_ID in error) return
55+
if (Object.hasOwn(error, ATTEMPTED_EXECUTION_ID)) return
5656
Object.assign(error, { [ATTEMPTED_EXECUTION_ID]: executionId })
5757
}
5858

5959
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
6060
export function readAttemptedExecutionId(error: unknown): string | undefined {
61-
if (!isAttachableThrown(error) || !(ATTEMPTED_EXECUTION_ID in error)) return undefined
61+
/**
62+
* Own property only. `in` would accept one reached through the prototype chain, which
63+
* would let an unrelated run's id be disclosed for a refusal that started nothing.
64+
*/
65+
if (!isAttachableThrown(error) || !Object.hasOwn(error, ATTEMPTED_EXECUTION_ID)) return undefined
6266
const value = (error as Record<string, unknown>)[ATTEMPTED_EXECUTION_ID]
6367
return typeof value === 'string' && value.length > 0 ? value : undefined
6468
}

apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,23 @@ describe('effect disclosure on a withheld result', () => {
492492
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
493493
})
494494

495+
it.each(['effect', 'resultWithheld'])(
496+
'voids the disclosure when an id would take the reserved key %s',
497+
(reserved) => {
498+
expect(
499+
projectToolResultForCopilot(
500+
{
501+
success: false,
502+
error: 'why',
503+
effect: { phase: 'performed', ids: { [reserved]: EXECUTION_ID } },
504+
},
505+
undefined,
506+
'run_workflow'
507+
)
508+
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
509+
}
510+
)
511+
495512
it('reports the phase and ids when every id is vouchable', () => {
496513
expect(
497514
projectToolResultForCopilot(

apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ describe('Copilot workflow run application commands', () => {
326326

327327
it('passes through the id executeWorkflow attached at its dispatch boundary', async () => {
328328
mocks.executeWorkflow.mockImplementationOnce(() => {
329-
// Exactly what executeWorkflow does once it enters the core.
329+
// Exactly what the execution core does once its logging session has started.
330330
const dispatchFailure = new Error('database unavailable')
331331
Object.assign(dispatchFailure, { attemptedExecutionId: 'child-execution-1' })
332332
throw dispatchFailure

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

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { WorkflowExecutionPrincipal } from '@sim/auth/principal'
22
import { createLogger } from '@sim/logger'
3-
import { toError } from '@sim/utils/errors'
43
import { generateId } from '@sim/utils/id'
54
import {
65
assertBillingAttributionSnapshot,
@@ -14,7 +13,6 @@ import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-pe
1413
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
1514
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
1615
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
17-
import { attachAttemptedExecutionId } from '@/executor/utils/errors'
1816
import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry'
1917
import type { CoreTriggerType } from '@/stores/logs/filters/types'
2018

@@ -130,7 +128,6 @@ export async function executeWorkflow(
130128
loggingSession.setTrustedExecutionCorrelation(streamConfig.trustedExecutionCorrelation)
131129
}
132130
let postExecutionOwnershipTransferred = false
133-
let dispatched = false
134131

135132
try {
136133
const metadata: ExecutionMetadata = {
@@ -172,13 +169,6 @@ export async function executeWorkflow(
172169

173170
const executionStartMs = Date.now()
174171

175-
/**
176-
* Entering the core is the point past which an execution row may exist. Everything above
177-
* it — workspace and billing preflight, snapshot construction — fails without creating
178-
* anything, so a caller can read the absence of this id as "nothing was started" rather
179-
* than as an admission of not knowing.
180-
*/
181-
dispatched = true
182172
const result = await executeWorkflowCore({
183173
snapshot,
184174
callbacks: {
@@ -250,14 +240,7 @@ export async function executeWorkflow(
250240
}
251241

252242
return result
253-
} catch (thrown: unknown) {
254-
/**
255-
* A thrown primitive has nowhere to carry the dispatched-run id, and losing it would make
256-
* a real run read as never started. Normalizing only past the dispatch boundary keeps the
257-
* rethrown value identical on every other path.
258-
*/
259-
const error = dispatched && typeof thrown !== 'object' ? toError(thrown) : thrown
260-
if (dispatched) attachAttemptedExecutionId(error, executionId)
243+
} catch (error: unknown) {
261244
const errorDiagnostic = loggingSession.projectDiagnosticError(error)
262245
logger.error(`[${requestId}] Workflow execution failed`, errorDiagnostic)
263246

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import type {
4949
SerializableExecutionState,
5050
} from '@/executor/execution/types'
5151
import type { ExecutionResult, StartBlockRunMetadata } from '@/executor/types'
52-
import { hasExecutionResult } from '@/executor/utils/errors'
52+
import { attachAttemptedExecutionId, hasExecutionResult } from '@/executor/utils/errors'
5353
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
5454
import {
5555
createResolvedSecretTraceRegistry,
@@ -1097,6 +1097,18 @@ async function executeWorkflowCoreImpl(
10971097

10981098
return result
10991099
} catch (error: unknown) {
1100+
/**
1101+
* Whether the run had started when it failed, read before the recovery `safeStart` below
1102+
* writes a row for the failure itself. This — not entry into this function — is what says
1103+
* blocks may have executed: everything above it (custom-block loading, state loading,
1104+
* environment and secret setup) fails having run nothing, and a caller told otherwise
1105+
* would refuse a retry that was safe.
1106+
*
1107+
* The thrown value is rethrown exactly as received, including a non-Error one, because
1108+
* the finalization guard below identifies it. A primitive therefore carries no id, which
1109+
* costs nothing today: every throw site past `safeStart` raises an Error.
1110+
*/
1111+
if (loggingStarted) attachAttemptedExecutionId(error, executionId)
11001112
const errorCause = describeErrorCause(error)
11011113
logger.error(
11021114
`[${requestId}] Execution failed:`,

0 commit comments

Comments
 (0)