Skip to content

Commit d931268

Browse files
committed
fix(copilot): route run cancellations through internal API
1 parent 5928f37 commit d931268

2 files changed

Lines changed: 87 additions & 24 deletions

File tree

apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ import type { ExecutionContext } from '@/lib/copilot/request/types'
77
const { mocks } = vi.hoisted(() => ({
88
mocks: {
99
apiKey: vi.fn(),
10+
cancelWorkflowExecutionRoute: vi.fn(),
1011
executeWorkflowUseCase: vi.fn(),
12+
generateInternalToken: vi.fn(),
1113
hasExecutionResult: vi.fn(),
1214
},
1315
}))
1416

17+
vi.mock('@/app/api/workflows/[id]/executions/[executionId]/cancel/route', () => ({
18+
POST: mocks.cancelWorkflowExecutionRoute,
19+
}))
20+
21+
vi.mock('@/lib/auth/internal', () => ({
22+
generateInternalToken: mocks.generateInternalToken,
23+
}))
24+
1525
vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({
1626
executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase,
1727
messageForCopilotWorkflowError: (_error: unknown, fallback = 'Workflow operation failed') =>
@@ -51,12 +61,14 @@ const context = {
5161
workspaceId: 'workspace-1',
5262
workflowId: 'workflow-1',
5363
toolCallId: 'tool-call-1',
64+
copilotToolExecution: true,
5465
billingAttribution: { workspaceId: 'workspace-1' },
5566
} as ExecutionContext
5667

5768
describe('workflow mutation Copilot adapters', () => {
5869
beforeEach(() => {
5970
vi.clearAllMocks()
71+
mocks.generateInternalToken.mockResolvedValue('internal-token')
6072
mocks.hasExecutionResult.mockReturnValue(false)
6173
})
6274

@@ -159,18 +171,18 @@ describe('workflow mutation Copilot adapters', () => {
159171
)
160172
})
161173

162-
it('cancels a workflow run through the registered application command', async () => {
163-
mocks.executeWorkflowUseCase.mockResolvedValue({
164-
success: true,
165-
executionId: 'execution-1',
166-
redisAvailable: true,
167-
durablyRecorded: true,
168-
locallyAborted: false,
169-
pausedCancelled: false,
170-
reason: 'recorded',
171-
workflowId: 'workflow-1',
172-
workspaceId: 'workspace-1',
173-
})
174+
it('cancels a workflow run through the same route as the logs UI', async () => {
175+
mocks.cancelWorkflowExecutionRoute.mockResolvedValue(
176+
Response.json({
177+
success: true,
178+
executionId: 'execution-1',
179+
redisAvailable: true,
180+
durablyRecorded: true,
181+
locallyAborted: false,
182+
pausedCancelled: false,
183+
reason: 'recorded',
184+
})
185+
)
174186

175187
const result = await executeCancelWorkflowRun(
176188
{ workflowId: 'workflow-1', executionId: 'execution-1' },
@@ -188,19 +200,40 @@ describe('workflow mutation Copilot adapters', () => {
188200
reason: 'recorded',
189201
},
190202
})
191-
expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith(
192-
context,
193-
expect.objectContaining({
194-
operation: expect.objectContaining({ id: 'workflows.runs.cancel' }),
195-
}),
196-
{ workflowId: 'workflow-1', runId: 'execution-1' }
203+
expect(mocks.generateInternalToken).toHaveBeenCalledWith('user-1')
204+
expect(mocks.cancelWorkflowExecutionRoute).toHaveBeenCalledOnce()
205+
const [request, routeContext] = mocks.cancelWorkflowExecutionRoute.mock.calls[0]
206+
expect(request.method).toBe('POST')
207+
expect(request.headers.get('authorization')).toBe('Bearer internal-token')
208+
expect(request.nextUrl.pathname).toBe('/api/workflows/workflow-1/executions/execution-1/cancel')
209+
await expect(routeContext.params).resolves.toEqual({
210+
id: 'workflow-1',
211+
executionId: 'execution-1',
212+
})
213+
expect(mocks.executeWorkflowUseCase).not.toHaveBeenCalled()
214+
})
215+
216+
it('returns the cancellation route error to the Run agent', async () => {
217+
mocks.cancelWorkflowExecutionRoute.mockResolvedValue(
218+
Response.json({ error: 'Execution cannot be cancelled while completed' }, { status: 409 })
197219
)
220+
221+
const result = await executeCancelWorkflowRun(
222+
{ workflowId: 'workflow-1', executionId: 'execution-1' },
223+
context
224+
)
225+
226+
expect(result).toEqual({
227+
success: false,
228+
error: 'Execution cannot be cancelled while completed',
229+
})
198230
})
199231

200232
it('requires an execution ID before attempting workflow-run cancellation', async () => {
201233
const result = await executeCancelWorkflowRun({ workflowId: 'workflow-1' }, context)
202234

203235
expect(result).toEqual({ success: false, error: 'executionId is required' })
236+
expect(mocks.cancelWorkflowExecutionRoute).not.toHaveBeenCalled()
204237
expect(mocks.executeWorkflowUseCase).not.toHaveBeenCalled()
205238
})
206239

apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { createLogger } from '@sim/logger'
2+
import { NextRequest } from 'next/server'
3+
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
24
import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key'
5+
import { generateInternalToken } from '@/lib/auth/internal'
36
import { messageForCopilotApplicationError } from '@/lib/copilot/application/error'
47
import { executeCopilotApiKeyUseCase } from '@/lib/copilot/application/execute-api-key-use-case'
58
import {
69
executeCopilotWorkflowUseCase,
710
messageForCopilotWorkflowError,
811
} from '@/lib/copilot/application/execute-workflow-use-case'
12+
import { requireTrustedCopilotExecutionContext } from '@/lib/copilot/auth/application-delegation'
913
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
1014
import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope'
1115
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
1216
import { PlatformEvents } from '@/lib/core/telemetry'
13-
import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run'
1417
import { createWorkflow } from '@/lib/workflows/application/create-workflow'
1518
import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk'
1619
import {
@@ -98,6 +101,12 @@ function resolveInputFromExecutionId(value: unknown): string | undefined {
98101
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
99102
}
100103

104+
function readCancellationRouteError(body: unknown): string | undefined {
105+
if (!body || typeof body !== 'object' || !('error' in body)) return undefined
106+
const error = body.error
107+
return typeof error === 'string' && error.trim() ? error : undefined
108+
}
109+
101110
function copilotRunLifecycle(context: ExecutionContext) {
102111
return {
103112
billingAttribution: context.billingAttribution,
@@ -247,15 +256,36 @@ export async function executeCancelWorkflowRun(
247256
context,
248257
'Request aborted before workflow run cancellation could be applied.'
249258
)
250-
const result = await executeCopilotWorkflowUseCase(context, cancelWorkflowRun, {
251-
workflowId,
252-
runId: executionId,
253-
})
259+
const trustedContext = requireTrustedCopilotExecutionContext(context)
260+
const internalToken = await generateInternalToken(trustedContext.userId)
261+
const { POST: cancelWorkflowExecution } = await import(
262+
'@/app/api/workflows/[id]/executions/[executionId]/cancel/route'
263+
)
264+
const response = await cancelWorkflowExecution(
265+
new NextRequest(
266+
`http://localhost/api/workflows/${encodeURIComponent(workflowId)}/executions/${encodeURIComponent(executionId)}/cancel`,
267+
{
268+
method: 'POST',
269+
headers: { authorization: `Bearer ${internalToken}` },
270+
}
271+
),
272+
{ params: Promise.resolve({ id: workflowId, executionId }) }
273+
)
274+
const responseBody: unknown = await response.json()
275+
if (!response.ok) {
276+
return {
277+
success: false,
278+
error:
279+
readCancellationRouteError(responseBody) ||
280+
`Failed to cancel workflow run (${response.status})`,
281+
}
282+
}
283+
const result = cancelWorkflowExecutionContract.response.schema.parse(responseBody)
254284

255285
return {
256286
success: result.success,
257287
output: {
258-
workflowId: result.workflowId,
288+
workflowId,
259289
executionId: result.executionId,
260290
durablyRecorded: result.durablyRecorded,
261291
locallyAborted: result.locallyAborted,

0 commit comments

Comments
 (0)