Skip to content

Commit 6a6de27

Browse files
committed
improvement(workflows): unify run cancellation
1 parent e6c8777 commit 6a6de27

18 files changed

Lines changed: 2430 additions & 3667 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 7 additions & 4 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. Always false for a run that was already terminal, where the request is satisfied but nothing was written."
9989+
"description": "Whether this request durably recorded a cancellation. False when an already-cancelled run needed no further durable write."
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, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.",
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.",
1000110001
"type": "string",
1000210002
"enum": [
1000310003
"recorded",
@@ -10007,7 +10007,10 @@
1000710007
"redis_unavailable",
1000810008
"redis_write_failed",
1000910009
"paused_event_publish_failed",
10010-
"paused_database_cancel_failed"
10010+
"paused_database_cancel_failed",
10011+
"queue_cancelled",
10012+
"active_resume_signal_failed",
10013+
"cancellation_not_finalized"
1001110014
]
1001210015
}
1001310016
},
@@ -10021,7 +10024,7 @@
1002110024
],
1002210025
"additionalProperties": false,
1002310026
"title": "Cancel workflow run result",
10024-
"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."
10027+
"description": "Outcome of the shared workflow-run cancellation lifecycle used by internal UI, Copilot, and v2 callers."
1002510028
},
1002610029
"CancelWorkflowRunResponse": {
1002710030
"type": "object",

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

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

1515
const mocks = vi.hoisted(() => ({
1616
cancel: vi.fn(),
17-
capture: vi.fn(),
1817
}))
1918

2019
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
2120
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
22-
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
2321
vi.mock('@/lib/workflows/application/cancel-run', () => ({
2422
cancelWorkflowRun: { operation: { id: 'workflows.runs.cancel' }, execute: mocks.cancel },
2523
}))
@@ -91,18 +89,10 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => {
9189
})
9290
})
9391

94-
/**
95-
* The published outcome of a cancel against a run that had already finished.
96-
* `durablyRecorded: true` here is the defect this suite pins: nothing was
97-
* written, so a caller reconciling on that flag would trust a write that never
98-
* happened.
99-
*/
100-
it.each([
101-
['cancelled', 'already_cancelled'],
102-
['completed', 'already_completed'],
103-
['failed', 'already_failed'],
104-
])('reports a terminal %s run as a no-op the caller can tell apart', async (_status, reason) => {
105-
mocks.cancel.mockResolvedValue(serviceResult({ success: true, durablyRecorded: false, reason }))
92+
it('reports an already-cancelled run as an idempotent no-op', async () => {
93+
mocks.cancel.mockResolvedValue(
94+
serviceResult({ success: true, durablyRecorded: false, reason: 'already_cancelled' })
95+
)
10696

10797
const response = await POST(request(), context)
10898

@@ -111,7 +101,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => {
111101
success: true,
112102
runId: RUN_ID,
113103
durablyRecorded: false,
114-
reason,
104+
reason: 'already_cancelled',
115105
})
116106
})
117107
})

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

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { v2CancelWorkflowRunContract } from '@/lib/api/contracts/v2/workflows'
22
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
3-
import { captureServerEvent } from '@/lib/posthog/server'
43
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
54
import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run'
65
import { workflowOperations } from '@/lib/workflows/application/operations'
@@ -27,21 +26,4 @@ export const POST = defineV2JsonRoute({
2726
reason: result.reason,
2827
},
2928
}),
30-
/**
31-
* Reports a cancellation, so it needs the run to have actually been
32-
* cancelled. `success` alone no longer implies that: a cancel against an
33-
* already-terminal run satisfies the request without writing anything, and
34-
* reports `success: true` with `durablyRecorded: false`. Requiring both also
35-
* keeps the event off a cancellation that reached the row but failed its
36-
* paused reconciliation, which reports the inverse pair.
37-
*/
38-
onSuccess: ({ principal, result }) => {
39-
if (!result.success || !result.durablyRecorded || principal.kind !== 'personal_api_key') return
40-
captureServerEvent(
41-
principal.userId,
42-
'workflow_execution_cancelled',
43-
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
44-
{ groups: { workspace: result.workspaceId } }
45-
)
46-
},
4729
})

0 commit comments

Comments
 (0)