@@ -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' ,
@@ -875,6 +883,94 @@ describe('workflow execute async route', () => {
875883 expect ( mockExecuteWorkflowCore ) . not . toHaveBeenCalled ( )
876884 } )
877885
886+ it ( 'queues a bound Copilot workflow execution asynchronously' , async ( ) => {
887+ const request = createBoundCopilotExecutionRequest ( {
888+ stream : false ,
889+ triggerBlockId : 'trigger-async' ,
890+ } )
891+ request . headers . set ( 'X-Execution-Mode' , 'async' )
892+
893+ const response = await POST ( request , {
894+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
895+ } )
896+
897+ expect ( response . status ) . toBe ( 202 )
898+ expect ( mockClaimWorkflowToolExecution ) . toHaveBeenCalledWith ( 'copilot-tool-1' , 'execution-123' )
899+ expect ( mockPreprocessExecution ) . toHaveBeenCalledWith (
900+ expect . objectContaining ( { checkDeployment : true , executionType : 'async' } )
901+ )
902+ expect ( loggingSessionMockFns . mockSetTrustedExecutionCorrelation ) . toHaveBeenCalledWith ( {
903+ executionId : 'execution-123' ,
904+ requestId : 'req-12345678' ,
905+ source : 'workflow' ,
906+ workflowId : 'workflow-1' ,
907+ triggerType : 'copilot' ,
908+ copilotToolCallId : 'copilot-tool-1' ,
909+ } )
910+ expect ( mockEnqueue ) . toHaveBeenCalledWith (
911+ 'workflow-execution' ,
912+ expect . objectContaining ( {
913+ executionId : 'execution-123' ,
914+ triggerBlockId : 'trigger-async' ,
915+ correlation : expect . objectContaining ( { copilotToolCallId : 'copilot-tool-1' } ) ,
916+ } ) ,
917+ expect . any ( Object )
918+ )
919+ } )
920+
921+ it ( 'rejects a bound async run when the deployed workflow is stale' , async ( ) => {
922+ mockCheckNeedsRedeployment . mockResolvedValueOnce ( true )
923+ const request = createBoundCopilotExecutionRequest ( { stream : false } )
924+ request . headers . set ( 'X-Execution-Mode' , 'async' )
925+
926+ const response = await POST ( request , {
927+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
928+ } )
929+
930+ expect ( response . status ) . toBe ( 409 )
931+ await expect ( response . json ( ) ) . resolves . toEqual ( {
932+ error : 'Async execution requires the current workflow to match its deployed version' ,
933+ code : 'ASYNC_WORKFLOW_DEPLOYMENT_STALE' ,
934+ } )
935+ expect ( mockClaimWorkflowToolExecution ) . toHaveBeenCalledWith ( 'copilot-tool-1' , 'execution-123' )
936+ expect ( mockReleaseExecutionSlot ) . toHaveBeenCalledWith ( 'execution-123' )
937+ expect ( mockReleaseWorkflowToolExecutionClaim ) . toHaveBeenCalledWith (
938+ 'copilot-tool-1' ,
939+ 'execution-123'
940+ )
941+ expect ( mockEnqueue ) . not . toHaveBeenCalled ( )
942+ } )
943+
944+ it ( 'rejects a bound async run when the workflow has not been deployed' , async ( ) => {
945+ mockPreprocessExecution . mockResolvedValueOnce ( {
946+ success : false ,
947+ error : {
948+ message : 'Workflow is not deployed' ,
949+ statusCode : 403 ,
950+ code : WORKFLOW_NOT_DEPLOYED_CODE ,
951+ } ,
952+ } )
953+ const request = createBoundCopilotExecutionRequest ( { stream : false } )
954+ request . headers . set ( 'X-Execution-Mode' , 'async' )
955+
956+ const response = await POST ( request , {
957+ params : Promise . resolve ( { id : 'workflow-1' } ) ,
958+ } )
959+
960+ expect ( response . status ) . toBe ( 403 )
961+ await expect ( response . json ( ) ) . resolves . toEqual ( {
962+ error : 'Async execution requires the workflow to be deployed first' ,
963+ code : 'ASYNC_WORKFLOW_DEPLOYMENT_MISSING' ,
964+ } )
965+ expect ( mockReleaseExecutionSlot ) . toHaveBeenCalledWith ( 'execution-123' )
966+ expect ( mockReleaseWorkflowToolExecutionClaim ) . toHaveBeenCalledWith (
967+ 'copilot-tool-1' ,
968+ 'execution-123'
969+ )
970+ expect ( mockEnqueue ) . not . toHaveBeenCalled ( )
971+ expect ( mockCheckNeedsRedeployment ) . not . toHaveBeenCalled ( )
972+ } )
973+
878974 it . each ( [
879975 [
880976 'cancelled' ,
0 commit comments