GeezerVison is showing github.com — fix(api): widen the cancel-execution reason contract to what the rout… · simstudioai/sim@a1a05c6
File treeExpand file treeCollapse file tree Open diff view settings app/api/workflows/[id]/executions/[executionId]/cancel
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +18-1Lines changed: 18 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -112,7 +112,24 @@ vi.mock('@/lib/execution/event-buffer', () => ({ | 112 | 112 | }), | 113 | 113 | })) | 114 | 114 |
| 115 | | -import { POST } from './route' | | 115 | +import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' | | 116 | +import { POST as cancelExecution } from './route' | | 117 | + | | 118 | +/** | | 119 | + * Drives the route and validates every success body against the contract that | | 120 | + * `requestJson` enforces on the client. The route builds its responses by hand | | 121 | + * rather than through a declarative builder, so nothing else checks that the | | 122 | + * two agree — and a `reason` the contract omits makes the client throw on a | | 123 | + * cancellation that actually applied. Routing every case in this suite through | | 124 | + * here covers each `NextResponse.json` shape the route can return. | | 125 | + */ | | 126 | +const POST = async (...args: Parameters<typeof cancelExecution>) => { | | 127 | + const response = await cancelExecution(...args) | | 128 | + if (response.status < 400) { | | 129 | + cancelWorkflowExecutionContract.response.schema.parse(await response.clone().json()) | | 130 | + } | | 131 | + return response | | 132 | +} | 116 | 133 |
| 117 | 134 | const makeRequest = () => | 118 | 135 | new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', { | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +23-8Lines changed: 23 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -6,7 +6,10 @@ import { toError } from '@sim/utils/errors' | 6 | 6 | import { sleep } from '@sim/utils/helpers' | 7 | 7 | import { and, eq, inArray } from 'drizzle-orm' | 8 | 8 | import { type NextRequest, NextResponse } from 'next/server' | 9 | | -import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' | | 9 | +import { | | 10 | + type CancelWorkflowExecutionResponse, | | 11 | + cancelWorkflowExecutionContract, | | 12 | +} from '@/lib/api/contracts/workflows' | 10 | 13 | import { parseRequest } from '@/lib/api/server' | 11 | 14 | import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages' | 12 | 15 | import { checkHybridAuth } from '@/lib/auth/hybrid' | @@ -34,6 +37,18 @@ const logger = createLogger('CancelExecutionAPI') | 34 | 37 | const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 | 35 | 38 | const PAUSED_CANCELLATION_DB_RETRY_MS = 200 | 36 | 39 |
| | 40 | +/** | | 41 | + * Builds the single success shape this route returns. The route hand-builds its | | 42 | + * responses rather than going through a declarative builder, so nothing else | | 43 | + * checks that they satisfy the contract the client validates against — and | | 44 | + * several outcomes the route resolves itself (a still-queued run, an | | 45 | + * already-cancelled run) ride on `success: true`, where a rejected body turns a | | 46 | + * cancellation that worked into a client-side failure. | | 47 | + */ | | 48 | +function cancellationOutcome(body: CancelWorkflowExecutionResponse) { | | 49 | + return NextResponse.json(body) | | 50 | +} | | 51 | + | 37 | 52 | async function cancelQueuedExecutionJobs( | 38 | 53 | workflowId: string, | 39 | 54 | executionId: string, | @@ -397,7 +412,7 @@ export const POST = withRouteHandler( | 397 | 412 | workspaceId ? { groups: { workspace: workspaceId } } : undefined | 398 | 413 | ) | 399 | 414 |
| 400 | | - return NextResponse.json({ | | 415 | + return cancellationOutcome({ | 401 | 416 | success: true, | 402 | 417 | executionId, | 403 | 418 | redisAvailable: cancellation.reason !== 'redis_unavailable', | @@ -524,7 +539,7 @@ export const POST = withRouteHandler( | 524 | 539 | const pausedReconciliationSucceeded = | 525 | 540 | exactStopSatisfied && | 526 | 541 | (!hasPausedCancellation || (cancellationEventPublished && pausedCancelled)) | 527 | | - return NextResponse.json({ | | 542 | + return cancellationOutcome({ | 528 | 543 | success: pausedReconciliationSucceeded, | 529 | 544 | executionId, | 530 | 545 | redisAvailable: requiresCancellationEvent ? cancellationEventPublished : true, | @@ -597,7 +612,7 @@ export const POST = withRouteHandler( | 597 | 612 | }) | 598 | 613 | }) | 599 | 614 | await clearStopSignalMarkers(stopSummary) | 600 | | - return NextResponse.json({ | | 615 | + return cancellationOutcome({ | 601 | 616 | success: false, | 602 | 617 | executionId, | 603 | 618 | redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable', | @@ -658,7 +673,7 @@ export const POST = withRouteHandler( | 658 | 673 | }) | 659 | 674 | }) | 660 | 675 | await clearStopSignalMarkers(stopSummary) | 661 | | - return NextResponse.json({ | | 676 | + return cancellationOutcome({ | 662 | 677 | success: false, | 663 | 678 | executionId, | 664 | 679 | redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable', | @@ -669,7 +684,7 @@ export const POST = withRouteHandler( | 669 | 684 | }) | 670 | 685 | } | 671 | 686 | } else if (!effectivePausedCancellationPath) { | 672 | | - return NextResponse.json({ | | 687 | + return cancellationOutcome({ | 673 | 688 | success: false, | 674 | 689 | executionId, | 675 | 690 | redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable', | @@ -801,7 +816,7 @@ export const POST = withRouteHandler( | 801 | 816 | await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId) | 802 | 817 | } | 803 | 818 | } | 804 | | - return NextResponse.json({ | | 819 | + return cancellationOutcome({ | 805 | 820 | success: false, | 806 | 821 | executionId, | 807 | 822 | redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable', | @@ -963,7 +978,7 @@ export const POST = withRouteHandler( | 963 | 978 | ? 'queue_cancelled' | 964 | 979 | : stopSummary.cancellation.reason | 965 | 980 |
| 966 | | - return NextResponse.json({ | | 981 | + return cancellationOutcome({ | 967 | 982 | success, | 968 | 983 | executionId, | 969 | 984 | redisAvailable: | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/api/contracts/logs.ts -27Lines changed: 0 additions & 27 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { z } from 'zod' | 2 | 2 | import { userFileSchema } from '@/lib/api/contracts/primitives' | 3 | 3 | import { defineRouteContract } from '@/lib/api/contracts/types' | 4 | | -import { cancelWorkflowExecutionReasonSchema } from '@/lib/api/contracts/workflows' | 5 | 4 |
| 6 | 5 | const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!=']) | 7 | 6 |
| @@ -13,11 +12,6 @@ export const executionIdParamsSchema = z.object({ | 13 | 12 | executionId: z.string().min(1), | 14 | 13 | }) | 15 | 14 |
| 16 | | -export const cancelWorkflowExecutionParamsSchema = z.object({ | 17 | | - id: z.string().min(1, 'Invalid workflow ID'), | 18 | | - executionId: z.string().min(1, 'Invalid execution ID'), | 19 | | -}) | 20 | | - | 21 | 15 | const logFilterQuerySchema = z.object({ | 22 | 16 | workspaceId: z.string(), | 23 | 17 | level: z.string().optional(), | @@ -353,21 +347,10 @@ export const triggersQuerySchema = z.object({ | 353 | 347 | }) | 354 | 348 | export type TriggersQuery = z.output<typeof triggersQuerySchema> | 355 | 349 |
| 356 | | -export const cancelWorkflowExecutionResponseSchema = z.object({ | 357 | | - success: z.boolean(), | 358 | | - executionId: z.string(), | 359 | | - redisAvailable: z.boolean(), | 360 | | - durablyRecorded: z.boolean(), | 361 | | - locallyAborted: z.boolean(), | 362 | | - pausedCancelled: z.boolean(), | 363 | | - reason: cancelWorkflowExecutionReasonSchema, | 364 | | -}) | 365 | | - | 366 | 350 | export type SegmentStats = z.output<typeof segmentStatsSchema> | 367 | 351 | export type WorkflowStats = z.output<typeof workflowStatsSchema> | 368 | 352 | export type DashboardStatsResponse = z.output<typeof dashboardStatsResponseSchema> | 369 | 353 | export type ExecutionSnapshotData = z.output<typeof executionSnapshotDataSchema> | 370 | | -export type CancelWorkflowExecutionResponse = z.output<typeof cancelWorkflowExecutionResponseSchema> | 371 | 354 |
| 372 | 355 | export const listLogsContract = defineRouteContract({ | 373 | 356 | method: 'GET', | @@ -424,13 +407,3 @@ export const getExecutionSnapshotContract = defineRouteContract({ | 424 | 407 | schema: executionSnapshotDataSchema, | 425 | 408 | }, | 426 | 409 | }) | 427 | | - | 428 | | -export const cancelWorkflowExecutionContract = defineRouteContract({ | 429 | | - method: 'POST', | 430 | | - path: '/api/workflows/[id]/executions/[executionId]/cancel', | 431 | | - params: cancelWorkflowExecutionParamsSchema, | 432 | | - response: { | 433 | | - mode: 'json', | 434 | | - schema: cancelWorkflowExecutionResponseSchema, | 435 | | - }, | 436 | | -}) |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/api/contracts/workflows.test.ts +21Lines changed: 21 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { describe, expect, it } from 'vitest' | 2 | 2 | import { | | 3 | + cancelWorkflowExecutionReasonSchema, | 3 | 4 | executeWorkflowBodySchema, | 4 | 5 | getWorkflowResponseDataSchema, | | 6 | + internalCancelWorkflowExecutionReasonSchema, | 5 | 7 | updateWorkflowBodySchema, | 6 | 8 | workflowListItemSchema, | 7 | 9 | } from '@/lib/api/contracts/workflows' | @@ -127,4 +129,23 @@ describe('workflow contracts', () => { | 127 | 129 | expect(forkPolicySchema.parse({ forkSyncExcluded: true }).forkSyncExcluded).toBe(true) | 128 | 130 | expect(forkPolicySchema.parse({}).forkSyncExcluded).toBe(false) | 129 | 131 | }) | | 132 | + | | 133 | + /** | | 134 | + * The v2 cancel endpoint presents `cancelWorkflowRun`'s result unchanged, and | | 135 | + * that use case delegates wholly to the cancellation service — so it cannot | | 136 | + * emit the outcomes the internal route resolves for itself. Folding those into | | 137 | + * the service enum would publish four reasons v2 never returns, because the | | 138 | + * v2 contract documents this enum value by value. | | 139 | + */ | | 140 | + it('keeps internal-only cancellation reasons out of the enum v2 publishes', () => { | | 141 | + for (const reason of [ | | 142 | + 'queue_cancelled', | | 143 | + 'already_cancelled', | | 144 | + 'active_resume_signal_failed', | | 145 | + 'cancellation_not_finalized', | | 146 | + ]) { | | 147 | + expect(internalCancelWorkflowExecutionReasonSchema.options).toContain(reason) | | 148 | + expect(cancelWorkflowExecutionReasonSchema.options).not.toContain(reason) | | 149 | + } | | 150 | + }) | 130 | 151 | }) |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/api/contracts/workflows.ts +25-6Lines changed: 25 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -661,11 +661,12 @@ export const workflowExecutionStatusQuerySchema = z.object({ | 661 | 661 | }) | 662 | 662 |
| 663 | 663 | /** | 664 | | - * Full cancellation-outcome vocabulary — mirrors | 665 | | - * `CancelWorkflowExecutionReason` in `lib/execution/cancel-workflow-execution` | 666 | | - * (contracts stay import-clean of server modules). The paused-HITL path emits | 667 | | - * the two `paused_*` values; a narrower copy of this enum previously lived in | 668 | | - * `contracts/logs.ts` and made the client reject those responses. | | 664 | + * Cancellation outcomes produced by the cancellation service, and so the whole | | 665 | + * vocabulary the public v2 endpoint can return — `cancelWorkflowRun` delegates | | 666 | + * its outcome to that service. Mirrors `CancelWorkflowExecutionReason` in | | 667 | + * `lib/execution/cancel-workflow-execution` (contracts stay import-clean of | | 668 | + * server modules). Keeping the internal route's extra outcomes out of here is | | 669 | + * what stops the published v2 schema advertising reasons v2 cannot emit. | 669 | 670 | */ | 670 | 671 | export const cancelWorkflowExecutionReasonSchema = z.enum([ | 671 | 672 | 'recorded', | @@ -675,16 +676,34 @@ export const cancelWorkflowExecutionReasonSchema = z.enum([ | 675 | 676 | 'paused_database_cancel_failed', | 676 | 677 | ]) | 677 | 678 |
| | 679 | +/** | | 680 | + * The internal route's vocabulary. It resolves four outcomes before the service | | 681 | + * is ever reached: `queue_cancelled` (the run was still queued, so no execution | | 682 | + * log row existed), `already_cancelled` (reconciling a run already cancelled), | | 683 | + * and the two stop-signal failures. Several ride on `success: true` responses, | | 684 | + * so validating them against the service enum makes `requestJson` reject | | 685 | + * cancellations that genuinely applied. | | 686 | + */ | | 687 | +export const internalCancelWorkflowExecutionReasonSchema = z.enum([ | | 688 | + ...cancelWorkflowExecutionReasonSchema.options, | | 689 | + 'queue_cancelled', | | 690 | + 'already_cancelled', | | 691 | + 'active_resume_signal_failed', | | 692 | + 'cancellation_not_finalized', | | 693 | +]) | | 694 | + | 678 | 695 | const cancelWorkflowExecutionResponseSchema = z.object({ | 679 | 696 | success: z.boolean(), | 680 | 697 | executionId: z.string(), | 681 | 698 | redisAvailable: z.boolean(), | 682 | 699 | durablyRecorded: z.boolean(), | 683 | 700 | locallyAborted: z.boolean(), | 684 | 701 | pausedCancelled: z.boolean(), | 685 | | - reason: cancelWorkflowExecutionReasonSchema.optional(), | | 702 | + reason: internalCancelWorkflowExecutionReasonSchema.optional(), | 686 | 703 | }) | 687 | 704 |
| | 705 | +export type CancelWorkflowExecutionResponse = z.output<typeof cancelWorkflowExecutionResponseSchema> | | 706 | + | 688 | 707 | const resumeWorkflowExecutionContextResponseSchema = z | 689 | 708 | .object({ | 690 | 709 | status: z.enum(['queued', 'started']).optional(), | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/execution/cancel-workflow-execution.ts +5-2Lines changed: 5 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -41,9 +41,12 @@ async function cancelActiveWorkflowJob(executionId: string): Promise<boolean> { | 41 | 41 | } | 42 | 42 |
| 43 | 43 | /** | 44 | | - * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ | | 44 | + * Cancellation outcome vocabulary produced by this service, and so the whole | | 45 | + * vocabulary the public v2 endpoint can return. `recorded`/`redis_unavailable`/ | 45 | 46 | * `redis_write_failed` come from the Redis record step; the two `paused_*` | 46 | | - * values from the paused-HITL path. | | 47 | + * values from the paused-HITL path. The internal cancel route resolves further | | 48 | + * outcomes on top of these — see `internalCancelWorkflowExecutionReasonSchema` | | 49 | + * in `lib/api/contracts/workflows`. | 47 | 50 | */ | 48 | 51 | export type CancelWorkflowExecutionReason = | 49 | 52 | | 'recorded' | |
|