@@ -23,6 +23,7 @@ use crate::functions::report::{
2323 CommandStatus , FileStatus , HardFailureReason , PushFileReport , PushSummary , ReportError ,
2424 SoftSkipReason ,
2525} ;
26+ use crate :: http:: ApiClient ;
2627use crate :: js_runner;
2728use crate :: projects:: api:: { create_project, get_project_by_name, list_projects} ;
2829use crate :: python_runner;
@@ -133,6 +134,12 @@ struct FileFailure {
133134 message : String ,
134135}
135136
137+ #[ derive( Debug , Clone ) ]
138+ struct EnvironmentPlanEntry {
139+ environment_slugs : Vec < String > ,
140+ if_exists : String ,
141+ }
142+
136143fn error_chain ( err : & anyhow:: Error ) -> String {
137144 format ! ( "{err:#}" )
138145}
@@ -936,7 +943,7 @@ async fn push_file(
936943 } ) ;
937944 }
938945
939- let insert_result = api :: insert_functions ( & auth_ctx. client , & function_events)
946+ let insert_result = insert_functions_with_environment_workaround ( & auth_ctx. client , & function_events)
940947 . await
941948 . map_err ( |err| FileFailure {
942949 reason : HardFailureReason :: InsertFunctionsFailed ,
@@ -970,6 +977,194 @@ async fn push_file(
970977 } )
971978}
972979
980+ async fn insert_functions_with_environment_workaround (
981+ client : & ApiClient ,
982+ function_events : & [ Value ] ,
983+ ) -> Result < api:: InsertFunctionsResult > {
984+ let environment_plan = collect_environment_plan ( function_events) ?;
985+ if !environment_plan. iter ( ) . any ( Option :: is_some) {
986+ return api:: insert_functions ( client, function_events) . await ;
987+ }
988+
989+ let stripped_events = strip_environments_from_function_events ( function_events) ;
990+ let insert_result = api:: insert_functions ( client, & stripped_events)
991+ . await
992+ . context ( "failed to insert functions" ) ?;
993+ apply_environment_plan ( client, & insert_result, & environment_plan) . await ?;
994+ Ok ( insert_result)
995+ }
996+
997+ fn collect_environment_plan (
998+ function_events : & [ Value ] ,
999+ ) -> Result < Vec < Option < EnvironmentPlanEntry > > > {
1000+ let mut plan = Vec :: with_capacity ( function_events. len ( ) ) ;
1001+
1002+ for ( index, function_event) in function_events. iter ( ) . enumerate ( ) {
1003+ let object = function_event
1004+ . as_object ( )
1005+ . ok_or_else ( || anyhow ! ( "function event entry {index} is not a JSON object" ) ) ?;
1006+
1007+ let Some ( environments) = object. get ( "environments" ) else {
1008+ plan. push ( None ) ;
1009+ continue ;
1010+ } ;
1011+ if environments. is_null ( ) {
1012+ plan. push ( None ) ;
1013+ continue ;
1014+ }
1015+
1016+ let environment_list = environments. as_array ( ) . ok_or_else ( || {
1017+ anyhow ! ( "function event entry {index} has invalid 'environments' (expected array)" )
1018+ } ) ?;
1019+ if environment_list. len ( ) > 10 {
1020+ bail ! ( "function event entry {index} exceeds the maximum of 10 environments" ) ;
1021+ }
1022+
1023+ let mut seen = BTreeSet :: new ( ) ;
1024+ let mut slugs = Vec :: new ( ) ;
1025+ for ( env_index, env) in environment_list. iter ( ) . enumerate ( ) {
1026+ let slug = env
1027+ . as_str ( )
1028+ . map ( str:: trim)
1029+ . filter ( |value| !value. is_empty ( ) )
1030+ . or_else ( || {
1031+ env. as_object ( )
1032+ . and_then ( |value| value. get ( "slug" ) )
1033+ . and_then ( Value :: as_str)
1034+ . map ( str:: trim)
1035+ . filter ( |value| !value. is_empty ( ) )
1036+ } )
1037+ . ok_or_else ( || {
1038+ anyhow ! (
1039+ "function event entry {index} has invalid environments[{env_index}] (expected string or object with non-empty slug)"
1040+ )
1041+ } ) ?;
1042+ if seen. insert ( slug. to_string ( ) ) {
1043+ slugs. push ( slug. to_string ( ) ) ;
1044+ }
1045+ }
1046+ if slugs. is_empty ( ) {
1047+ plan. push ( None ) ;
1048+ continue ;
1049+ }
1050+
1051+ let function_type = object
1052+ . get ( "function_data" )
1053+ . and_then ( Value :: as_object)
1054+ . and_then ( |data| data. get ( "type" ) )
1055+ . and_then ( Value :: as_str)
1056+ . map ( str:: trim)
1057+ . filter ( |value| !value. is_empty ( ) ) ;
1058+ let is_prompt_function = function_type. is_none ( ) || function_type == Some ( "prompt" ) ;
1059+ if !is_prompt_function {
1060+ let function_type = function_type. unwrap_or ( "unknown" ) ;
1061+ bail ! (
1062+ "environments is only supported for prompt functions, but got function_data.type=\" {function_type}\" "
1063+ ) ;
1064+ }
1065+
1066+ let if_exists = object
1067+ . get ( "if_exists" )
1068+ . and_then ( Value :: as_str)
1069+ . map ( str:: trim)
1070+ . filter ( |value| !value. is_empty ( ) )
1071+ . unwrap_or ( "error" )
1072+ . to_ascii_lowercase ( ) ;
1073+ plan. push ( Some ( EnvironmentPlanEntry {
1074+ environment_slugs : slugs,
1075+ if_exists,
1076+ } ) ) ;
1077+ }
1078+
1079+ Ok ( plan)
1080+ }
1081+
1082+ fn strip_environments_from_function_events ( function_events : & [ Value ] ) -> Vec < Value > {
1083+ function_events
1084+ . iter ( )
1085+ . map ( |function_event| {
1086+ let mut stripped = function_event. clone ( ) ;
1087+ if let Some ( object) = stripped. as_object_mut ( ) {
1088+ object. remove ( "environments" ) ;
1089+ }
1090+ stripped
1091+ } )
1092+ . collect ( )
1093+ }
1094+
1095+ async fn apply_environment_plan (
1096+ client : & ApiClient ,
1097+ insert_result : & api:: InsertFunctionsResult ,
1098+ environment_plan : & [ Option < EnvironmentPlanEntry > ] ,
1099+ ) -> Result < ( ) > {
1100+ if !environment_plan. iter ( ) . any ( Option :: is_some) {
1101+ return Ok ( ( ) ) ;
1102+ }
1103+ if insert_result. functions . len ( ) != environment_plan. len ( ) {
1104+ bail ! (
1105+ "insert-functions response returned {} function records for {} inputs while applying environments" ,
1106+ insert_result. functions. len( ) ,
1107+ environment_plan. len( )
1108+ ) ;
1109+ }
1110+
1111+ let has_updates = insert_result
1112+ . functions
1113+ . iter ( )
1114+ . zip ( environment_plan. iter ( ) )
1115+ . any ( |( inserted_function, plan_entry) | {
1116+ plan_entry. as_ref ( ) . is_some_and ( |entry| {
1117+ !entry. environment_slugs . is_empty ( )
1118+ && ( !inserted_function. found_existing || entry. if_exists == "replace" )
1119+ } )
1120+ } ) ;
1121+ if !has_updates {
1122+ return Ok ( ( ) ) ;
1123+ }
1124+
1125+ let object_version = insert_result
1126+ . xact_id
1127+ . as_deref ( )
1128+ . map ( str:: trim)
1129+ . filter ( |value| !value. is_empty ( ) )
1130+ . ok_or_else ( || {
1131+ anyhow ! ( "insert-functions response missing xact_id while applying environments" )
1132+ } ) ?
1133+ . to_string ( ) ;
1134+
1135+ for ( inserted_function, plan_entry) in
1136+ insert_result. functions . iter ( ) . zip ( environment_plan. iter ( ) )
1137+ {
1138+ let Some ( plan_entry) = plan_entry else {
1139+ continue ;
1140+ } ;
1141+ if inserted_function. found_existing && plan_entry. if_exists != "replace" {
1142+ continue ;
1143+ }
1144+
1145+ for environment_slug in & plan_entry. environment_slugs {
1146+ api:: upsert_environment_object_for_prompt (
1147+ client,
1148+ & inserted_function. id ,
1149+ environment_slug,
1150+ & object_version,
1151+ )
1152+ . await
1153+ . with_context ( || {
1154+ format ! (
1155+ "failed to upsert environment association for prompt '{}' ({}) in project '{}' and environment '{}'" ,
1156+ inserted_function. slug,
1157+ inserted_function. id,
1158+ inserted_function. project_id,
1159+ environment_slug
1160+ )
1161+ } ) ?;
1162+ }
1163+ }
1164+
1165+ Ok ( ( ) )
1166+ }
1167+
9731168fn build_js_bundle (
9741169 source_path : & Path ,
9751170 args : & PushArgs ,
@@ -3030,6 +3225,61 @@ mod tests {
30303225 assert_eq ! ( calculate_upload_counts( 3 , None ) , ( 3 , 0 ) ) ;
30313226 }
30323227
3228+ #[ test]
3229+ fn environment_plan_collects_prompt_environments ( ) {
3230+ let function_events = vec ! [ serde_json:: json!( {
3231+ "project_id" : "proj_1" ,
3232+ "slug" : "hello" ,
3233+ "if_exists" : "replace" ,
3234+ "function_data" : { "type" : "prompt" } ,
3235+ "environments" : [ { "slug" : "staging" } , "prod" ] ,
3236+ } ) ] ;
3237+
3238+ let plan = collect_environment_plan ( & function_events) . expect ( "collect plan" ) ;
3239+ assert_eq ! ( plan. len( ) , 1 ) ;
3240+ let entry = plan[ 0 ] . as_ref ( ) . expect ( "plan entry" ) ;
3241+ assert_eq ! ( entry. environment_slugs, vec![ "staging" , "prod" ] ) ;
3242+ assert_eq ! ( entry. if_exists, "replace" ) ;
3243+ }
3244+
3245+ #[ test]
3246+ fn environment_plan_rejects_non_prompt_function_type ( ) {
3247+ let function_events = vec ! [ serde_json:: json!( {
3248+ "project_id" : "proj_1" ,
3249+ "slug" : "hello" ,
3250+ "if_exists" : "replace" ,
3251+ "function_data" : { "type" : "code" } ,
3252+ "environments" : [ { "slug" : "staging" } ] ,
3253+ } ) ] ;
3254+
3255+ let err = collect_environment_plan ( & function_events) . expect_err ( "must fail" ) ;
3256+ assert ! ( err
3257+ . to_string( )
3258+ . contains( "environments is only supported for prompt functions" ) ) ;
3259+ }
3260+
3261+ #[ test]
3262+ fn strip_environments_removes_environment_field ( ) {
3263+ let function_events = vec ! [ serde_json:: json!( {
3264+ "project_id" : "proj_1" ,
3265+ "slug" : "hello" ,
3266+ "environments" : [ { "slug" : "staging" } ] ,
3267+ "metadata" : { "source" : "test" }
3268+ } ) ] ;
3269+
3270+ let stripped = strip_environments_from_function_events ( & function_events) ;
3271+ assert_eq ! ( stripped. len( ) , 1 ) ;
3272+ let obj = stripped[ 0 ] . as_object ( ) . expect ( "object" ) ;
3273+ assert ! ( !obj. contains_key( "environments" ) ) ;
3274+ assert_eq ! (
3275+ obj. get( "metadata" )
3276+ . and_then( Value :: as_object)
3277+ . and_then( |metadata| metadata. get( "source" ) )
3278+ . and_then( Value :: as_str) ,
3279+ Some ( "test" )
3280+ ) ;
3281+ }
3282+
30333283 #[ test]
30343284 fn requirements_reference_escape_is_rejected ( ) {
30353285 let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
0 commit comments