From 78e9c87f30947d3db5b268ac70a06c295043396e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:57:33 -0700 Subject: [PATCH] fix(workflows): preserve cancellation scope assertions --- .../[workflowId]/runs/[runId]/cancel/route.ts | 2 +- .../[workflowId]/runs/[runId]/route.test.ts | 4 +- .../[executionId]/cancel/route.test.ts | 1 + .../executions/[executionId]/cancel/route.ts | 1 + .../tools/handlers/workflow/mutations.test.ts | 1 + .../tools/handlers/workflow/mutations.ts | 1 + .../lib/workflows/application/cancel-run.ts | 4 ++ .../lib/workflows/application/context.test.ts | 41 +++++++++++++++++++ .../application/workflow-run-control.test.ts | 21 ++++++++++ 9 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts index 6968b78bce3..c65e0c0d693 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: workflowOperations.cancelRun, rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.cancelRun, - mapInput: ({ params }) => ({ runId: params.runId }), + mapInput: ({ params }) => ({ workflowId: params.workflowId, runId: params.runId }), useCase: cancelWorkflowRun, present: (result) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index c7c0db44799..60a093868c9 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -359,7 +359,7 @@ describe('v2 run detail and cancel adapters', () => { }) expect(mocks.cancel).toHaveBeenCalledWith({ principal, - input: { runId: 'run-1' }, + input: { workflowId: 'workflow-1', runId: 'run-1' }, request: expect.anything(), }) expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) @@ -417,7 +417,7 @@ describe('v2 run detail and cancel adapters', () => { expect(response.status).toBe(200) expect(mocks.cancel).toHaveBeenCalledWith({ principal: personalPrincipal, - input: { runId: 'run-1' }, + input: { workflowId: 'workflow-1', runId: 'run-1' }, request: expect.anything(), }) }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index c458e12faeb..7208c0129bc 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -64,6 +64,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { expect(mocks.cancel).toHaveBeenCalledWith({ principal, input: { + workflowId: 'workflow-1', runId: 'execution-1', abortSignal: expect.any(AbortSignal), }, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 738710eeca7..d516f33c489 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -19,6 +19,7 @@ export const POST = defineInternalJsonRoute({ }), errorPolicy: internalWorkflowErrorPolicies.concealRunAuthorization, mapInput: ({ params }, { request }) => ({ + workflowId: params.id, runId: params.executionId, abortSignal: request.signal, }), diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 289bed4d35b..2023b7a6de9 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -198,6 +198,7 @@ describe('workflow mutation Copilot adapters', () => { }), { runId: 'execution-1', + assertedWorkspaceId: 'workspace-1', } ) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 5b5e9487f86..93cc1956b1f 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -300,6 +300,7 @@ export async function executeCancelWorkflowRun( ) const result = await executeCopilotWorkflowUseCase(context, cancelWorkflowRun, { runId: executionId, + assertedWorkspaceId: context.workspaceId, ...(context.abortSignal ? { abortSignal: context.abortSignal } : {}), }) diff --git a/apps/sim/lib/workflows/application/cancel-run.ts b/apps/sim/lib/workflows/application/cancel-run.ts index f946231c6c9..df68bae37e1 100644 --- a/apps/sim/lib/workflows/application/cancel-run.ts +++ b/apps/sim/lib/workflows/application/cancel-run.ts @@ -10,7 +10,9 @@ import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/appl import { workflowOperations } from '@/lib/workflows/application/operations' export interface CancelWorkflowRunInput { + workflowId?: string runId: string + assertedWorkspaceId?: string abortSignal?: AbortSignal } @@ -19,6 +21,8 @@ export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: CancelWorkflowRunInput }) => resolveActiveWorkflowRunApplicationContext({ runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, }), async execute({ principal, context, input }) { const attribution = resolvePrincipalAttribution(principal, { diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts index 68c9c5f1f10..e0e722ce5af 100644 --- a/apps/sim/lib/workflows/application/context.test.ts +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -144,6 +144,47 @@ describe('workflow application contexts', () => { }) }) + it('resolves the canonical workflow from an execution ID without a workflow assertion', async () => { + queueCanonicalBindings({ log: 'workflow-1' }) + queueTableRows(schemaMock.workflow, [ + { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Canonical workflow' }, + workspaceId: 'workspace-1', + }, + ]) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toMatchObject({ + runId: 'run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + }) + + it('conceals a workspace mismatch for an execution-only lookup', async () => { + queueCanonicalBindings({ log: 'workflow-1' }) + queueTableRows(schemaMock.workflow, [ + { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Canonical workflow' }, + workspaceId: 'workspace-1', + }, + ]) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkspaceId: 'workspace-2', + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + it('binds live execution authority to the deployment version stored on its durable log', async () => { queueTableRows(schemaMock.workflowExecutionLogs, [ { diff --git a/apps/sim/lib/workflows/application/workflow-run-control.test.ts b/apps/sim/lib/workflows/application/workflow-run-control.test.ts index a7440c3c14a..e09a339585e 100644 --- a/apps/sim/lib/workflows/application/workflow-run-control.test.ts +++ b/apps/sim/lib/workflows/application/workflow-run-control.test.ts @@ -140,6 +140,27 @@ describe('workflow run-control application use cases', () => { } ) + it('derives cancellation workflow scope from the execution ID when no workflow is asserted', async () => { + const result = await cancelWorkflowRun.execute({ + principal: principals[0].principal, + input: { runId: 'parent-run-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'parent-run-1', + assertedWorkflowId: undefined, + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: 'parent-run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + ) + expect(result).toMatchObject({ workflowId: 'workflow-1', workspaceId: 'workspace-1' }) + }) + it.each(principals)( 'authorizes $principal.kind resume and preserves the parent/new run distinction', async ({ principal, actorUserId }) => {