Skip to content

Commit d078c06

Browse files
committed
fix(workflows): preserve v2 terminal cancellation responses
1 parent 6a6de27 commit d078c06

10 files changed

Lines changed: 184 additions & 22 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9986,7 +9986,7 @@
99869986
},
99879987
"durablyRecorded": {
99889988
"type": "boolean",
9989-
"description": "Whether this request durably recorded a cancellation. False when an already-cancelled run needed no further durable write."
9989+
"description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written."
99909990
},
99919991
"locallyAborted": {
99929992
"type": "boolean",
@@ -9997,7 +9997,7 @@
99979997
"description": "Whether a paused execution was cancelled."
99989998
},
99999999
"reason": {
10000-
"description": "Machine-readable cancellation outcome. `recorded`, `queue_cancelled`, and `already_cancelled` are successful outcomes; the remaining values identify a degraded or incomplete cancellation step.",
10000+
"description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.",
1000110001
"type": "string",
1000210002
"enum": [
1000310003
"recorded",
@@ -10024,7 +10024,7 @@
1002410024
],
1002510025
"additionalProperties": false,
1002610026
"title": "Cancel workflow run result",
10027-
"description": "Outcome of the shared workflow-run cancellation lifecycle used by internal UI, Copilot, and v2 callers."
10027+
"description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed."
1002810028
},
1002910029
"CancelWorkflowRunResponse": {
1003010030
"type": "object",

apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@sim/testing'
1212
import { NextRequest } from 'next/server'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
1415

1516
const mocks = vi.hoisted(() => ({
1617
cancel: vi.fn(),
@@ -104,4 +105,36 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => {
104105
reason: 'already_cancelled',
105106
})
106107
})
108+
109+
it.each([
110+
['completed', 'already_completed'],
111+
['failed', 'already_failed'],
112+
] as const)(
113+
'preserves the v2 terminal no-op response when a standalone run is already %s',
114+
async (executionStatus, reason) => {
115+
mocks.cancel.mockRejectedValue(
116+
new WorkflowRunAlreadyTerminalError({
117+
executionId: RUN_ID,
118+
executionStatus,
119+
redisAvailable: true,
120+
locallyAborted: false,
121+
})
122+
)
123+
124+
const response = await POST(request(), context)
125+
126+
expect(response.status).toBe(200)
127+
await expect(response.json()).resolves.toEqual({
128+
data: {
129+
success: true,
130+
runId: RUN_ID,
131+
redisAvailable: true,
132+
durablyRecorded: false,
133+
locallyAborted: false,
134+
pausedCancelled: false,
135+
reason,
136+
},
137+
})
138+
}
139+
)
107140
})

apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const POST = defineV2JsonRoute({
1212
auth: v2ApiKeyAuth,
1313
operation: workflowOperations.cancelRun,
1414
rateLimit: v2RateLimits.publicApi,
15-
errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization,
15+
errorPolicy: v2WorkflowErrorPolicies.cancelRun,
1616
mapInput: ({ params }) => ({ workflowId: params.workflowId, runId: params.runId }),
1717
useCase: cancelWorkflowRun,
1818
present: (result) => ({

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6-
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
77

88
const mocks = vi.hoisted(() => ({
99
cancel: vi.fn(),
@@ -83,7 +83,12 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
8383

8484
it('projects application conflicts without reimplementing cancellation errors', async () => {
8585
mocks.cancel.mockRejectedValue(
86-
new OrchestrationError('conflict', 'Execution cannot be cancelled while completed')
86+
new WorkflowRunAlreadyTerminalError({
87+
executionId: 'execution-1',
88+
executionStatus: 'completed',
89+
redisAvailable: true,
90+
locallyAborted: false,
91+
})
8792
)
8893

8994
const response = await POST(request(), context)

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,21 +1865,21 @@ export const v2CancelWorkflowRunDataSchema = z
18651865
durablyRecorded: z
18661866
.boolean()
18671867
.describe(
1868-
'Whether this request durably recorded a cancellation. False when an already-cancelled run needed no further durable write.'
1868+
'Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written.'
18691869
),
18701870
locallyAborted: z.boolean().describe('Whether an in-process execution was aborted.'),
18711871
pausedCancelled: z.boolean().describe('Whether a paused execution was cancelled.'),
18721872
reason: cancelWorkflowExecutionReasonSchema
18731873
.optional()
18741874
.describe(
1875-
'Machine-readable cancellation outcome. `recorded`, `queue_cancelled`, and `already_cancelled` are successful outcomes; the remaining values identify a degraded or incomplete cancellation step.'
1875+
'Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.'
18761876
),
18771877
})
18781878
.meta({
18791879
id: 'CancelWorkflowRunResult',
18801880
title: 'Cancel workflow run result',
18811881
description:
1882-
'Outcome of the shared workflow-run cancellation lifecycle used by internal UI, Copilot, and v2 callers.',
1882+
'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed.',
18831883
})
18841884
export type V2CancelWorkflowRunData = z.output<typeof v2CancelWorkflowRunDataSchema>
18851885

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import type { ExecutionContext } from '@/lib/copilot/request/types'
6+
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
67

78
const { mocks } = vi.hoisted(() => ({
89
mocks: {
@@ -202,8 +203,11 @@ describe('workflow mutation Copilot adapters', () => {
202203

203204
it('returns a cancellation application error to the Run agent', async () => {
204205
mocks.executeWorkflowUseCase.mockRejectedValue(
205-
Object.assign(new Error('Execution cannot be cancelled while completed'), {
206-
code: 'conflict',
206+
new WorkflowRunAlreadyTerminalError({
207+
executionId: 'execution-1',
208+
executionStatus: 'completed',
209+
redisAvailable: true,
210+
locallyAborted: false,
207211
})
208212
)
209213

apps/sim/lib/execution/cancel-workflow-execution.test.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import {
106106
cancelWorkflowExecution,
107107
WorkflowExecutionNotFoundError,
108108
} from '@/lib/execution/cancel-workflow-execution'
109+
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
109110

110111
const INPUT: CancelWorkflowExecutionInput = {
111112
workflowId: 'wf-1',
@@ -1369,14 +1370,44 @@ describe('cancelWorkflowExecution', () => {
13691370
await expect(response.json()).resolves.toEqual({ error: 'database unavailable' })
13701371
})
13711372

1372-
it('returns 409 when the execution is already terminal', async () => {
1373+
it.each(['completed', 'failed'] as const)(
1374+
'raises a typed conflict when a standalone execution is already %s',
1375+
async (executionStatus) => {
1376+
dbChainMockFns.limit.mockResolvedValueOnce([
1377+
{ status: executionStatus, workspaceId: 'workspace-1' },
1378+
])
1379+
1380+
const error = await cancelWorkflowExecution(INPUT).catch((caught: unknown) => caught)
1381+
1382+
expect(error).toBeInstanceOf(WorkflowRunAlreadyTerminalError)
1383+
expect(error).toEqual(
1384+
expect.objectContaining({
1385+
code: 'conflict',
1386+
executionId: 'ex-1',
1387+
executionStatus,
1388+
redisAvailable: true,
1389+
locallyAborted: false,
1390+
})
1391+
)
1392+
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
1393+
expect(mockCancelByExecution).not.toHaveBeenCalled()
1394+
}
1395+
)
1396+
1397+
it('keeps workflow-group terminal conflicts strict', async () => {
13731398
dbChainMockFns.limit.mockResolvedValueOnce([
1374-
{ status: 'completed', workspaceId: 'workspace-1' },
1399+
{
1400+
executionOrigin: 'workflow_group',
1401+
status: 'completed',
1402+
workspaceId: 'workspace-1',
1403+
},
13751404
])
13761405

1377-
const response = await POST(makeRequest(), makeParams())
1378-
1379-
expect(response.status).toBe(409)
1406+
await expect(cancelWorkflowExecution(INPUT)).rejects.toMatchObject({
1407+
name: 'OrchestrationError',
1408+
code: 'conflict',
1409+
message: 'Execution cannot be cancelled while completed',
1410+
})
13801411
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
13811412
expect(mockCancelByExecution).not.toHaveBeenCalled()
13821413
})
@@ -1392,12 +1423,18 @@ describe('cancelWorkflowExecution', () => {
13921423
])
13931424
.mockResolvedValueOnce([{ status: 'completed' }])
13941425

1395-
const response = await POST(makeRequest(), makeParams())
1426+
const error = await cancelWorkflowExecution(INPUT).catch((caught: unknown) => caught)
13961427

1397-
expect(response.status).toBe(409)
1398-
await expect(response.json()).resolves.toEqual({
1399-
error: 'Execution cannot be cancelled while completed',
1400-
})
1428+
expect(error).toBeInstanceOf(WorkflowRunAlreadyTerminalError)
1429+
expect(error).toEqual(
1430+
expect.objectContaining({
1431+
code: 'conflict',
1432+
executionId: 'ex-1',
1433+
executionStatus: 'completed',
1434+
redisAvailable: true,
1435+
locallyAborted: false,
1436+
})
1437+
)
14011438
expect(returning).toHaveBeenCalledOnce()
14021439
expect(mockClearExecutionCancellation).toHaveBeenCalledWith('ex-1')
14031440
expect(mockWriteTerminalEvent).not.toHaveBeenCalled()

apps/sim/lib/execution/cancel-workflow-execution.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import {
1515
} from '@/lib/execution/cancellation'
1616
import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer'
1717
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
18+
import {
19+
isWorkflowRunAlreadyTerminalStatus,
20+
WorkflowRunAlreadyTerminalError,
21+
} from '@/lib/execution/workflow-run-already-terminal-error'
1822
import { cancelledExecutionLogFields } from '@/lib/logs/execution/cancellation'
1923
import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin'
2024
import {
@@ -622,6 +626,14 @@ export async function cancelWorkflowExecution({
622626
}
623627

624628
if (execution.status !== 'running' && execution.status !== 'pending') {
629+
if (!isWorkflowGroupExecution && isWorkflowRunAlreadyTerminalStatus(execution.status)) {
630+
throw new WorkflowRunAlreadyTerminalError({
631+
executionId,
632+
executionStatus: execution.status,
633+
redisAvailable: true,
634+
locallyAborted: false,
635+
})
636+
}
625637
throw new OrchestrationError(
626638
'conflict',
627639
`Execution cannot be cancelled while ${execution.status}`
@@ -859,6 +871,17 @@ export async function cancelWorkflowExecution({
859871
}
860872
)
861873
}
874+
if (
875+
!isWorkflowGroupExecution &&
876+
isWorkflowRunAlreadyTerminalStatus(competingTerminalStatus)
877+
) {
878+
throw new WorkflowRunAlreadyTerminalError({
879+
executionId,
880+
executionStatus: competingTerminalStatus,
881+
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
882+
locallyAborted: stopSummary.locallyAborted,
883+
})
884+
}
862885
throw new OrchestrationError(
863886
'conflict',
864887
isWorkflowGroupExecution
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { OrchestrationError } from '@/lib/core/orchestration/types'
2+
3+
export type WorkflowRunAlreadyTerminalStatus = 'completed' | 'failed'
4+
5+
interface WorkflowRunAlreadyTerminalErrorOptions {
6+
executionId: string
7+
executionStatus: WorkflowRunAlreadyTerminalStatus
8+
redisAvailable: boolean
9+
locallyAborted: boolean
10+
}
11+
12+
/** A standalone run reached a non-cancellable terminal state before cancellation won. */
13+
export class WorkflowRunAlreadyTerminalError extends OrchestrationError {
14+
readonly executionId: string
15+
readonly executionStatus: WorkflowRunAlreadyTerminalStatus
16+
readonly redisAvailable: boolean
17+
readonly locallyAborted: boolean
18+
19+
constructor(options: WorkflowRunAlreadyTerminalErrorOptions) {
20+
super('conflict', `Execution cannot be cancelled while ${options.executionStatus}`)
21+
this.name = 'WorkflowRunAlreadyTerminalError'
22+
this.executionId = options.executionId
23+
this.executionStatus = options.executionStatus
24+
this.redisAvailable = options.redisAvailable
25+
this.locallyAborted = options.locallyAborted
26+
}
27+
}
28+
29+
export function isWorkflowRunAlreadyTerminalStatus(
30+
status: string
31+
): status is WorkflowRunAlreadyTerminalStatus {
32+
return status === 'completed' || status === 'failed'
33+
}

apps/sim/lib/workflows/api/route-policies.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
SessionPrincipal,
55
WorkspaceApiKeyPrincipal,
66
} from '@sim/auth/principal'
7+
import { v2CancelWorkflowRunDataSchema } from '@/lib/api/contracts/v2/workflows'
78
import {
89
createInternalResourceConcealmentPolicy,
910
createInternalSessionOrExecutorAuth,
@@ -19,10 +20,32 @@ import {
1920
} from '@/lib/api/server/routes'
2021
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
2122
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
23+
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
2224
import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization'
2325
import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error'
2426
import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error'
25-
import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response'
27+
import {
28+
v2CaughtOrchestrationError,
29+
v2Data,
30+
v2ErrorForOrchestration,
31+
} from '@/app/api/v2/lib/response'
32+
33+
function v2CancelRunErrorResponse(error: unknown) {
34+
if (error instanceof WorkflowRunAlreadyTerminalError) {
35+
return v2Data(
36+
v2CancelWorkflowRunDataSchema.parse({
37+
success: true,
38+
runId: error.executionId,
39+
redisAvailable: error.redisAvailable,
40+
durablyRecorded: false,
41+
locallyAborted: error.locallyAborted,
42+
pausedCancelled: false,
43+
reason: error.executionStatus === 'completed' ? 'already_completed' : 'already_failed',
44+
})
45+
)
46+
}
47+
return v2CaughtOrchestrationError(error)
48+
}
2649

2750
export const v2WorkflowErrorPolicies = {
2851
default: v2OrchestrationErrorPolicy,
@@ -61,6 +84,10 @@ export const v2WorkflowErrorPolicies = {
6184
concealRunAuthorization: createV2ResourceConcealmentPolicy({
6285
notFoundMessage: 'Run not found',
6386
}),
87+
cancelRun: createV2ResourceConcealmentPolicy({
88+
notFoundMessage: 'Run not found',
89+
render: v2CancelRunErrorResponse,
90+
}),
6491
} as const
6592

6693
export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({

0 commit comments

Comments
 (0)