@@ -134,6 +134,7 @@ import {
134134import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
135135import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer'
136136import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state'
137+ import { calculateNextRunTime , getScheduleTimeValues } from '@/lib/workflows/schedules/utils'
137138import { fileOperations } from '@/lib/workspace-files/application/operations'
138139import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact'
139140import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text'
@@ -155,6 +156,7 @@ import {
155156 POST as createRowsRoute ,
156157 GET as tableRowsRoute ,
157158} from '@/app/api/v2/tables/[tableId]/rows/route'
159+ import { POST as deployWorkflowRoute } from '@/app/api/v2/workflows/[workflowId]/deploy/route'
158160import { POST as executeRoute } from '@/app/api/v2/workflows/[workflowId]/execute/route'
159161import { POST as workflowOperationsRoute } from '@/app/api/v2/workflows/[workflowId]/operations/route'
160162import { GET as runRoute } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/route'
@@ -540,6 +542,9 @@ const identity = {
540542 const execution = request . nextUrl . pathname . match ( / ^ \/ a p i \/ v 2 \/ w o r k f l o w s \/ ( [ ^ / ] + ) \/ e x e c u t e $ / )
541543 if ( execution )
542544 return executeRoute ( request , { params : Promise . resolve ( { workflowId : execution [ 1 ] } ) } )
545+ const deploy = request . nextUrl . pathname . match ( / ^ \/ a p i \/ v 2 \/ w o r k f l o w s \/ ( [ ^ / ] + ) \/ d e p l o y $ / )
546+ if ( deploy )
547+ return deployWorkflowRoute ( request , { params : Promise . resolve ( { workflowId : deploy [ 1 ] } ) } )
543548 const run = request . nextUrl . pathname . match ( / ^ \/ a p i \/ v 2 \/ w o r k f l o w s \/ ( [ ^ / ] + ) \/ r u n s \/ ( [ ^ / ] + ) $ / )
544549 if ( run ) {
545550 return runRoute ( request , {
@@ -3730,6 +3735,341 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
37303735 60_000
37313736 )
37323737
3738+ it . skipIf ( process . env . SIM_HELPERS_SMOKE !== '1' ) . each ( [ 'loop' , 'parallel' ] as const ) (
3739+ 'constructs a %s through the CLI and verifies every actual iteration from saved output' ,
3740+ async ( kind ) => {
3741+ fixture . permission = 'admin'
3742+ const id = generateId ( )
3743+ await db . insert ( workflow ) . values ( {
3744+ id,
3745+ workspaceId,
3746+ userId : 'run-reader' ,
3747+ name : `${ kind } composition` ,
3748+ lastSynced : now ,
3749+ createdAt : now ,
3750+ updatedAt : now ,
3751+ isDeployed : false ,
3752+ } )
3753+ fixture . physicalWorkflows . add ( id )
3754+ const initial = runnableState ( 'return 0;' )
3755+ initial . edges = [ ]
3756+ const startId = generateId ( )
3757+ initial . blocks = {
3758+ [ startId ] : {
3759+ ...initial . blocks . start ,
3760+ id : startId ,
3761+ outputs : { items : { type : 'array' } } ,
3762+ } ,
3763+ }
3764+ await replaceWorkflowNormalizedState ( {
3765+ workflowId : id ,
3766+ workspaceId,
3767+ attributedUserId : 'run-reader' ,
3768+ state : initial ,
3769+ } )
3770+ const itemId = generateId ( )
3771+ const operations = [
3772+ {
3773+ operation_type : 'add' ,
3774+ block_id : 'batch' ,
3775+ params : {
3776+ type : kind ,
3777+ name : 'batch' ,
3778+ inputs :
3779+ kind === 'loop'
3780+ ? { loopType : 'forEach' , collection : '<start.items>' }
3781+ : { parallelType : 'collection' , collection : '<start.items>' } ,
3782+ nestedNodes : {
3783+ [ itemId ] : {
3784+ type : 'function' ,
3785+ name : 'doubleitem' ,
3786+ inputs : { language : 'javascript' , code : `return <${ kind } .currentItem> * 2;` } ,
3787+ } ,
3788+ } ,
3789+ connections : { [ `${ kind } -start-source` ] : itemId , [ `${ kind } -end-source` ] : 'report' } ,
3790+ } ,
3791+ } ,
3792+ {
3793+ operation_type : 'add' ,
3794+ block_id : 'report' ,
3795+ params : {
3796+ type : 'function' ,
3797+ name : 'report' ,
3798+ inputs : { language : 'javascript' , code : 'return <batch.results>;' } ,
3799+ } ,
3800+ } ,
3801+ {
3802+ operation_type : 'edit' ,
3803+ block_id : startId ,
3804+ params : { connections : { source : 'batch' } } ,
3805+ } ,
3806+ ]
3807+ const applied = await runCli (
3808+ [
3809+ 'workflows' ,
3810+ 'operations' ,
3811+ 'apply' ,
3812+ id ,
3813+ '--atomic' ,
3814+ '--yes' ,
3815+ '--operations' ,
3816+ JSON . stringify ( operations ) ,
3817+ ] ,
3818+ identity ,
3819+ null
3820+ )
3821+ expect ( applied . exitCode , applied . stderr ) . toBe ( 0 )
3822+ expect ( JSON . parse ( applied . stdout ) . skipped ) . toEqual ( [ ] )
3823+ for ( const items of [ [ 1 , 3 , 5 ] , [ 7 ] ] ) {
3824+ const executed = await runCli (
3825+ [ 'workflows' , 'run' , id , '--manual' , '--input' , JSON . stringify ( { items } ) ] ,
3826+ identity ,
3827+ null
3828+ )
3829+ expect ( executed . exitCode , JSON . stringify ( { ...executed , errors : fixture . errors } ) ) . toBe ( 0 )
3830+ const run = v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( executed . stdout ) )
3831+ await Promise . all ( postExecution . mock . calls . map ( ( [ promise ] ) => promise ) )
3832+ clearLargeValueCacheForTests ( )
3833+ const saved = await runCli (
3834+ [ 'workflows' , 'runs' , 'get' , run . runId , '--workflow' , id , '--include-output' ] ,
3835+ identity ,
3836+ null
3837+ )
3838+ expect ( saved . exitCode , saved . stderr ) . toBe ( 0 )
3839+ const output = v2WorkflowRunStatusSchema . parse ( JSON . parse ( saved . stdout ) )
3840+ expect ( output . status ) . toBe ( 'completed' )
3841+ expect ( output . output ) . toEqual ( run . output )
3842+ const numbers : number [ ] = [ ]
3843+ const pending : unknown [ ] = [ output . output ]
3844+ while ( pending . length ) {
3845+ const value = pending . pop ( )
3846+ if ( typeof value === 'number' ) numbers . push ( value )
3847+ else if ( Array . isArray ( value ) ) pending . push ( ...value )
3848+ else if ( value && typeof value === 'object' ) pending . push ( ...Object . values ( value ) )
3849+ }
3850+ expect (
3851+ numbers . sort ( ( a , b ) => a - b ) ,
3852+ JSON . stringify ( output . output )
3853+ ) . toEqual ( items . map ( ( item ) => item * 2 ) )
3854+ }
3855+ expect ( executeInSandbox ) . not . toHaveBeenCalled ( )
3856+ expect ( executeShellInSandbox ) . not . toHaveBeenCalled ( )
3857+ } ,
3858+ 60_000
3859+ )
3860+
3861+ it . skipIf ( process . env . SIM_HELPERS_SMOKE !== '1' ) (
3862+ 'constructs condition routing through the CLI and executes both sides of each threshold' ,
3863+ async ( ) => {
3864+ fixture . permission = 'admin'
3865+ const id = generateId ( )
3866+ await db . insert ( workflow ) . values ( {
3867+ id,
3868+ workspaceId,
3869+ userId : 'run-reader' ,
3870+ name : 'Routing composition' ,
3871+ lastSynced : now ,
3872+ createdAt : now ,
3873+ updatedAt : now ,
3874+ isDeployed : false ,
3875+ } )
3876+ fixture . physicalWorkflows . add ( id )
3877+ const initial = runnableState ( 'return 0;' )
3878+ initial . edges = [ ]
3879+ const startId = generateId ( )
3880+ initial . blocks = { [ startId ] : { ...initial . blocks . start , id : startId } }
3881+ await replaceWorkflowNormalizedState ( {
3882+ workflowId : id ,
3883+ workspaceId,
3884+ attributedUserId : 'run-reader' ,
3885+ state : initial ,
3886+ } )
3887+ const operations = [
3888+ {
3889+ operation_type : 'add' ,
3890+ block_id : 'gate' ,
3891+ params : {
3892+ type : 'condition' ,
3893+ name : 'gate' ,
3894+ inputs : {
3895+ conditions : [
3896+ { title : 'If' , value : '<start.amount> < 100' } ,
3897+ { title : 'Else If' , value : '<start.amount> < 500' } ,
3898+ { title : 'Else' , value : '' } ,
3899+ ] ,
3900+ } ,
3901+ connections : { if : 'small' , 'else-if-0' : 'medium' , else : 'large' } ,
3902+ } ,
3903+ } ,
3904+ ...[ 'small' , 'medium' , 'large' ] . map ( ( label ) => ( {
3905+ operation_type : 'add' ,
3906+ block_id : label ,
3907+ params : {
3908+ type : 'function' ,
3909+ name : label ,
3910+ inputs : { language : 'javascript' , code : `return '${ label } :' + <start.amount>;` } ,
3911+ } ,
3912+ } ) ) ,
3913+ {
3914+ operation_type : 'edit' ,
3915+ block_id : startId ,
3916+ params : { connections : { source : 'gate' } } ,
3917+ } ,
3918+ ]
3919+ const applied = await runCli (
3920+ [
3921+ 'workflows' ,
3922+ 'operations' ,
3923+ 'apply' ,
3924+ id ,
3925+ '--atomic' ,
3926+ '--yes' ,
3927+ '--operations' ,
3928+ JSON . stringify ( operations ) ,
3929+ ] ,
3930+ identity ,
3931+ null
3932+ )
3933+ expect ( applied . exitCode , applied . stderr ) . toBe ( 0 )
3934+ expect ( JSON . parse ( applied . stdout ) . skipped ) . toEqual ( [ ] )
3935+ for ( const [ amount , label ] of [
3936+ [ 42 , 'small' ] ,
3937+ [ 99 , 'small' ] ,
3938+ [ 100 , 'medium' ] ,
3939+ [ 499 , 'medium' ] ,
3940+ [ 500 , 'large' ] ,
3941+ [ 750 , 'large' ] ,
3942+ ] as const ) {
3943+ const executed = await runCli (
3944+ [ 'workflows' , 'run' , id , '--manual' , '--input' , JSON . stringify ( { amount } ) ] ,
3945+ identity ,
3946+ null
3947+ )
3948+ expect ( executed . exitCode , JSON . stringify ( { ...executed , errors : fixture . errors } ) ) . toBe ( 0 )
3949+ const run = v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( executed . stdout ) )
3950+ expect ( run . status ) . toBe ( 'completed' )
3951+ expect ( run . output ) . toMatchObject ( { result : `${ label } :${ amount } ` } )
3952+ }
3953+ await Promise . all ( postExecution . mock . calls . map ( ( [ promise ] ) => promise ) )
3954+ expect ( executeInSandbox ) . not . toHaveBeenCalled ( )
3955+ expect ( executeShellInSandbox ) . not . toHaveBeenCalled ( )
3956+ } ,
3957+ 60_000
3958+ )
3959+
3960+ it . skipIf ( process . env . SIM_HELPERS_SMOKE !== '1' ) (
3961+ 'constructs a weekday schedule, executes its error route, and enforces deployment permissions' ,
3962+ async ( ) => {
3963+ fixture . permission = 'admin'
3964+ const id = generateId ( )
3965+ await db . insert ( workflow ) . values ( {
3966+ id,
3967+ workspaceId,
3968+ userId : 'run-reader' ,
3969+ name : 'Schedule composition' ,
3970+ lastSynced : now ,
3971+ createdAt : now ,
3972+ updatedAt : now ,
3973+ isDeployed : false ,
3974+ } )
3975+ fixture . physicalWorkflows . add ( id )
3976+ await replaceWorkflowNormalizedState ( {
3977+ workflowId : id ,
3978+ workspaceId,
3979+ attributedUserId : 'run-reader' ,
3980+ state : { blocks : { } , edges : [ ] } ,
3981+ } )
3982+ const operations = [
3983+ {
3984+ operation_type : 'add' ,
3985+ block_id : 'schedule' ,
3986+ params : {
3987+ type : 'schedule' ,
3988+ name : 'weekday' ,
3989+ inputs : { scheduleType : 'custom' , cronExpression : '30 6 * * 1-5' , timezone : 'UTC' } ,
3990+ connections : { source : 'fail' } ,
3991+ } ,
3992+ } ,
3993+ {
3994+ operation_type : 'add' ,
3995+ block_id : 'fail' ,
3996+ params : {
3997+ type : 'function' ,
3998+ name : 'fail' ,
3999+ inputs : {
4000+ language : 'javascript' ,
4001+ code : 'throw new Error("synthetic schedule failure");' ,
4002+ } ,
4003+ connections : { error : 'report' } ,
4004+ } ,
4005+ } ,
4006+ {
4007+ operation_type : 'add' ,
4008+ block_id : 'report' ,
4009+ params : {
4010+ type : 'function' ,
4011+ name : 'report' ,
4012+ inputs : { language : 'javascript' , code : 'return { handled: <fail.error> };' } ,
4013+ } ,
4014+ } ,
4015+ ]
4016+ const applied = await runCli (
4017+ [
4018+ 'workflows' ,
4019+ 'operations' ,
4020+ 'apply' ,
4021+ id ,
4022+ '--atomic' ,
4023+ '--yes' ,
4024+ '--operations' ,
4025+ JSON . stringify ( operations ) ,
4026+ ] ,
4027+ identity ,
4028+ null
4029+ )
4030+ expect ( applied . exitCode , applied . stderr ) . toBe ( 0 )
4031+ expect ( JSON . parse ( applied . stdout ) . skipped ) . toEqual ( [ ] )
4032+ const stateRead = await runCli ( [ 'workflows' , 'state' , 'get' , id ] , identity , null )
4033+ expect ( stateRead . exitCode , stateRead . stderr ) . toBe ( 0 )
4034+ const state : WorkflowState = JSON . parse ( stateRead . stdout )
4035+ const scheduled = Object . values ( state . blocks ) . find ( ( block ) => block . type === 'schedule' )
4036+ if ( ! scheduled ) throw new Error ( 'Schedule was not persisted' )
4037+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
4038+ try {
4039+ vi . setSystemTime ( new Date ( '2026-09-04T06:31:00Z' ) )
4040+ expect (
4041+ calculateNextRunTime ( 'custom' , getScheduleTimeValues ( scheduled ) ) . toISOString ( )
4042+ ) . toBe ( '2026-09-07T06:30:00.000Z' )
4043+ } finally {
4044+ vi . useRealTimers ( )
4045+ }
4046+ const executed = await runCli (
4047+ [ 'workflows' , 'run' , id , '--manual' , '--trigger' , scheduled . id ] ,
4048+ identity ,
4049+ null
4050+ )
4051+ expect ( executed . exitCode , JSON . stringify ( { ...executed , errors : fixture . errors } ) ) . toBe ( 0 )
4052+ const run = v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( executed . stdout ) )
4053+ expect ( run . output ) . toMatchObject ( {
4054+ result : { handled : expect . stringContaining ( 'synthetic schedule failure' ) } ,
4055+ } )
4056+ await Promise . all ( postExecution . mock . calls . map ( ( [ promise ] ) => promise ) )
4057+ fixture . permission = 'read'
4058+ const deploy = await runCli ( [ 'workflows' , 'deploy' , id ] , identity , null )
4059+ expect ( deploy . exitCode ) . not . toBe ( 0 )
4060+ expect ( deploy . stderr ) . toMatch ( / F O R B I D D E N | N O T _ F O U N D | p e r m i s s i o n | a c c e s s / i)
4061+ const [ stored ] = await db . select ( ) . from ( workflow ) . where ( eq ( workflow . id , id ) )
4062+ expect ( stored . isDeployed ) . toBe ( false )
4063+ expect (
4064+ await db
4065+ . select ( )
4066+ . from ( workflowDeploymentVersion )
4067+ . where ( eq ( workflowDeploymentVersion . workflowId , id ) )
4068+ ) . toEqual ( [ ] )
4069+ } ,
4070+ 60_000
4071+ )
4072+
37334073 it . skipIf ( process . env . SIM_HELPERS_SMOKE !== '1' ) . each ( [ 'completed' , 'failed' ] as const ) (
37344074 'executes a saved workflow and reads its %s result from externalized log bytes' ,
37354075 async ( expectedStatus ) => {
0 commit comments