@@ -29,6 +29,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
2929import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types'
3030import { getRemainingExecutionMs } from '@/lib/core/execution-limits'
3131import { INTERNAL_EXECUTION_DEADLINE_HEADER } from '@/lib/execution/execution-deadline-header'
32+ import { WORKFLOW_NOT_DEPLOYED_CODE } from '@/lib/execution/preprocessing'
3233import {
3334 PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 ,
3435 PRIVATE_SECRET_PROVENANCE_FIELD ,
@@ -39,6 +40,7 @@ const {
3940 mockAssertBillingAttributionSnapshot,
4041 mockClaimExecutionId,
4142 mockClaimWorkflowToolExecution,
43+ mockCheckNeedsRedeployment,
4244 mockEnqueue,
4345 mockExecuteWorkflowJob,
4446 mockExecuteWorkflowCore,
@@ -67,6 +69,7 @@ const {
6769 } ) ,
6870 mockClaimExecutionId : vi . fn ( ) ,
6971 mockClaimWorkflowToolExecution : vi . fn ( ) ,
72+ mockCheckNeedsRedeployment : vi . fn ( ) ,
7073 mockEnqueue : vi . fn ( ) . mockResolvedValue ( 'job-123' ) ,
7174 mockExecuteWorkflowJob : vi . fn ( ) ,
7275 mockExecuteWorkflowCore : vi . fn ( ) ,
@@ -118,6 +121,10 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
118121
119122vi . mock ( '@/lib/execution/preprocessing' , ( ) => executionPreprocessingMock )
120123
124+ vi . mock ( '@/app/api/workflows/utils' , ( ) => ( {
125+ checkNeedsRedeployment : mockCheckNeedsRedeployment ,
126+ } ) )
127+
121128vi . mock ( '@/lib/workflows/persistence/utils' , ( ) => workflowsPersistenceUtilsMock )
122129
123130vi . mock ( '@/lib/workflows/executor/execution-core' , ( ) => ( {
@@ -415,6 +422,7 @@ describe('workflow execute async route', () => {
415422 toolCallId : 'copilot-tool-1' ,
416423 claimedBy : 'workflow:execution-123' ,
417424 } )
425+ mockCheckNeedsRedeployment . mockResolvedValue ( false )
418426 mockHasDurableExecutionOwner . mockResolvedValue ( false )
419427 mockGetAsyncToolCall . mockReset ( ) . mockResolvedValue ( {
420428 toolCallId : 'copilot-tool-1' ,
@@ -873,6 +881,93 @@ describe('workflow execute async route', () => {
873881 expect ( mockExecuteWorkflowCore ) . not . toHaveBeenCalled ( )
874882 } )
875883
884+ it ( 'queues a bound Copilot workflow execution asynchronously' , async ( ) => {
885+ const request = createBoundCopilotExecutionRequest ( {
886+ stream : false ,
887+ triggerBlockId : 'trigger-async' ,
888+ } )
889+ request . headers . set ( 'X-Execution-Mode' , 'async' )
890+
891+ const response = await POST ( request , {
892+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
893+ } )
894+
895+ expect ( response . status ) . toBe ( 202 )
896+ expect ( mockClaimWorkflowToolExecution ) . toHaveBeenCalledWith ( 'copilot-tool-1' , 'execution-123' )
897+ expect ( mockPreprocessExecution ) . toHaveBeenCalledWith (
898+ expect . objectContaining ( { checkDeployment : true , executionType : 'async' } )
899+ )
900+ expect ( loggingSessionMockFns . mockSetTrustedExecutionCorrelation ) . toHaveBeenCalledWith ( {
901+ executionId : 'execution-123' ,
902+ requestId : 'req-12345678' ,
903+ source : 'workflow' ,
904+ workflowId : 'workflow-1' ,
905+ triggerType : 'copilot' ,
906+ copilotToolCallId : 'copilot-tool-1' ,
907+ } )
908+ expect ( mockEnqueue ) . toHaveBeenCalledWith (
909+ 'workflow-execution' ,
910+ expect . objectContaining ( {
911+ executionId : 'execution-123' ,
912+ triggerBlockId : 'trigger-async' ,
913+ correlation : expect . objectContaining ( { copilotToolCallId : 'copilot-tool-1' } ) ,
914+ } ) ,
915+ expect . any ( Object )
916+ )
917+ } )
918+
919+ it ( 'rejects a bound async run when the deployed workflow is stale' , async ( ) => {
920+ mockCheckNeedsRedeployment . mockResolvedValueOnce ( true )
921+ const request = createBoundCopilotExecutionRequest ( { stream : false } )
922+ request . headers . set ( 'X-Execution-Mode' , 'async' )
923+
924+ const response = await POST ( request , {
925+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
926+ } )
927+
928+ expect ( response . status ) . toBe ( 409 )
929+ await expect ( response . json ( ) ) . resolves . toEqual ( {
930+ error : 'Async execution requires the current workflow to match its deployed version' ,
931+ code : 'ASYNC_WORKFLOW_DEPLOYMENT_STALE' ,
932+ } )
933+ expect ( mockClaimWorkflowToolExecution ) . toHaveBeenCalledWith ( 'copilot-tool-1' , 'execution-123' )
934+ expect ( mockReleaseExecutionSlot ) . toHaveBeenCalledWith ( 'execution-123' )
935+ expect ( mockReleaseWorkflowToolExecutionClaim ) . toHaveBeenCalledWith (
936+ 'copilot-tool-1' ,
937+ 'execution-123'
938+ )
939+ expect ( mockEnqueue ) . not . toHaveBeenCalled ( )
940+ } )
941+
942+ it ( 'rejects a bound async run when the workflow has not been deployed' , async ( ) => {
943+ mockPreprocessExecution . mockResolvedValueOnce ( {
944+ success : false ,
945+ error : {
946+ message : 'Workflow is not deployed' ,
947+ statusCode : 403 ,
948+ code : WORKFLOW_NOT_DEPLOYED_CODE ,
949+ } ,
950+ } )
951+ const request = createBoundCopilotExecutionRequest ( { stream : false } )
952+ request . headers . set ( 'X-Execution-Mode' , 'async' )
953+
954+ const response = await POST ( request , {
955+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
956+ } )
957+
958+ expect ( response . status ) . toBe ( 403 )
959+ await expect ( response . json ( ) ) . resolves . toEqual ( {
960+ error : 'Async execution requires the workflow to be deployed first' ,
961+ code : 'ASYNC_WORKFLOW_DEPLOYMENT_MISSING' ,
962+ } )
963+ expect ( mockReleaseWorkflowToolExecutionClaim ) . toHaveBeenCalledWith (
964+ 'copilot-tool-1' ,
965+ 'execution-123'
966+ )
967+ expect ( mockEnqueue ) . not . toHaveBeenCalled ( )
968+ expect ( mockCheckNeedsRedeployment ) . not . toHaveBeenCalled ( )
969+ } )
970+
876971 it . each ( [
877972 [
878973 'cancelled' ,
0 commit comments