Skip to content

Commit 9a81f27

Browse files
committed
feat(functions): push prompt environments via environment-object
1 parent 7a167f4 commit 9a81f27

File tree

2 files changed

+526
-2
lines changed

2 files changed

+526
-2
lines changed

src/functions/push.rs

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::functions::report::{
2323
CommandStatus, FileStatus, HardFailureReason, PushFileReport, PushSummary, ReportError,
2424
SoftSkipReason,
2525
};
26+
use crate::http::ApiClient;
2627
use crate::js_runner;
2728
use crate::projects::api::{create_project, get_project_by_name, list_projects};
2829
use 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+
136143
fn 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+
9731168
fn 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

Comments
 (0)