Skip to content

Commit b5bae34

Browse files
committed
Make async tool resume delivery recoverable
1 parent a63c8fa commit b5bae34

21 files changed

Lines changed: 910 additions & 126 deletions

apps/sim/app/api/workflows/[id]/execute/route.async.test.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -852,8 +852,13 @@ describe('workflow execute async route', () => {
852852
status: 'pending',
853853
},
854854
{ id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' },
855+
403,
856+
'COPILOT_WORKFLOW_TOOL_BINDING_AWAITING_APPROVAL',
855857
],
856858
[
859+
// A finished call is a benign duplicate, not a defect: some other runner
860+
// already owns this tool call, so it reports the same conflict the
861+
// execution claim does and the client stays silent.
857862
'terminal tool row',
858863
{
859864
toolCallId: 'copilot-tool-1',
@@ -863,6 +868,8 @@ describe('workflow execute async route', () => {
863868
status: 'completed',
864869
},
865870
{ id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' },
871+
409,
872+
'COPILOT_WORKFLOW_EXECUTION_CONFLICT',
866873
],
867874
[
868875
'different workflow target',
@@ -874,6 +881,8 @@ describe('workflow execute async route', () => {
874881
status: 'running',
875882
},
876883
{ id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' },
884+
403,
885+
'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH',
877886
],
878887
[
879888
'different execution actor',
@@ -885,19 +894,28 @@ describe('workflow execute async route', () => {
885894
status: 'running',
886895
},
887896
{ id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' },
897+
403,
898+
'COPILOT_WORKFLOW_TOOL_BINDING_FOREIGN_OWNER',
888899
],
889-
])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => {
890-
mockGetAsyncToolCall.mockResolvedValueOnce(toolCall)
891-
mockGetRunSegment.mockResolvedValueOnce(run)
900+
['missing tool row', null, null, 404, 'COPILOT_WORKFLOW_TOOL_BINDING_UNKNOWN'],
901+
])(
902+
'rejects a Copilot binding owned by a %s',
903+
async (_caseName, toolCall, run, expectedStatus, expectedCode) => {
904+
mockGetAsyncToolCall.mockResolvedValueOnce(toolCall)
905+
mockGetRunSegment.mockResolvedValueOnce(run)
892906

893-
const response = await POST(createBoundCopilotExecutionRequest(), {
894-
params: Promise.resolve({ id: 'workflow-1' }),
895-
})
907+
const response = await POST(createBoundCopilotExecutionRequest(), {
908+
params: Promise.resolve({ id: 'workflow-1' }),
909+
})
896910

897-
expect(response.status).toBe(403)
898-
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
899-
expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled()
900-
})
911+
expect(response.status).toBe(expectedStatus)
912+
// The reason must be machine-readable — an opaque 403 is what stopped the
913+
// client telling a benign duplicate from a real failure.
914+
await expect(response.json()).resolves.toMatchObject({ code: expectedCode })
915+
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
916+
expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled()
917+
}
918+
)
901919

902920
it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => {
903921
const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), {

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,19 @@ import {
2121
type BillingAttributionSnapshot,
2222
requireBillingAttributionHeader,
2323
} from '@/lib/billing/core/billing-attribution'
24-
import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle'
2524
import {
2625
claimWorkflowToolExecution,
2726
getAsyncToolCall,
2827
getRunSegment,
2928
releaseWorkflowToolExecutionClaim,
3029
} from '@/lib/copilot/async-runs/repository'
3130
import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants'
31+
import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1'
32+
import { recordDegraded } from '@/lib/copilot/request/metrics'
3233
import {
3334
ASYNC_WORKFLOW_DEPLOYMENT_ERRORS,
34-
isWorkflowToolName,
35-
resolveWorkflowToolTargetId,
35+
type CopilotWorkflowToolBindingResult,
36+
classifyWorkflowToolBinding,
3637
} from '@/lib/copilot/tools/workflow-tools'
3738
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
3839
import {
@@ -168,25 +169,19 @@ const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3
168169
export const runtime = 'nodejs'
169170
export const dynamic = 'force-dynamic'
170171

171-
async function isValidCopilotWorkflowToolBinding(params: {
172+
async function resolveCopilotWorkflowToolBinding(params: {
172173
toolCallId: string
173174
userId: string
174175
workflowId: string
175-
}): Promise<boolean> {
176+
}): Promise<CopilotWorkflowToolBindingResult> {
176177
const toolCall = await getAsyncToolCall(params.toolCallId)
177-
if (
178-
!toolCall ||
179-
!isWorkflowToolName(toolCall.toolName) ||
180-
!isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision)
181-
) {
182-
return false
183-
}
184-
185-
const run = await getRunSegment(toolCall.runId)
186-
return (
187-
run?.userId === params.userId &&
188-
resolveWorkflowToolTargetId(toolCall.args, run.workflowId) === params.workflowId
189-
)
178+
const run = toolCall ? await getRunSegment(toolCall.runId) : null
179+
return classifyWorkflowToolBinding({
180+
toolCall,
181+
run,
182+
userId: params.userId,
183+
workflowId: params.workflowId,
184+
})
190185
}
191186

192187
function createExecutionJsonResponse(
@@ -1019,18 +1014,29 @@ async function handleExecutePost(
10191014
)
10201015
}
10211016

1022-
if (
1023-
copilotToolCallId &&
1024-
!(await isValidCopilotWorkflowToolBinding({
1017+
if (copilotToolCallId) {
1018+
const binding = await resolveCopilotWorkflowToolBinding({
10251019
toolCallId: copilotToolCallId,
10261020
userId,
10271021
workflowId,
1028-
}))
1029-
) {
1030-
return NextResponse.json(
1031-
{ error: 'Copilot workflow tool binding was not found' },
1032-
{ status: 403 }
1033-
)
1022+
})
1023+
if (!binding.ok) {
1024+
// This rejection happens before any LoggingSession exists, so it leaves
1025+
// no execution log and no workflow span — log the reason or it is
1026+
// invisible everywhere except the browser console.
1027+
// This rejection happens before a LoggingSession or any workflow span
1028+
// exists, so the counter is the only place it becomes visible.
1029+
recordDegraded(CopilotDegradedReason.BindingRejected)
1030+
reqLogger.warn('Rejected Copilot workflow tool execution', {
1031+
copilotToolCallId,
1032+
workflowId,
1033+
reason: binding.rejection.code,
1034+
})
1035+
return NextResponse.json(
1036+
{ error: binding.rejection.message, code: binding.rejection.code },
1037+
{ status: binding.rejection.statusCode }
1038+
)
1039+
}
10341040
}
10351041

10361042
if (inputFromExecutionId) {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1070,7 +1070,10 @@ export async function executeWorkflowWithFullLogging(
10701070
error: errorMessage,
10711071
httpStatus: response.status,
10721072
})
1073-
throw new Error(errorMessage)
1073+
// Keep the status and code on the thrown error. Downgrading to a bare Error
1074+
// discarded both, so callers could not tell a Copilot binding rejection from
1075+
// any other 4xx — and the reason never reached the agent that could fix it.
1076+
throw new ExecutionStreamHttpError(errorMessage, response.status, errorCode)
10741077
}
10751078

10761079
if (!response.body) {

apps/sim/lib/copilot/generated/metrics-v1.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const Metric = {
1919
CopilotCacheWrite: 'copilot.cache.write',
2020
CopilotChatBlobBytes: 'copilot.chat.blob.bytes',
2121
CopilotChatBlobCount: 'copilot.chat.blob.count',
22+
CopilotDegradedCount: 'copilot.degraded.count',
2223
CopilotFileReadDuration: 'copilot.file.read.duration',
2324
CopilotFileReadSize: 'copilot.file.read.size',
2425
CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration',
@@ -48,6 +49,7 @@ export const MetricValues: readonly MetricValue[] = [
4849
'copilot.cache.write',
4950
'copilot.chat.blob.bytes',
5051
'copilot.chat.blob.count',
52+
'copilot.degraded.count',
5153
'copilot.file.read.duration',
5254
'copilot.file.read.size',
5355
'copilot.messages.serialize.duration',

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4268,14 +4268,14 @@ export const RunBlock: ToolCatalogEntry = {
42684268
workflowId: {
42694269
type: 'string',
42704270
description:
4271-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4271+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
42724272
},
42734273
workflow_input: {
42744274
type: 'object',
42754275
description: 'JSON object with key-value mappings where each key is an input field name',
42764276
},
42774277
},
4278-
required: ['blockId'],
4278+
required: ['workflowId', 'blockId'],
42794279
},
42804280
clientExecutable: true,
42814281
}
@@ -4396,14 +4396,14 @@ export const RunFromBlock: ToolCatalogEntry = {
43964396
workflowId: {
43974397
type: 'string',
43984398
description:
4399-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4399+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
44004400
},
44014401
workflow_input: {
44024402
type: 'object',
44034403
description: 'JSON object with key-value mappings where each key is an input field name',
44044404
},
44054405
},
4406-
required: ['startBlockId'],
4406+
required: ['workflowId', 'startBlockId'],
44074407
},
44084408
clientExecutable: true,
44094409
}
@@ -4444,14 +4444,15 @@ export const RunWorkflow: ToolCatalogEntry = {
44444444
workflowId: {
44454445
type: 'string',
44464446
description:
4447-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4447+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
44484448
},
44494449
workflow_input: {
44504450
type: 'object',
44514451
description:
44524452
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
44534453
},
44544454
},
4455+
required: ['workflowId'],
44554456
},
44564457
clientExecutable: true,
44574458
requiresApproval: true,
@@ -4492,15 +4493,15 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = {
44924493
workflowId: {
44934494
type: 'string',
44944495
description:
4495-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4496+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
44964497
},
44974498
workflow_input: {
44984499
type: 'object',
44994500
description:
45004501
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
45014502
},
45024503
},
4503-
required: ['stopAfterBlockId'],
4504+
required: ['workflowId', 'stopAfterBlockId'],
45044505
},
45054506
clientExecutable: true,
45064507
requiresApproval: true,

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4145,14 +4145,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
41454145
workflowId: {
41464146
type: 'string',
41474147
description:
4148-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4148+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
41494149
},
41504150
workflow_input: {
41514151
type: 'object',
41524152
description: 'JSON object with key-value mappings where each key is an input field name',
41534153
},
41544154
},
4155-
required: ['blockId'],
4155+
required: ['workflowId', 'blockId'],
41564156
},
41574157
resultSchema: undefined,
41584158
},
@@ -4270,14 +4270,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
42704270
workflowId: {
42714271
type: 'string',
42724272
description:
4273-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4273+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
42744274
},
42754275
workflow_input: {
42764276
type: 'object',
42774277
description: 'JSON object with key-value mappings where each key is an input field name',
42784278
},
42794279
},
4280-
required: ['startBlockId'],
4280+
required: ['workflowId', 'startBlockId'],
42814281
},
42824282
resultSchema: undefined,
42834283
},
@@ -4313,14 +4313,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
43134313
workflowId: {
43144314
type: 'string',
43154315
description:
4316-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4316+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
43174317
},
43184318
workflow_input: {
43194319
type: 'object',
43204320
description:
43214321
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
43224322
},
43234323
},
4324+
required: ['workflowId'],
43244325
},
43254326
resultSchema: undefined,
43264327
},
@@ -4355,15 +4356,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
43554356
workflowId: {
43564357
type: 'string',
43574358
description:
4358-
'Optional workflow ID to run. If not provided, uses the current workflow in context.',
4359+
'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
43594360
},
43604361
workflow_input: {
43614362
type: 'object',
43624363
description:
43634364
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
43644365
},
43654366
},
4366-
required: ['stopAfterBlockId'],
4367+
required: ['workflowId', 'stopAfterBlockId'],
43674368
},
43684369
resultSchema: undefined,
43694370
},

apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ export const CopilotConfirmOutcome = {
117117
export type CopilotConfirmOutcomeKey = keyof typeof CopilotConfirmOutcome
118118
export type CopilotConfirmOutcomeValue = (typeof CopilotConfirmOutcome)[CopilotConfirmOutcomeKey]
119119

120+
export const CopilotDegradedReason = {
121+
BindingRejected: 'binding_rejected',
122+
ClientPickupTimeout: 'client_pickup_timeout',
123+
MissingToolResult: 'missing_tool_result',
124+
StreamDeadBeforeDispatch: 'stream_dead_before_dispatch',
125+
} as const
126+
127+
export type CopilotDegradedReasonKey = keyof typeof CopilotDegradedReason
128+
export type CopilotDegradedReasonValue = (typeof CopilotDegradedReason)[CopilotDegradedReasonKey]
129+
120130
export const CopilotFinalizeOutcome = {
121131
Aborted: 'aborted',
122132
Error: 'error',

apps/sim/lib/copilot/generated/trace-attributes-v1.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export const TraceAttr = {
189189
CopilotCommandsCount: 'copilot.commands.count',
190190
CopilotConfirmOutcome: 'copilot.confirm.outcome',
191191
CopilotContextsCount: 'copilot.contexts.count',
192+
CopilotDegradedReason: 'copilot.degraded.reason',
192193
CopilotExecutionId: 'copilot.execution.id',
193194
CopilotFileAttachmentsCount: 'copilot.file_attachments.count',
194195
CopilotFinalizeOutcome: 'copilot.finalize.outcome',
@@ -833,6 +834,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
833834
'copilot.commands.count',
834835
'copilot.confirm.outcome',
835836
'copilot.contexts.count',
837+
'copilot.degraded.reason',
836838
'copilot.execution.id',
837839
'copilot.file_attachments.count',
838840
'copilot.finalize.outcome',

0 commit comments

Comments
 (0)