Skip to content

Commit 557a12c

Browse files
j15zVikhyath Mondreti
authored andcommitted
fix(copilot): route run cancellations through internal API
1 parent 1bec59c commit 557a12c

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,12 +7,22 @@ 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
readAttemptedExecutionId: vi.fn(),
1315
},
1416
}))
1517

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

5970
describe('workflow mutation Copilot adapters', () => {
6071
beforeEach(() => {
6172
vi.clearAllMocks()
73+
mocks.generateInternalToken.mockResolvedValue('internal-token')
6274
mocks.hasExecutionResult.mockReturnValue(false)
6375
mocks.readAttemptedExecutionId.mockReturnValue(undefined)
6476
})
@@ -162,18 +174,18 @@ describe('workflow mutation Copilot adapters', () => {
162174
)
163175
})
164176

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

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

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

206238
expect(result).toEqual({ success: false, error: 'executionId is required' })
239+
expect(mocks.cancelWorkflowExecutionRoute).not.toHaveBeenCalled()
207240
expect(mocks.executeWorkflowUseCase).not.toHaveBeenCalled()
208241
})
209242

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
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 {
1115
TOOL_EFFECT_PHASE,
@@ -15,7 +19,6 @@ import {
1519
import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope'
1620
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
1721
import { PlatformEvents } from '@/lib/core/telemetry'
18-
import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run'
1922
import { createWorkflow } from '@/lib/workflows/application/create-workflow'
2023
import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk'
2124
import {
@@ -153,6 +156,12 @@ function resolveInputFromExecutionId(value: unknown): string | undefined {
153156
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
154157
}
155158

159+
function readCancellationRouteError(body: unknown): string | undefined {
160+
if (!body || typeof body !== 'object' || !('error' in body)) return undefined
161+
const error = body.error
162+
return typeof error === 'string' && error.trim() ? error : undefined
163+
}
164+
156165
function copilotRunLifecycle(context: ExecutionContext) {
157166
return {
158167
billingAttribution: context.billingAttribution,
@@ -302,15 +311,36 @@ export async function executeCancelWorkflowRun(
302311
context,
303312
'Request aborted before workflow run cancellation could be applied.'
304313
)
305-
const result = await executeCopilotWorkflowUseCase(context, cancelWorkflowRun, {
306-
workflowId,
307-
runId: executionId,
308-
})
314+
const trustedContext = requireTrustedCopilotExecutionContext(context)
315+
const internalToken = await generateInternalToken(trustedContext.userId)
316+
const { POST: cancelWorkflowExecution } = await import(
317+
'@/app/api/workflows/[id]/executions/[executionId]/cancel/route'
318+
)
319+
const response = await cancelWorkflowExecution(
320+
new NextRequest(
321+
`http://localhost/api/workflows/${encodeURIComponent(workflowId)}/executions/${encodeURIComponent(executionId)}/cancel`,
322+
{
323+
method: 'POST',
324+
headers: { authorization: `Bearer ${internalToken}` },
325+
}
326+
),
327+
{ params: Promise.resolve({ id: workflowId, executionId }) }
328+
)
329+
const responseBody: unknown = await response.json()
330+
if (!response.ok) {
331+
return {
332+
success: false,
333+
error:
334+
readCancellationRouteError(responseBody) ||
335+
`Failed to cancel workflow run (${response.status})`,
336+
}
337+
}
338+
const result = cancelWorkflowExecutionContract.response.schema.parse(responseBody)
309339

310340
return {
311341
success: result.success,
312342
output: {
313-
workflowId: result.workflowId,
343+
workflowId,
314344
executionId: result.executionId,
315345
durablyRecorded: result.durablyRecorded,
316346
locallyAborted: result.locallyAborted,

0 commit comments

Comments
 (0)