From b20e83e9c27bff83a7b2beb36697c2eec8e63857 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Mon, 17 Aug 2026 21:11:26 -0700 Subject: [PATCH 01/13] add bt automations --- src/automation.rs | 464 ++++++++++++++++++++++++++++++++ src/facet.rs | 564 +++++++++++++++++++++++++++++++++++++++ src/http.rs | 20 ++ src/main.rs | 75 ++++++ src/resource_template.rs | 207 ++++++++++++++ 5 files changed, 1330 insertions(+) create mode 100644 src/automation.rs create mode 100644 src/facet.rs create mode 100644 src/resource_template.rs diff --git a/src/automation.rs b/src/automation.rs new file mode 100644 index 00000000..a2a7a54c --- /dev/null +++ b/src/automation.rs @@ -0,0 +1,464 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use urlencoding::encode; + +use crate::args::BaseArgs; +use crate::http::ApiClient; +use crate::project_context::resolve_project_command_context_with_auth_mode; +use crate::resource_template::{self, SCHEMA_VERSION}; +use crate::ui::{print_command_status, CommandStatus}; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt automation fetch my-loop --output my-loop.automation.json + bt automation push my-loop.automation.json --org test-org --project test-project + bt automation push my-loop.automation.json --name renamed-loop + bt automation push https://example.com/my-loop.automation.json + bt automation fetch my-loop | bt automation push - --project test-project +")] +pub(crate) struct AutomationArgs { + #[command(subcommand)] + command: AutomationCommand, +} + +#[derive(Debug, Clone, Subcommand)] +enum AutomationCommand { + /// Fetch a Loop automation as a portable JSON template + Fetch(FetchArgs), + /// Create or replace a Loop automation from a JSON template + Push(PushArgs), +} + +#[derive(Debug, Clone, Args)] +struct FetchArgs { + /// Automation name + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Automation name + #[arg( + long = "name", + short = 'n', + env = "BT_AUTOMATION_FETCH_NAME", + value_name = "NAME" + )] + name_flag: Option, + + /// Write the template to this path instead of stdout + #[arg( + long, + short = 'O', + env = "BT_AUTOMATION_FETCH_OUTPUT", + value_name = "PATH" + )] + output: Option, +} + +impl FetchArgs { + fn name(&self) -> Result<&str> { + resolve_required_selector( + self.name_positional.as_deref(), + self.name_flag.as_deref(), + "automation name", + "bt automation fetch ", + ) + } +} + +#[derive(Debug, Clone, Args)] +struct PushArgs { + /// Automation template path, HTTP(S) URL, or - to read from stdin + #[arg(value_name = "SOURCE")] + file_positional: Option, + + /// Automation template path, HTTP(S) URL, or - to read from stdin + #[arg( + long = "file", + short = 'f', + env = "BT_AUTOMATION_PUSH_FILE", + value_name = "SOURCE" + )] + file_flag: Option, + + /// Override the automation name from the template + #[arg( + long = "name", + short = 'n', + env = "BT_AUTOMATION_PUSH_NAME", + value_name = "NAME" + )] + name: Option, +} + +impl PushArgs { + fn file(&self) -> Result<&str> { + match (&self.file_positional, &self.file_flag) { + (Some(_), Some(_)) => bail!("use either a template path or --file, not both"), + (Some(source), None) | (None, Some(source)) => Ok(source), + (None, None) => { + bail!("automation template path required. Use: bt automation push ") + } + } + } +} + +fn resolve_required_selector<'a>( + positional: Option<&'a str>, + flag: Option<&'a str>, + label: &str, + usage: &str, +) -> Result<&'a str> { + match (positional, flag) { + (Some(_), Some(_)) => bail!("use either a positional {label} or --name, not both"), + (Some(value), None) | (None, Some(value)) if !value.trim().is_empty() => Ok(value), + _ => bail!("{label} required. Use: {usage}"), + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TemplateKind { + Automation, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +struct AutomationTemplate { + kind: TemplateKind, + schema_version: u32, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + config: Value, +} + +#[derive(Debug, Clone, Deserialize)] +struct RemoteAutomation { + id: String, + name: String, + #[serde(default)] + description: Option, + config: Value, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +#[derive(Debug, Serialize)] +struct UpsertAutomationRequest<'a> { + project_id: &'a str, + name: &'a str, + description: Option<&'a str>, + config: &'a Value, +} + +pub(crate) async fn run(base: BaseArgs, args: AutomationArgs) -> Result<()> { + match args.command { + AutomationCommand::Fetch(args) => fetch(base, args).await, + AutomationCommand::Push(args) => push(base, args).await, + } +} + +async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { + let name = args.name()?.to_string(); + let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; + let automation = get_by_name(&ctx.client, &ctx.project.id, &name) + .await? + .ok_or_else(|| { + anyhow!( + "automation '{name}' not found in project '{}'", + ctx.project.name + ) + })?; + let template = template_from_remote(automation)?; + + resource_template::write(&template, args.output.as_deref()).with_context(|| { + args.output.as_ref().map_or_else( + || "failed to write automation template to stdout".to_string(), + |path| format!("failed to write automation template to {}", path.display()), + ) + })?; + + if let Some(path) = args + .output + .as_deref() + .filter(|path| *path != Path::new("-")) + { + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "kind": "automation", + "name": template.name, + "project": ctx.project.name, + "output": path, + "status": "fetched", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!( + "Fetched automation '{}' to {}", + template.name, + path.display() + ), + ); + } + } + + Ok(()) +} + +async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { + let template: AutomationTemplate = resource_template::read(args.file()?, "automation").await?; + validate_template(&template)?; + let name = push_name(args.name.as_deref(), &template.name)?; + + let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; + let request = UpsertAutomationRequest { + project_id: &ctx.project.id, + name, + description: template.description.as_deref(), + config: &template.config, + }; + let pushed: RemoteAutomation = ctx + .client + .put("/v1/project_automation", &request) + .await + .with_context(|| { + format!( + "failed to push automation '{}' to project '{}'", + name, ctx.project.name + ) + })?; + + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "id": pushed.id, + "kind": "automation", + "name": pushed.name, + "project": ctx.project.name, + "status": "pushed", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!( + "Pushed automation '{}' to project '{}'", + pushed.name, ctx.project.name + ), + ); + } + + Ok(()) +} + +async fn get_by_name( + client: &ApiClient, + project_id: &str, + name: &str, +) -> Result> { + let path = format!( + "/v1/project_automation?project_id={}&name={}", + encode(project_id), + encode(name) + ); + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list automations via {path}"))?; + + let mut matches = response + .objects + .into_iter() + .filter(|automation| automation.name == name); + let found = matches.next(); + if matches.next().is_some() { + bail!("multiple automations named '{name}' found in the selected project"); + } + Ok(found) +} + +fn template_from_remote(remote: RemoteAutomation) -> Result { + validate_loop_config(&remote.config, &remote.name)?; + Ok(AutomationTemplate { + kind: TemplateKind::Automation, + schema_version: SCHEMA_VERSION, + name: remote.name, + description: remote.description, + config: remote.config, + }) +} + +fn validate_template(template: &AutomationTemplate) -> Result<()> { + resource_template::validate_version(template.schema_version)?; + if template.name.trim().is_empty() { + bail!("automation template name must not be empty"); + } + validate_loop_config(&template.config, &template.name) +} + +fn push_name<'a>(override_name: Option<&'a str>, template_name: &'a str) -> Result<&'a str> { + match override_name { + Some(name) if name.trim().is_empty() => { + bail!("automation name override must not be empty") + } + Some(name) => Ok(name.trim()), + None => Ok(template_name), + } +} + +fn validate_loop_config(config: &Value, name: &str) -> Result<()> { + let event_type = config.get("event_type").and_then(Value::as_str); + if event_type != Some("windowed") || config.get("loop").and_then(Value::as_object).is_none() { + let event_type = event_type.unwrap_or(""); + bail!( + "automation '{name}' is not a Loop automation (event_type: {event_type}); only windowed automations with a loop config are portable with this command" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote_loop() -> RemoteAutomation { + RemoteAutomation { + id: "fake-automation-id".to_string(), + name: "test-loop".to_string(), + description: Some("A synthetic Loop automation".to_string()), + config: serde_json::json!({ + "event_type": "windowed", + "status": "active", + "window": { + "window_seconds": 3600, + "schedule": { + "type": "interval", + "evaluation_interval_seconds": 300 + }, + "evaluation_delay_seconds": 0 + }, + "loop": { + "prompt": "Review recent traces", + "include_trigger_input": false, + "agent_slug": "loop-chat", + "auto_approve_tools": [], + "harness": "codex" + }, + "actions": [] + }), + } + } + + #[test] + fn automation_template_omits_remote_identity() { + let template = template_from_remote(remote_loop()).expect("template"); + let value = serde_json::to_value(template).expect("json"); + + assert_eq!(value["kind"], "automation"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["name"], "test-loop"); + assert!(value.get("id").is_none()); + assert!(value.get("project_id").is_none()); + assert!(value.get("user_id").is_none()); + } + + #[test] + fn rejects_non_loop_automation() { + let mut remote = remote_loop(); + remote.config = serde_json::json!({ + "event_type": "logs", + "btql_filter": "created >= NOW() - INTERVAL 1 HOUR", + "interval_seconds": 60, + "action": {"type": "webhook", "url": "https://example.invalid/hook"} + }); + + let err = template_from_remote(remote).expect_err("non-loop automation"); + + assert!(err.to_string().contains("is not a Loop automation")); + } + + #[test] + fn rejects_missing_loop_config() { + let mut remote = remote_loop(); + remote + .config + .as_object_mut() + .expect("object") + .remove("loop"); + + let err = template_from_remote(remote).expect_err("missing loop config"); + + assert!(err + .to_string() + .contains("windowed automations with a loop config")); + } + + #[test] + fn validates_template_version_and_name() { + let mut template = template_from_remote(remote_loop()).expect("template"); + template.schema_version = 2; + assert!(validate_template(&template).is_err()); + + template.schema_version = SCHEMA_VERSION; + template.name = " ".to_string(); + assert!(validate_template(&template).is_err()); + } + + #[test] + fn resolves_fetch_name_from_positional_or_flag() { + assert_eq!( + resolve_required_selector(Some("test-loop"), None, "automation name", "usage") + .expect("positional"), + "test-loop" + ); + assert_eq!( + resolve_required_selector(None, Some("test-loop"), "automation name", "usage") + .expect("flag"), + "test-loop" + ); + assert!(resolve_required_selector( + Some("test-loop"), + Some("other-loop"), + "automation name", + "usage" + ) + .is_err()); + } + + #[test] + fn push_name_prefers_non_empty_override() { + assert_eq!( + push_name(Some("renamed-loop"), "test-loop").expect("override"), + "renamed-loop" + ); + assert_eq!(push_name(None, "test-loop").expect("template"), "test-loop"); + assert!(push_name(Some(" "), "test-loop").is_err()); + } + + #[test] + fn upsert_request_uses_overridden_name() { + let template = template_from_remote(remote_loop()).expect("template"); + let request = UpsertAutomationRequest { + project_id: "fake-project-id", + name: push_name(Some("renamed-loop"), &template.name).expect("name"), + description: template.description.as_deref(), + config: &template.config, + }; + + let value = serde_json::to_value(request).expect("request JSON"); + assert_eq!(value["name"], "renamed-loop"); + assert_eq!(value["project_id"], "fake-project-id"); + } +} diff --git a/src/facet.rs b/src/facet.rs new file mode 100644 index 00000000..4e8c3909 --- /dev/null +++ b/src/facet.rs @@ -0,0 +1,564 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use urlencoding::encode; + +use crate::args::BaseArgs; +use crate::http::ApiClient; +use crate::project_context::resolve_project_command_context_with_auth_mode; +use crate::resource_template::{self, SCHEMA_VERSION}; +use crate::ui::{print_command_status, CommandStatus}; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt facet fetch my-facet --output my-facet.facet.json + bt facet push my-facet.facet.json --org test-org --project test-project + bt facet push my-facet.facet.json --name \"Renamed facet\" + bt facet push https://example.com/my-facet.facet.json + bt facet fetch my-facet | bt facet push - --project test-project +")] +pub(crate) struct FacetArgs { + #[command(subcommand)] + command: FacetCommand, +} + +#[derive(Debug, Clone, Subcommand)] +enum FacetCommand { + /// Fetch a facet as a portable JSON template + Fetch(FetchArgs), + /// Create or replace a facet from a JSON template + Push(PushArgs), +} + +#[derive(Debug, Clone, Args)] +struct FetchArgs { + /// Facet name + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Facet name + #[arg( + long = "name", + short = 'n', + env = "BT_FACET_FETCH_NAME", + value_name = "NAME" + )] + name_flag: Option, + + /// Write the template to this path instead of stdout + #[arg(long, short = 'O', env = "BT_FACET_FETCH_OUTPUT", value_name = "PATH")] + output: Option, +} + +impl FetchArgs { + fn name(&self) -> Result<&str> { + match (self.name_positional.as_deref(), self.name_flag.as_deref()) { + (Some(_), Some(_)) => bail!("use either a positional facet name or --name, not both"), + (Some(value), None) | (None, Some(value)) if !value.trim().is_empty() => Ok(value), + _ => bail!("facet name required. Use: bt facet fetch "), + } + } +} + +#[derive(Debug, Clone, Args)] +struct PushArgs { + /// Facet template path, HTTP(S) URL, or - to read from stdin + #[arg(value_name = "SOURCE")] + file_positional: Option, + + /// Facet template path, HTTP(S) URL, or - to read from stdin + #[arg( + long = "file", + short = 'f', + env = "BT_FACET_PUSH_FILE", + value_name = "SOURCE" + )] + file_flag: Option, + + /// Override the facet name from the template (the slug is unchanged) + #[arg( + long = "name", + short = 'n', + env = "BT_FACET_PUSH_NAME", + value_name = "NAME" + )] + name: Option, +} + +impl PushArgs { + fn file(&self) -> Result<&str> { + match (&self.file_positional, &self.file_flag) { + (Some(_), Some(_)) => bail!("use either a template path or --file, not both"), + (Some(source), None) | (None, Some(source)) => Ok(source), + (None, None) => bail!("facet template path required. Use: bt facet push "), + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TemplateKind { + Facet, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +struct FacetTemplate { + kind: TemplateKind, + schema_version: u32, + name: String, + slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + function_data: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + function_schema: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RemoteFunction { + id: String, + name: String, + slug: String, + #[serde(default)] + description: Option, + #[serde(default)] + function_type: Option, + function_data: Value, + #[serde(default)] + prompt_data: Option, + #[serde(default)] + tags: Option>, + #[serde(default)] + function_schema: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +#[derive(Debug, Serialize)] +struct UpsertFacetRequest<'a> { + project_id: &'a str, + name: &'a str, + slug: &'a str, + description: Option<&'a str>, + function_type: &'static str, + function_data: &'a Value, + prompt_data: Option<&'a Value>, + tags: Option<&'a [String]>, + function_schema: Option<&'a Value>, +} + +pub(crate) async fn run(base: BaseArgs, args: FacetArgs) -> Result<()> { + match args.command { + FacetCommand::Fetch(args) => fetch(base, args).await, + FacetCommand::Push(args) => push(base, args).await, + } +} + +async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { + let name = args.name()?.to_string(); + let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; + let facet = get_facet_by_name(&ctx.client, &ctx.project.id, &name) + .await? + .ok_or_else(|| anyhow!("facet '{name}' not found in project '{}'", ctx.project.name))?; + let template = template_from_remote(&ctx.client, facet).await?; + + resource_template::write(&template, args.output.as_deref()).with_context(|| { + args.output.as_ref().map_or_else( + || "failed to write facet template to stdout".to_string(), + |path| format!("failed to write facet template to {}", path.display()), + ) + })?; + + if let Some(path) = args + .output + .as_deref() + .filter(|path| *path != Path::new("-")) + { + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "kind": "facet", + "name": template.name, + "project": ctx.project.name, + "output": path, + "status": "fetched", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!("Fetched facet '{}' to {}", template.name, path.display()), + ); + } + } + + Ok(()) +} + +async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { + let template: FacetTemplate = resource_template::read(args.file()?, "facet").await?; + validate_template(&template)?; + let name = push_name(args.name.as_deref(), &template.name)?; + + let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; + let function_data = resolve_preprocessor_reference( + &ctx.client, + &ctx.project.id, + template.function_data.clone(), + ) + .await?; + let request = UpsertFacetRequest { + project_id: &ctx.project.id, + name, + slug: &template.slug, + description: template.description.as_deref(), + function_type: "facet", + function_data: &function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), + }; + let pushed: RemoteFunction = ctx + .client + .put("/v1/function", &request) + .await + .with_context(|| { + format!( + "failed to push facet '{}' to project '{}'", + name, ctx.project.name + ) + })?; + + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "id": pushed.id, + "kind": "facet", + "name": pushed.name, + "project": ctx.project.name, + "slug": pushed.slug, + "status": "pushed", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!( + "Pushed facet '{}' to project '{}'", + pushed.name, ctx.project.name + ), + ); + } + + Ok(()) +} + +async fn get_facet_by_name( + client: &ApiClient, + project_id: &str, + name: &str, +) -> Result> { + let path = format!( + "/v1/function?project_id={}&name={}", + encode(project_id), + encode(name) + ); + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list facets via {path}"))?; + + let mut matches = response.objects.into_iter().filter(|function| { + function.name == name && function.function_type.as_deref() == Some("facet") + }); + let found = matches.next(); + if matches.next().is_some() { + bail!("multiple facets named '{name}' found in the selected project"); + } + Ok(found) +} + +async fn get_function_by_id(client: &ApiClient, id: &str) -> Result { + let path = format!("/v1/function/{}", encode(id)); + client + .get(&path) + .await + .with_context(|| format!("failed to resolve referenced function '{id}'")) +} + +async fn get_preprocessor_by_slug( + client: &ApiClient, + project_id: &str, + slug: &str, +) -> Result> { + let path = format!( + "/v1/function?project_id={}&slug={}", + encode(project_id), + encode(slug) + ); + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to resolve preprocessor with slug '{slug}'"))?; + Ok(response.objects.into_iter().find(|function| { + function.slug == slug && function.function_type.as_deref() == Some("preprocessor") + })) +} + +async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Result { + if remote.function_type.as_deref() != Some("facet") { + bail!("function '{}' is not a facet", remote.name); + } + let function_data = make_preprocessor_reference_portable(client, remote.function_data).await?; + let template = FacetTemplate { + kind: TemplateKind::Facet, + schema_version: SCHEMA_VERSION, + name: remote.name, + slug: remote.slug, + description: remote.description, + function_data, + prompt_data: remote.prompt_data, + tags: remote.tags, + function_schema: remote.function_schema, + }; + validate_template(&template)?; + Ok(template) +} + +async fn make_preprocessor_reference_portable( + client: &ApiClient, + mut function_data: Value, +) -> Result { + let Some(preprocessor) = function_data + .get_mut("preprocessor") + .and_then(Value::as_object_mut) + else { + return Ok(function_data); + }; + if preprocessor.get("type").and_then(Value::as_str) != Some("function") { + return Ok(function_data); + } + + let id = preprocessor + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("facet has a project preprocessor reference without an id"))?; + let referenced = get_function_by_id(client, id).await?; + if referenced.function_type.as_deref() != Some("preprocessor") { + bail!( + "facet references function '{}' as a preprocessor, but its function type is '{}'", + referenced.name, + referenced.function_type.as_deref().unwrap_or("") + ); + } + *preprocessor = serde_json::Map::from_iter([ + ("type".to_string(), Value::String("function".to_string())), + ("slug".to_string(), Value::String(referenced.slug)), + ]); + Ok(function_data) +} + +async fn resolve_preprocessor_reference( + client: &ApiClient, + project_id: &str, + mut function_data: Value, +) -> Result { + let Some(preprocessor) = function_data + .get_mut("preprocessor") + .and_then(Value::as_object_mut) + else { + return Ok(function_data); + }; + if preprocessor.get("type").and_then(Value::as_str) != Some("function") { + return Ok(function_data); + } + + let slug = preprocessor + .get("slug") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!( + "facet template project preprocessor reference must use a portable 'slug' field" + ) + })?; + let referenced = get_preprocessor_by_slug(client, project_id, slug) + .await? + .ok_or_else(|| { + anyhow!( + "preprocessor with slug '{slug}' not found in the target project; push that preprocessor before pushing this facet" + ) + })?; + *preprocessor = serde_json::Map::from_iter([ + ("type".to_string(), Value::String("function".to_string())), + ("id".to_string(), Value::String(referenced.id)), + ]); + Ok(function_data) +} + +fn validate_template(template: &FacetTemplate) -> Result<()> { + resource_template::validate_version(template.schema_version)?; + if template.name.trim().is_empty() { + bail!("facet template name must not be empty"); + } + if template.slug.trim().is_empty() { + bail!("facet template slug must not be empty"); + } + if template.function_data.get("type").and_then(Value::as_str) != Some("facet") { + bail!("facet template function_data.type must be 'facet'"); + } + if let Some(preprocessor) = template + .function_data + .get("preprocessor") + .and_then(Value::as_object) + .filter(|preprocessor| preprocessor.get("type").and_then(Value::as_str) == Some("function")) + { + if preprocessor.get("slug").and_then(Value::as_str).is_none() { + bail!("facet template project preprocessor reference must use a portable 'slug' field"); + } + if preprocessor.contains_key("id") { + bail!("facet template must not contain a source-project preprocessor id"); + } + } + Ok(()) +} + +fn push_name<'a>(override_name: Option<&'a str>, template_name: &'a str) -> Result<&'a str> { + match override_name { + Some(name) if name.trim().is_empty() => bail!("facet name override must not be empty"), + Some(name) => Ok(name.trim()), + None => Ok(template_name), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facet_template() -> FacetTemplate { + FacetTemplate { + kind: TemplateKind::Facet, + schema_version: SCHEMA_VERSION, + name: "test-facet".to_string(), + slug: "test-facet".to_string(), + description: Some("A synthetic facet".to_string()), + function_data: serde_json::json!({ + "type": "facet", + "prompt": "Classify the trace", + "model": "gpt-4.1-mini" + }), + prompt_data: None, + tags: Some(vec!["test-tag".to_string()]), + function_schema: None, + } + } + + #[test] + fn facet_template_contains_only_portable_identity() { + let value = serde_json::to_value(facet_template()).expect("json"); + + assert_eq!(value["kind"], "facet"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["name"], "test-facet"); + assert_eq!(value["slug"], "test-facet"); + assert!(value.get("id").is_none()); + assert!(value.get("project_id").is_none()); + assert!(value.get("user_id").is_none()); + assert!(value.get("function_type").is_none()); + } + + #[test] + fn validates_facet_shape() { + validate_template(&facet_template()).expect("valid facet"); + + let mut template = facet_template(); + template.function_data["type"] = Value::String("prompt".to_string()); + let err = validate_template(&template).expect_err("wrong type"); + assert!(err.to_string().contains("function_data.type")); + } + + #[test] + fn accepts_portable_project_preprocessor_reference() { + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": "test-preprocessor" + }); + + validate_template(&template).expect("portable reference"); + } + + #[test] + fn rejects_source_project_preprocessor_id() { + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "id": "fake-preprocessor-id" + }); + + let err = validate_template(&template).expect_err("source id"); + assert!(err.to_string().contains("portable 'slug' field")); + } + + #[test] + fn fetch_args_require_one_name_selector() { + let args = FetchArgs { + name_positional: Some("test-facet".to_string()), + name_flag: None, + output: None, + }; + assert_eq!(args.name().expect("name"), "test-facet"); + + let both = FetchArgs { + name_positional: Some("test-facet".to_string()), + name_flag: Some("other-facet".to_string()), + output: None, + }; + assert!(both.name().is_err()); + } + + #[test] + fn push_name_prefers_non_empty_override() { + assert_eq!( + push_name(Some("Renamed facet"), "test-facet").expect("override"), + "Renamed facet" + ); + assert_eq!( + push_name(None, "test-facet").expect("template"), + "test-facet" + ); + assert!(push_name(Some(" "), "test-facet").is_err()); + } + + #[test] + fn upsert_request_uses_overridden_name_without_changing_slug() { + let template = facet_template(); + let request = UpsertFacetRequest { + project_id: "fake-project-id", + name: push_name(Some("Renamed facet"), &template.name).expect("name"), + slug: &template.slug, + description: template.description.as_deref(), + function_type: "facet", + function_data: &template.function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), + }; + + let value = serde_json::to_value(request).expect("request JSON"); + assert_eq!(value["name"], "Renamed facet"); + assert_eq!(value["slug"], "test-facet"); + } +} diff --git a/src/http.rs b/src/http.rs index d5a500d0..b21e1c3a 100644 --- a/src/http.rs +++ b/src/http.rs @@ -233,6 +233,26 @@ impl ApiClient { parse_json_response(response, "POST", path).await } + pub async fn put(&self, path: &str, body: &B) -> Result { + let url = self.url(path); + let response = self + .http + .put(&url) + .bearer_auth(&self.api_key) + .json(body) + .send() + .await + .context("request failed")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(HttpError { status, body }.into()); + } + + parse_json_response(response, "PUT", path).await + } + pub async fn patch( &self, path: &str, diff --git a/src/main.rs b/src/main.rs index 69d49a49..97dca7d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::ffi::{OsStr, OsString}; mod args; mod auth; +mod automation; #[allow(dead_code)] mod config; mod datasets; @@ -11,6 +12,7 @@ mod env; #[cfg(unix)] mod eval; mod experiments; +mod facet; mod functions; mod http; mod init; @@ -19,6 +21,7 @@ mod project_context; mod projects; mod prompts; mod python_runner; +mod resource_template; mod runner_sse; mod scorers; mod self_update; @@ -65,6 +68,8 @@ Core Projects & resources projects Manage projects + automation Fetch and push Loop automation templates + facet Fetch and push facet templates topics Inspect and control Topics automation datasets Manage datasets prompts Manage prompts @@ -143,6 +148,12 @@ enum Commands { Eval(CLIArgs), /// Manage projects Projects(CLIArgs), + #[command(visible_alias = "automations")] + /// Fetch and push Loop automation templates + Automation(CLIArgs), + #[command(visible_alias = "facets")] + /// Fetch and push facet templates + Facet(CLIArgs), /// Inspect and control Topics automation Topics(CLIArgs), /// Manage datasets @@ -189,6 +200,8 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, + Commands::Automation(cmd) => &cmd.base, + Commands::Facet(cmd) => &cmd.base, Commands::Topics(cmd) => &cmd.base, Commands::Datasets(cmd) => &cmd.base, Commands::Prompts(cmd) => &cmd.base, @@ -218,6 +231,8 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, + Commands::Automation(cmd) => &mut cmd.base, + Commands::Facet(cmd) => &mut cmd.base, Commands::Datasets(cmd) => &mut cmd.base, Commands::Topics(cmd) => &mut cmd.base, Commands::Prompts(cmd) => &mut cmd.base, @@ -328,6 +343,8 @@ fn try_main() -> Result<()> { #[cfg(unix)] Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, + Commands::Automation(cmd) => automation::run(cmd.base, cmd.args).await?, + Commands::Facet(cmd) => facet::run(cmd.base, cmd.args).await?, Commands::Datasets(cmd) => datasets::run(cmd.base, cmd.args).await?, Commands::Topics(cmd) => topics::run(cmd.base, cmd.args).await?, Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, @@ -607,6 +624,64 @@ mod tests { } } + #[test] + fn automation_template_commands_and_alias_parse() { + for args in [ + vec!["bt", "automation", "fetch", "test-loop"], + vec!["bt", "automation", "fetch", "--name", "test-loop"], + vec!["bt", "automation", "push", "test-loop.automation.json"], + vec![ + "bt", + "automation", + "push", + "test-loop.automation.json", + "--name", + "renamed-loop", + ], + vec![ + "bt", + "automation", + "push", + "https://example.com/test-loop.automation.json", + ], + vec![ + "bt", + "automations", + "push", + "--file", + "test-loop.automation.json", + ], + ] { + Cli::try_parse_from(args).expect("automation command should parse"); + } + } + + #[test] + fn facet_template_commands_and_alias_parse() { + for args in [ + vec!["bt", "facet", "fetch", "test-facet"], + vec!["bt", "facet", "fetch", "--name", "test-facet"], + vec!["bt", "facet", "push", "test-facet.facet.json"], + vec![ + "bt", + "facet", + "push", + "test-facet.facet.json", + "--name", + "Renamed facet", + ], + vec![ + "bt", + "facet", + "push", + "https://example.com/test-facet.facet.json", + ], + vec!["bt", "facets", "push", "--file", "test-facet.facet.json"], + ] { + Cli::try_parse_from(args).expect("facet command should parse"); + } + } + #[test] fn default_verbose_output_is_not_explicit_verbose() { let matches = Cli::command() diff --git a/src/resource_template.rs b/src/resource_template.rs new file mode 100644 index 00000000..2ab0086e --- /dev/null +++ b/src/resource_template.rs @@ -0,0 +1,207 @@ +use std::io::{self, Read as _}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::http::{build_http_client, DEFAULT_HTTP_TIMEOUT}; +use crate::utils::write_json_atomic; + +pub(crate) const SCHEMA_VERSION: u32 = 1; +const MAX_TEMPLATE_BYTES: u64 = 10 * 1024 * 1024; + +pub(crate) async fn read(source: &str, expected_kind: &str) -> Result { + let contents = if source == "-" { + let mut contents = String::new(); + io::stdin() + .read_to_string(&mut contents) + .context("failed to read template from stdin")?; + contents + } else if is_http_url(source) { + fetch_url(source).await? + } else { + std::fs::read_to_string(source) + .with_context(|| format!("failed to read template {}", Path::new(source).display()))? + }; + + let value: serde_json::Value = serde_json::from_str(&contents).with_context(|| { + if source == "-" { + "failed to parse template from stdin as JSON".to_string() + } else if is_http_url(source) { + "failed to parse template URL as JSON; for GitHub Gists, use the Raw URL".to_string() + } else { + format!( + "failed to parse template {} as JSON", + Path::new(source).display() + ) + } + })?; + let actual_kind = value.get("kind").and_then(serde_json::Value::as_str); + if actual_kind != Some(expected_kind) { + let actual = actual_kind.unwrap_or(""); + bail!("expected a {expected_kind} template, but template kind is '{actual}'"); + } + + serde_json::from_value(value).context("template does not match the expected schema") +} + +fn is_http_url(source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") +} + +async fn fetch_url(source: &str) -> Result { + let url = reqwest::Url::parse(source).context("invalid template URL")?; + let response = build_http_client(DEFAULT_HTTP_TIMEOUT)? + .get(url) + .send() + .await + .context("failed to download template URL")?; + let status = response.status(); + if !status.is_success() { + bail!("failed to download template URL: HTTP {status}"); + } + if response + .content_length() + .is_some_and(|length| length > MAX_TEMPLATE_BYTES) + { + bail!("template URL is larger than the 10 MiB limit"); + } + let bytes = response + .bytes() + .await + .context("failed to read template URL response")?; + if bytes.len() as u64 > MAX_TEMPLATE_BYTES { + bail!("template URL is larger than the 10 MiB limit"); + } + String::from_utf8(bytes.to_vec()).context("template URL response is not valid UTF-8") +} + +pub(crate) fn validate_version(version: u32) -> Result<()> { + if version != SCHEMA_VERSION { + bail!( + "unsupported template schema version {version}; this version of bt supports schema version {SCHEMA_VERSION}" + ); + } + Ok(()) +} + +pub(crate) fn write(value: &T, output: Option<&Path>) -> Result<()> { + match output { + Some(path) if path != Path::new("-") => write_json_atomic(path, value), + _ => { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + use std::path::PathBuf; + use std::thread; + + use serde::Deserialize; + use tempfile::tempdir; + + use super::*; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestTemplate { + kind: String, + schema_version: u32, + } + + #[tokio::test] + async fn reads_template_from_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("template.json"); + std::fs::write(&path, r#"{"kind":"facet","schema_version":1}"#).expect("write"); + + let template: TestTemplate = read(path.to_str().expect("path"), "facet") + .await + .expect("template"); + + assert_eq!( + template, + TestTemplate { + kind: "facet".to_string(), + schema_version: 1, + } + ); + } + + #[tokio::test] + async fn rejects_wrong_template_kind() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("template.json"); + std::fs::write(&path, r#"{"kind":"automation","schema_version":1}"#).expect("write"); + + let err = read::(path.to_str().expect("path"), "facet") + .await + .expect_err("kind mismatch"); + + assert!(err.to_string().contains("expected a facet template")); + } + + #[tokio::test] + async fn reads_template_from_http_url() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let body = r#"{"kind":"facet","schema_version":1}"#; + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request).expect("read request"); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("write response"); + }); + + let template: TestTemplate = read(&format!("http://{address}/template.json"), "facet") + .await + .expect("template"); + server.join().expect("test server"); + + assert_eq!(template.kind, "facet"); + assert_eq!(template.schema_version, SCHEMA_VERSION); + } + + #[test] + fn validates_schema_version() { + validate_version(SCHEMA_VERSION).expect("current version"); + let err = validate_version(SCHEMA_VERSION + 1).expect_err("future version"); + assert!(err + .to_string() + .contains("unsupported template schema version")); + } + + #[test] + fn recognizes_only_http_urls() { + assert!(is_http_url("https://example.invalid/template.json")); + assert!(is_http_url("http://example.invalid/template.json")); + assert!(!is_http_url("template.json")); + assert!(!is_http_url("file:///tmp/template.json")); + } + + #[test] + fn writes_pretty_json_atomically() { + let dir = tempdir().expect("tempdir"); + let path = PathBuf::from(dir.path()).join("nested/template.json"); + + write( + &serde_json::json!({"kind": "facet", "schema_version": 1}), + Some(&path), + ) + .expect("write"); + + assert_eq!( + std::fs::read_to_string(path).expect("read"), + "{\n \"kind\": \"facet\",\n \"schema_version\": 1\n}\n" + ); + } +} From 62fd0a1febc97da17ee32ffb982d6386c89d97df Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Mon, 17 Aug 2026 21:46:04 -0700 Subject: [PATCH 02/13] add spinner --- src/automation.rs | 48 ++++++++++++++++++++++++------------------ src/facet.rs | 53 +++++++++++++++++++++++++++++------------------ 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/src/automation.rs b/src/automation.rs index a2a7a54c..147bc364 100644 --- a/src/automation.rs +++ b/src/automation.rs @@ -10,7 +10,7 @@ use crate::args::BaseArgs; use crate::http::ApiClient; use crate::project_context::resolve_project_command_context_with_auth_mode; use crate::resource_template::{self, SCHEMA_VERSION}; -use crate::ui::{print_command_status, CommandStatus}; +use crate::ui::{print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ @@ -168,14 +168,17 @@ pub(crate) async fn run(base: BaseArgs, args: AutomationArgs) -> Result<()> { async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { let name = args.name()?.to_string(); let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let automation = get_by_name(&ctx.client, &ctx.project.id, &name) - .await? - .ok_or_else(|| { - anyhow!( - "automation '{name}' not found in project '{}'", - ctx.project.name - ) - })?; + let automation = with_spinner( + "Loading automation...", + get_by_name(&ctx.client, &ctx.project.id, &name), + ) + .await? + .ok_or_else(|| { + anyhow!( + "automation '{name}' not found in project '{}'", + ctx.project.name + ) + })?; let template = template_from_remote(automation)?; resource_template::write(&template, args.output.as_deref()).with_context(|| { @@ -217,7 +220,11 @@ async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { } async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: AutomationTemplate = resource_template::read(args.file()?, "automation").await?; + let template: AutomationTemplate = with_spinner( + "Loading automation template...", + resource_template::read(args.file()?, "automation"), + ) + .await?; validate_template(&template)?; let name = push_name(args.name.as_deref(), &template.name)?; @@ -228,16 +235,17 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { description: template.description.as_deref(), config: &template.config, }; - let pushed: RemoteAutomation = ctx - .client - .put("/v1/project_automation", &request) - .await - .with_context(|| { - format!( - "failed to push automation '{}' to project '{}'", - name, ctx.project.name - ) - })?; + let pushed: RemoteAutomation = with_spinner( + "Pushing automation...", + ctx.client.put("/v1/project_automation", &request), + ) + .await + .with_context(|| { + format!( + "failed to push automation '{}' to project '{}'", + name, ctx.project.name + ) + })?; if base.json { println!( diff --git a/src/facet.rs b/src/facet.rs index 4e8c3909..f255908f 100644 --- a/src/facet.rs +++ b/src/facet.rs @@ -10,7 +10,7 @@ use crate::args::BaseArgs; use crate::http::ApiClient; use crate::project_context::resolve_project_command_context_with_auth_mode; use crate::resource_template::{self, SCHEMA_VERSION}; -use crate::ui::{print_command_status, CommandStatus}; +use crate::ui::{print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ @@ -168,10 +168,17 @@ pub(crate) async fn run(base: BaseArgs, args: FacetArgs) -> Result<()> { async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { let name = args.name()?.to_string(); let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let facet = get_facet_by_name(&ctx.client, &ctx.project.id, &name) - .await? - .ok_or_else(|| anyhow!("facet '{name}' not found in project '{}'", ctx.project.name))?; - let template = template_from_remote(&ctx.client, facet).await?; + let facet = with_spinner( + "Loading facet...", + get_facet_by_name(&ctx.client, &ctx.project.id, &name), + ) + .await? + .ok_or_else(|| anyhow!("facet '{name}' not found in project '{}'", ctx.project.name))?; + let template = with_spinner( + "Resolving facet references...", + template_from_remote(&ctx.client, facet), + ) + .await?; resource_template::write(&template, args.output.as_deref()).with_context(|| { args.output.as_ref().map_or_else( @@ -208,15 +215,22 @@ async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { } async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: FacetTemplate = resource_template::read(args.file()?, "facet").await?; + let template: FacetTemplate = with_spinner( + "Loading facet template...", + resource_template::read(args.file()?, "facet"), + ) + .await?; validate_template(&template)?; let name = push_name(args.name.as_deref(), &template.name)?; let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; - let function_data = resolve_preprocessor_reference( - &ctx.client, - &ctx.project.id, - template.function_data.clone(), + let function_data = with_spinner( + "Resolving facet references...", + resolve_preprocessor_reference( + &ctx.client, + &ctx.project.id, + template.function_data.clone(), + ), ) .await?; let request = UpsertFacetRequest { @@ -230,16 +244,15 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { tags: template.tags.as_deref(), function_schema: template.function_schema.as_ref(), }; - let pushed: RemoteFunction = ctx - .client - .put("/v1/function", &request) - .await - .with_context(|| { - format!( - "failed to push facet '{}' to project '{}'", - name, ctx.project.name - ) - })?; + let pushed: RemoteFunction = + with_spinner("Pushing facet...", ctx.client.put("/v1/function", &request)) + .await + .with_context(|| { + format!( + "failed to push facet '{}' to project '{}'", + name, ctx.project.name + ) + })?; if base.json { println!( From 2e49e670cb512c693600eb8d17aec4fc7da18658 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 18 Aug 2026 11:42:01 -0700 Subject: [PATCH 03/13] analytics config commands --- src/analytics_config.rs | 345 ++++++++++++++++++++++++++++++++++++++++ src/automation.rs | 100 ++++++++++-- src/facet.rs | 140 +++++++++++----- src/main.rs | 37 +++++ 4 files changed, 572 insertions(+), 50 deletions(-) create mode 100644 src/analytics_config.rs diff --git a/src/analytics_config.rs b/src/analytics_config.rs new file mode 100644 index 00000000..6c48b808 --- /dev/null +++ b/src/analytics_config.rs @@ -0,0 +1,345 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; + +use crate::args::BaseArgs; +use crate::automation::{self, AutomationTemplate, RemoteAutomation}; +use crate::facet::{self, FacetTemplate, RemoteFunction}; +use crate::project_context::resolve_project_command_context_with_auth_mode; +use crate::resource_template::{self, SCHEMA_VERSION}; +use crate::ui::{print_command_status, with_spinner, CommandStatus}; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt analytics-config pull --output analytics-config.json + bt analytics-config push analytics-config.json --org test-org --project test-project + bt analytics-config push https://example.com/analytics-config.json + bt analytics-config pull | bt analytics-config push - --project test-project +")] +pub(crate) struct AnalyticsConfigArgs { + #[command(subcommand)] + command: AnalyticsConfigCommand, +} + +#[derive(Debug, Clone, Subcommand)] +enum AnalyticsConfigCommand { + /// Pull all facets and Loop automations into one portable analytics config + Pull(PullArgs), + /// Create or replace facets and Loop automations from an analytics config + Push(PushArgs), +} + +#[derive(Debug, Clone, Args)] +struct PullArgs { + /// Write the analytics config to this path instead of stdout + #[arg( + long, + short = 'O', + env = "BT_ANALYTICS_CONFIG_PULL_OUTPUT", + value_name = "PATH" + )] + output: Option, +} + +#[derive(Debug, Clone, Args)] +struct PushArgs { + /// Project template path, HTTP(S) URL, or - to read from stdin + #[arg(value_name = "SOURCE")] + file_positional: Option, + + /// Project template path, HTTP(S) URL, or - to read from stdin + #[arg( + long = "file", + short = 'f', + env = "BT_ANALYTICS_CONFIG_PUSH_FILE", + value_name = "SOURCE" + )] + file_flag: Option, +} + +impl PushArgs { + fn file(&self) -> Result<&str> { + match (&self.file_positional, &self.file_flag) { + (Some(_), Some(_)) => bail!("use either a template source or --file, not both"), + (Some(source), None) | (None, Some(source)) => Ok(source), + (None, None) => { + bail!("analytics config source required. Use: bt analytics-config push ") + } + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TemplateKind { + AnalyticsConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +struct AnalyticsConfigTemplate { + kind: TemplateKind, + schema_version: u32, + #[serde(default)] + facets: Vec, + #[serde(default)] + automations: Vec, +} + +pub(crate) async fn run(base: BaseArgs, args: AnalyticsConfigArgs) -> Result<()> { + match args.command { + AnalyticsConfigCommand::Pull(args) => pull(base, args).await, + AnalyticsConfigCommand::Push(args) => push(base, args).await, + } +} + +async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { + let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; + let (facets, automations) = with_spinner("Loading analytics config...", async { + tokio::try_join!( + facet::list_templates(&ctx.client, &ctx.project.id), + automation::list_templates(&ctx.client, &ctx.project.id), + ) + }) + .await?; + let template = AnalyticsConfigTemplate { + kind: TemplateKind::AnalyticsConfig, + schema_version: SCHEMA_VERSION, + facets, + automations, + }; + + resource_template::write(&template, args.output.as_deref()).with_context(|| { + args.output.as_ref().map_or_else( + || "failed to write analytics config to stdout".to_string(), + |path| format!("failed to write analytics config to {}", path.display()), + ) + })?; + + if let Some(path) = args + .output + .as_deref() + .filter(|path| *path != Path::new("-")) + { + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "automation_count": template.automations.len(), + "facet_count": template.facets.len(), + "kind": "analytics_config", + "output": path, + "project": ctx.project.name, + "status": "pulled", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!( + "Pulled analytics config from '{}' to {} ({} facets, {} Loop automations)", + ctx.project.name, + path.display(), + template.facets.len(), + template.automations.len(), + ), + ); + } + } + + Ok(()) +} + +async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { + let template: AnalyticsConfigTemplate = with_spinner( + "Loading analytics config...", + resource_template::read(args.file()?, "analytics_config"), + ) + .await?; + validate_template(&template)?; + + let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; + let (pushed_facets, pushed_automations) = with_spinner( + "Pushing analytics config...", + push_resources(&ctx.client, &ctx.project.id, &template), + ) + .await + .with_context(|| { + format!( + "failed to push analytics config to project '{}'", + ctx.project.name + ) + })?; + + if base.json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "automations": pushed_automations + .iter() + .map(|automation| serde_json::json!({ + "id": automation.id, + "name": automation.name, + })) + .collect::>(), + "facets": pushed_facets + .iter() + .map(|facet| serde_json::json!({ + "id": facet.id, + "name": facet.name, + "slug": facet.slug, + })) + .collect::>(), + "kind": "analytics_config", + "project": ctx.project.name, + "status": "pushed", + }))? + ); + } else { + print_command_status( + CommandStatus::Success, + &format!( + "Pushed analytics config to '{}' ({} facets, {} Loop automations)", + ctx.project.name, + pushed_facets.len(), + pushed_automations.len(), + ), + ); + } + + Ok(()) +} + +async fn push_resources( + client: &crate::http::ApiClient, + project_id: &str, + template: &AnalyticsConfigTemplate, +) -> Result<(Vec, Vec)> { + let mut pushed_facets = Vec::with_capacity(template.facets.len()); + for facet in &template.facets { + pushed_facets.push(facet::push_template(client, project_id, facet, None).await?); + } + + let mut pushed_automations = Vec::with_capacity(template.automations.len()); + for automation in &template.automations { + pushed_automations + .push(automation::push_template(client, project_id, automation, None).await?); + } + + Ok((pushed_facets, pushed_automations)) +} + +fn validate_template(template: &AnalyticsConfigTemplate) -> Result<()> { + resource_template::validate_version(template.schema_version)?; + + let mut facet_slugs = HashSet::new(); + for facet in &template.facets { + facet::validate_template(facet)?; + if !facet_slugs.insert(facet.slug()) { + bail!( + "analytics config contains duplicate facet slug '{}'", + facet.slug() + ); + } + } + + let mut automation_names = HashSet::new(); + for automation in &template.automations { + automation::validate_template(automation)?; + if !automation_names.insert(automation.name()) { + bail!( + "analytics config contains duplicate automation name '{}'", + automation.name() + ); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn analytics_config_json() -> serde_json::Value { + serde_json::json!({ + "kind": "analytics_config", + "schema_version": 1, + "facets": [{ + "kind": "facet", + "schema_version": 1, + "name": "test-facet", + "slug": "test-facet", + "function_data": { + "type": "facet", + "prompt": "Classify the trace" + } + }], + "automations": [{ + "kind": "automation", + "schema_version": 1, + "name": "test-loop", + "config": { + "event_type": "windowed", + "loop": {"prompt": "Review recent traces"} + } + }] + }) + } + + #[test] + fn validates_analytics_config() { + let template: AnalyticsConfigTemplate = + serde_json::from_value(analytics_config_json()).expect("analytics config"); + + validate_template(&template).expect("valid analytics config"); + assert_eq!(template.facets.len(), 1); + assert_eq!(template.automations.len(), 1); + } + + #[test] + fn defaults_missing_resource_arrays_to_empty() { + let template: AnalyticsConfigTemplate = serde_json::from_value(serde_json::json!({ + "kind": "analytics_config", + "schema_version": 1 + })) + .expect("empty analytics config"); + + validate_template(&template).expect("valid empty analytics config"); + assert!(template.facets.is_empty()); + assert!(template.automations.is_empty()); + } + + #[test] + fn rejects_duplicate_resource_identity() { + let mut value = analytics_config_json(); + let facet = value["facets"][0].clone(); + value["facets"].as_array_mut().expect("facets").push(facet); + let template: AnalyticsConfigTemplate = + serde_json::from_value(value).expect("analytics config"); + + let err = validate_template(&template).expect_err("duplicate facet slug"); + assert!(err.to_string().contains("duplicate facet slug")); + } + + #[test] + fn push_args_accept_one_source() { + let positional = PushArgs { + file_positional: Some("analytics-config.json".to_string()), + file_flag: None, + }; + assert_eq!( + positional.file().expect("positional"), + "analytics-config.json" + ); + + let conflicting = PushArgs { + file_positional: Some("analytics-config.json".to_string()), + file_flag: Some("other.json".to_string()), + }; + assert!(conflicting.file().is_err()); + } +} diff --git a/src/automation.rs b/src/automation.rs index 147bc364..8014bf6c 100644 --- a/src/automation.rs +++ b/src/automation.rs @@ -122,12 +122,12 @@ fn resolve_required_selector<'a>( #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -enum TemplateKind { +pub(crate) enum TemplateKind { Automation, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -struct AutomationTemplate { +pub(crate) struct AutomationTemplate { kind: TemplateKind, schema_version: u32, name: String, @@ -137,9 +137,9 @@ struct AutomationTemplate { } #[derive(Debug, Clone, Deserialize)] -struct RemoteAutomation { - id: String, - name: String, +pub(crate) struct RemoteAutomation { + pub(crate) id: String, + pub(crate) name: String, #[serde(default)] description: Option, config: Value, @@ -148,6 +148,10 @@ struct RemoteAutomation { #[derive(Debug, Deserialize)] struct ListResponse { objects: Vec, + #[serde(default)] + next_cursor: Option, + #[serde(default)] + snapshot: Option, } #[derive(Debug, Serialize)] @@ -225,19 +229,12 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { resource_template::read(args.file()?, "automation"), ) .await?; - validate_template(&template)?; let name = push_name(args.name.as_deref(), &template.name)?; let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; - let request = UpsertAutomationRequest { - project_id: &ctx.project.id, - name, - description: template.description.as_deref(), - config: &template.config, - }; let pushed: RemoteAutomation = with_spinner( "Pushing automation...", - ctx.client.put("/v1/project_automation", &request), + push_template(&ctx.client, &ctx.project.id, &template, Some(name)), ) .await .with_context(|| { @@ -271,6 +268,70 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { Ok(()) } +pub(crate) async fn list_templates( + client: &ApiClient, + project_id: &str, +) -> Result> { + let automations = list_all(client, project_id).await?; + let mut templates = automations + .into_iter() + .filter(|automation| is_loop_config(&automation.config)) + .map(template_from_remote) + .collect::>>()?; + templates.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(templates) +} + +async fn list_all(client: &ApiClient, project_id: &str) -> Result> { + let mut objects = Vec::new(); + let mut cursor: Option = None; + let mut snapshot: Option = None; + + loop { + let mut path = format!("/v1/project_automation?project_id={}", encode(project_id)); + if let Some(cursor) = cursor.as_deref() { + path.push_str(&format!("&cursor={}", encode(cursor))); + } + if let Some(snapshot) = snapshot.as_deref() { + path.push_str(&format!("&snapshot={}", encode(snapshot))); + } + + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list automations via {path}"))?; + objects.extend(response.objects); + snapshot = response.snapshot.or(snapshot); + match response.next_cursor { + Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { + cursor = Some(next_cursor); + } + Some(_) => bail!("automation list returned a repeated cursor"), + None => return Ok(objects), + } + } +} + +pub(crate) async fn push_template( + client: &ApiClient, + project_id: &str, + template: &AutomationTemplate, + override_name: Option<&str>, +) -> Result { + validate_template(template)?; + let name = push_name(override_name, &template.name)?; + let request = UpsertAutomationRequest { + project_id, + name, + description: template.description.as_deref(), + config: &template.config, + }; + client + .put("/v1/project_automation", &request) + .await + .with_context(|| format!("failed to push automation '{name}'")) +} + async fn get_by_name( client: &ApiClient, project_id: &str, @@ -308,7 +369,7 @@ fn template_from_remote(remote: RemoteAutomation) -> Result }) } -fn validate_template(template: &AutomationTemplate) -> Result<()> { +pub(crate) fn validate_template(template: &AutomationTemplate) -> Result<()> { resource_template::validate_version(template.schema_version)?; if template.name.trim().is_empty() { bail!("automation template name must not be empty"); @@ -337,6 +398,17 @@ fn validate_loop_config(config: &Value, name: &str) -> Result<()> { Ok(()) } +fn is_loop_config(config: &Value) -> bool { + config.get("event_type").and_then(Value::as_str) == Some("windowed") + && config.get("loop").and_then(Value::as_object).is_some() +} + +impl AutomationTemplate { + pub(crate) fn name(&self) -> &str { + &self.name + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/facet.rs b/src/facet.rs index f255908f..38fc7e56 100644 --- a/src/facet.rs +++ b/src/facet.rs @@ -101,12 +101,12 @@ impl PushArgs { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -enum TemplateKind { +pub(crate) enum TemplateKind { Facet, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -struct FacetTemplate { +pub(crate) struct FacetTemplate { kind: TemplateKind, schema_version: u32, name: String, @@ -123,10 +123,10 @@ struct FacetTemplate { } #[derive(Debug, Clone, Deserialize)] -struct RemoteFunction { - id: String, - name: String, - slug: String, +pub(crate) struct RemoteFunction { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) slug: String, #[serde(default)] description: Option, #[serde(default)] @@ -143,6 +143,10 @@ struct RemoteFunction { #[derive(Debug, Deserialize)] struct ListResponse { objects: Vec, + #[serde(default)] + next_cursor: Option, + #[serde(default)] + snapshot: Option, } #[derive(Debug, Serialize)] @@ -220,39 +224,20 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { resource_template::read(args.file()?, "facet"), ) .await?; - validate_template(&template)?; let name = push_name(args.name.as_deref(), &template.name)?; let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; - let function_data = with_spinner( - "Resolving facet references...", - resolve_preprocessor_reference( - &ctx.client, - &ctx.project.id, - template.function_data.clone(), - ), + let pushed: RemoteFunction = with_spinner( + "Pushing facet...", + push_template(&ctx.client, &ctx.project.id, &template, Some(name)), ) - .await?; - let request = UpsertFacetRequest { - project_id: &ctx.project.id, - name, - slug: &template.slug, - description: template.description.as_deref(), - function_type: "facet", - function_data: &function_data, - prompt_data: template.prompt_data.as_ref(), - tags: template.tags.as_deref(), - function_schema: template.function_schema.as_ref(), - }; - let pushed: RemoteFunction = - with_spinner("Pushing facet...", ctx.client.put("/v1/function", &request)) - .await - .with_context(|| { - format!( - "failed to push facet '{}' to project '{}'", - name, ctx.project.name - ) - })?; + .await + .with_context(|| { + format!( + "failed to push facet '{}' to project '{}'", + name, ctx.project.name + ) + })?; if base.json { println!( @@ -279,6 +264,83 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { Ok(()) } +pub(crate) async fn list_templates( + client: &ApiClient, + project_id: &str, +) -> Result> { + let mut templates = Vec::new(); + for facet in list_all(client, project_id) + .await? + .into_iter() + .filter(|function| function.function_type.as_deref() == Some("facet")) + { + templates.push(template_from_remote(client, facet).await?); + } + templates.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.slug.cmp(&right.slug)) + }); + Ok(templates) +} + +async fn list_all(client: &ApiClient, project_id: &str) -> Result> { + let mut objects = Vec::new(); + let mut cursor: Option = None; + let mut snapshot: Option = None; + + loop { + let mut path = format!("/v1/function?project_id={}", encode(project_id)); + if let Some(cursor) = cursor.as_deref() { + path.push_str(&format!("&cursor={}", encode(cursor))); + } + if let Some(snapshot) = snapshot.as_deref() { + path.push_str(&format!("&snapshot={}", encode(snapshot))); + } + + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list facets via {path}"))?; + objects.extend(response.objects); + snapshot = response.snapshot.or(snapshot); + match response.next_cursor { + Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { + cursor = Some(next_cursor); + } + Some(_) => bail!("function list returned a repeated cursor"), + None => return Ok(objects), + } + } +} + +pub(crate) async fn push_template( + client: &ApiClient, + project_id: &str, + template: &FacetTemplate, + override_name: Option<&str>, +) -> Result { + validate_template(template)?; + let name = push_name(override_name, &template.name)?; + let function_data = + resolve_preprocessor_reference(client, project_id, template.function_data.clone()).await?; + let request = UpsertFacetRequest { + project_id, + name, + slug: &template.slug, + description: template.description.as_deref(), + function_type: "facet", + function_data: &function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), + }; + client + .put("/v1/function", &request) + .await + .with_context(|| format!("failed to push facet '{name}'")) +} + async fn get_facet_by_name( client: &ApiClient, project_id: &str, @@ -421,7 +483,7 @@ async fn resolve_preprocessor_reference( Ok(function_data) } -fn validate_template(template: &FacetTemplate) -> Result<()> { +pub(crate) fn validate_template(template: &FacetTemplate) -> Result<()> { resource_template::validate_version(template.schema_version)?; if template.name.trim().is_empty() { bail!("facet template name must not be empty"); @@ -448,6 +510,12 @@ fn validate_template(template: &FacetTemplate) -> Result<()> { Ok(()) } +impl FacetTemplate { + pub(crate) fn slug(&self) -> &str { + &self.slug + } +} + fn push_name<'a>(override_name: Option<&'a str>, template_name: &'a str) -> Result<&'a str> { match override_name { Some(name) if name.trim().is_empty() => bail!("facet name override must not be empty"), diff --git a/src/main.rs b/src/main.rs index 97dca7d0..36de14a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use clap::{parser::ValueSource, ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; +mod analytics_config; mod args; mod auth; mod automation; @@ -68,6 +69,7 @@ Core Projects & resources projects Manage projects + analytics-config Pull and push portable analytics configuration automation Fetch and push Loop automation templates facet Fetch and push facet templates topics Inspect and control Topics automation @@ -148,6 +150,8 @@ enum Commands { Eval(CLIArgs), /// Manage projects Projects(CLIArgs), + /// Pull and push facets and Loop automations as one analytics config + AnalyticsConfig(CLIArgs), #[command(visible_alias = "automations")] /// Fetch and push Loop automation templates Automation(CLIArgs), @@ -200,6 +204,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, + Commands::AnalyticsConfig(cmd) => &cmd.base, Commands::Automation(cmd) => &cmd.base, Commands::Facet(cmd) => &cmd.base, Commands::Topics(cmd) => &cmd.base, @@ -231,6 +236,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, + Commands::AnalyticsConfig(cmd) => &mut cmd.base, Commands::Automation(cmd) => &mut cmd.base, Commands::Facet(cmd) => &mut cmd.base, Commands::Datasets(cmd) => &mut cmd.base, @@ -343,6 +349,7 @@ fn try_main() -> Result<()> { #[cfg(unix)] Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, + Commands::AnalyticsConfig(cmd) => analytics_config::run(cmd.base, cmd.args).await?, Commands::Automation(cmd) => automation::run(cmd.base, cmd.args).await?, Commands::Facet(cmd) => facet::run(cmd.base, cmd.args).await?, Commands::Datasets(cmd) => datasets::run(cmd.base, cmd.args).await?, @@ -682,6 +689,36 @@ mod tests { } } + #[test] + fn analytics_config_template_commands_parse() { + for args in [ + vec!["bt", "analytics-config", "pull"], + vec![ + "bt", + "analytics-config", + "pull", + "--output", + "analytics-config.json", + ], + vec!["bt", "analytics-config", "push", "analytics-config.json"], + vec![ + "bt", + "analytics-config", + "push", + "https://example.com/analytics-config.json", + ], + vec![ + "bt", + "analytics-config", + "push", + "--file", + "analytics-config.json", + ], + ] { + Cli::try_parse_from(args).expect("analytics-config command should parse"); + } + } + #[test] fn default_verbose_output_is_not_explicit_verbose() { let matches = Cli::command() From 88cbf540df435d0e7f957c643cf014af288170ac Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 18 Aug 2026 16:54:15 -0700 Subject: [PATCH 04/13] standardize commands --- src/analytics_config.rs | 22 +- src/automation.rs | 28 +-- src/facet.rs | 470 ++++++++++++++++++++++++++++++++++++++-- src/main.rs | 32 ++- 4 files changed, 509 insertions(+), 43 deletions(-) diff --git a/src/analytics_config.rs b/src/analytics_config.rs index 6c48b808..b68a16d7 100644 --- a/src/analytics_config.rs +++ b/src/analytics_config.rs @@ -17,6 +17,7 @@ use crate::ui::{print_command_status, with_spinner, CommandStatus}; Examples: bt analytics-config pull --output analytics-config.json bt analytics-config push analytics-config.json --org test-org --project test-project + bt analytics-config push analytics-config.json --topics-automation Topics bt analytics-config push https://example.com/analytics-config.json bt analytics-config pull | bt analytics-config push - --project test-project ")] @@ -59,6 +60,14 @@ struct PushArgs { value_name = "SOURCE" )] file_flag: Option, + + /// Topics automation name or ID to attach newly created facets to + #[arg( + long, + env = "BT_ANALYTICS_CONFIG_PUSH_TOPICS_AUTOMATION", + value_name = "NAME_OR_ID" + )] + topics_automation: Option, } impl PushArgs { @@ -164,7 +173,12 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; let (pushed_facets, pushed_automations) = with_spinner( "Pushing analytics config...", - push_resources(&ctx.client, &ctx.project.id, &template), + push_resources( + &ctx.client, + &ctx.project.id, + &template, + args.topics_automation.as_deref(), + ), ) .await .with_context(|| { @@ -217,10 +231,12 @@ async fn push_resources( client: &crate::http::ApiClient, project_id: &str, template: &AnalyticsConfigTemplate, + topics_automation: Option<&str>, ) -> Result<(Vec, Vec)> { let mut pushed_facets = Vec::with_capacity(template.facets.len()); for facet in &template.facets { - pushed_facets.push(facet::push_template(client, project_id, facet, None).await?); + pushed_facets + .push(facet::push_template(client, project_id, facet, None, topics_automation).await?); } let mut pushed_automations = Vec::with_capacity(template.automations.len()); @@ -330,6 +346,7 @@ mod tests { let positional = PushArgs { file_positional: Some("analytics-config.json".to_string()), file_flag: None, + topics_automation: None, }; assert_eq!( positional.file().expect("positional"), @@ -339,6 +356,7 @@ mod tests { let conflicting = PushArgs { file_positional: Some("analytics-config.json".to_string()), file_flag: Some("other.json".to_string()), + topics_automation: None, }; assert!(conflicting.file().is_err()); } diff --git a/src/automation.rs b/src/automation.rs index 8014bf6c..5ed1a7b3 100644 --- a/src/automation.rs +++ b/src/automation.rs @@ -15,11 +15,11 @@ use crate::ui::{print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt automation fetch my-loop --output my-loop.automation.json + bt automation pull my-loop --output my-loop.automation.json bt automation push my-loop.automation.json --org test-org --project test-project bt automation push my-loop.automation.json --name renamed-loop bt automation push https://example.com/my-loop.automation.json - bt automation fetch my-loop | bt automation push - --project test-project + bt automation pull my-loop | bt automation push - --project test-project ")] pub(crate) struct AutomationArgs { #[command(subcommand)] @@ -28,14 +28,14 @@ pub(crate) struct AutomationArgs { #[derive(Debug, Clone, Subcommand)] enum AutomationCommand { - /// Fetch a Loop automation as a portable JSON template - Fetch(FetchArgs), + /// Pull a Loop automation as a portable JSON template + Pull(PullArgs), /// Create or replace a Loop automation from a JSON template Push(PushArgs), } #[derive(Debug, Clone, Args)] -struct FetchArgs { +struct PullArgs { /// Automation name #[arg(value_name = "NAME")] name_positional: Option, @@ -44,7 +44,7 @@ struct FetchArgs { #[arg( long = "name", short = 'n', - env = "BT_AUTOMATION_FETCH_NAME", + env = "BT_AUTOMATION_PULL_NAME", value_name = "NAME" )] name_flag: Option, @@ -53,19 +53,19 @@ struct FetchArgs { #[arg( long, short = 'O', - env = "BT_AUTOMATION_FETCH_OUTPUT", + env = "BT_AUTOMATION_PULL_OUTPUT", value_name = "PATH" )] output: Option, } -impl FetchArgs { +impl PullArgs { fn name(&self) -> Result<&str> { resolve_required_selector( self.name_positional.as_deref(), self.name_flag.as_deref(), "automation name", - "bt automation fetch ", + "bt automation pull ", ) } } @@ -164,12 +164,12 @@ struct UpsertAutomationRequest<'a> { pub(crate) async fn run(base: BaseArgs, args: AutomationArgs) -> Result<()> { match args.command { - AutomationCommand::Fetch(args) => fetch(base, args).await, + AutomationCommand::Pull(args) => pull(base, args).await, AutomationCommand::Push(args) => push(base, args).await, } } -async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { +async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { let name = args.name()?.to_string(); let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; let automation = with_spinner( @@ -205,14 +205,14 @@ async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { "name": template.name, "project": ctx.project.name, "output": path, - "status": "fetched", + "status": "pulled", }))? ); } else { print_command_status( CommandStatus::Success, &format!( - "Fetched automation '{}' to {}", + "Pulled automation '{}' to {}", template.name, path.display() ), @@ -497,7 +497,7 @@ mod tests { } #[test] - fn resolves_fetch_name_from_positional_or_flag() { + fn resolves_pull_name_from_positional_or_flag() { assert_eq!( resolve_required_selector(Some("test-loop"), None, "automation name", "usage") .expect("positional"), diff --git a/src/facet.rs b/src/facet.rs index 38fc7e56..ead11679 100644 --- a/src/facet.rs +++ b/src/facet.rs @@ -15,11 +15,12 @@ use crate::ui::{print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt facet fetch my-facet --output my-facet.facet.json + bt facet pull my-facet --output my-facet.facet.json bt facet push my-facet.facet.json --org test-org --project test-project bt facet push my-facet.facet.json --name \"Renamed facet\" + bt facet push my-facet.facet.json --topics-automation Topics bt facet push https://example.com/my-facet.facet.json - bt facet fetch my-facet | bt facet push - --project test-project + bt facet pull my-facet | bt facet push - --project test-project ")] pub(crate) struct FacetArgs { #[command(subcommand)] @@ -28,14 +29,14 @@ pub(crate) struct FacetArgs { #[derive(Debug, Clone, Subcommand)] enum FacetCommand { - /// Fetch a facet as a portable JSON template - Fetch(FetchArgs), + /// Pull a facet as a portable JSON template + Pull(PullArgs), /// Create or replace a facet from a JSON template Push(PushArgs), } #[derive(Debug, Clone, Args)] -struct FetchArgs { +struct PullArgs { /// Facet name #[arg(value_name = "NAME")] name_positional: Option, @@ -44,22 +45,22 @@ struct FetchArgs { #[arg( long = "name", short = 'n', - env = "BT_FACET_FETCH_NAME", + env = "BT_FACET_PULL_NAME", value_name = "NAME" )] name_flag: Option, /// Write the template to this path instead of stdout - #[arg(long, short = 'O', env = "BT_FACET_FETCH_OUTPUT", value_name = "PATH")] + #[arg(long, short = 'O', env = "BT_FACET_PULL_OUTPUT", value_name = "PATH")] output: Option, } -impl FetchArgs { +impl PullArgs { fn name(&self) -> Result<&str> { match (self.name_positional.as_deref(), self.name_flag.as_deref()) { (Some(_), Some(_)) => bail!("use either a positional facet name or --name, not both"), (Some(value), None) | (None, Some(value)) if !value.trim().is_empty() => Ok(value), - _ => bail!("facet name required. Use: bt facet fetch "), + _ => bail!("facet name required. Use: bt facet pull "), } } } @@ -87,6 +88,14 @@ struct PushArgs { value_name = "NAME" )] name: Option, + + /// Topics automation name or ID to attach a newly created facet to + #[arg( + long, + env = "BT_FACET_PUSH_TOPICS_AUTOMATION", + value_name = "NAME_OR_ID" + )] + topics_automation: Option, } impl PushArgs { @@ -162,14 +171,30 @@ struct UpsertFacetRequest<'a> { function_schema: Option<&'a Value>, } +#[derive(Debug, Clone, Deserialize)] +struct RemoteProjectAutomation { + id: String, + name: String, + #[serde(default)] + description: Option, + config: Value, +} + +struct TopicsSetup { + automation: RemoteProjectAutomation, + embedding_model: String, +} + +const DEFAULT_TOPICS_EMBEDDING_MODEL: &str = "brain-embedding-1"; + pub(crate) async fn run(base: BaseArgs, args: FacetArgs) -> Result<()> { match args.command { - FacetCommand::Fetch(args) => fetch(base, args).await, + FacetCommand::Pull(args) => pull(base, args).await, FacetCommand::Push(args) => push(base, args).await, } } -async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { +async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { let name = args.name()?.to_string(); let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; let facet = with_spinner( @@ -204,13 +229,13 @@ async fn fetch(base: BaseArgs, args: FetchArgs) -> Result<()> { "name": template.name, "project": ctx.project.name, "output": path, - "status": "fetched", + "status": "pulled", }))? ); } else { print_command_status( CommandStatus::Success, - &format!("Fetched facet '{}' to {}", template.name, path.display()), + &format!("Pulled facet '{}' to {}", template.name, path.display()), ); } } @@ -229,7 +254,13 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; let pushed: RemoteFunction = with_spinner( "Pushing facet...", - push_template(&ctx.client, &ctx.project.id, &template, Some(name)), + push_template( + &ctx.client, + &ctx.project.id, + &template, + Some(name), + args.topics_automation.as_deref(), + ), ) .await .with_context(|| { @@ -319,9 +350,16 @@ pub(crate) async fn push_template( project_id: &str, template: &FacetTemplate, override_name: Option<&str>, + topics_automation: Option<&str>, ) -> Result { validate_template(template)?; let name = push_name(override_name, &template.name)?; + let existing = get_facet_by_slug(client, project_id, &template.slug).await?; + let topics_setup = if existing.is_none() { + Some(resolve_topics_setup(client, project_id, topics_automation).await?) + } else { + None + }; let function_data = resolve_preprocessor_reference(client, project_id, template.function_data.clone()).await?; let request = UpsertFacetRequest { @@ -335,10 +373,295 @@ pub(crate) async fn push_template( tags: template.tags.as_deref(), function_schema: template.function_schema.as_ref(), }; - client + let pushed: RemoteFunction = client .put("/v1/function", &request) .await - .with_context(|| format!("failed to push facet '{name}'")) + .with_context(|| format!("failed to push facet '{name}'"))?; + + if let Some(topics_setup) = topics_setup { + let topic_map_id = create_topic_map_function( + client, + project_id, + &pushed, + template.description.as_deref(), + &topics_setup.embedding_model, + ) + .await + .with_context(|| { + format!( + "facet '{}' was created, but its topic map could not be created", + pushed.name + ) + })?; + attach_facet_to_topics_automation( + client, + &topics_setup.automation, + &pushed.id, + &topic_map_id, + ) + .await + .with_context(|| { + format!( + "facet '{}' and its topic map were created, but the Topics automation could not be updated", + pushed.name + ) + })?; + } + + Ok(pushed) +} + +async fn get_facet_by_slug( + client: &ApiClient, + project_id: &str, + slug: &str, +) -> Result> { + let path = format!( + "/v1/function?project_id={}&slug={}", + encode(project_id), + encode(slug) + ); + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to check for an existing facet with slug '{slug}'"))?; + let mut matches = response.objects.into_iter().filter(|function| { + function.slug == slug && function.function_type.as_deref() == Some("facet") + }); + let found = matches.next(); + if matches.next().is_some() { + bail!("multiple facets with slug '{slug}' found in the selected project"); + } + Ok(found) +} + +async fn resolve_topics_setup( + client: &ApiClient, + project_id: &str, + selector: Option<&str>, +) -> Result { + let automations = list_topic_automations(client, project_id).await?; + let automation = select_topic_automation(automations, selector)?; + let embedding_model = embedding_model_for_automation(client, &automation).await?; + Ok(TopicsSetup { + automation, + embedding_model, + }) +} + +async fn list_topic_automations( + client: &ApiClient, + project_id: &str, +) -> Result> { + let mut objects = Vec::new(); + let mut cursor: Option = None; + let mut snapshot: Option = None; + + loop { + let mut path = format!("/v1/project_automation?project_id={}", encode(project_id)); + if let Some(cursor) = cursor.as_deref() { + path.push_str(&format!("&cursor={}", encode(cursor))); + } + if let Some(snapshot) = snapshot.as_deref() { + path.push_str(&format!("&snapshot={}", encode(snapshot))); + } + + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list Topics automations via {path}"))?; + objects.extend(response.objects.into_iter().filter(|automation| { + automation.config.get("event_type").and_then(Value::as_str) == Some("topic") + })); + snapshot = response.snapshot.or(snapshot); + match response.next_cursor { + Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { + cursor = Some(next_cursor); + } + Some(_) => bail!("Topics automation list returned a repeated cursor"), + None => return Ok(objects), + } + } +} + +fn select_topic_automation( + automations: Vec, + selector: Option<&str>, +) -> Result { + if automations.is_empty() { + bail!( + "Topics is not enabled in the target project; run `bt topics config enable` before pushing a new facet" + ); + } + + if let Some(selector) = selector { + let selector = selector.trim(); + if selector.is_empty() { + bail!("--topics-automation must not be empty"); + } + let mut matches = automations + .into_iter() + .filter(|automation| automation.id == selector || automation.name == selector); + let found = matches.next().ok_or_else(|| { + anyhow!( + "Topics automation '{selector}' was not found in the target project; use its exact name or ID" + ) + })?; + if matches.next().is_some() { + bail!( + "multiple Topics automations named '{selector}' were found; use an automation ID with --topics-automation" + ); + } + return Ok(found); + } + + if automations.len() > 1 { + bail!( + "multiple Topics automations were found in the target project; select one with --topics-automation " + ); + } + Ok(automations.into_iter().next().expect("checked non-empty")) +} + +async fn embedding_model_for_automation( + client: &ApiClient, + automation: &RemoteProjectAutomation, +) -> Result { + for function_id in topic_map_function_ids(&automation.config) { + let function = get_function_by_id(client, function_id).await?; + if let Some(model) = function + .function_data + .get("embedding_model") + .and_then(Value::as_str) + .filter(|model| !model.trim().is_empty()) + { + return Ok(model.to_string()); + } + } + Ok(DEFAULT_TOPICS_EMBEDDING_MODEL.to_string()) +} + +fn topic_map_function_ids(config: &Value) -> impl Iterator { + config + .get("topic_map_functions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("function")) + .filter(|function| function.get("type").and_then(Value::as_str) == Some("function")) + .filter_map(|function| function.get("id").and_then(Value::as_str)) +} + +async fn create_topic_map_function( + client: &ApiClient, + project_id: &str, + facet: &RemoteFunction, + description: Option<&str>, + embedding_model: &str, +) -> Result { + let request = topic_map_insert_request(project_id, facet, description, embedding_model); + let response: Value = client.post("/insert-functions", &request).await?; + response + .get("functions") + .and_then(Value::as_array) + .and_then(|functions| functions.first()) + .and_then(|function| function.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow!("unexpected response while creating the topic map function")) +} + +fn topic_map_insert_request( + project_id: &str, + facet: &RemoteFunction, + description: Option<&str>, + embedding_model: &str, +) -> Value { + serde_json::json!({ + "functions": [{ + "project_id": project_id, + "name": facet.name, + "slug": format!("{}-topic-map", facet.slug), + "description": description, + "function_type": "classifier", + "function_data": { + "type": "topic_map", + "source_facet": facet.name, + "source_facet_function": { + "type": "function", + "id": facet.id, + }, + "embedding_model": embedding_model, + }, + "if_exists": "ignore", + }] + }) +} + +async fn attach_facet_to_topics_automation( + client: &ApiClient, + automation: &RemoteProjectAutomation, + facet_id: &str, + topic_map_id: &str, +) -> Result<()> { + let config = topics_config_with_functions(&automation.config, facet_id, topic_map_id)?; + let request = serde_json::json!({ + "id": automation.id, + "name": automation.name, + "description": automation.description, + "config": config, + }); + let _: Value = client + .post("/api/project_automation/patch_id", &request) + .await?; + Ok(()) +} + +fn topics_config_with_functions( + config: &Value, + facet_id: &str, + topic_map_id: &str, +) -> Result { + let mut config = config + .as_object() + .cloned() + .ok_or_else(|| anyhow!("Topics automation config must be a JSON object"))?; + + let facets = config + .entry("facet_functions") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| anyhow!("Topics automation facet_functions must be an array"))?; + if !facets.iter().any(|entry| { + entry.get("type").and_then(Value::as_str) == Some("function") + && entry.get("id").and_then(Value::as_str) == Some(facet_id) + }) { + facets.push(serde_json::json!({"type": "function", "id": facet_id})); + } + + let topic_maps = config + .entry("topic_map_functions") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| anyhow!("Topics automation topic_map_functions must be an array"))?; + if !topic_maps.iter().any(|entry| { + entry + .get("function") + .and_then(|function| function.get("type")) + .and_then(Value::as_str) + == Some("function") + && entry + .get("function") + .and_then(|function| function.get("id")) + .and_then(Value::as_str) + == Some(topic_map_id) + }) { + topic_maps.push(serde_json::json!({ + "function": {"type": "function", "id": topic_map_id} + })); + } + + Ok(Value::Object(config)) } async fn get_facet_by_name( @@ -546,6 +869,34 @@ mod tests { } } + fn topic_automation(id: &str, name: &str) -> RemoteProjectAutomation { + RemoteProjectAutomation { + id: id.to_string(), + name: name.to_string(), + description: Some("Synthetic Topics automation".to_string()), + config: serde_json::json!({ + "event_type": "topic", + "facet_functions": [], + "topic_map_functions": [], + "sampling_rate": 1.0 + }), + } + } + + fn remote_facet() -> RemoteFunction { + RemoteFunction { + id: "fake-facet-id".to_string(), + name: "Renamed facet".to_string(), + slug: "test-facet".to_string(), + description: Some("A synthetic facet".to_string()), + function_type: Some("facet".to_string()), + function_data: serde_json::json!({"type": "facet"}), + prompt_data: None, + tags: None, + function_schema: None, + } + } + #[test] fn facet_template_contains_only_portable_identity() { let value = serde_json::to_value(facet_template()).expect("json"); @@ -594,15 +945,15 @@ mod tests { } #[test] - fn fetch_args_require_one_name_selector() { - let args = FetchArgs { + fn pull_args_require_one_name_selector() { + let args = PullArgs { name_positional: Some("test-facet".to_string()), name_flag: None, output: None, }; assert_eq!(args.name().expect("name"), "test-facet"); - let both = FetchArgs { + let both = PullArgs { name_positional: Some("test-facet".to_string()), name_flag: Some("other-facet".to_string()), output: None, @@ -642,4 +993,85 @@ mod tests { assert_eq!(value["name"], "Renamed facet"); assert_eq!(value["slug"], "test-facet"); } + + #[test] + fn requires_topics_before_creating_a_facet() { + let err = select_topic_automation(Vec::new(), None).expect_err("Topics required"); + assert!(err.to_string().contains("bt topics config enable")); + } + + #[test] + fn selects_the_only_topics_automation_or_an_explicit_id() { + let selected = + select_topic_automation(vec![topic_automation("fake-topics-id", "Topics")], None) + .expect("single Topics automation"); + assert_eq!(selected.id, "fake-topics-id"); + + let selected = select_topic_automation( + vec![ + topic_automation("fake-topics-id-1", "Topics"), + topic_automation("fake-topics-id-2", "Topics"), + ], + Some("fake-topics-id-2"), + ) + .expect("explicit Topics automation ID"); + assert_eq!(selected.id, "fake-topics-id-2"); + } + + #[test] + fn requires_a_selector_for_multiple_topics_automations() { + let err = select_topic_automation( + vec![ + topic_automation("fake-topics-id-1", "Topics A"), + topic_automation("fake-topics-id-2", "Topics B"), + ], + None, + ) + .expect_err("selector required"); + assert!(err.to_string().contains("--topics-automation")); + } + + #[test] + fn topic_map_uses_the_pushed_facet_identity() { + let request = topic_map_insert_request( + "fake-project-id", + &remote_facet(), + Some("A synthetic facet"), + "brain-embedding-1", + ); + let topic_map = &request["functions"][0]; + + assert_eq!(topic_map["name"], "Renamed facet"); + assert_eq!(topic_map["slug"], "test-facet-topic-map"); + assert_eq!(topic_map["function_type"], "classifier"); + assert_eq!(topic_map["function_data"]["type"], "topic_map"); + assert_eq!(topic_map["function_data"]["source_facet"], "Renamed facet"); + assert_eq!( + topic_map["function_data"]["source_facet_function"]["id"], + "fake-facet-id" + ); + } + + #[test] + fn attaches_facet_and_topic_map_without_losing_topics_config() { + let config = serde_json::json!({ + "event_type": "topic", + "facet_functions": [{"type": "function", "id": "fake-existing-facet-id"}], + "topic_map_functions": [{ + "function": {"type": "function", "id": "fake-existing-map-id"} + }], + "sampling_rate": 0.5 + }); + let updated = topics_config_with_functions(&config, "fake-new-facet-id", "fake-new-map-id") + .expect("updated config"); + + assert_eq!(updated["sampling_rate"], 0.5); + assert_eq!(updated["facet_functions"].as_array().unwrap().len(), 2); + assert_eq!(updated["topic_map_functions"].as_array().unwrap().len(), 2); + + let unchanged = + topics_config_with_functions(&updated, "fake-new-facet-id", "fake-new-map-id") + .expect("idempotent config"); + assert_eq!(unchanged, updated); + } } diff --git a/src/main.rs b/src/main.rs index 36de14a2..f493fec7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,8 +70,8 @@ Core Projects & resources projects Manage projects analytics-config Pull and push portable analytics configuration - automation Fetch and push Loop automation templates - facet Fetch and push facet templates + automation Pull and push Loop automation templates + facet Pull and push facet templates topics Inspect and control Topics automation datasets Manage datasets prompts Manage prompts @@ -153,10 +153,10 @@ enum Commands { /// Pull and push facets and Loop automations as one analytics config AnalyticsConfig(CLIArgs), #[command(visible_alias = "automations")] - /// Fetch and push Loop automation templates + /// Pull and push Loop automation templates Automation(CLIArgs), #[command(visible_alias = "facets")] - /// Fetch and push facet templates + /// Pull and push facet templates Facet(CLIArgs), /// Inspect and control Topics automation Topics(CLIArgs), @@ -634,8 +634,8 @@ mod tests { #[test] fn automation_template_commands_and_alias_parse() { for args in [ - vec!["bt", "automation", "fetch", "test-loop"], - vec!["bt", "automation", "fetch", "--name", "test-loop"], + vec!["bt", "automation", "pull", "test-loop"], + vec!["bt", "automation", "pull", "--name", "test-loop"], vec!["bt", "automation", "push", "test-loop.automation.json"], vec![ "bt", @@ -666,9 +666,17 @@ mod tests { #[test] fn facet_template_commands_and_alias_parse() { for args in [ - vec!["bt", "facet", "fetch", "test-facet"], - vec!["bt", "facet", "fetch", "--name", "test-facet"], + vec!["bt", "facet", "pull", "test-facet"], + vec!["bt", "facet", "pull", "--name", "test-facet"], vec!["bt", "facet", "push", "test-facet.facet.json"], + vec![ + "bt", + "facet", + "push", + "test-facet.facet.json", + "--topics-automation", + "Topics", + ], vec![ "bt", "facet", @@ -701,6 +709,14 @@ mod tests { "analytics-config.json", ], vec!["bt", "analytics-config", "push", "analytics-config.json"], + vec![ + "bt", + "analytics-config", + "push", + "analytics-config.json", + "--topics-automation", + "Topics", + ], vec![ "bt", "analytics-config", From a947424de0374f19f4defc7489867ddf8d1ac85f Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Wed, 19 Aug 2026 15:22:45 -0700 Subject: [PATCH 05/13] fix missing preprocessor thing --- ...nfig.rs => active_observability_config.rs} | 219 +++++++++++++----- src/automation.rs | 70 +++++- src/facet.rs | 207 +++++++++++++++-- src/main.rs | 71 ++++-- 4 files changed, 462 insertions(+), 105 deletions(-) rename src/{analytics_config.rs => active_observability_config.rs} (51%) diff --git a/src/analytics_config.rs b/src/active_observability_config.rs similarity index 51% rename from src/analytics_config.rs rename to src/active_observability_config.rs index b68a16d7..ae366eb4 100644 --- a/src/analytics_config.rs +++ b/src/active_observability_config.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use clap::{Args, Subcommand}; +use dialoguer::{theme::ColorfulTheme, MultiSelect}; use serde::{Deserialize, Serialize}; use crate::args::BaseArgs; @@ -10,37 +11,42 @@ use crate::automation::{self, AutomationTemplate, RemoteAutomation}; use crate::facet::{self, FacetTemplate, RemoteFunction}; use crate::project_context::resolve_project_command_context_with_auth_mode; use crate::resource_template::{self, SCHEMA_VERSION}; -use crate::ui::{print_command_status, with_spinner, CommandStatus}; +use crate::ui::{self, print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt analytics-config pull --output analytics-config.json - bt analytics-config push analytics-config.json --org test-org --project test-project - bt analytics-config push analytics-config.json --topics-automation Topics - bt analytics-config push https://example.com/analytics-config.json - bt analytics-config pull | bt analytics-config push - --project test-project + bt active-observability-config pull --output active-observability-config.json + bt active-observability-config push active-observability-config.json --org test-org --project test-project + bt active-observability-config push active-observability-config.json --topics-automation Topics + bt active-observability-config push https://example.com/active-observability-config.json + bt active-observability-config pull | bt active-observability-config push - --project test-project ")] -pub(crate) struct AnalyticsConfigArgs { +pub(crate) struct ActiveObservabilityConfigArgs { #[command(subcommand)] - command: AnalyticsConfigCommand, + command: ActiveObservabilityConfigCommand, } #[derive(Debug, Clone, Subcommand)] -enum AnalyticsConfigCommand { - /// Pull all facets and Loop automations into one portable analytics config +enum ActiveObservabilityConfigCommand { + /// Pull facets and Loop automations into one portable active observability config + /// + /// In an interactive terminal, all resources are selected by default. Use Space to + /// exclude resources and Enter to confirm. Use --no-input to include everything + /// without prompting. Project-specific preprocessors used by selected facets are + /// included automatically. Automation destination actions are excluded. Pull(PullArgs), - /// Create or replace facets and Loop automations from an analytics config + /// Create or replace facets and Loop automations from an active observability config Push(PushArgs), } #[derive(Debug, Clone, Args)] struct PullArgs { - /// Write the analytics config to this path instead of stdout + /// Write the active observability config to this path instead of stdout #[arg( long, short = 'O', - env = "BT_ANALYTICS_CONFIG_PULL_OUTPUT", + env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PULL_OUTPUT", value_name = "PATH" )] output: Option, @@ -56,7 +62,7 @@ struct PushArgs { #[arg( long = "file", short = 'f', - env = "BT_ANALYTICS_CONFIG_PUSH_FILE", + env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PUSH_FILE", value_name = "SOURCE" )] file_flag: Option, @@ -64,7 +70,7 @@ struct PushArgs { /// Topics automation name or ID to attach newly created facets to #[arg( long, - env = "BT_ANALYTICS_CONFIG_PUSH_TOPICS_AUTOMATION", + env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PUSH_TOPICS_AUTOMATION", value_name = "NAME_OR_ID" )] topics_automation: Option, @@ -76,7 +82,9 @@ impl PushArgs { (Some(_), Some(_)) => bail!("use either a template source or --file, not both"), (Some(source), None) | (None, Some(source)) => Ok(source), (None, None) => { - bail!("analytics config source required. Use: bt analytics-config push ") + bail!( + "active observability config source required. Use: bt active-observability-config push " + ) } } } @@ -85,11 +93,11 @@ impl PushArgs { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum TemplateKind { - AnalyticsConfig, + ActiveObservabilityConfig, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -struct AnalyticsConfigTemplate { +struct ActiveObservabilityConfigTemplate { kind: TemplateKind, schema_version: u32, #[serde(default)] @@ -98,24 +106,29 @@ struct AnalyticsConfigTemplate { automations: Vec, } -pub(crate) async fn run(base: BaseArgs, args: AnalyticsConfigArgs) -> Result<()> { +pub(crate) async fn run(base: BaseArgs, args: ActiveObservabilityConfigArgs) -> Result<()> { match args.command { - AnalyticsConfigCommand::Pull(args) => pull(base, args).await, - AnalyticsConfigCommand::Push(args) => push(base, args).await, + ActiveObservabilityConfigCommand::Pull(args) => pull(base, args).await, + ActiveObservabilityConfigCommand::Push(args) => push(base, args).await, } } async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let (facets, automations) = with_spinner("Loading analytics config...", async { + let (facets, automations) = with_spinner("Loading active observability config...", async { tokio::try_join!( facet::list_templates(&ctx.client, &ctx.project.id), automation::list_templates(&ctx.client, &ctx.project.id), ) }) .await?; - let template = AnalyticsConfigTemplate { - kind: TemplateKind::AnalyticsConfig, + let (facets, automations) = if !base.json && ui::is_interactive() && ui::can_prompt() { + select_resources(facets, automations)? + } else { + (facets, automations) + }; + let template = ActiveObservabilityConfigTemplate { + kind: TemplateKind::ActiveObservabilityConfig, schema_version: SCHEMA_VERSION, facets, automations, @@ -123,8 +136,13 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { resource_template::write(&template, args.output.as_deref()).with_context(|| { args.output.as_ref().map_or_else( - || "failed to write analytics config to stdout".to_string(), - |path| format!("failed to write analytics config to {}", path.display()), + || "failed to write active observability config to stdout".to_string(), + |path| { + format!( + "failed to write active observability config to {}", + path.display() + ) + }, ) })?; @@ -139,7 +157,7 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { serde_json::to_string(&serde_json::json!({ "automation_count": template.automations.len(), "facet_count": template.facets.len(), - "kind": "analytics_config", + "kind": "active_observability_config", "output": path, "project": ctx.project.name, "status": "pulled", @@ -149,7 +167,7 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { print_command_status( CommandStatus::Success, &format!( - "Pulled analytics config from '{}' to {} ({} facets, {} Loop automations)", + "Pulled active observability config from '{}' to {} ({} facets, {} Loop automations)", ctx.project.name, path.display(), template.facets.len(), @@ -162,17 +180,71 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { Ok(()) } +fn select_resources( + facets: Vec, + automations: Vec, +) -> Result<(Vec, Vec)> { + if facets.is_empty() && automations.is_empty() { + return Ok((facets, automations)); + } + + let labels = facets + .iter() + .map(|facet| format!("Facet {}", facet.name())) + .chain( + automations + .iter() + .map(|automation| format!("Automation {}", automation.name())), + ) + .collect::>(); + let defaults = vec![true; labels.len()]; + let term = ui::prompt_term().ok_or_else(|| anyhow::anyhow!("interactive mode requires TTY"))?; + let selected = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select facets and Loop automations to include") + .items(&labels) + .defaults(&defaults) + .interact_on(&term) + .context("failed to select active observability resources")?; + + Ok(filter_resources(facets, automations, &selected)) +} + +fn filter_resources( + facets: Vec, + automations: Vec, + selected: &[usize], +) -> (Vec, Vec) { + let facet_count = facets.len(); + let selected = selected.iter().copied().collect::>(); + let facets = facets + .into_iter() + .enumerate() + .filter_map(|(index, facet)| selected.contains(&index).then_some(facet)) + .collect(); + let automations = automations + .into_iter() + .enumerate() + .filter_map(|(index, automation)| { + selected + .contains(&(facet_count + index)) + .then_some(automation) + }) + .collect(); + + (facets, automations) +} + async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: AnalyticsConfigTemplate = with_spinner( - "Loading analytics config...", - resource_template::read(args.file()?, "analytics_config"), + let template: ActiveObservabilityConfigTemplate = with_spinner( + "Loading active observability config...", + resource_template::read(args.file()?, "active_observability_config"), ) .await?; validate_template(&template)?; let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; let (pushed_facets, pushed_automations) = with_spinner( - "Pushing analytics config...", + "Pushing active observability config...", push_resources( &ctx.client, &ctx.project.id, @@ -183,7 +255,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { .await .with_context(|| { format!( - "failed to push analytics config to project '{}'", + "failed to push active observability config to project '{}'", ctx.project.name ) })?; @@ -207,7 +279,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { "slug": facet.slug, })) .collect::>(), - "kind": "analytics_config", + "kind": "active_observability_config", "project": ctx.project.name, "status": "pushed", }))? @@ -216,7 +288,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { print_command_status( CommandStatus::Success, &format!( - "Pushed analytics config to '{}' ({} facets, {} Loop automations)", + "Pushed active observability config to '{}' ({} facets, {} Loop automations)", ctx.project.name, pushed_facets.len(), pushed_automations.len(), @@ -230,25 +302,33 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { async fn push_resources( client: &crate::http::ApiClient, project_id: &str, - template: &AnalyticsConfigTemplate, + template: &ActiveObservabilityConfigTemplate, topics_automation: Option<&str>, ) -> Result<(Vec, Vec)> { let mut pushed_facets = Vec::with_capacity(template.facets.len()); for facet in &template.facets { - pushed_facets - .push(facet::push_template(client, project_id, facet, None, topics_automation).await?); + pushed_facets.push( + facet::push_template(client, project_id, facet, None, topics_automation) + .await + .with_context(|| format!("failed to push facet '{}'", facet.name()))?, + ); } let mut pushed_automations = Vec::with_capacity(template.automations.len()); for automation in &template.automations { - pushed_automations - .push(automation::push_template(client, project_id, automation, None).await?); + pushed_automations.push( + automation::push_template(client, project_id, automation, None) + .await + .with_context(|| { + format!("failed to push Loop automation '{}'", automation.name()) + })?, + ); } Ok((pushed_facets, pushed_automations)) } -fn validate_template(template: &AnalyticsConfigTemplate) -> Result<()> { +fn validate_template(template: &ActiveObservabilityConfigTemplate) -> Result<()> { resource_template::validate_version(template.schema_version)?; let mut facet_slugs = HashSet::new(); @@ -256,7 +336,7 @@ fn validate_template(template: &AnalyticsConfigTemplate) -> Result<()> { facet::validate_template(facet)?; if !facet_slugs.insert(facet.slug()) { bail!( - "analytics config contains duplicate facet slug '{}'", + "active observability config contains duplicate facet slug '{}'", facet.slug() ); } @@ -267,7 +347,7 @@ fn validate_template(template: &AnalyticsConfigTemplate) -> Result<()> { automation::validate_template(automation)?; if !automation_names.insert(automation.name()) { bail!( - "analytics config contains duplicate automation name '{}'", + "active observability config contains duplicate automation name '{}'", automation.name() ); } @@ -280,9 +360,9 @@ fn validate_template(template: &AnalyticsConfigTemplate) -> Result<()> { mod tests { use super::*; - fn analytics_config_json() -> serde_json::Value { + fn active_observability_config_json() -> serde_json::Value { serde_json::json!({ - "kind": "analytics_config", + "kind": "active_observability_config", "schema_version": 1, "facets": [{ "kind": "facet", @@ -307,35 +387,50 @@ mod tests { } #[test] - fn validates_analytics_config() { - let template: AnalyticsConfigTemplate = - serde_json::from_value(analytics_config_json()).expect("analytics config"); + fn validates_active_observability_config() { + let template: ActiveObservabilityConfigTemplate = + serde_json::from_value(active_observability_config_json()) + .expect("active observability config"); - validate_template(&template).expect("valid analytics config"); + validate_template(&template).expect("valid active observability config"); assert_eq!(template.facets.len(), 1); assert_eq!(template.automations.len(), 1); } #[test] fn defaults_missing_resource_arrays_to_empty() { - let template: AnalyticsConfigTemplate = serde_json::from_value(serde_json::json!({ - "kind": "analytics_config", - "schema_version": 1 - })) - .expect("empty analytics config"); - - validate_template(&template).expect("valid empty analytics config"); + let template: ActiveObservabilityConfigTemplate = + serde_json::from_value(serde_json::json!({ + "kind": "active_observability_config", + "schema_version": 1 + })) + .expect("empty active observability config"); + + validate_template(&template).expect("valid empty active observability config"); assert!(template.facets.is_empty()); assert!(template.automations.is_empty()); } + #[test] + fn filters_resources_by_picker_index() { + let template: ActiveObservabilityConfigTemplate = + serde_json::from_value(active_observability_config_json()) + .expect("active observability config"); + + let (facets, automations) = filter_resources(template.facets, template.automations, &[1]); + + assert!(facets.is_empty()); + assert_eq!(automations.len(), 1); + assert_eq!(automations[0].name(), "test-loop"); + } + #[test] fn rejects_duplicate_resource_identity() { - let mut value = analytics_config_json(); + let mut value = active_observability_config_json(); let facet = value["facets"][0].clone(); value["facets"].as_array_mut().expect("facets").push(facet); - let template: AnalyticsConfigTemplate = - serde_json::from_value(value).expect("analytics config"); + let template: ActiveObservabilityConfigTemplate = + serde_json::from_value(value).expect("active observability config"); let err = validate_template(&template).expect_err("duplicate facet slug"); assert!(err.to_string().contains("duplicate facet slug")); @@ -344,17 +439,17 @@ mod tests { #[test] fn push_args_accept_one_source() { let positional = PushArgs { - file_positional: Some("analytics-config.json".to_string()), + file_positional: Some("active-observability-config.json".to_string()), file_flag: None, topics_automation: None, }; assert_eq!( positional.file().expect("positional"), - "analytics-config.json" + "active-observability-config.json" ); let conflicting = PushArgs { - file_positional: Some("analytics-config.json".to_string()), + file_positional: Some("active-observability-config.json".to_string()), file_flag: Some("other.json".to_string()), topics_automation: None, }; diff --git a/src/automation.rs b/src/automation.rs index 5ed1a7b3..c051887d 100644 --- a/src/automation.rs +++ b/src/automation.rs @@ -29,6 +29,8 @@ pub(crate) struct AutomationArgs { #[derive(Debug, Clone, Subcommand)] enum AutomationCommand { /// Pull a Loop automation as a portable JSON template + /// + /// Destination actions such as Slack channels and webhook URLs are not included. Pull(PullArgs), /// Create or replace a Loop automation from a JSON template Push(PushArgs), @@ -320,11 +322,16 @@ pub(crate) async fn push_template( ) -> Result { validate_template(template)?; let name = push_name(override_name, &template.name)?; + let existing = get_by_name(client, project_id, name).await?; + let config = config_for_push( + template.config.clone(), + existing.as_ref().map(|automation| &automation.config), + )?; let request = UpsertAutomationRequest { project_id, name, description: template.description.as_deref(), - config: &template.config, + config: &config, }; client .put("/v1/project_automation", &request) @@ -365,10 +372,35 @@ fn template_from_remote(remote: RemoteAutomation) -> Result schema_version: SCHEMA_VERSION, name: remote.name, description: remote.description, - config: remote.config, + config: strip_destination_config(remote.config)?, }) } +fn strip_destination_config(mut config: Value) -> Result { + // Delivery actions contain destination-owned Slack identifiers and webhook URLs. + // They must not cross project or organization boundaries in a portable template. + config + .as_object_mut() + .ok_or_else(|| anyhow!("Loop automation config must be a JSON object"))? + .remove("actions"); + Ok(config) +} + +fn config_for_push(config: Value, existing_config: Option<&Value>) -> Result { + let mut config = strip_destination_config(config)?; + let existing_actions = existing_config + .and_then(Value::as_object) + .and_then(|config| config.get("actions")) + .cloned(); + if let Some(existing_actions) = existing_actions { + config + .as_object_mut() + .expect("strip_destination_config returns an object") + .insert("actions".to_string(), existing_actions); + } + Ok(config) +} + pub(crate) fn validate_template(template: &AutomationTemplate) -> Result<()> { resource_template::validate_version(template.schema_version)?; if template.name.trim().is_empty() { @@ -452,6 +484,40 @@ mod tests { assert!(value.get("id").is_none()); assert!(value.get("project_id").is_none()); assert!(value.get("user_id").is_none()); + assert!(value["config"].get("actions").is_none()); + } + + #[test] + fn push_ignores_template_destinations_and_preserves_target_destinations() { + let source_config = serde_json::json!({ + "event_type": "windowed", + "loop": {"prompt": "Review recent traces"}, + "actions": [{ + "type": "slack", + "workspace_id": "fake-source-workspace", + "channel": "fake-source-channel" + }] + }); + let target_config = serde_json::json!({ + "event_type": "windowed", + "loop": {"prompt": "Old prompt"}, + "actions": [{ + "type": "webhook", + "url": "https://example.invalid/destination" + }] + }); + + let config = config_for_push(source_config, Some(&target_config)).expect("push config"); + + assert_eq!(config["loop"]["prompt"], "Review recent traces"); + assert_eq!(config["actions"], target_config["actions"]); + } + + #[test] + fn push_new_automation_has_no_destination_config() { + let config = config_for_push(remote_loop().config, None).expect("push config"); + + assert!(config.get("actions").is_none()); } #[test] diff --git a/src/facet.rs b/src/facet.rs index ead11679..5e64af59 100644 --- a/src/facet.rs +++ b/src/facet.rs @@ -30,6 +30,8 @@ pub(crate) struct FacetArgs { #[derive(Debug, Clone, Subcommand)] enum FacetCommand { /// Pull a facet as a portable JSON template + /// + /// Project-specific preprocessors referenced by the facet are embedded in the template. Pull(PullArgs), /// Create or replace a facet from a JSON template Push(PushArgs), @@ -114,6 +116,29 @@ pub(crate) enum TemplateKind { Facet, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum PreprocessorTemplateKind { + Preprocessor, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +struct PreprocessorTemplate { + kind: PreprocessorTemplateKind, + schema_version: u32, + name: String, + slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + function_data: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + function_schema: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub(crate) struct FacetTemplate { kind: TemplateKind, @@ -122,6 +147,8 @@ pub(crate) struct FacetTemplate { slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + preprocessor: Option, function_data: Value, #[serde(default, skip_serializing_if = "Option::is_none")] prompt_data: Option, @@ -159,7 +186,7 @@ struct ListResponse { } #[derive(Debug, Serialize)] -struct UpsertFacetRequest<'a> { +struct UpsertFunctionRequest<'a> { project_id: &'a str, name: &'a str, slug: &'a str, @@ -360,9 +387,19 @@ pub(crate) async fn push_template( } else { None }; + if let Some(preprocessor) = template.preprocessor.as_ref() { + push_preprocessor_template(client, project_id, preprocessor) + .await + .with_context(|| { + format!( + "failed to push preprocessor '{}' required by facet '{name}'", + preprocessor.name + ) + })?; + } let function_data = resolve_preprocessor_reference(client, project_id, template.function_data.clone()).await?; - let request = UpsertFacetRequest { + let request = UpsertFunctionRequest { project_id, name, slug: &template.slug, @@ -720,13 +757,15 @@ async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Res if remote.function_type.as_deref() != Some("facet") { bail!("function '{}' is not a facet", remote.name); } - let function_data = make_preprocessor_reference_portable(client, remote.function_data).await?; + let (function_data, preprocessor) = + make_preprocessor_reference_portable(client, remote.function_data).await?; let template = FacetTemplate { kind: TemplateKind::Facet, schema_version: SCHEMA_VERSION, name: remote.name, slug: remote.slug, description: remote.description, + preprocessor, function_data, prompt_data: remote.prompt_data, tags: remote.tags, @@ -739,15 +778,15 @@ async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Res async fn make_preprocessor_reference_portable( client: &ApiClient, mut function_data: Value, -) -> Result { +) -> Result<(Value, Option)> { let Some(preprocessor) = function_data .get_mut("preprocessor") .and_then(Value::as_object_mut) else { - return Ok(function_data); + return Ok((function_data, None)); }; if preprocessor.get("type").and_then(Value::as_str) != Some("function") { - return Ok(function_data); + return Ok((function_data, None)); } let id = preprocessor @@ -762,11 +801,54 @@ async fn make_preprocessor_reference_portable( referenced.function_type.as_deref().unwrap_or("") ); } + let template = preprocessor_template_from_remote(&referenced)?; *preprocessor = serde_json::Map::from_iter([ ("type".to_string(), Value::String("function".to_string())), - ("slug".to_string(), Value::String(referenced.slug)), + ("slug".to_string(), Value::String(template.slug.clone())), ]); - Ok(function_data) + Ok((function_data, Some(template))) +} + +fn preprocessor_template_from_remote(remote: &RemoteFunction) -> Result { + if remote.function_type.as_deref() != Some("preprocessor") { + bail!("function '{}' is not a preprocessor", remote.name); + } + let template = PreprocessorTemplate { + kind: PreprocessorTemplateKind::Preprocessor, + schema_version: SCHEMA_VERSION, + name: remote.name.clone(), + slug: remote.slug.clone(), + description: remote.description.clone(), + function_data: remote.function_data.clone(), + prompt_data: remote.prompt_data.clone(), + tags: remote.tags.clone(), + function_schema: remote.function_schema.clone(), + }; + validate_preprocessor_template(&template)?; + Ok(template) +} + +async fn push_preprocessor_template( + client: &ApiClient, + project_id: &str, + template: &PreprocessorTemplate, +) -> Result { + validate_preprocessor_template(template)?; + let request = UpsertFunctionRequest { + project_id, + name: &template.name, + slug: &template.slug, + description: template.description.as_deref(), + function_type: "preprocessor", + function_data: &template.function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), + }; + client + .put("/v1/function", &request) + .await + .with_context(|| format!("failed to push preprocessor '{}'", template.name)) } async fn resolve_preprocessor_reference( @@ -817,23 +899,62 @@ pub(crate) fn validate_template(template: &FacetTemplate) -> Result<()> { if template.function_data.get("type").and_then(Value::as_str) != Some("facet") { bail!("facet template function_data.type must be 'facet'"); } - if let Some(preprocessor) = template + let preprocessor_reference = template .function_data .get("preprocessor") .and_then(Value::as_object) - .filter(|preprocessor| preprocessor.get("type").and_then(Value::as_str) == Some("function")) - { - if preprocessor.get("slug").and_then(Value::as_str).is_none() { - bail!("facet template project preprocessor reference must use a portable 'slug' field"); - } - if preprocessor.contains_key("id") { + .filter(|preprocessor| { + preprocessor.get("type").and_then(Value::as_str) == Some("function") + }); + if let Some(preprocessor_reference) = preprocessor_reference { + let slug = preprocessor_reference + .get("slug") + .and_then(Value::as_str) + .filter(|slug| !slug.trim().is_empty()) + .ok_or_else(|| { + anyhow!( + "facet template project preprocessor reference must use a portable 'slug' field" + ) + })?; + if preprocessor_reference.contains_key("id") { bail!("facet template must not contain a source-project preprocessor id"); } + if let Some(preprocessor) = template.preprocessor.as_ref() { + validate_preprocessor_template(preprocessor)?; + if preprocessor.slug != slug { + bail!( + "facet template preprocessor slug '{}' does not match referenced slug '{slug}'", + preprocessor.slug + ); + } + } + } else if template.preprocessor.is_some() { + bail!( + "facet template bundles a preprocessor but function_data has no project preprocessor reference" + ); + } + Ok(()) +} + +fn validate_preprocessor_template(template: &PreprocessorTemplate) -> Result<()> { + resource_template::validate_version(template.schema_version)?; + if template.name.trim().is_empty() { + bail!("preprocessor template name must not be empty"); + } + if template.slug.trim().is_empty() { + bail!("preprocessor template slug must not be empty"); + } + if !template.function_data.is_object() { + bail!("preprocessor template function_data must be an object"); } Ok(()) } impl FacetTemplate { + pub(crate) fn name(&self) -> &str { + &self.name + } + pub(crate) fn slug(&self) -> &str { &self.slug } @@ -858,6 +979,7 @@ mod tests { name: "test-facet".to_string(), slug: "test-facet".to_string(), description: Some("A synthetic facet".to_string()), + preprocessor: None, function_data: serde_json::json!({ "type": "facet", "prompt": "Classify the trace", @@ -897,6 +1019,27 @@ mod tests { } } + fn remote_preprocessor() -> RemoteFunction { + RemoteFunction { + id: "fake-preprocessor-id".to_string(), + name: "Test preprocessor".to_string(), + slug: "test-preprocessor".to_string(), + description: Some("A synthetic preprocessor".to_string()), + function_type: Some("preprocessor".to_string()), + function_data: serde_json::json!({ + "type": "code", + "data": { + "type": "inline", + "runtime_context": {"runtime": "quickjs", "version": "ES2023"}, + "code": "function handler(input) { return input; }" + } + }), + prompt_data: None, + tags: Some(vec!["test-tag".to_string()]), + function_schema: None, + } + } + #[test] fn facet_template_contains_only_portable_identity() { let value = serde_json::to_value(facet_template()).expect("json"); @@ -932,6 +1075,38 @@ mod tests { validate_template(&template).expect("portable reference"); } + #[test] + fn accepts_bundled_project_preprocessor() { + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": "test-preprocessor" + }); + template.preprocessor = + Some(preprocessor_template_from_remote(&remote_preprocessor()).expect("template")); + + validate_template(&template).expect("bundled preprocessor"); + let value = serde_json::to_value(template).expect("JSON"); + assert_eq!(value["preprocessor"]["kind"], "preprocessor"); + assert_eq!(value["preprocessor"]["slug"], "test-preprocessor"); + assert_eq!(value["preprocessor"]["function_data"]["type"], "code"); + assert!(value["preprocessor"].get("id").is_none()); + } + + #[test] + fn rejects_bundled_preprocessor_that_does_not_match_reference() { + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": "other-preprocessor" + }); + template.preprocessor = + Some(preprocessor_template_from_remote(&remote_preprocessor()).expect("template")); + + let err = validate_template(&template).expect_err("mismatched preprocessor"); + assert!(err.to_string().contains("does not match referenced slug")); + } + #[test] fn rejects_source_project_preprocessor_id() { let mut template = facet_template(); @@ -977,7 +1152,7 @@ mod tests { #[test] fn upsert_request_uses_overridden_name_without_changing_slug() { let template = facet_template(); - let request = UpsertFacetRequest { + let request = UpsertFunctionRequest { project_id: "fake-project-id", name: push_name(Some("Renamed facet"), &template.name).expect("name"), slug: &template.slug, diff --git a/src/main.rs b/src/main.rs index f493fec7..15396075 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::{parser::ValueSource, ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; -mod analytics_config; +mod active_observability_config; mod args; mod auth; mod automation; @@ -69,7 +69,7 @@ Core Projects & resources projects Manage projects - analytics-config Pull and push portable analytics configuration + active-observability-config Pull and push portable active observability configuration automation Pull and push Loop automation templates facet Pull and push facet templates topics Inspect and control Topics automation @@ -150,8 +150,8 @@ enum Commands { Eval(CLIArgs), /// Manage projects Projects(CLIArgs), - /// Pull and push facets and Loop automations as one analytics config - AnalyticsConfig(CLIArgs), + /// Pull and push facets and Loop automations as one active observability config + ActiveObservabilityConfig(CLIArgs), #[command(visible_alias = "automations")] /// Pull and push Loop automation templates Automation(CLIArgs), @@ -204,7 +204,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, - Commands::AnalyticsConfig(cmd) => &cmd.base, + Commands::ActiveObservabilityConfig(cmd) => &cmd.base, Commands::Automation(cmd) => &cmd.base, Commands::Facet(cmd) => &cmd.base, Commands::Topics(cmd) => &cmd.base, @@ -236,7 +236,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, - Commands::AnalyticsConfig(cmd) => &mut cmd.base, + Commands::ActiveObservabilityConfig(cmd) => &mut cmd.base, Commands::Automation(cmd) => &mut cmd.base, Commands::Facet(cmd) => &mut cmd.base, Commands::Datasets(cmd) => &mut cmd.base, @@ -349,7 +349,9 @@ fn try_main() -> Result<()> { #[cfg(unix)] Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, - Commands::AnalyticsConfig(cmd) => analytics_config::run(cmd.base, cmd.args).await?, + Commands::ActiveObservabilityConfig(cmd) => { + active_observability_config::run(cmd.base, cmd.args).await? + } Commands::Automation(cmd) => automation::run(cmd.base, cmd.args).await?, Commands::Facet(cmd) => facet::run(cmd.base, cmd.args).await?, Commands::Datasets(cmd) => datasets::run(cmd.base, cmd.args).await?, @@ -529,15 +531,17 @@ fn has_io_error(err: &anyhow::Error) -> bool { } fn looks_like_user_error(err: &anyhow::Error) -> bool { - let message = err.to_string().to_lowercase(); - message.contains("required") - || message.contains("use:") - || message.contains("not found") - || message.contains("invalid") + err.chain().any(|source| { + let message = source.to_string().to_lowercase(); + message.contains("required") + || message.contains("use:") + || message.contains("not found") + || message.contains("invalid") + }) } fn print_error(err: &anyhow::Error, code: ExitCode, missing_credential: bool) { - eprintln!("error: {err}"); + eprintln!("error: {err:#}"); if code == ExitCode::Auth && !missing_credential { eprintln!("Your credentials may be expired or invalid. For OAuth profiles, try `bt login --refresh --profile `; if refresh fails, re-run `bt login --oauth --profile `. Run `bt status --all` to inspect profile status."); } @@ -565,6 +569,18 @@ mod tests { } } + #[test] + fn nested_user_errors_are_classified_and_rendered_with_their_cause() { + let err = anyhow::anyhow!("Topics is not enabled; use: bt topics config enable") + .context("failed to push active observability config"); + + assert!(looks_like_user_error(&err)); + assert_eq!( + format!("{err:#}"), + "failed to push active observability config: Topics is not enabled; use: bt topics config enable" + ); + } + #[test] fn apply_base_arg_sources_tracks_cli_api_key() { let _guard = env_test_lock().lock().expect("env test lock"); @@ -698,40 +714,45 @@ mod tests { } #[test] - fn analytics_config_template_commands_parse() { + fn active_observability_config_template_commands_parse() { for args in [ - vec!["bt", "analytics-config", "pull"], + vec!["bt", "active-observability-config", "pull"], vec![ "bt", - "analytics-config", + "active-observability-config", "pull", "--output", - "analytics-config.json", + "active-observability-config.json", + ], + vec![ + "bt", + "active-observability-config", + "push", + "active-observability-config.json", ], - vec!["bt", "analytics-config", "push", "analytics-config.json"], vec![ "bt", - "analytics-config", + "active-observability-config", "push", - "analytics-config.json", + "active-observability-config.json", "--topics-automation", "Topics", ], vec![ "bt", - "analytics-config", + "active-observability-config", "push", - "https://example.com/analytics-config.json", + "https://example.com/active-observability-config.json", ], vec![ "bt", - "analytics-config", + "active-observability-config", "push", "--file", - "analytics-config.json", + "active-observability-config.json", ], ] { - Cli::try_parse_from(args).expect("analytics-config command should parse"); + Cli::try_parse_from(args).expect("active-observability-config command should parse"); } } From 3793f6a33d5dc9d52e65764d7482947b482090c2 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Mon, 24 Aug 2026 15:07:27 -0700 Subject: [PATCH 06/13] rename to active observability template --- ...ig.rs => active_observability_template.rs} | 88 +++++++++---------- src/main.rs | 48 +++++----- 2 files changed, 69 insertions(+), 67 deletions(-) rename src/{active_observability_config.rs => active_observability_template.rs} (81%) diff --git a/src/active_observability_config.rs b/src/active_observability_template.rs similarity index 81% rename from src/active_observability_config.rs rename to src/active_observability_template.rs index ae366eb4..893dad0b 100644 --- a/src/active_observability_config.rs +++ b/src/active_observability_template.rs @@ -16,37 +16,37 @@ use crate::ui::{self, print_command_status, with_spinner, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt active-observability-config pull --output active-observability-config.json - bt active-observability-config push active-observability-config.json --org test-org --project test-project - bt active-observability-config push active-observability-config.json --topics-automation Topics - bt active-observability-config push https://example.com/active-observability-config.json - bt active-observability-config pull | bt active-observability-config push - --project test-project + bt active-observability-template pull --output active-observability-template.json + bt active-observability-template push active-observability-template.json --org test-org --project test-project + bt active-observability-template push active-observability-template.json --topics-automation Topics + bt active-observability-template push https://example.com/active-observability-template.json + bt active-observability-template pull | bt active-observability-template push - --project test-project ")] -pub(crate) struct ActiveObservabilityConfigArgs { +pub(crate) struct ActiveObservabilityTemplateArgs { #[command(subcommand)] - command: ActiveObservabilityConfigCommand, + command: ActiveObservabilityTemplateCommand, } #[derive(Debug, Clone, Subcommand)] -enum ActiveObservabilityConfigCommand { - /// Pull facets and Loop automations into one portable active observability config +enum ActiveObservabilityTemplateCommand { + /// Pull facets and Loop automations into one portable active observability template /// /// In an interactive terminal, all resources are selected by default. Use Space to /// exclude resources and Enter to confirm. Use --no-input to include everything /// without prompting. Project-specific preprocessors used by selected facets are /// included automatically. Automation destination actions are excluded. Pull(PullArgs), - /// Create or replace facets and Loop automations from an active observability config + /// Create or replace facets and Loop automations from an active observability template Push(PushArgs), } #[derive(Debug, Clone, Args)] struct PullArgs { - /// Write the active observability config to this path instead of stdout + /// Write the active observability template to this path instead of stdout #[arg( long, short = 'O', - env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PULL_OUTPUT", + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PULL_OUTPUT", value_name = "PATH" )] output: Option, @@ -54,15 +54,15 @@ struct PullArgs { #[derive(Debug, Clone, Args)] struct PushArgs { - /// Project template path, HTTP(S) URL, or - to read from stdin + /// Active observability template path, HTTP(S) URL, or - to read from stdin #[arg(value_name = "SOURCE")] file_positional: Option, - /// Project template path, HTTP(S) URL, or - to read from stdin + /// Active observability template path, HTTP(S) URL, or - to read from stdin #[arg( long = "file", short = 'f', - env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PUSH_FILE", + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_FILE", value_name = "SOURCE" )] file_flag: Option, @@ -70,7 +70,7 @@ struct PushArgs { /// Topics automation name or ID to attach newly created facets to #[arg( long, - env = "BT_ACTIVE_OBSERVABILITY_CONFIG_PUSH_TOPICS_AUTOMATION", + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_TOPICS_AUTOMATION", value_name = "NAME_OR_ID" )] topics_automation: Option, @@ -83,7 +83,7 @@ impl PushArgs { (Some(source), None) | (None, Some(source)) => Ok(source), (None, None) => { bail!( - "active observability config source required. Use: bt active-observability-config push " + "active observability template source required. Use: bt active-observability-template push " ) } } @@ -106,16 +106,16 @@ struct ActiveObservabilityConfigTemplate { automations: Vec, } -pub(crate) async fn run(base: BaseArgs, args: ActiveObservabilityConfigArgs) -> Result<()> { +pub(crate) async fn run(base: BaseArgs, args: ActiveObservabilityTemplateArgs) -> Result<()> { match args.command { - ActiveObservabilityConfigCommand::Pull(args) => pull(base, args).await, - ActiveObservabilityConfigCommand::Push(args) => push(base, args).await, + ActiveObservabilityTemplateCommand::Pull(args) => pull(base, args).await, + ActiveObservabilityTemplateCommand::Push(args) => push(base, args).await, } } async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let (facets, automations) = with_spinner("Loading active observability config...", async { + let (facets, automations) = with_spinner("Loading active observability template...", async { tokio::try_join!( facet::list_templates(&ctx.client, &ctx.project.id), automation::list_templates(&ctx.client, &ctx.project.id), @@ -136,10 +136,10 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { resource_template::write(&template, args.output.as_deref()).with_context(|| { args.output.as_ref().map_or_else( - || "failed to write active observability config to stdout".to_string(), + || "failed to write active observability template to stdout".to_string(), |path| { format!( - "failed to write active observability config to {}", + "failed to write active observability template to {}", path.display() ) }, @@ -167,7 +167,7 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { print_command_status( CommandStatus::Success, &format!( - "Pulled active observability config from '{}' to {} ({} facets, {} Loop automations)", + "Pulled active observability template from '{}' to {} ({} facets, {} Loop automations)", ctx.project.name, path.display(), template.facets.len(), @@ -236,7 +236,7 @@ fn filter_resources( async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { let template: ActiveObservabilityConfigTemplate = with_spinner( - "Loading active observability config...", + "Loading active observability template...", resource_template::read(args.file()?, "active_observability_config"), ) .await?; @@ -244,7 +244,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; let (pushed_facets, pushed_automations) = with_spinner( - "Pushing active observability config...", + "Pushing active observability template...", push_resources( &ctx.client, &ctx.project.id, @@ -255,7 +255,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { .await .with_context(|| { format!( - "failed to push active observability config to project '{}'", + "failed to push active observability template to project '{}'", ctx.project.name ) })?; @@ -288,7 +288,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { print_command_status( CommandStatus::Success, &format!( - "Pushed active observability config to '{}' ({} facets, {} Loop automations)", + "Pushed active observability template to '{}' ({} facets, {} Loop automations)", ctx.project.name, pushed_facets.len(), pushed_automations.len(), @@ -336,7 +336,7 @@ fn validate_template(template: &ActiveObservabilityConfigTemplate) -> Result<()> facet::validate_template(facet)?; if !facet_slugs.insert(facet.slug()) { bail!( - "active observability config contains duplicate facet slug '{}'", + "active observability template contains duplicate facet slug '{}'", facet.slug() ); } @@ -347,7 +347,7 @@ fn validate_template(template: &ActiveObservabilityConfigTemplate) -> Result<()> automation::validate_template(automation)?; if !automation_names.insert(automation.name()) { bail!( - "active observability config contains duplicate automation name '{}'", + "active observability template contains duplicate automation name '{}'", automation.name() ); } @@ -360,7 +360,7 @@ fn validate_template(template: &ActiveObservabilityConfigTemplate) -> Result<()> mod tests { use super::*; - fn active_observability_config_json() -> serde_json::Value { + fn active_observability_template_json() -> serde_json::Value { serde_json::json!({ "kind": "active_observability_config", "schema_version": 1, @@ -387,12 +387,12 @@ mod tests { } #[test] - fn validates_active_observability_config() { + fn validates_active_observability_template() { let template: ActiveObservabilityConfigTemplate = - serde_json::from_value(active_observability_config_json()) - .expect("active observability config"); + serde_json::from_value(active_observability_template_json()) + .expect("active observability template"); - validate_template(&template).expect("valid active observability config"); + validate_template(&template).expect("valid active observability template"); assert_eq!(template.facets.len(), 1); assert_eq!(template.automations.len(), 1); } @@ -404,9 +404,9 @@ mod tests { "kind": "active_observability_config", "schema_version": 1 })) - .expect("empty active observability config"); + .expect("empty active observability template"); - validate_template(&template).expect("valid empty active observability config"); + validate_template(&template).expect("valid empty active observability template"); assert!(template.facets.is_empty()); assert!(template.automations.is_empty()); } @@ -414,8 +414,8 @@ mod tests { #[test] fn filters_resources_by_picker_index() { let template: ActiveObservabilityConfigTemplate = - serde_json::from_value(active_observability_config_json()) - .expect("active observability config"); + serde_json::from_value(active_observability_template_json()) + .expect("active observability template"); let (facets, automations) = filter_resources(template.facets, template.automations, &[1]); @@ -426,11 +426,11 @@ mod tests { #[test] fn rejects_duplicate_resource_identity() { - let mut value = active_observability_config_json(); + let mut value = active_observability_template_json(); let facet = value["facets"][0].clone(); value["facets"].as_array_mut().expect("facets").push(facet); let template: ActiveObservabilityConfigTemplate = - serde_json::from_value(value).expect("active observability config"); + serde_json::from_value(value).expect("active observability template"); let err = validate_template(&template).expect_err("duplicate facet slug"); assert!(err.to_string().contains("duplicate facet slug")); @@ -439,17 +439,17 @@ mod tests { #[test] fn push_args_accept_one_source() { let positional = PushArgs { - file_positional: Some("active-observability-config.json".to_string()), + file_positional: Some("active-observability-template.json".to_string()), file_flag: None, topics_automation: None, }; assert_eq!( positional.file().expect("positional"), - "active-observability-config.json" + "active-observability-template.json" ); let conflicting = PushArgs { - file_positional: Some("active-observability-config.json".to_string()), + file_positional: Some("active-observability-template.json".to_string()), file_flag: Some("other.json".to_string()), topics_automation: None, }; diff --git a/src/main.rs b/src/main.rs index 15396075..ecf39dd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::{parser::ValueSource, ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; -mod active_observability_config; +mod active_observability_template; mod args; mod auth; mod automation; @@ -69,7 +69,7 @@ Core Projects & resources projects Manage projects - active-observability-config Pull and push portable active observability configuration + active-observability-template Pull and push portable active observability templates automation Pull and push Loop automation templates facet Pull and push facet templates topics Inspect and control Topics automation @@ -150,8 +150,10 @@ enum Commands { Eval(CLIArgs), /// Manage projects Projects(CLIArgs), - /// Pull and push facets and Loop automations as one active observability config - ActiveObservabilityConfig(CLIArgs), + /// Pull and push facets and Loop automations as one active observability template + ActiveObservabilityTemplate( + CLIArgs, + ), #[command(visible_alias = "automations")] /// Pull and push Loop automation templates Automation(CLIArgs), @@ -204,7 +206,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, - Commands::ActiveObservabilityConfig(cmd) => &cmd.base, + Commands::ActiveObservabilityTemplate(cmd) => &cmd.base, Commands::Automation(cmd) => &cmd.base, Commands::Facet(cmd) => &cmd.base, Commands::Topics(cmd) => &cmd.base, @@ -236,7 +238,7 @@ impl Commands { #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, - Commands::ActiveObservabilityConfig(cmd) => &mut cmd.base, + Commands::ActiveObservabilityTemplate(cmd) => &mut cmd.base, Commands::Automation(cmd) => &mut cmd.base, Commands::Facet(cmd) => &mut cmd.base, Commands::Datasets(cmd) => &mut cmd.base, @@ -349,8 +351,8 @@ fn try_main() -> Result<()> { #[cfg(unix)] Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, - Commands::ActiveObservabilityConfig(cmd) => { - active_observability_config::run(cmd.base, cmd.args).await? + Commands::ActiveObservabilityTemplate(cmd) => { + active_observability_template::run(cmd.base, cmd.args).await? } Commands::Automation(cmd) => automation::run(cmd.base, cmd.args).await?, Commands::Facet(cmd) => facet::run(cmd.base, cmd.args).await?, @@ -572,12 +574,12 @@ mod tests { #[test] fn nested_user_errors_are_classified_and_rendered_with_their_cause() { let err = anyhow::anyhow!("Topics is not enabled; use: bt topics config enable") - .context("failed to push active observability config"); + .context("failed to push active observability template"); assert!(looks_like_user_error(&err)); assert_eq!( format!("{err:#}"), - "failed to push active observability config: Topics is not enabled; use: bt topics config enable" + "failed to push active observability template: Topics is not enabled; use: bt topics config enable" ); } @@ -714,45 +716,45 @@ mod tests { } #[test] - fn active_observability_config_template_commands_parse() { + fn active_observability_template_commands_parse() { for args in [ - vec!["bt", "active-observability-config", "pull"], + vec!["bt", "active-observability-template", "pull"], vec![ "bt", - "active-observability-config", + "active-observability-template", "pull", "--output", - "active-observability-config.json", + "active-observability-template.json", ], vec![ "bt", - "active-observability-config", + "active-observability-template", "push", - "active-observability-config.json", + "active-observability-template.json", ], vec![ "bt", - "active-observability-config", + "active-observability-template", "push", - "active-observability-config.json", + "active-observability-template.json", "--topics-automation", "Topics", ], vec![ "bt", - "active-observability-config", + "active-observability-template", "push", - "https://example.com/active-observability-config.json", + "https://example.com/active-observability-template.json", ], vec![ "bt", - "active-observability-config", + "active-observability-template", "push", "--file", - "active-observability-config.json", + "active-observability-template.json", ], ] { - Cli::try_parse_from(args).expect("active-observability-config command should parse"); + Cli::try_parse_from(args).expect("active-observability-template command should parse"); } } From 887a1884327715f7ac9095fe3d40cfd5e552baa6 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 12:03:54 -0700 Subject: [PATCH 07/13] cleanup command cruft --- .../automation.rs | 320 +--------------- .../facet.rs | 350 +----------------- .../io.rs} | 49 +-- .../mod.rs} | 75 ++-- src/main.rs | 92 +---- 5 files changed, 96 insertions(+), 790 deletions(-) rename src/{ => active_observability_template}/automation.rs (50%) rename src/{ => active_observability_template}/facet.rs (74%) rename src/{resource_template.rs => active_observability_template/io.rs} (78%) rename src/{active_observability_template.rs => active_observability_template/mod.rs} (88%) diff --git a/src/automation.rs b/src/active_observability_template/automation.rs similarity index 50% rename from src/automation.rs rename to src/active_observability_template/automation.rs index c051887d..db231de2 100644 --- a/src/automation.rs +++ b/src/active_observability_template/automation.rs @@ -1,137 +1,13 @@ -use std::path::{Path, PathBuf}; - use anyhow::{anyhow, bail, Context, Result}; -use clap::{Args, Subcommand}; use serde::{Deserialize, Serialize}; use serde_json::Value; use urlencoding::encode; -use crate::args::BaseArgs; use crate::http::ApiClient; -use crate::project_context::resolve_project_command_context_with_auth_mode; -use crate::resource_template::{self, SCHEMA_VERSION}; -use crate::ui::{print_command_status, with_spinner, CommandStatus}; - -#[derive(Debug, Clone, Args)] -#[command(after_help = "\ -Examples: - bt automation pull my-loop --output my-loop.automation.json - bt automation push my-loop.automation.json --org test-org --project test-project - bt automation push my-loop.automation.json --name renamed-loop - bt automation push https://example.com/my-loop.automation.json - bt automation pull my-loop | bt automation push - --project test-project -")] -pub(crate) struct AutomationArgs { - #[command(subcommand)] - command: AutomationCommand, -} - -#[derive(Debug, Clone, Subcommand)] -enum AutomationCommand { - /// Pull a Loop automation as a portable JSON template - /// - /// Destination actions such as Slack channels and webhook URLs are not included. - Pull(PullArgs), - /// Create or replace a Loop automation from a JSON template - Push(PushArgs), -} - -#[derive(Debug, Clone, Args)] -struct PullArgs { - /// Automation name - #[arg(value_name = "NAME")] - name_positional: Option, - - /// Automation name - #[arg( - long = "name", - short = 'n', - env = "BT_AUTOMATION_PULL_NAME", - value_name = "NAME" - )] - name_flag: Option, - - /// Write the template to this path instead of stdout - #[arg( - long, - short = 'O', - env = "BT_AUTOMATION_PULL_OUTPUT", - value_name = "PATH" - )] - output: Option, -} - -impl PullArgs { - fn name(&self) -> Result<&str> { - resolve_required_selector( - self.name_positional.as_deref(), - self.name_flag.as_deref(), - "automation name", - "bt automation pull ", - ) - } -} - -#[derive(Debug, Clone, Args)] -struct PushArgs { - /// Automation template path, HTTP(S) URL, or - to read from stdin - #[arg(value_name = "SOURCE")] - file_positional: Option, - - /// Automation template path, HTTP(S) URL, or - to read from stdin - #[arg( - long = "file", - short = 'f', - env = "BT_AUTOMATION_PUSH_FILE", - value_name = "SOURCE" - )] - file_flag: Option, - - /// Override the automation name from the template - #[arg( - long = "name", - short = 'n', - env = "BT_AUTOMATION_PUSH_NAME", - value_name = "NAME" - )] - name: Option, -} - -impl PushArgs { - fn file(&self) -> Result<&str> { - match (&self.file_positional, &self.file_flag) { - (Some(_), Some(_)) => bail!("use either a template path or --file, not both"), - (Some(source), None) | (None, Some(source)) => Ok(source), - (None, None) => { - bail!("automation template path required. Use: bt automation push ") - } - } - } -} - -fn resolve_required_selector<'a>( - positional: Option<&'a str>, - flag: Option<&'a str>, - label: &str, - usage: &str, -) -> Result<&'a str> { - match (positional, flag) { - (Some(_), Some(_)) => bail!("use either a positional {label} or --name, not both"), - (Some(value), None) | (None, Some(value)) if !value.trim().is_empty() => Ok(value), - _ => bail!("{label} required. Use: {usage}"), - } -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum TemplateKind { - Automation, -} #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub(crate) struct AutomationTemplate { - kind: TemplateKind, - schema_version: u32, +#[serde(deny_unknown_fields)] +pub(super) struct AutomationTemplate { name: String, #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, @@ -139,9 +15,9 @@ pub(crate) struct AutomationTemplate { } #[derive(Debug, Clone, Deserialize)] -pub(crate) struct RemoteAutomation { - pub(crate) id: String, - pub(crate) name: String, +pub(super) struct RemoteAutomation { + pub(super) id: String, + pub(super) name: String, #[serde(default)] description: Option, config: Value, @@ -164,113 +40,7 @@ struct UpsertAutomationRequest<'a> { config: &'a Value, } -pub(crate) async fn run(base: BaseArgs, args: AutomationArgs) -> Result<()> { - match args.command { - AutomationCommand::Pull(args) => pull(base, args).await, - AutomationCommand::Push(args) => push(base, args).await, - } -} - -async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { - let name = args.name()?.to_string(); - let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let automation = with_spinner( - "Loading automation...", - get_by_name(&ctx.client, &ctx.project.id, &name), - ) - .await? - .ok_or_else(|| { - anyhow!( - "automation '{name}' not found in project '{}'", - ctx.project.name - ) - })?; - let template = template_from_remote(automation)?; - - resource_template::write(&template, args.output.as_deref()).with_context(|| { - args.output.as_ref().map_or_else( - || "failed to write automation template to stdout".to_string(), - |path| format!("failed to write automation template to {}", path.display()), - ) - })?; - - if let Some(path) = args - .output - .as_deref() - .filter(|path| *path != Path::new("-")) - { - if base.json { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ - "kind": "automation", - "name": template.name, - "project": ctx.project.name, - "output": path, - "status": "pulled", - }))? - ); - } else { - print_command_status( - CommandStatus::Success, - &format!( - "Pulled automation '{}' to {}", - template.name, - path.display() - ), - ); - } - } - - Ok(()) -} - -async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: AutomationTemplate = with_spinner( - "Loading automation template...", - resource_template::read(args.file()?, "automation"), - ) - .await?; - let name = push_name(args.name.as_deref(), &template.name)?; - - let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; - let pushed: RemoteAutomation = with_spinner( - "Pushing automation...", - push_template(&ctx.client, &ctx.project.id, &template, Some(name)), - ) - .await - .with_context(|| { - format!( - "failed to push automation '{}' to project '{}'", - name, ctx.project.name - ) - })?; - - if base.json { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ - "id": pushed.id, - "kind": "automation", - "name": pushed.name, - "project": ctx.project.name, - "status": "pushed", - }))? - ); - } else { - print_command_status( - CommandStatus::Success, - &format!( - "Pushed automation '{}' to project '{}'", - pushed.name, ctx.project.name - ), - ); - } - - Ok(()) -} - -pub(crate) async fn list_templates( +pub(super) async fn list_templates( client: &ApiClient, project_id: &str, ) -> Result> { @@ -314,14 +84,13 @@ async fn list_all(client: &ApiClient, project_id: &str) -> Result, ) -> Result { validate_template(template)?; - let name = push_name(override_name, &template.name)?; + let name = template.name.trim(); let existing = get_by_name(client, project_id, name).await?; let config = config_for_push( template.config.clone(), @@ -368,8 +137,6 @@ async fn get_by_name( fn template_from_remote(remote: RemoteAutomation) -> Result { validate_loop_config(&remote.config, &remote.name)?; Ok(AutomationTemplate { - kind: TemplateKind::Automation, - schema_version: SCHEMA_VERSION, name: remote.name, description: remote.description, config: strip_destination_config(remote.config)?, @@ -401,24 +168,13 @@ fn config_for_push(config: Value, existing_config: Option<&Value>) -> Result Result<()> { - resource_template::validate_version(template.schema_version)?; +pub(super) fn validate_template(template: &AutomationTemplate) -> Result<()> { if template.name.trim().is_empty() { bail!("automation template name must not be empty"); } validate_loop_config(&template.config, &template.name) } -fn push_name<'a>(override_name: Option<&'a str>, template_name: &'a str) -> Result<&'a str> { - match override_name { - Some(name) if name.trim().is_empty() => { - bail!("automation name override must not be empty") - } - Some(name) => Ok(name.trim()), - None => Ok(template_name), - } -} - fn validate_loop_config(config: &Value, name: &str) -> Result<()> { let event_type = config.get("event_type").and_then(Value::as_str); if event_type != Some("windowed") || config.get("loop").and_then(Value::as_object).is_none() { @@ -436,7 +192,7 @@ fn is_loop_config(config: &Value) -> bool { } impl AutomationTemplate { - pub(crate) fn name(&self) -> &str { + pub(super) fn name(&self) -> &str { &self.name } } @@ -478,9 +234,9 @@ mod tests { let template = template_from_remote(remote_loop()).expect("template"); let value = serde_json::to_value(template).expect("json"); - assert_eq!(value["kind"], "automation"); - assert_eq!(value["schema_version"], 1); assert_eq!(value["name"], "test-loop"); + assert!(value.get("kind").is_none()); + assert!(value.get("schema_version").is_none()); assert!(value.get("id").is_none()); assert!(value.get("project_id").is_none()); assert!(value.get("user_id").is_none()); @@ -552,59 +308,9 @@ mod tests { } #[test] - fn validates_template_version_and_name() { + fn validates_template_name() { let mut template = template_from_remote(remote_loop()).expect("template"); - template.schema_version = 2; - assert!(validate_template(&template).is_err()); - - template.schema_version = SCHEMA_VERSION; template.name = " ".to_string(); assert!(validate_template(&template).is_err()); } - - #[test] - fn resolves_pull_name_from_positional_or_flag() { - assert_eq!( - resolve_required_selector(Some("test-loop"), None, "automation name", "usage") - .expect("positional"), - "test-loop" - ); - assert_eq!( - resolve_required_selector(None, Some("test-loop"), "automation name", "usage") - .expect("flag"), - "test-loop" - ); - assert!(resolve_required_selector( - Some("test-loop"), - Some("other-loop"), - "automation name", - "usage" - ) - .is_err()); - } - - #[test] - fn push_name_prefers_non_empty_override() { - assert_eq!( - push_name(Some("renamed-loop"), "test-loop").expect("override"), - "renamed-loop" - ); - assert_eq!(push_name(None, "test-loop").expect("template"), "test-loop"); - assert!(push_name(Some(" "), "test-loop").is_err()); - } - - #[test] - fn upsert_request_uses_overridden_name() { - let template = template_from_remote(remote_loop()).expect("template"); - let request = UpsertAutomationRequest { - project_id: "fake-project-id", - name: push_name(Some("renamed-loop"), &template.name).expect("name"), - description: template.description.as_deref(), - config: &template.config, - }; - - let value = serde_json::to_value(request).expect("request JSON"); - assert_eq!(value["name"], "renamed-loop"); - assert_eq!(value["project_id"], "fake-project-id"); - } } diff --git a/src/facet.rs b/src/active_observability_template/facet.rs similarity index 74% rename from src/facet.rs rename to src/active_observability_template/facet.rs index 5e64af59..18b430a3 100644 --- a/src/facet.rs +++ b/src/active_observability_template/facet.rs @@ -1,131 +1,13 @@ -use std::path::{Path, PathBuf}; - use anyhow::{anyhow, bail, Context, Result}; -use clap::{Args, Subcommand}; use serde::{Deserialize, Serialize}; use serde_json::Value; use urlencoding::encode; -use crate::args::BaseArgs; use crate::http::ApiClient; -use crate::project_context::resolve_project_command_context_with_auth_mode; -use crate::resource_template::{self, SCHEMA_VERSION}; -use crate::ui::{print_command_status, with_spinner, CommandStatus}; - -#[derive(Debug, Clone, Args)] -#[command(after_help = "\ -Examples: - bt facet pull my-facet --output my-facet.facet.json - bt facet push my-facet.facet.json --org test-org --project test-project - bt facet push my-facet.facet.json --name \"Renamed facet\" - bt facet push my-facet.facet.json --topics-automation Topics - bt facet push https://example.com/my-facet.facet.json - bt facet pull my-facet | bt facet push - --project test-project -")] -pub(crate) struct FacetArgs { - #[command(subcommand)] - command: FacetCommand, -} - -#[derive(Debug, Clone, Subcommand)] -enum FacetCommand { - /// Pull a facet as a portable JSON template - /// - /// Project-specific preprocessors referenced by the facet are embedded in the template. - Pull(PullArgs), - /// Create or replace a facet from a JSON template - Push(PushArgs), -} - -#[derive(Debug, Clone, Args)] -struct PullArgs { - /// Facet name - #[arg(value_name = "NAME")] - name_positional: Option, - - /// Facet name - #[arg( - long = "name", - short = 'n', - env = "BT_FACET_PULL_NAME", - value_name = "NAME" - )] - name_flag: Option, - - /// Write the template to this path instead of stdout - #[arg(long, short = 'O', env = "BT_FACET_PULL_OUTPUT", value_name = "PATH")] - output: Option, -} - -impl PullArgs { - fn name(&self) -> Result<&str> { - match (self.name_positional.as_deref(), self.name_flag.as_deref()) { - (Some(_), Some(_)) => bail!("use either a positional facet name or --name, not both"), - (Some(value), None) | (None, Some(value)) if !value.trim().is_empty() => Ok(value), - _ => bail!("facet name required. Use: bt facet pull "), - } - } -} - -#[derive(Debug, Clone, Args)] -struct PushArgs { - /// Facet template path, HTTP(S) URL, or - to read from stdin - #[arg(value_name = "SOURCE")] - file_positional: Option, - - /// Facet template path, HTTP(S) URL, or - to read from stdin - #[arg( - long = "file", - short = 'f', - env = "BT_FACET_PUSH_FILE", - value_name = "SOURCE" - )] - file_flag: Option, - - /// Override the facet name from the template (the slug is unchanged) - #[arg( - long = "name", - short = 'n', - env = "BT_FACET_PUSH_NAME", - value_name = "NAME" - )] - name: Option, - - /// Topics automation name or ID to attach a newly created facet to - #[arg( - long, - env = "BT_FACET_PUSH_TOPICS_AUTOMATION", - value_name = "NAME_OR_ID" - )] - topics_automation: Option, -} - -impl PushArgs { - fn file(&self) -> Result<&str> { - match (&self.file_positional, &self.file_flag) { - (Some(_), Some(_)) => bail!("use either a template path or --file, not both"), - (Some(source), None) | (None, Some(source)) => Ok(source), - (None, None) => bail!("facet template path required. Use: bt facet push "), - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum TemplateKind { - Facet, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum PreprocessorTemplateKind { - Preprocessor, -} #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] struct PreprocessorTemplate { - kind: PreprocessorTemplateKind, - schema_version: u32, name: String, slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -140,9 +22,8 @@ struct PreprocessorTemplate { } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub(crate) struct FacetTemplate { - kind: TemplateKind, - schema_version: u32, +#[serde(deny_unknown_fields)] +pub(super) struct FacetTemplate { name: String, slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -159,10 +40,10 @@ pub(crate) struct FacetTemplate { } #[derive(Debug, Clone, Deserialize)] -pub(crate) struct RemoteFunction { - pub(crate) id: String, - pub(crate) name: String, - pub(crate) slug: String, +pub(super) struct RemoteFunction { + pub(super) id: String, + pub(super) name: String, + pub(super) slug: String, #[serde(default)] description: Option, #[serde(default)] @@ -214,115 +95,7 @@ struct TopicsSetup { const DEFAULT_TOPICS_EMBEDDING_MODEL: &str = "brain-embedding-1"; -pub(crate) async fn run(base: BaseArgs, args: FacetArgs) -> Result<()> { - match args.command { - FacetCommand::Pull(args) => pull(base, args).await, - FacetCommand::Push(args) => push(base, args).await, - } -} - -async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { - let name = args.name()?.to_string(); - let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let facet = with_spinner( - "Loading facet...", - get_facet_by_name(&ctx.client, &ctx.project.id, &name), - ) - .await? - .ok_or_else(|| anyhow!("facet '{name}' not found in project '{}'", ctx.project.name))?; - let template = with_spinner( - "Resolving facet references...", - template_from_remote(&ctx.client, facet), - ) - .await?; - - resource_template::write(&template, args.output.as_deref()).with_context(|| { - args.output.as_ref().map_or_else( - || "failed to write facet template to stdout".to_string(), - |path| format!("failed to write facet template to {}", path.display()), - ) - })?; - - if let Some(path) = args - .output - .as_deref() - .filter(|path| *path != Path::new("-")) - { - if base.json { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ - "kind": "facet", - "name": template.name, - "project": ctx.project.name, - "output": path, - "status": "pulled", - }))? - ); - } else { - print_command_status( - CommandStatus::Success, - &format!("Pulled facet '{}' to {}", template.name, path.display()), - ); - } - } - - Ok(()) -} - -async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: FacetTemplate = with_spinner( - "Loading facet template...", - resource_template::read(args.file()?, "facet"), - ) - .await?; - let name = push_name(args.name.as_deref(), &template.name)?; - - let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; - let pushed: RemoteFunction = with_spinner( - "Pushing facet...", - push_template( - &ctx.client, - &ctx.project.id, - &template, - Some(name), - args.topics_automation.as_deref(), - ), - ) - .await - .with_context(|| { - format!( - "failed to push facet '{}' to project '{}'", - name, ctx.project.name - ) - })?; - - if base.json { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ - "id": pushed.id, - "kind": "facet", - "name": pushed.name, - "project": ctx.project.name, - "slug": pushed.slug, - "status": "pushed", - }))? - ); - } else { - print_command_status( - CommandStatus::Success, - &format!( - "Pushed facet '{}' to project '{}'", - pushed.name, ctx.project.name - ), - ); - } - - Ok(()) -} - -pub(crate) async fn list_templates( +pub(super) async fn list_templates( client: &ApiClient, project_id: &str, ) -> Result> { @@ -372,15 +145,14 @@ async fn list_all(client: &ApiClient, project_id: &str) -> Result, topics_automation: Option<&str>, ) -> Result { validate_template(template)?; - let name = push_name(override_name, &template.name)?; + let name = template.name.trim(); let existing = get_facet_by_slug(client, project_id, &template.slug).await?; let topics_setup = if existing.is_none() { Some(resolve_topics_setup(client, project_id, topics_automation).await?) @@ -701,31 +473,6 @@ fn topics_config_with_functions( Ok(Value::Object(config)) } -async fn get_facet_by_name( - client: &ApiClient, - project_id: &str, - name: &str, -) -> Result> { - let path = format!( - "/v1/function?project_id={}&name={}", - encode(project_id), - encode(name) - ); - let response: ListResponse = client - .get(&path) - .await - .with_context(|| format!("failed to list facets via {path}"))?; - - let mut matches = response.objects.into_iter().filter(|function| { - function.name == name && function.function_type.as_deref() == Some("facet") - }); - let found = matches.next(); - if matches.next().is_some() { - bail!("multiple facets named '{name}' found in the selected project"); - } - Ok(found) -} - async fn get_function_by_id(client: &ApiClient, id: &str) -> Result { let path = format!("/v1/function/{}", encode(id)); client @@ -760,8 +507,6 @@ async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Res let (function_data, preprocessor) = make_preprocessor_reference_portable(client, remote.function_data).await?; let template = FacetTemplate { - kind: TemplateKind::Facet, - schema_version: SCHEMA_VERSION, name: remote.name, slug: remote.slug, description: remote.description, @@ -814,8 +559,6 @@ fn preprocessor_template_from_remote(remote: &RemoteFunction) -> Result Result<()> { - resource_template::validate_version(template.schema_version)?; +pub(super) fn validate_template(template: &FacetTemplate) -> Result<()> { if template.name.trim().is_empty() { bail!("facet template name must not be empty"); } @@ -937,7 +679,6 @@ pub(crate) fn validate_template(template: &FacetTemplate) -> Result<()> { } fn validate_preprocessor_template(template: &PreprocessorTemplate) -> Result<()> { - resource_template::validate_version(template.schema_version)?; if template.name.trim().is_empty() { bail!("preprocessor template name must not be empty"); } @@ -951,31 +692,21 @@ fn validate_preprocessor_template(template: &PreprocessorTemplate) -> Result<()> } impl FacetTemplate { - pub(crate) fn name(&self) -> &str { + pub(super) fn name(&self) -> &str { &self.name } - pub(crate) fn slug(&self) -> &str { + pub(super) fn slug(&self) -> &str { &self.slug } } -fn push_name<'a>(override_name: Option<&'a str>, template_name: &'a str) -> Result<&'a str> { - match override_name { - Some(name) if name.trim().is_empty() => bail!("facet name override must not be empty"), - Some(name) => Ok(name.trim()), - None => Ok(template_name), - } -} - #[cfg(test)] mod tests { use super::*; fn facet_template() -> FacetTemplate { FacetTemplate { - kind: TemplateKind::Facet, - schema_version: SCHEMA_VERSION, name: "test-facet".to_string(), slug: "test-facet".to_string(), description: Some("A synthetic facet".to_string()), @@ -1044,10 +775,10 @@ mod tests { fn facet_template_contains_only_portable_identity() { let value = serde_json::to_value(facet_template()).expect("json"); - assert_eq!(value["kind"], "facet"); - assert_eq!(value["schema_version"], 1); assert_eq!(value["name"], "test-facet"); assert_eq!(value["slug"], "test-facet"); + assert!(value.get("kind").is_none()); + assert!(value.get("schema_version").is_none()); assert!(value.get("id").is_none()); assert!(value.get("project_id").is_none()); assert!(value.get("user_id").is_none()); @@ -1087,7 +818,6 @@ mod tests { validate_template(&template).expect("bundled preprocessor"); let value = serde_json::to_value(template).expect("JSON"); - assert_eq!(value["preprocessor"]["kind"], "preprocessor"); assert_eq!(value["preprocessor"]["slug"], "test-preprocessor"); assert_eq!(value["preprocessor"]["function_data"]["type"], "code"); assert!(value["preprocessor"].get("id").is_none()); @@ -1119,56 +849,6 @@ mod tests { assert!(err.to_string().contains("portable 'slug' field")); } - #[test] - fn pull_args_require_one_name_selector() { - let args = PullArgs { - name_positional: Some("test-facet".to_string()), - name_flag: None, - output: None, - }; - assert_eq!(args.name().expect("name"), "test-facet"); - - let both = PullArgs { - name_positional: Some("test-facet".to_string()), - name_flag: Some("other-facet".to_string()), - output: None, - }; - assert!(both.name().is_err()); - } - - #[test] - fn push_name_prefers_non_empty_override() { - assert_eq!( - push_name(Some("Renamed facet"), "test-facet").expect("override"), - "Renamed facet" - ); - assert_eq!( - push_name(None, "test-facet").expect("template"), - "test-facet" - ); - assert!(push_name(Some(" "), "test-facet").is_err()); - } - - #[test] - fn upsert_request_uses_overridden_name_without_changing_slug() { - let template = facet_template(); - let request = UpsertFunctionRequest { - project_id: "fake-project-id", - name: push_name(Some("Renamed facet"), &template.name).expect("name"), - slug: &template.slug, - description: template.description.as_deref(), - function_type: "facet", - function_data: &template.function_data, - prompt_data: template.prompt_data.as_ref(), - tags: template.tags.as_deref(), - function_schema: template.function_schema.as_ref(), - }; - - let value = serde_json::to_value(request).expect("request JSON"); - assert_eq!(value["name"], "Renamed facet"); - assert_eq!(value["slug"], "test-facet"); - } - #[test] fn requires_topics_before_creating_a_facet() { let err = select_topic_automation(Vec::new(), None).expect_err("Topics required"); diff --git a/src/resource_template.rs b/src/active_observability_template/io.rs similarity index 78% rename from src/resource_template.rs rename to src/active_observability_template/io.rs index 2ab0086e..60449b61 100644 --- a/src/resource_template.rs +++ b/src/active_observability_template/io.rs @@ -7,10 +7,10 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::http::{build_http_client, DEFAULT_HTTP_TIMEOUT}; use crate::utils::write_json_atomic; -pub(crate) const SCHEMA_VERSION: u32 = 1; +pub(super) const SCHEMA_VERSION: u32 = 1; const MAX_TEMPLATE_BYTES: u64 = 10 * 1024 * 1024; -pub(crate) async fn read(source: &str, expected_kind: &str) -> Result { +pub(super) async fn read(source: &str) -> Result { let contents = if source == "-" { let mut contents = String::new(); io::stdin() @@ -36,12 +36,6 @@ pub(crate) async fn read(source: &str, expected_kind: &str) ) } })?; - let actual_kind = value.get("kind").and_then(serde_json::Value::as_str); - if actual_kind != Some(expected_kind) { - let actual = actual_kind.unwrap_or(""); - bail!("expected a {expected_kind} template, but template kind is '{actual}'"); - } - serde_json::from_value(value).context("template does not match the expected schema") } @@ -76,7 +70,7 @@ async fn fetch_url(source: &str) -> Result { String::from_utf8(bytes.to_vec()).context("template URL response is not valid UTF-8") } -pub(crate) fn validate_version(version: u32) -> Result<()> { +pub(super) fn validate_version(version: u32) -> Result<()> { if version != SCHEMA_VERSION { bail!( "unsupported template schema version {version}; this version of bt supports schema version {SCHEMA_VERSION}" @@ -85,7 +79,7 @@ pub(crate) fn validate_version(version: u32) -> Result<()> { Ok(()) } -pub(crate) fn write(value: &T, output: Option<&Path>) -> Result<()> { +pub(super) fn write(value: &T, output: Option<&Path>) -> Result<()> { match output { Some(path) if path != Path::new("-") => write_json_atomic(path, value), _ => { @@ -117,39 +111,28 @@ mod tests { async fn reads_template_from_file() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("template.json"); - std::fs::write(&path, r#"{"kind":"facet","schema_version":1}"#).expect("write"); + std::fs::write( + &path, + r#"{"kind":"active_observability_template","schema_version":1}"#, + ) + .expect("write"); - let template: TestTemplate = read(path.to_str().expect("path"), "facet") - .await - .expect("template"); + let template: TestTemplate = read(path.to_str().expect("path")).await.expect("template"); assert_eq!( template, TestTemplate { - kind: "facet".to_string(), + kind: "active_observability_template".to_string(), schema_version: 1, } ); } - #[tokio::test] - async fn rejects_wrong_template_kind() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("template.json"); - std::fs::write(&path, r#"{"kind":"automation","schema_version":1}"#).expect("write"); - - let err = read::(path.to_str().expect("path"), "facet") - .await - .expect_err("kind mismatch"); - - assert!(err.to_string().contains("expected a facet template")); - } - #[tokio::test] async fn reads_template_from_http_url() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server"); let address = listener.local_addr().expect("test server address"); - let body = r#"{"kind":"facet","schema_version":1}"#; + let body = r#"{"kind":"active_observability_template","schema_version":1}"#; let server = thread::spawn(move || { let (mut stream, _) = listener.accept().expect("accept request"); let mut request = [0_u8; 1024]; @@ -162,12 +145,12 @@ mod tests { .expect("write response"); }); - let template: TestTemplate = read(&format!("http://{address}/template.json"), "facet") + let template: TestTemplate = read(&format!("http://{address}/template.json")) .await .expect("template"); server.join().expect("test server"); - assert_eq!(template.kind, "facet"); + assert_eq!(template.kind, "active_observability_template"); assert_eq!(template.schema_version, SCHEMA_VERSION); } @@ -194,14 +177,14 @@ mod tests { let path = PathBuf::from(dir.path()).join("nested/template.json"); write( - &serde_json::json!({"kind": "facet", "schema_version": 1}), + &serde_json::json!({"kind": "active_observability_template", "schema_version": 1}), Some(&path), ) .expect("write"); assert_eq!( std::fs::read_to_string(path).expect("read"), - "{\n \"kind\": \"facet\",\n \"schema_version\": 1\n}\n" + "{\n \"kind\": \"active_observability_template\",\n \"schema_version\": 1\n}\n" ); } } diff --git a/src/active_observability_template.rs b/src/active_observability_template/mod.rs similarity index 88% rename from src/active_observability_template.rs rename to src/active_observability_template/mod.rs index 893dad0b..c2367c7a 100644 --- a/src/active_observability_template.rs +++ b/src/active_observability_template/mod.rs @@ -1,3 +1,7 @@ +mod automation; +mod facet; +mod io; + use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -7,12 +11,12 @@ use dialoguer::{theme::ColorfulTheme, MultiSelect}; use serde::{Deserialize, Serialize}; use crate::args::BaseArgs; -use crate::automation::{self, AutomationTemplate, RemoteAutomation}; -use crate::facet::{self, FacetTemplate, RemoteFunction}; use crate::project_context::resolve_project_command_context_with_auth_mode; -use crate::resource_template::{self, SCHEMA_VERSION}; use crate::ui::{self, print_command_status, with_spinner, CommandStatus}; +use self::automation::{AutomationTemplate, RemoteAutomation}; +use self::facet::{FacetTemplate, RemoteFunction}; + #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: @@ -93,11 +97,12 @@ impl PushArgs { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum TemplateKind { - ActiveObservabilityConfig, + ActiveObservabilityTemplate, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -struct ActiveObservabilityConfigTemplate { +#[serde(deny_unknown_fields)] +struct ActiveObservabilityTemplate { kind: TemplateKind, schema_version: u32, #[serde(default)] @@ -127,14 +132,14 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { } else { (facets, automations) }; - let template = ActiveObservabilityConfigTemplate { - kind: TemplateKind::ActiveObservabilityConfig, - schema_version: SCHEMA_VERSION, + let template = ActiveObservabilityTemplate { + kind: TemplateKind::ActiveObservabilityTemplate, + schema_version: io::SCHEMA_VERSION, facets, automations, }; - resource_template::write(&template, args.output.as_deref()).with_context(|| { + io::write(&template, args.output.as_deref()).with_context(|| { args.output.as_ref().map_or_else( || "failed to write active observability template to stdout".to_string(), |path| { @@ -157,7 +162,7 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { serde_json::to_string(&serde_json::json!({ "automation_count": template.automations.len(), "facet_count": template.facets.len(), - "kind": "active_observability_config", + "kind": "active_observability_template", "output": path, "project": ctx.project.name, "status": "pulled", @@ -235,9 +240,9 @@ fn filter_resources( } async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { - let template: ActiveObservabilityConfigTemplate = with_spinner( + let template: ActiveObservabilityTemplate = with_spinner( "Loading active observability template...", - resource_template::read(args.file()?, "active_observability_config"), + io::read(args.file()?), ) .await?; validate_template(&template)?; @@ -279,7 +284,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { "slug": facet.slug, })) .collect::>(), - "kind": "active_observability_config", + "kind": "active_observability_template", "project": ctx.project.name, "status": "pushed", }))? @@ -302,13 +307,13 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { async fn push_resources( client: &crate::http::ApiClient, project_id: &str, - template: &ActiveObservabilityConfigTemplate, + template: &ActiveObservabilityTemplate, topics_automation: Option<&str>, ) -> Result<(Vec, Vec)> { let mut pushed_facets = Vec::with_capacity(template.facets.len()); for facet in &template.facets { pushed_facets.push( - facet::push_template(client, project_id, facet, None, topics_automation) + facet::push_template(client, project_id, facet, topics_automation) .await .with_context(|| format!("failed to push facet '{}'", facet.name()))?, ); @@ -317,7 +322,7 @@ async fn push_resources( let mut pushed_automations = Vec::with_capacity(template.automations.len()); for automation in &template.automations { pushed_automations.push( - automation::push_template(client, project_id, automation, None) + automation::push_template(client, project_id, automation) .await .with_context(|| { format!("failed to push Loop automation '{}'", automation.name()) @@ -328,8 +333,8 @@ async fn push_resources( Ok((pushed_facets, pushed_automations)) } -fn validate_template(template: &ActiveObservabilityConfigTemplate) -> Result<()> { - resource_template::validate_version(template.schema_version)?; +fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { + io::validate_version(template.schema_version)?; let mut facet_slugs = HashSet::new(); for facet in &template.facets { @@ -362,11 +367,9 @@ mod tests { fn active_observability_template_json() -> serde_json::Value { serde_json::json!({ - "kind": "active_observability_config", + "kind": "active_observability_template", "schema_version": 1, "facets": [{ - "kind": "facet", - "schema_version": 1, "name": "test-facet", "slug": "test-facet", "function_data": { @@ -375,8 +378,6 @@ mod tests { } }], "automations": [{ - "kind": "automation", - "schema_version": 1, "name": "test-loop", "config": { "event_type": "windowed", @@ -388,7 +389,7 @@ mod tests { #[test] fn validates_active_observability_template() { - let template: ActiveObservabilityConfigTemplate = + let template: ActiveObservabilityTemplate = serde_json::from_value(active_observability_template_json()) .expect("active observability template"); @@ -399,12 +400,11 @@ mod tests { #[test] fn defaults_missing_resource_arrays_to_empty() { - let template: ActiveObservabilityConfigTemplate = - serde_json::from_value(serde_json::json!({ - "kind": "active_observability_config", - "schema_version": 1 - })) - .expect("empty active observability template"); + let template: ActiveObservabilityTemplate = serde_json::from_value(serde_json::json!({ + "kind": "active_observability_template", + "schema_version": 1 + })) + .expect("empty active observability template"); validate_template(&template).expect("valid empty active observability template"); assert!(template.facets.is_empty()); @@ -413,7 +413,7 @@ mod tests { #[test] fn filters_resources_by_picker_index() { - let template: ActiveObservabilityConfigTemplate = + let template: ActiveObservabilityTemplate = serde_json::from_value(active_observability_template_json()) .expect("active observability template"); @@ -429,13 +429,24 @@ mod tests { let mut value = active_observability_template_json(); let facet = value["facets"][0].clone(); value["facets"].as_array_mut().expect("facets").push(facet); - let template: ActiveObservabilityConfigTemplate = + let template: ActiveObservabilityTemplate = serde_json::from_value(value).expect("active observability template"); let err = validate_template(&template).expect_err("duplicate facet slug"); assert!(err.to_string().contains("duplicate facet slug")); } + #[test] + fn rejects_unknown_template_fields() { + let mut value = active_observability_template_json(); + value["automation"] = value["automations"].clone(); + + let err = serde_json::from_value::(value) + .expect_err("unknown root field"); + + assert!(err.to_string().contains("unknown field `automation`")); + } + #[test] fn push_args_accept_one_source() { let positional = PushArgs { diff --git a/src/main.rs b/src/main.rs index d9922ecf..63f19fdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ use std::ffi::{OsStr, OsString}; mod active_observability_template; mod args; mod auth; -mod automation; #[allow(dead_code)] mod config; mod datasets; @@ -13,7 +12,6 @@ mod env; #[cfg(unix)] mod eval; mod experiments; -mod facet; mod functions; mod http; mod init; @@ -23,7 +21,6 @@ mod project_context; mod projects; mod prompts; mod python_runner; -mod resource_template; mod runner_sse; mod scorers; mod self_update; @@ -72,8 +69,6 @@ Core Projects & resources projects Manage projects active-observability-template Pull and push portable active observability templates - automation Pull and push Loop automation templates - facet Pull and push facet templates topics Inspect and control Topics automation datasets Manage datasets prompts Manage prompts @@ -158,12 +153,6 @@ enum Commands { ActiveObservabilityTemplate( CLIArgs, ), - #[command(visible_alias = "automations")] - /// Pull and push Loop automation templates - Automation(CLIArgs), - #[command(visible_alias = "facets")] - /// Pull and push facet templates - Facet(CLIArgs), /// Inspect and control Topics automation Topics(CLIArgs), /// Manage datasets @@ -212,8 +201,6 @@ impl Commands { Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, Commands::ActiveObservabilityTemplate(cmd) => &cmd.base, - Commands::Automation(cmd) => &cmd.base, - Commands::Facet(cmd) => &cmd.base, Commands::Topics(cmd) => &cmd.base, Commands::Datasets(cmd) => &cmd.base, Commands::Prompts(cmd) => &cmd.base, @@ -245,8 +232,6 @@ impl Commands { Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, Commands::ActiveObservabilityTemplate(cmd) => &mut cmd.base, - Commands::Automation(cmd) => &mut cmd.base, - Commands::Facet(cmd) => &mut cmd.base, Commands::Datasets(cmd) => &mut cmd.base, Commands::Topics(cmd) => &mut cmd.base, Commands::Prompts(cmd) => &mut cmd.base, @@ -383,8 +368,6 @@ fn try_main() -> Result<()> { Commands::ActiveObservabilityTemplate(cmd) => { active_observability_template::run(cmd.base, cmd.args).await? } - Commands::Automation(cmd) => automation::run(cmd.base, cmd.args).await?, - Commands::Facet(cmd) => facet::run(cmd.base, cmd.args).await?, Commands::Datasets(cmd) => datasets::run(cmd.base, cmd.args).await?, Commands::Topics(cmd) => topics::run(cmd.base, cmd.args).await?, Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, @@ -717,72 +700,6 @@ mod tests { } } - #[test] - fn automation_template_commands_and_alias_parse() { - for args in [ - vec!["bt", "automation", "pull", "test-loop"], - vec!["bt", "automation", "pull", "--name", "test-loop"], - vec!["bt", "automation", "push", "test-loop.automation.json"], - vec![ - "bt", - "automation", - "push", - "test-loop.automation.json", - "--name", - "renamed-loop", - ], - vec![ - "bt", - "automation", - "push", - "https://example.com/test-loop.automation.json", - ], - vec![ - "bt", - "automations", - "push", - "--file", - "test-loop.automation.json", - ], - ] { - Cli::try_parse_from(args).expect("automation command should parse"); - } - } - - #[test] - fn facet_template_commands_and_alias_parse() { - for args in [ - vec!["bt", "facet", "pull", "test-facet"], - vec!["bt", "facet", "pull", "--name", "test-facet"], - vec!["bt", "facet", "push", "test-facet.facet.json"], - vec![ - "bt", - "facet", - "push", - "test-facet.facet.json", - "--topics-automation", - "Topics", - ], - vec![ - "bt", - "facet", - "push", - "test-facet.facet.json", - "--name", - "Renamed facet", - ], - vec![ - "bt", - "facet", - "push", - "https://example.com/test-facet.facet.json", - ], - vec!["bt", "facets", "push", "--file", "test-facet.facet.json"], - ] { - Cli::try_parse_from(args).expect("facet command should parse"); - } - } - #[test] fn active_observability_template_commands_parse() { for args in [ @@ -826,6 +743,15 @@ mod tests { } } + #[test] + fn individual_active_observability_resource_commands_are_not_exposed() { + for command in ["automation", "automations", "facet", "facets"] { + let err = Cli::try_parse_from(["bt", command, "pull"]) + .expect_err("individual resource command should not parse"); + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); + } + } + #[test] fn default_verbose_output_is_not_explicit_verbose() { let matches = Cli::command() From c06ca2e4f60f9434bdf9040ea47916ee9a636120 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 12:32:58 -0700 Subject: [PATCH 08/13] add pagination helper --- .../automation.rs | 55 ++--------- src/active_observability_template/facet.rs | 92 ++++--------------- src/active_observability_template/mod.rs | 1 + .../pagination.rs | 85 +++++++++++++++++ 4 files changed, 111 insertions(+), 122 deletions(-) create mode 100644 src/active_observability_template/pagination.rs diff --git a/src/active_observability_template/automation.rs b/src/active_observability_template/automation.rs index db231de2..3c8ce402 100644 --- a/src/active_observability_template/automation.rs +++ b/src/active_observability_template/automation.rs @@ -5,6 +5,8 @@ use urlencoding::encode; use crate::http::ApiClient; +use super::pagination; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub(super) struct AutomationTemplate { @@ -23,15 +25,6 @@ pub(super) struct RemoteAutomation { config: Value, } -#[derive(Debug, Deserialize)] -struct ListResponse { - objects: Vec, - #[serde(default)] - next_cursor: Option, - #[serde(default)] - snapshot: Option, -} - #[derive(Debug, Serialize)] struct UpsertAutomationRequest<'a> { project_id: &'a str, @@ -44,7 +37,9 @@ pub(super) async fn list_templates( client: &ApiClient, project_id: &str, ) -> Result> { - let automations = list_all(client, project_id).await?; + let path = format!("/v1/project_automation?project_id={}", encode(project_id)); + let automations: Vec = + pagination::list_all(client, &path, "automations").await?; let mut templates = automations .into_iter() .filter(|automation| is_loop_config(&automation.config)) @@ -54,36 +49,6 @@ pub(super) async fn list_templates( Ok(templates) } -async fn list_all(client: &ApiClient, project_id: &str) -> Result> { - let mut objects = Vec::new(); - let mut cursor: Option = None; - let mut snapshot: Option = None; - - loop { - let mut path = format!("/v1/project_automation?project_id={}", encode(project_id)); - if let Some(cursor) = cursor.as_deref() { - path.push_str(&format!("&cursor={}", encode(cursor))); - } - if let Some(snapshot) = snapshot.as_deref() { - path.push_str(&format!("&snapshot={}", encode(snapshot))); - } - - let response: ListResponse = client - .get(&path) - .await - .with_context(|| format!("failed to list automations via {path}"))?; - objects.extend(response.objects); - snapshot = response.snapshot.or(snapshot); - match response.next_cursor { - Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { - cursor = Some(next_cursor); - } - Some(_) => bail!("automation list returned a repeated cursor"), - None => return Ok(objects), - } - } -} - pub(super) async fn push_template( client: &ApiClient, project_id: &str, @@ -118,13 +83,9 @@ async fn get_by_name( encode(project_id), encode(name) ); - let response: ListResponse = client - .get(&path) - .await - .with_context(|| format!("failed to list automations via {path}"))?; - - let mut matches = response - .objects + let automations: Vec = + pagination::list_all(client, &path, "automations").await?; + let mut matches = automations .into_iter() .filter(|automation| automation.name == name); let found = matches.next(); diff --git a/src/active_observability_template/facet.rs b/src/active_observability_template/facet.rs index 18b430a3..87ccb166 100644 --- a/src/active_observability_template/facet.rs +++ b/src/active_observability_template/facet.rs @@ -5,6 +5,8 @@ use urlencoding::encode; use crate::http::ApiClient; +use super::pagination; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] struct PreprocessorTemplate { @@ -57,15 +59,6 @@ pub(super) struct RemoteFunction { function_schema: Option, } -#[derive(Debug, Deserialize)] -struct ListResponse { - objects: Vec, - #[serde(default)] - next_cursor: Option, - #[serde(default)] - snapshot: Option, -} - #[derive(Debug, Serialize)] struct UpsertFunctionRequest<'a> { project_id: &'a str, @@ -100,8 +93,9 @@ pub(super) async fn list_templates( project_id: &str, ) -> Result> { let mut templates = Vec::new(); - for facet in list_all(client, project_id) - .await? + let path = format!("/v1/function?project_id={}", encode(project_id)); + let functions: Vec = pagination::list_all(client, &path, "functions").await?; + for facet in functions .into_iter() .filter(|function| function.function_type.as_deref() == Some("facet")) { @@ -115,36 +109,6 @@ pub(super) async fn list_templates( Ok(templates) } -async fn list_all(client: &ApiClient, project_id: &str) -> Result> { - let mut objects = Vec::new(); - let mut cursor: Option = None; - let mut snapshot: Option = None; - - loop { - let mut path = format!("/v1/function?project_id={}", encode(project_id)); - if let Some(cursor) = cursor.as_deref() { - path.push_str(&format!("&cursor={}", encode(cursor))); - } - if let Some(snapshot) = snapshot.as_deref() { - path.push_str(&format!("&snapshot={}", encode(snapshot))); - } - - let response: ListResponse = client - .get(&path) - .await - .with_context(|| format!("failed to list facets via {path}"))?; - objects.extend(response.objects); - snapshot = response.snapshot.or(snapshot); - match response.next_cursor { - Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { - cursor = Some(next_cursor); - } - Some(_) => bail!("function list returned a repeated cursor"), - None => return Ok(objects), - } - } -} - pub(super) async fn push_template( client: &ApiClient, project_id: &str, @@ -230,11 +194,10 @@ async fn get_facet_by_slug( encode(project_id), encode(slug) ); - let response: ListResponse = client - .get(&path) + let functions: Vec = pagination::list_all(client, &path, "functions") .await .with_context(|| format!("failed to check for an existing facet with slug '{slug}'"))?; - let mut matches = response.objects.into_iter().filter(|function| { + let mut matches = functions.into_iter().filter(|function| { function.slug == slug && function.function_type.as_deref() == Some("facet") }); let found = matches.next(); @@ -262,35 +225,15 @@ async fn list_topic_automations( client: &ApiClient, project_id: &str, ) -> Result> { - let mut objects = Vec::new(); - let mut cursor: Option = None; - let mut snapshot: Option = None; - - loop { - let mut path = format!("/v1/project_automation?project_id={}", encode(project_id)); - if let Some(cursor) = cursor.as_deref() { - path.push_str(&format!("&cursor={}", encode(cursor))); - } - if let Some(snapshot) = snapshot.as_deref() { - path.push_str(&format!("&snapshot={}", encode(snapshot))); - } - - let response: ListResponse = client - .get(&path) - .await - .with_context(|| format!("failed to list Topics automations via {path}"))?; - objects.extend(response.objects.into_iter().filter(|automation| { + let path = format!("/v1/project_automation?project_id={}", encode(project_id)); + let automations: Vec = + pagination::list_all(client, &path, "Topics automations").await?; + Ok(automations + .into_iter() + .filter(|automation| { automation.config.get("event_type").and_then(Value::as_str) == Some("topic") - })); - snapshot = response.snapshot.or(snapshot); - match response.next_cursor { - Some(next_cursor) if Some(next_cursor.as_str()) != cursor.as_deref() => { - cursor = Some(next_cursor); - } - Some(_) => bail!("Topics automation list returned a repeated cursor"), - None => return Ok(objects), - } - } + }) + .collect()) } fn select_topic_automation( @@ -491,11 +434,10 @@ async fn get_preprocessor_by_slug( encode(project_id), encode(slug) ); - let response: ListResponse = client - .get(&path) + let functions: Vec = pagination::list_all(client, &path, "functions") .await .with_context(|| format!("failed to resolve preprocessor with slug '{slug}'"))?; - Ok(response.objects.into_iter().find(|function| { + Ok(functions.into_iter().find(|function| { function.slug == slug && function.function_type.as_deref() == Some("preprocessor") })) } diff --git a/src/active_observability_template/mod.rs b/src/active_observability_template/mod.rs index c2367c7a..870870f7 100644 --- a/src/active_observability_template/mod.rs +++ b/src/active_observability_template/mod.rs @@ -1,6 +1,7 @@ mod automation; mod facet; mod io; +mod pagination; use std::collections::HashSet; use std::path::{Path, PathBuf}; diff --git a/src/active_observability_template/pagination.rs b/src/active_observability_template/pagination.rs new file mode 100644 index 00000000..ec3c47be --- /dev/null +++ b/src/active_observability_template/pagination.rs @@ -0,0 +1,85 @@ +use std::collections::HashSet; + +use anyhow::{bail, Context, Result}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use urlencoding::encode; + +use crate::http::ApiClient; + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, + #[serde(default)] + next_cursor: Option, + #[serde(default)] + snapshot: Option, +} + +pub(super) async fn list_all( + client: &ApiClient, + base_path: &str, + resource_label: &str, +) -> Result> +where + T: DeserializeOwned, +{ + let mut objects = Vec::new(); + let mut cursor: Option = None; + let mut snapshot: Option = None; + let mut seen_cursors = HashSet::new(); + + loop { + let path = page_path(base_path, cursor.as_deref(), snapshot.as_deref()); + let response: ListResponse = client + .get(&path) + .await + .with_context(|| format!("failed to list {resource_label} via {path}"))?; + objects.extend(response.objects); + snapshot = response.snapshot.or(snapshot); + + match response.next_cursor.filter(|cursor| !cursor.is_empty()) { + Some(next_cursor) if seen_cursors.insert(next_cursor.clone()) => { + cursor = Some(next_cursor); + } + Some(_) => bail!("{resource_label} list returned a repeated cursor"), + None => return Ok(objects), + } + } +} + +fn page_path(base_path: &str, cursor: Option<&str>, snapshot: Option<&str>) -> String { + let mut path = base_path.to_string(); + let mut separator = if base_path.contains('?') { '&' } else { '?' }; + for (key, value) in [("cursor", cursor), ("snapshot", snapshot)] { + if let Some(value) = value { + path.push(separator); + path.push_str(key); + path.push('='); + path.push_str(&encode(value)); + separator = '&'; + } + } + path +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_encoded_pagination_parameters() { + assert_eq!( + page_path( + "/v1/function?project_id=test-project-id", + Some("next cursor"), + Some("snapshot/value"), + ), + "/v1/function?project_id=test-project-id&cursor=next%20cursor&snapshot=snapshot%2Fvalue" + ); + assert_eq!( + page_path("/v1/function", Some("next"), None), + "/v1/function?cursor=next" + ); + } +} From 49069a34b5a7da665f3f2591cde7c7fb2b612039 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 13:59:53 -0700 Subject: [PATCH 09/13] cleanup --- .../automation.rs | 49 +++- src/active_observability_template/facet.rs | 264 +++++++++++++----- src/active_observability_template/io.rs | 37 ++- src/active_observability_template/mod.rs | 215 +++++++++++++- .../pagination.rs | 73 ++++- 5 files changed, 548 insertions(+), 90 deletions(-) diff --git a/src/active_observability_template/automation.rs b/src/active_observability_template/automation.rs index 3c8ce402..38cec128 100644 --- a/src/active_observability_template/automation.rs +++ b/src/active_observability_template/automation.rs @@ -5,7 +5,7 @@ use urlencoding::encode; use crate::http::ApiClient; -use super::pagination; +use super::{pagination, IfExistsMode}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -53,10 +53,19 @@ pub(super) async fn push_template( client: &ApiClient, project_id: &str, template: &AutomationTemplate, + if_exists: IfExistsMode, ) -> Result { validate_template(template)?; let name = template.name.trim(); let existing = get_by_name(client, project_id, name).await?; + if let Some(existing) = existing.as_ref() { + ensure_existing_is_loop(existing, name)?; + match if_exists { + IfExistsMode::Error => bail!(existing_resource_error(name)), + IfExistsMode::Ignore => return Ok(existing.clone()), + IfExistsMode::Replace => {} + } + } let config = config_for_push( template.config.clone(), existing.as_ref().map(|automation| &automation.config), @@ -73,6 +82,37 @@ pub(super) async fn push_template( .with_context(|| format!("failed to push automation '{name}'")) } +pub(super) async fn preflight_push( + client: &ApiClient, + project_id: &str, + template: &AutomationTemplate, + if_exists: IfExistsMode, +) -> Result<()> { + let name = template.name.trim(); + if let Some(existing) = get_by_name(client, project_id, name).await? { + ensure_existing_is_loop(&existing, name)?; + if if_exists == IfExistsMode::Error { + bail!(existing_resource_error(name)); + } + } + Ok(()) +} + +fn ensure_existing_is_loop(existing: &RemoteAutomation, name: &str) -> Result<()> { + if !is_loop_config(&existing.config) { + bail!( + "automation '{name}' already exists but is not a Loop automation; rename the template resource to avoid replacing a different automation type" + ); + } + Ok(()) +} + +fn existing_resource_error(name: &str) -> String { + format!( + "Loop automation '{name}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it" + ) +} + async fn get_by_name( client: &ApiClient, project_id: &str, @@ -133,6 +173,9 @@ pub(super) fn validate_template(template: &AutomationTemplate) -> Result<()> { if template.name.trim().is_empty() { bail!("automation template name must not be empty"); } + if template.name != template.name.trim() { + bail!("automation template name must not have leading or trailing whitespace"); + } validate_loop_config(&template.config, &template.name) } @@ -273,5 +316,9 @@ mod tests { let mut template = template_from_remote(remote_loop()).expect("template"); template.name = " ".to_string(); assert!(validate_template(&template).is_err()); + + template.name = " test-loop".to_string(); + let err = validate_template(&template).expect_err("leading whitespace"); + assert!(err.to_string().contains("leading or trailing whitespace")); } } diff --git a/src/active_observability_template/facet.rs b/src/active_observability_template/facet.rs index 87ccb166..a9a36225 100644 --- a/src/active_observability_template/facet.rs +++ b/src/active_observability_template/facet.rs @@ -5,11 +5,11 @@ use urlencoding::encode; use crate::http::ApiClient; -use super::pagination; +use super::{pagination, IfExistsMode}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] -struct PreprocessorTemplate { +pub(super) struct PreprocessorTemplate { name: String, slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -114,80 +114,145 @@ pub(super) async fn push_template( project_id: &str, template: &FacetTemplate, topics_automation: Option<&str>, + if_exists: IfExistsMode, + preprocessor_if_exists: IfExistsMode, ) -> Result { validate_template(template)?; let name = template.name.trim(); let existing = get_facet_by_slug(client, project_id, &template.slug).await?; - let topics_setup = if existing.is_none() { - Some(resolve_topics_setup(client, project_id, topics_automation).await?) + if existing.is_some() && if_exists == IfExistsMode::Error { + bail!(existing_facet_error(&template.slug)); + } + let keep_existing_facet = existing.is_some() && if_exists == IfExistsMode::Ignore; + let topics_setup = resolve_topics_setup(client, project_id, topics_automation).await?; + if !keep_existing_facet { + if let Some(preprocessor) = template.preprocessor.as_ref() { + push_preprocessor_template(client, project_id, preprocessor, preprocessor_if_exists) + .await + .with_context(|| { + format!( + "failed to push preprocessor '{}' required by facet '{name}'", + preprocessor.name + ) + })?; + } + } + let pushed = if let (Some(existing), true) = (existing, keep_existing_facet) { + existing } else { - None - }; - if let Some(preprocessor) = template.preprocessor.as_ref() { - push_preprocessor_template(client, project_id, preprocessor) + let function_data = + resolve_preprocessor_reference(client, project_id, template.function_data.clone()) + .await?; + let request = UpsertFunctionRequest { + project_id, + name, + slug: &template.slug, + description: template.description.as_deref(), + function_type: "facet", + function_data: &function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), + }; + client + .put("/v1/function", &request) .await - .with_context(|| { - format!( - "failed to push preprocessor '{}' required by facet '{name}'", - preprocessor.name - ) - })?; - } - let function_data = - resolve_preprocessor_reference(client, project_id, template.function_data.clone()).await?; - let request = UpsertFunctionRequest { - project_id, - name, - slug: &template.slug, - description: template.description.as_deref(), - function_type: "facet", - function_data: &function_data, - prompt_data: template.prompt_data.as_ref(), - tags: template.tags.as_deref(), - function_schema: template.function_schema.as_ref(), + .with_context(|| format!("failed to push facet '{name}'"))? }; - let pushed: RemoteFunction = client - .put("/v1/function", &request) - .await - .with_context(|| format!("failed to push facet '{name}'"))?; - if let Some(topics_setup) = topics_setup { - let topic_map_id = create_topic_map_function( - client, - project_id, - &pushed, - template.description.as_deref(), - &topics_setup.embedding_model, + let topic_map_id = ensure_topic_map_function( + client, + project_id, + &pushed, + template.description.as_deref(), + &topics_setup.embedding_model, + ) + .await + .with_context(|| { + format!( + "facet '{}' was pushed, but its topic map could not be reconciled", + pushed.name ) - .await - .with_context(|| { - format!( - "facet '{}' was created, but its topic map could not be created", - pushed.name - ) - })?; - attach_facet_to_topics_automation( - client, - &topics_setup.automation, - &pushed.id, - &topic_map_id, + })?; + attach_facet_to_topics_automation( + client, + &topics_setup.automation, + &pushed.id, + &topic_map_id, + ) + .await + .with_context(|| { + format!( + "facet '{}' and its topic map were pushed, but the Topics automation could not be updated", + pushed.name ) - .await - .with_context(|| { - format!( - "facet '{}' and its topic map were created, but the Topics automation could not be updated", - pushed.name - ) - })?; - } + })?; Ok(pushed) } +pub(super) async fn preflight_push( + client: &ApiClient, + project_id: &str, + template: &FacetTemplate, + topics_automation: Option<&str>, + if_exists: IfExistsMode, +) -> Result<()> { + let existing = get_facet_by_slug(client, project_id, &template.slug).await?; + if existing.is_some() && if_exists == IfExistsMode::Error { + bail!(existing_facet_error(&template.slug)); + } + + if !(existing.is_some() && if_exists == IfExistsMode::Ignore) { + if let Some(preprocessor) = template.preprocessor.as_ref() { + if let Some(existing) = + get_function_by_slug(client, project_id, &preprocessor.slug).await? + { + ensure_function_type(&existing, "preprocessor", &preprocessor.slug)?; + if if_exists == IfExistsMode::Error { + bail!(existing_preprocessor_error(&preprocessor.slug)); + } + } + } + } + + if let Some(existing) = + get_function_by_slug(client, project_id, &topic_map_slug(&template.slug)).await? + { + ensure_topic_map_type(&existing)?; + } + resolve_topics_setup(client, project_id, topics_automation).await?; + Ok(()) +} + +fn existing_facet_error(slug: &str) -> String { + format!( + "facet with slug '{slug}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it and reconcile its Topics setup" + ) +} + +fn existing_preprocessor_error(slug: &str) -> String { + format!( + "preprocessor with slug '{slug}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it" + ) +} + async fn get_facet_by_slug( client: &ApiClient, project_id: &str, slug: &str, +) -> Result> { + let function = get_function_by_slug(client, project_id, slug).await?; + if let Some(function) = function.as_ref() { + ensure_function_type(function, "facet", slug)?; + } + Ok(function) +} + +async fn get_function_by_slug( + client: &ApiClient, + project_id: &str, + slug: &str, ) -> Result> { let path = format!( "/v1/function?project_id={}&slug={}", @@ -196,17 +261,27 @@ async fn get_facet_by_slug( ); let functions: Vec = pagination::list_all(client, &path, "functions") .await - .with_context(|| format!("failed to check for an existing facet with slug '{slug}'"))?; - let mut matches = functions.into_iter().filter(|function| { - function.slug == slug && function.function_type.as_deref() == Some("facet") - }); + .with_context(|| format!("failed to check for an existing function with slug '{slug}'"))?; + let mut matches = functions + .into_iter() + .filter(|function| function.slug == slug); let found = matches.next(); if matches.next().is_some() { - bail!("multiple facets with slug '{slug}' found in the selected project"); + bail!("multiple functions with slug '{slug}' found in the selected project"); } Ok(found) } +fn ensure_function_type(function: &RemoteFunction, expected: &str, slug: &str) -> Result<()> { + if function.function_type.as_deref() != Some(expected) { + bail!( + "function slug '{slug}' is already used by a '{}' function; choose a different slug instead of replacing a different function type", + function.function_type.as_deref().unwrap_or("") + ); + } + Ok(()) +} + async fn resolve_topics_setup( client: &ApiClient, project_id: &str, @@ -304,13 +379,18 @@ fn topic_map_function_ids(config: &Value) -> impl Iterator { .filter_map(|function| function.get("id").and_then(Value::as_str)) } -async fn create_topic_map_function( +async fn ensure_topic_map_function( client: &ApiClient, project_id: &str, facet: &RemoteFunction, description: Option<&str>, embedding_model: &str, ) -> Result { + let slug = topic_map_slug(&facet.slug); + if let Some(existing) = get_function_by_slug(client, project_id, &slug).await? { + ensure_topic_map_type(&existing)?; + return Ok(existing.id); + } let request = topic_map_insert_request(project_id, facet, description, embedding_model); let response: Value = client.post("/insert-functions", &request).await?; response @@ -323,6 +403,21 @@ async fn create_topic_map_function( .ok_or_else(|| anyhow!("unexpected response while creating the topic map function")) } +fn topic_map_slug(facet_slug: &str) -> String { + format!("{facet_slug}-topic-map") +} + +fn ensure_topic_map_type(function: &RemoteFunction) -> Result<()> { + ensure_function_type(function, "classifier", &function.slug)?; + if function.function_data.get("type").and_then(Value::as_str) != Some("topic_map") { + bail!( + "function slug '{}' is already used by a non-topic-map classifier; choose a different facet slug", + function.slug + ); + } + Ok(()) +} + fn topic_map_insert_request( project_id: &str, facet: &RemoteFunction, @@ -333,7 +428,7 @@ fn topic_map_insert_request( "functions": [{ "project_id": project_id, "name": facet.name, - "slug": format!("{}-topic-map", facet.slug), + "slug": topic_map_slug(&facet.slug), "description": description, "function_type": "classifier", "function_data": { @@ -429,17 +524,13 @@ async fn get_preprocessor_by_slug( project_id: &str, slug: &str, ) -> Result> { - let path = format!( - "/v1/function?project_id={}&slug={}", - encode(project_id), - encode(slug) - ); - let functions: Vec = pagination::list_all(client, &path, "functions") + let function = get_function_by_slug(client, project_id, slug) .await .with_context(|| format!("failed to resolve preprocessor with slug '{slug}'"))?; - Ok(functions.into_iter().find(|function| { - function.slug == slug && function.function_type.as_deref() == Some("preprocessor") - })) + if let Some(function) = function.as_ref() { + ensure_function_type(function, "preprocessor", slug)?; + } + Ok(function) } async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Result { @@ -517,8 +608,17 @@ async fn push_preprocessor_template( client: &ApiClient, project_id: &str, template: &PreprocessorTemplate, + if_exists: IfExistsMode, ) -> Result { validate_preprocessor_template(template)?; + if let Some(existing) = get_function_by_slug(client, project_id, &template.slug).await? { + ensure_function_type(&existing, "preprocessor", &template.slug)?; + match if_exists { + IfExistsMode::Error => bail!(existing_preprocessor_error(&template.slug)), + IfExistsMode::Ignore => return Ok(existing), + IfExistsMode::Replace => {} + } + } let request = UpsertFunctionRequest { project_id, name: &template.name, @@ -641,6 +741,22 @@ impl FacetTemplate { pub(super) fn slug(&self) -> &str { &self.slug } + + pub(super) fn preprocessor_slug(&self) -> Option<&str> { + self.preprocessor + .as_ref() + .map(|preprocessor| preprocessor.slug.as_str()) + } + + pub(super) fn preprocessor(&self) -> Option<&PreprocessorTemplate> { + self.preprocessor.as_ref() + } +} + +impl PreprocessorTemplate { + pub(super) fn slug(&self) -> &str { + &self.slug + } } #[cfg(test)] diff --git a/src/active_observability_template/io.rs b/src/active_observability_template/io.rs index 60449b61..15929cef 100644 --- a/src/active_observability_template/io.rs +++ b/src/active_observability_template/io.rs @@ -79,9 +79,21 @@ pub(super) fn validate_version(version: u32) -> Result<()> { Ok(()) } -pub(super) fn write(value: &T, output: Option<&Path>) -> Result<()> { +pub(super) fn write(value: &T, output: Option<&Path>, force: bool) -> Result<()> { match output { - Some(path) if path != Path::new("-") => write_json_atomic(path, value), + Some(path) if path != Path::new("-") => { + if !force + && path + .try_exists() + .with_context(|| format!("failed to check whether {} exists", path.display()))? + { + bail!( + "output file {} already exists; use --force to overwrite it", + path.display() + ); + } + write_json_atomic(path, value) + } _ => { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -179,6 +191,7 @@ mod tests { write( &serde_json::json!({"kind": "active_observability_template", "schema_version": 1}), Some(&path), + false, ) .expect("write"); @@ -187,4 +200,24 @@ mod tests { "{\n \"kind\": \"active_observability_template\",\n \"schema_version\": 1\n}\n" ); } + + #[test] + fn refuses_to_overwrite_without_force() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("template.json"); + std::fs::write(&path, "keep me").expect("write existing file"); + + let value = serde_json::json!({ + "kind": "active_observability_template", + "schema_version": 1 + }); + let err = write(&value, Some(&path), false).expect_err("overwrite requires force"); + assert!(err.to_string().contains("--force")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me"); + + write(&value, Some(&path), true).expect("forced overwrite"); + assert!(std::fs::read_to_string(path) + .expect("read overwritten file") + .contains("active_observability_template")); + } } diff --git a/src/active_observability_template/mod.rs b/src/active_observability_template/mod.rs index 870870f7..5236c10e 100644 --- a/src/active_observability_template/mod.rs +++ b/src/active_observability_template/mod.rs @@ -3,12 +3,12 @@ mod facet; mod io; mod pagination; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use clap::{Args, Subcommand}; -use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use clap::{Args, Subcommand, ValueEnum}; +use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect}; use serde::{Deserialize, Serialize}; use crate::args::BaseArgs; @@ -41,7 +41,7 @@ enum ActiveObservabilityTemplateCommand { /// without prompting. Project-specific preprocessors used by selected facets are /// included automatically. Automation destination actions are excluded. Pull(PullArgs), - /// Create or replace facets and Loop automations from an active observability template + /// Push facets and Loop automations from an active observability template Push(PushArgs), } @@ -55,6 +55,15 @@ struct PullArgs { value_name = "PATH" )] output: Option, + + /// Overwrite an existing output file + #[arg( + long, + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PULL_FORCE", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new() + )] + force: bool, } #[derive(Debug, Clone, Args)] @@ -72,13 +81,32 @@ struct PushArgs { )] file_flag: Option, - /// Topics automation name or ID to attach newly created facets to + /// Topics automation name or ID to attach facets to #[arg( long, env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_TOPICS_AUTOMATION", value_name = "NAME_OR_ID" )] topics_automation: Option, + + /// Behavior when a facet, preprocessor, or Loop automation already exists + #[arg( + long = "if-exists", + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_IF_EXISTS", + value_enum, + default_value = "error" + )] + if_exists: IfExistsMode, + + /// Skip the confirmation prompt + #[arg( + long, + short = 'y', + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_YES", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new() + )] + yes: bool, } impl PushArgs { @@ -95,6 +123,23 @@ impl PushArgs { } } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub(super) enum IfExistsMode { + Error, + Replace, + Ignore, +} + +impl IfExistsMode { + fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Replace => "replace", + Self::Ignore => "ignore", + } + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum TemplateKind { @@ -140,7 +185,7 @@ async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { automations, }; - io::write(&template, args.output.as_deref()).with_context(|| { + io::write(&template, args.output.as_deref(), args.force).with_context(|| { args.output.as_ref().map_or_else( || "failed to write active observability template to stdout".to_string(), |path| { @@ -249,6 +294,37 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { validate_template(&template)?; let ctx = resolve_project_command_context_with_auth_mode(&base, false).await?; + with_spinner( + "Checking target resources...", + preflight_resources( + &ctx.client, + &ctx.project.id, + &template, + args.topics_automation.as_deref(), + args.if_exists, + ), + ) + .await?; + + if !args.yes && ui::is_interactive() { + let prompt = push_confirmation_prompt( + ctx.client.org_name(), + &ctx.project.name, + &template, + args.if_exists, + ); + let term = + ui::prompt_term().ok_or_else(|| anyhow::anyhow!("interactive mode requires TTY"))?; + let confirmed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .default(false) + .interact_on(&term) + .context("failed to confirm active observability template push")?; + if !confirmed { + return Ok(()); + } + } + let (pushed_facets, pushed_automations) = with_spinner( "Pushing active observability template...", push_resources( @@ -256,6 +332,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { &ctx.project.id, &template, args.topics_automation.as_deref(), + args.if_exists, ), ) .await @@ -310,20 +387,33 @@ async fn push_resources( project_id: &str, template: &ActiveObservabilityTemplate, topics_automation: Option<&str>, + if_exists: IfExistsMode, ) -> Result<(Vec, Vec)> { let mut pushed_facets = Vec::with_capacity(template.facets.len()); + let mut pushed_preprocessors = HashSet::new(); for facet in &template.facets { + let preprocessor_if_exists = match facet.preprocessor_slug() { + Some(slug) if !pushed_preprocessors.insert(slug) => IfExistsMode::Ignore, + _ => if_exists, + }; pushed_facets.push( - facet::push_template(client, project_id, facet, topics_automation) - .await - .with_context(|| format!("failed to push facet '{}'", facet.name()))?, + facet::push_template( + client, + project_id, + facet, + topics_automation, + if_exists, + preprocessor_if_exists, + ) + .await + .with_context(|| format!("failed to push facet '{}'", facet.name()))?, ); } let mut pushed_automations = Vec::with_capacity(template.automations.len()); for automation in &template.automations { pushed_automations.push( - automation::push_template(client, project_id, automation) + automation::push_template(client, project_id, automation, if_exists) .await .with_context(|| { format!("failed to push Loop automation '{}'", automation.name()) @@ -334,10 +424,66 @@ async fn push_resources( Ok((pushed_facets, pushed_automations)) } +async fn preflight_resources( + client: &crate::http::ApiClient, + project_id: &str, + template: &ActiveObservabilityTemplate, + topics_automation: Option<&str>, + if_exists: IfExistsMode, +) -> Result<()> { + for facet in &template.facets { + facet::preflight_push(client, project_id, facet, topics_automation, if_exists).await?; + } + for automation in &template.automations { + automation::preflight_push(client, project_id, automation, if_exists).await?; + } + Ok(()) +} + +fn push_confirmation_prompt( + org_name: &str, + project_name: &str, + template: &ActiveObservabilityTemplate, + if_exists: IfExistsMode, +) -> String { + let facet_labels = template + .facets + .iter() + .map(|facet| facet.slug()) + .collect::>() + .join(", "); + let automation_labels = template + .automations + .iter() + .map(|automation| automation.name()) + .collect::>() + .join(", "); + let facets = resource_summary(template.facets.len(), "facet", "facets", &facet_labels); + let automations = resource_summary( + template.automations.len(), + "Loop automation", + "Loop automations", + &automation_labels, + ); + format!( + "Push {facets} and {automations}, including required preprocessors and Topics wiring, to {org_name}/{project_name} (--if-exists {})", + if_exists.as_str(), + ) +} + +fn resource_summary(count: usize, singular: &str, plural: &str, labels: &str) -> String { + match count { + 0 => format!("no {plural}"), + 1 => format!("1 {singular} ({labels})"), + _ => format!("{count} {plural} ({labels})"), + } +} + fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { io::validate_version(template.schema_version)?; let mut facet_slugs = HashSet::new(); + let mut preprocessors = HashMap::new(); for facet in &template.facets { facet::validate_template(facet)?; if !facet_slugs.insert(facet.slug()) { @@ -346,12 +492,24 @@ fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { facet.slug() ); } + if let Some(preprocessor) = facet.preprocessor() { + match preprocessors.get(preprocessor.slug()) { + Some(existing) if *existing != preprocessor => bail!( + "active observability template contains conflicting preprocessors with slug '{}'", + preprocessor.slug() + ), + Some(_) => {} + None => { + preprocessors.insert(preprocessor.slug(), preprocessor); + } + } + } } let mut automation_names = HashSet::new(); for automation in &template.automations { automation::validate_template(automation)?; - if !automation_names.insert(automation.name()) { + if !automation_names.insert(automation.name().trim()) { bail!( "active observability template contains duplicate automation name '{}'", automation.name() @@ -437,6 +595,22 @@ mod tests { assert!(err.to_string().contains("duplicate facet slug")); } + #[test] + fn rejects_automation_names_that_change_during_push_normalization() { + let mut value = active_observability_template_json(); + let mut automation = value["automations"][0].clone(); + automation["name"] = serde_json::Value::String(" test-loop ".to_string()); + value["automations"] + .as_array_mut() + .expect("automations") + .push(automation); + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("active observability template"); + + let err = validate_template(&template).expect_err("non-canonical automation name"); + assert!(err.to_string().contains("leading or trailing whitespace")); + } + #[test] fn rejects_unknown_template_fields() { let mut value = active_observability_template_json(); @@ -454,6 +628,8 @@ mod tests { file_positional: Some("active-observability-template.json".to_string()), file_flag: None, topics_automation: None, + if_exists: IfExistsMode::Error, + yes: false, }; assert_eq!( positional.file().expect("positional"), @@ -464,7 +640,24 @@ mod tests { file_positional: Some("active-observability-template.json".to_string()), file_flag: Some("other.json".to_string()), topics_automation: None, + if_exists: IfExistsMode::Error, + yes: false, }; assert!(conflicting.file().is_err()); } + + #[test] + fn push_confirmation_names_resources_and_target() { + let template: ActiveObservabilityTemplate = + serde_json::from_value(active_observability_template_json()) + .expect("active observability template"); + + let prompt = + push_confirmation_prompt("test-org", "test-project", &template, IfExistsMode::Replace); + + assert!(prompt.contains("1 facet (test-facet)")); + assert!(prompt.contains("1 Loop automation (test-loop)")); + assert!(prompt.contains("test-org/test-project")); + assert!(prompt.contains("--if-exists replace")); + } } diff --git a/src/active_observability_template/pagination.rs b/src/active_observability_template/pagination.rs index ec3c47be..d1d797c9 100644 --- a/src/active_observability_template/pagination.rs +++ b/src/active_observability_template/pagination.rs @@ -7,6 +7,8 @@ use urlencoding::encode; use crate::http::ApiClient; +const PAGE_LIMIT: usize = 10_000; + #[derive(Debug, Deserialize)] struct ListResponse { objects: Vec, @@ -28,17 +30,26 @@ where let mut cursor: Option = None; let mut snapshot: Option = None; let mut seen_cursors = HashSet::new(); + let mut page_count = 0; loop { + ensure_page_available(page_count, resource_label)?; let path = page_path(base_path, cursor.as_deref(), snapshot.as_deref()); let response: ListResponse = client .get(&path) .await .with_context(|| format!("failed to list {resource_label} via {path}"))?; + page_count += 1; + update_snapshot(&mut snapshot, response.snapshot, resource_label)?; + let next_cursor = response.next_cursor.filter(|cursor| !cursor.is_empty()); + validate_page_progress( + response.objects.is_empty(), + next_cursor.as_deref(), + resource_label, + )?; objects.extend(response.objects); - snapshot = response.snapshot.or(snapshot); - match response.next_cursor.filter(|cursor| !cursor.is_empty()) { + match next_cursor { Some(next_cursor) if seen_cursors.insert(next_cursor.clone()) => { cursor = Some(next_cursor); } @@ -48,6 +59,39 @@ where } } +fn ensure_page_available(page_count: usize, resource_label: &str) -> Result<()> { + if page_count >= PAGE_LIMIT { + bail!("{resource_label} pagination exceeded the {PAGE_LIMIT}-page limit"); + } + Ok(()) +} + +fn validate_page_progress( + objects_are_empty: bool, + next_cursor: Option<&str>, + resource_label: &str, +) -> Result<()> { + if objects_are_empty && next_cursor.is_some() { + bail!("{resource_label} pagination returned an empty page with a next cursor"); + } + Ok(()) +} + +fn update_snapshot( + snapshot: &mut Option, + page_snapshot: Option, + resource_label: &str, +) -> Result<()> { + match (snapshot.as_deref(), page_snapshot) { + (None, Some(page_snapshot)) => *snapshot = Some(page_snapshot), + (Some(expected), Some(actual)) if actual != expected => { + bail!("{resource_label} pagination snapshot changed from '{expected}' to '{actual}'"); + } + _ => {} + } + Ok(()) +} + fn page_path(base_path: &str, cursor: Option<&str>, snapshot: Option<&str>) -> String { let mut path = base_path.to_string(); let mut separator = if base_path.contains('?') { '&' } else { '?' }; @@ -82,4 +126,29 @@ mod tests { "/v1/function?cursor=next" ); } + + #[test] + fn pins_the_first_snapshot_and_rejects_changes() { + let mut snapshot = None; + update_snapshot(&mut snapshot, Some("snapshot-1".to_string()), "functions") + .expect("first snapshot"); + update_snapshot(&mut snapshot, None, "functions").expect("missing snapshot is tolerated"); + assert_eq!(snapshot.as_deref(), Some("snapshot-1")); + + let err = update_snapshot(&mut snapshot, Some("snapshot-2".to_string()), "functions") + .expect_err("snapshot change"); + assert!(err.to_string().contains("snapshot changed")); + } + + #[test] + fn rejects_empty_progress_pages_and_excessive_page_counts() { + let err = validate_page_progress(true, Some("next"), "functions") + .expect_err("empty progress page"); + assert!(err.to_string().contains("empty page")); + validate_page_progress(false, Some("next"), "functions").expect("non-empty page"); + + ensure_page_available(PAGE_LIMIT - 1, "functions").expect("last allowed page"); + let err = ensure_page_available(PAGE_LIMIT, "functions").expect_err("page limit"); + assert!(err.to_string().contains("page limit")); + } } From 635a6a4336c4004fc04014d4865d2016fb480afc Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 14:21:01 -0700 Subject: [PATCH 10/13] validate slugs + fix topic maps --- src/active_observability_template/facet.rs | 116 +++++++++++++++- src/active_observability_template/mod.rs | 147 ++++++++++++++++++++- 2 files changed, 255 insertions(+), 8 deletions(-) diff --git a/src/active_observability_template/facet.rs b/src/active_observability_template/facet.rs index a9a36225..738cab17 100644 --- a/src/active_observability_template/facet.rs +++ b/src/active_observability_template/facet.rs @@ -389,6 +389,7 @@ async fn ensure_topic_map_function( let slug = topic_map_slug(&facet.slug); if let Some(existing) = get_function_by_slug(client, project_id, &slug).await? { ensure_topic_map_type(&existing)?; + reconcile_topic_map_identity(client, &existing, facet).await?; return Ok(existing.id); } let request = topic_map_insert_request(project_id, facet, description, embedding_model); @@ -403,7 +404,56 @@ async fn ensure_topic_map_function( .ok_or_else(|| anyhow!("unexpected response while creating the topic map function")) } -fn topic_map_slug(facet_slug: &str) -> String { +async fn reconcile_topic_map_identity( + client: &ApiClient, + topic_map: &RemoteFunction, + facet: &RemoteFunction, +) -> Result<()> { + let Some(payload) = topic_map_identity_patch(topic_map, facet)? else { + return Ok(()); + }; + let path = format!("/v1/function/{}", encode(&topic_map.id)); + let _: Value = client + .patch(&path, &payload) + .await + .with_context(|| format!("failed to refresh topic map '{}'", topic_map.name))?; + Ok(()) +} + +fn topic_map_identity_patch( + topic_map: &RemoteFunction, + facet: &RemoteFunction, +) -> Result> { + let mut function_data = topic_map + .function_data + .as_object() + .cloned() + .ok_or_else(|| anyhow!("topic map function_data must be a JSON object"))?; + let source_facet = Value::String(facet.name.clone()); + let source_facet_function = serde_json::json!({ + "type": "function", + "id": facet.id, + }); + let function_data_changed = function_data.get("source_facet") != Some(&source_facet) + || function_data.get("source_facet_function") != Some(&source_facet_function); + let name_changed = topic_map.name != facet.name; + if !function_data_changed && !name_changed { + return Ok(None); + } + + function_data.insert("source_facet".to_string(), source_facet); + function_data.insert("source_facet_function".to_string(), source_facet_function); + let mut payload = serde_json::Map::new(); + if name_changed { + payload.insert("name".to_string(), Value::String(facet.name.clone())); + } + if function_data_changed { + payload.insert("function_data".to_string(), Value::Object(function_data)); + } + Ok(Some(Value::Object(payload))) +} + +pub(super) fn topic_map_slug(facet_slug: &str) -> String { format!("{facet_slug}-topic-map") } @@ -829,6 +879,29 @@ mod tests { } } + fn remote_topic_map() -> RemoteFunction { + RemoteFunction { + id: "fake-topic-map-id".to_string(), + name: "Old facet name".to_string(), + slug: "test-facet-topic-map".to_string(), + description: Some("A synthetic topic map".to_string()), + function_type: Some("classifier".to_string()), + function_data: serde_json::json!({ + "type": "topic_map", + "source_facet": "Old facet name", + "source_facet_function": { + "type": "function", + "id": "fake-old-facet-id" + }, + "embedding_model": "test-embedding-model", + "generation_settings": {"max_topics": 12} + }), + prompt_data: None, + tags: Some(vec!["test-tag".to_string()]), + function_schema: None, + } + } + #[test] fn facet_template_contains_only_portable_identity() { let value = serde_json::to_value(facet_template()).expect("json"); @@ -965,6 +1038,47 @@ mod tests { ); } + #[test] + fn existing_topic_map_refreshes_facet_identity_without_losing_custom_config() { + let topic_map = remote_topic_map(); + let facet = remote_facet(); + + let patch = topic_map_identity_patch(&topic_map, &facet) + .expect("identity patch") + .expect("changed identity"); + + assert_eq!(patch["name"], "Renamed facet"); + assert_eq!(patch["function_data"]["source_facet"], "Renamed facet"); + assert_eq!( + patch["function_data"]["source_facet_function"]["id"], + "fake-facet-id" + ); + assert_eq!( + patch["function_data"]["embedding_model"], + "test-embedding-model" + ); + assert_eq!( + patch["function_data"]["generation_settings"]["max_topics"], + 12 + ); + } + + #[test] + fn current_topic_map_identity_does_not_create_a_patch() { + let facet = remote_facet(); + let mut topic_map = remote_topic_map(); + topic_map.name.clone_from(&facet.name); + topic_map.function_data["source_facet"] = Value::String(facet.name.clone()); + topic_map.function_data["source_facet_function"] = serde_json::json!({ + "type": "function", + "id": facet.id + }); + + assert!(topic_map_identity_patch(&topic_map, &facet) + .expect("identity check") + .is_none()); + } + #[test] fn attaches_facet_and_topic_map_without_losing_topics_config() { let config = serde_json::json!({ diff --git a/src/active_observability_template/mod.rs b/src/active_observability_template/mod.rs index 5236c10e..570ca228 100644 --- a/src/active_observability_template/mod.rs +++ b/src/active_observability_template/mod.rs @@ -482,16 +482,21 @@ fn resource_summary(count: usize, singular: &str, plural: &str, labels: &str) -> fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { io::validate_version(template.schema_version)?; - let mut facet_slugs = HashSet::new(); + let mut function_slugs = HashMap::new(); let mut preprocessors = HashMap::new(); for facet in &template.facets { facet::validate_template(facet)?; - if !facet_slugs.insert(facet.slug()) { - bail!( - "active observability template contains duplicate facet slug '{}'", - facet.slug() - ); - } + reserve_function_slug( + &mut function_slugs, + facet.slug(), + FunctionResourceKind::Facet, + )?; + let topic_map_slug = self::facet::topic_map_slug(facet.slug()); + reserve_function_slug( + &mut function_slugs, + &topic_map_slug, + FunctionResourceKind::TopicMap, + )?; if let Some(preprocessor) = facet.preprocessor() { match preprocessors.get(preprocessor.slug()) { Some(existing) if *existing != preprocessor => bail!( @@ -500,6 +505,11 @@ fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { ), Some(_) => {} None => { + reserve_function_slug( + &mut function_slugs, + preprocessor.slug(), + FunctionResourceKind::Preprocessor, + )?; preprocessors.insert(preprocessor.slug(), preprocessor); } } @@ -520,6 +530,44 @@ fn validate_template(template: &ActiveObservabilityTemplate) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FunctionResourceKind { + Facet, + Preprocessor, + TopicMap, +} + +impl FunctionResourceKind { + fn label(self) -> &'static str { + match self { + Self::Facet => "facet", + Self::Preprocessor => "bundled preprocessor", + Self::TopicMap => "generated topic map", + } + } +} + +fn reserve_function_slug( + function_slugs: &mut HashMap, + slug: &str, + resource_kind: FunctionResourceKind, +) -> Result<()> { + if let Some(existing_kind) = function_slugs.get(slug) { + if *existing_kind == FunctionResourceKind::Facet + && resource_kind == FunctionResourceKind::Facet + { + bail!("active observability template contains duplicate facet slug '{slug}'"); + } + let existing_kind = existing_kind.label(); + let resource_kind = resource_kind.label(); + bail!( + "active observability template uses function slug '{slug}' for both {existing_kind} and {resource_kind} resources" + ); + } + function_slugs.insert(slug.to_string(), resource_kind); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -546,6 +594,25 @@ mod tests { }) } + fn add_bundled_preprocessor(facet: &mut serde_json::Value, slug: &str) { + facet["function_data"]["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": slug + }); + facet["preprocessor"] = serde_json::json!({ + "name": "Test preprocessor", + "slug": slug, + "function_data": { + "type": "code", + "data": { + "type": "inline", + "runtime_context": {"runtime": "quickjs", "version": "ES2023"}, + "code": "function handler(input) { return input; }" + } + } + }); + } + #[test] fn validates_active_observability_template() { let template: ActiveObservabilityTemplate = @@ -595,6 +662,72 @@ mod tests { assert!(err.to_string().contains("duplicate facet slug")); } + #[test] + fn rejects_facet_and_preprocessor_slug_collision() { + let mut value = active_observability_template_json(); + add_bundled_preprocessor(&mut value["facets"][0], "test-facet"); + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("active observability template"); + + let err = validate_template(&template).expect_err("shared function slug"); + assert!(err.to_string().contains("function slug 'test-facet'")); + assert!(err.to_string().contains("facet")); + assert!(err.to_string().contains("bundled preprocessor")); + } + + #[test] + fn rejects_facet_and_generated_topic_map_slug_collision() { + let mut value = active_observability_template_json(); + let mut second_facet = value["facets"][0].clone(); + second_facet["name"] = serde_json::Value::String("Second test facet".to_string()); + second_facet["slug"] = serde_json::Value::String("test-facet-topic-map".to_string()); + value["facets"] + .as_array_mut() + .expect("facets") + .push(second_facet); + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("active observability template"); + + let err = validate_template(&template).expect_err("generated topic map collision"); + assert!(err + .to_string() + .contains("function slug 'test-facet-topic-map'")); + assert!(err.to_string().contains("generated topic map")); + assert!(err.to_string().contains("facet")); + } + + #[test] + fn rejects_preprocessor_and_generated_topic_map_slug_collision() { + let mut value = active_observability_template_json(); + add_bundled_preprocessor(&mut value["facets"][0], "test-facet-topic-map"); + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("active observability template"); + + let err = validate_template(&template).expect_err("generated topic map collision"); + assert!(err + .to_string() + .contains("function slug 'test-facet-topic-map'")); + assert!(err.to_string().contains("generated topic map")); + assert!(err.to_string().contains("bundled preprocessor")); + } + + #[test] + fn permits_identical_shared_preprocessor_templates() { + let mut value = active_observability_template_json(); + add_bundled_preprocessor(&mut value["facets"][0], "shared-test-preprocessor"); + let mut second_facet = value["facets"][0].clone(); + second_facet["name"] = serde_json::Value::String("Second test facet".to_string()); + second_facet["slug"] = serde_json::Value::String("second-test-facet".to_string()); + value["facets"] + .as_array_mut() + .expect("facets") + .push(second_facet); + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("active observability template"); + + validate_template(&template).expect("shared preprocessor is one function resource"); + } + #[test] fn rejects_automation_names_that_change_during_push_normalization() { let mut value = active_observability_template_json(); From 3ec6e634d24046bd61d926086932a3e99e7d570f Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 14:29:24 -0700 Subject: [PATCH 11/13] ensure streaming stops over chunk limit --- src/active_observability_template/io.rs | 65 +++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/active_observability_template/io.rs b/src/active_observability_template/io.rs index 15929cef..49905cae 100644 --- a/src/active_observability_template/io.rs +++ b/src/active_observability_template/io.rs @@ -8,7 +8,7 @@ use crate::http::{build_http_client, DEFAULT_HTTP_TIMEOUT}; use crate::utils::write_json_atomic; pub(super) const SCHEMA_VERSION: u32 = 1; -const MAX_TEMPLATE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_TEMPLATE_BYTES: usize = 10 * 1024 * 1024; pub(super) async fn read(source: &str) -> Result { let contents = if source == "-" { @@ -45,7 +45,7 @@ fn is_http_url(source: &str) -> bool { async fn fetch_url(source: &str) -> Result { let url = reqwest::Url::parse(source).context("invalid template URL")?; - let response = build_http_client(DEFAULT_HTTP_TIMEOUT)? + let mut response = build_http_client(DEFAULT_HTTP_TIMEOUT)? .get(url) .send() .await @@ -56,18 +56,27 @@ async fn fetch_url(source: &str) -> Result { } if response .content_length() - .is_some_and(|length| length > MAX_TEMPLATE_BYTES) + .is_some_and(|length| length > MAX_TEMPLATE_BYTES as u64) { bail!("template URL is larger than the 10 MiB limit"); } - let bytes = response - .bytes() + + let capacity = response + .content_length() + .unwrap_or_default() + .min(MAX_TEMPLATE_BYTES as u64) as usize; + let mut bytes = Vec::with_capacity(capacity); + while let Some(chunk) = response + .chunk() .await - .context("failed to read template URL response")?; - if bytes.len() as u64 > MAX_TEMPLATE_BYTES { - bail!("template URL is larger than the 10 MiB limit"); + .context("failed to read template URL response")? + { + if chunk.len() > MAX_TEMPLATE_BYTES - bytes.len() { + bail!("template URL is larger than the 10 MiB limit"); + } + bytes.extend_from_slice(&chunk); } - String::from_utf8(bytes.to_vec()).context("template URL response is not valid UTF-8") + String::from_utf8(bytes).context("template URL response is not valid UTF-8") } pub(super) fn validate_version(version: u32) -> Result<()> { @@ -166,6 +175,44 @@ mod tests { assert_eq!(template.schema_version, SCHEMA_VERSION); } + #[tokio::test] + async fn stops_streaming_chunked_urls_at_the_size_limit() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request).expect("read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + ) + .expect("write response headers"); + + let chunk = vec![b'x'; 1024 * 1024]; + for _ in 0..10 { + if write_chunk(&mut stream, &chunk).is_err() { + return; + } + } + let _ = write_chunk(&mut stream, b"x"); + let _ = stream.write_all(b"0\r\n\r\n"); + }); + + let err = fetch_url(&format!("http://{address}/template.json")) + .await + .expect_err("oversized chunked response"); + server.join().expect("test server"); + + assert!(err.to_string().contains("larger than the 10 MiB limit")); + } + + fn write_chunk(stream: &mut std::net::TcpStream, chunk: &[u8]) -> std::io::Result<()> { + write!(stream, "{:X}\r\n", chunk.len())?; + stream.write_all(chunk)?; + stream.write_all(b"\r\n") + } + #[test] fn validates_schema_version() { validate_version(SCHEMA_VERSION).expect("current version"); From 964c99ecaef56a5925f9439bde9d24e028baa485 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Tue, 25 Aug 2026 15:33:35 -0700 Subject: [PATCH 12/13] cleanup --- .../automation.rs | 18 +- src/active_observability_template/facet.rs | 255 ++++++++++++++---- src/active_observability_template/mod.rs | 111 ++++---- src/main.rs | 22 ++ 4 files changed, 298 insertions(+), 108 deletions(-) diff --git a/src/active_observability_template/automation.rs b/src/active_observability_template/automation.rs index 38cec128..d7eb7a08 100644 --- a/src/active_observability_template/automation.rs +++ b/src/active_observability_template/automation.rs @@ -5,7 +5,7 @@ use urlencoding::encode; use crate::http::ApiClient; -use super::{pagination, IfExistsMode}; +use super::pagination; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -53,17 +53,15 @@ pub(super) async fn push_template( client: &ApiClient, project_id: &str, template: &AutomationTemplate, - if_exists: IfExistsMode, + force: bool, ) -> Result { validate_template(template)?; let name = template.name.trim(); let existing = get_by_name(client, project_id, name).await?; if let Some(existing) = existing.as_ref() { ensure_existing_is_loop(existing, name)?; - match if_exists { - IfExistsMode::Error => bail!(existing_resource_error(name)), - IfExistsMode::Ignore => return Ok(existing.clone()), - IfExistsMode::Replace => {} + if !force { + bail!(existing_resource_error(name)); } } let config = config_for_push( @@ -86,12 +84,12 @@ pub(super) async fn preflight_push( client: &ApiClient, project_id: &str, template: &AutomationTemplate, - if_exists: IfExistsMode, + force: bool, ) -> Result<()> { let name = template.name.trim(); if let Some(existing) = get_by_name(client, project_id, name).await? { ensure_existing_is_loop(&existing, name)?; - if if_exists == IfExistsMode::Error { + if !force { bail!(existing_resource_error(name)); } } @@ -108,9 +106,7 @@ fn ensure_existing_is_loop(existing: &RemoteAutomation, name: &str) -> Result<() } fn existing_resource_error(name: &str) -> String { - format!( - "Loop automation '{name}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it" - ) + format!("Loop automation '{name}' already exists; use --force to replace it") } async fn get_by_name( diff --git a/src/active_observability_template/facet.rs b/src/active_observability_template/facet.rs index 738cab17..a3bd312e 100644 --- a/src/active_observability_template/facet.rs +++ b/src/active_observability_template/facet.rs @@ -5,7 +5,7 @@ use urlencoding::encode; use crate::http::ApiClient; -use super::{pagination, IfExistsMode}; +use super::pagination; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -114,20 +114,19 @@ pub(super) async fn push_template( project_id: &str, template: &FacetTemplate, topics_automation: Option<&str>, - if_exists: IfExistsMode, - preprocessor_if_exists: IfExistsMode, + force: bool, + push_preprocessor: bool, ) -> Result { validate_template(template)?; let name = template.name.trim(); let existing = get_facet_by_slug(client, project_id, &template.slug).await?; - if existing.is_some() && if_exists == IfExistsMode::Error { + if existing.is_some() && !force { bail!(existing_facet_error(&template.slug)); } - let keep_existing_facet = existing.is_some() && if_exists == IfExistsMode::Ignore; let topics_setup = resolve_topics_setup(client, project_id, topics_automation).await?; - if !keep_existing_facet { + if push_preprocessor { if let Some(preprocessor) = template.preprocessor.as_ref() { - push_preprocessor_template(client, project_id, preprocessor, preprocessor_if_exists) + push_preprocessor_template(client, project_id, preprocessor, force) .await .with_context(|| { format!( @@ -137,28 +136,23 @@ pub(super) async fn push_template( })?; } } - let pushed = if let (Some(existing), true) = (existing, keep_existing_facet) { - existing - } else { - let function_data = - resolve_preprocessor_reference(client, project_id, template.function_data.clone()) - .await?; - let request = UpsertFunctionRequest { - project_id, - name, - slug: &template.slug, - description: template.description.as_deref(), - function_type: "facet", - function_data: &function_data, - prompt_data: template.prompt_data.as_ref(), - tags: template.tags.as_deref(), - function_schema: template.function_schema.as_ref(), - }; - client - .put("/v1/function", &request) - .await - .with_context(|| format!("failed to push facet '{name}'"))? + let function_data = + resolve_preprocessor_reference(client, project_id, template.function_data.clone()).await?; + let request = UpsertFunctionRequest { + project_id, + name, + slug: &template.slug, + description: template.description.as_deref(), + function_type: "facet", + function_data: &function_data, + prompt_data: template.prompt_data.as_ref(), + tags: template.tags.as_deref(), + function_schema: template.function_schema.as_ref(), }; + let pushed = client + .put("/v1/function", &request) + .await + .with_context(|| format!("failed to push facet '{name}'"))?; let topic_map_id = ensure_topic_map_function( client, @@ -166,6 +160,7 @@ pub(super) async fn push_template( &pushed, template.description.as_deref(), &topics_setup.embedding_model, + force, ) .await .with_context(|| { @@ -196,47 +191,91 @@ pub(super) async fn preflight_push( project_id: &str, template: &FacetTemplate, topics_automation: Option<&str>, - if_exists: IfExistsMode, + force: bool, ) -> Result<()> { let existing = get_facet_by_slug(client, project_id, &template.slug).await?; - if existing.is_some() && if_exists == IfExistsMode::Error { + if existing.is_some() && !force { bail!(existing_facet_error(&template.slug)); } - if !(existing.is_some() && if_exists == IfExistsMode::Ignore) { - if let Some(preprocessor) = template.preprocessor.as_ref() { - if let Some(existing) = - get_function_by_slug(client, project_id, &preprocessor.slug).await? - { - ensure_function_type(&existing, "preprocessor", &preprocessor.slug)?; - if if_exists == IfExistsMode::Error { - bail!(existing_preprocessor_error(&preprocessor.slug)); - } - } - } - } + preflight_preprocessor_dependency(client, project_id, template, force).await?; if let Some(existing) = get_function_by_slug(client, project_id, &topic_map_slug(&template.slug)).await? { ensure_topic_map_type(&existing)?; + if !force { + bail!(existing_topic_map_error(&existing.slug)); + } } resolve_topics_setup(client, project_id, topics_automation).await?; Ok(()) } +async fn preflight_preprocessor_dependency( + client: &ApiClient, + project_id: &str, + template: &FacetTemplate, + force: bool, +) -> Result<()> { + let Some(slug) = referenced_preprocessor_slug(template)? else { + return Ok(()); + }; + let existing = get_preprocessor_by_slug(client, project_id, slug).await?; + + if template.preprocessor.is_some() { + if existing.is_some() && !force { + bail!(existing_preprocessor_error(slug)); + } + } else if existing.is_none() { + bail!(missing_preprocessor_dependency_error(slug, &template.name)); + } + + Ok(()) +} + +fn referenced_preprocessor_slug(template: &FacetTemplate) -> Result> { + let Some(preprocessor) = template + .function_data + .get("preprocessor") + .and_then(Value::as_object) + .filter(|preprocessor| { + preprocessor.get("type").and_then(Value::as_str) == Some("function") + }) + else { + return Ok(None); + }; + + preprocessor + .get("slug") + .and_then(Value::as_str) + .filter(|slug| !slug.trim().is_empty()) + .map(Some) + .ok_or_else(|| { + anyhow!( + "facet template project preprocessor reference must use a portable 'slug' field" + ) + }) +} + fn existing_facet_error(slug: &str) -> String { - format!( - "facet with slug '{slug}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it and reconcile its Topics setup" - ) + format!("facet with slug '{slug}' already exists; use --force to replace it") } fn existing_preprocessor_error(slug: &str) -> String { + format!("preprocessor with slug '{slug}' already exists; use --force to replace it") +} + +fn missing_preprocessor_dependency_error(slug: &str, facet_name: &str) -> String { format!( - "preprocessor with slug '{slug}' already exists; use --if-exists replace to update it or --if-exists ignore to keep it" + "facet '{facet_name}' references preprocessor '{slug}', but it is neither bundled in the template nor present in the target project; bundle the preprocessor or create it in the target project before pushing" ) } +fn existing_topic_map_error(slug: &str) -> String { + format!("topic map with slug '{slug}' already exists; use --force to reconcile it") +} + async fn get_facet_by_slug( client: &ApiClient, project_id: &str, @@ -385,10 +424,14 @@ async fn ensure_topic_map_function( facet: &RemoteFunction, description: Option<&str>, embedding_model: &str, + force: bool, ) -> Result { let slug = topic_map_slug(&facet.slug); if let Some(existing) = get_function_by_slug(client, project_id, &slug).await? { ensure_topic_map_type(&existing)?; + if !force { + bail!(existing_topic_map_error(&slug)); + } reconcile_topic_map_identity(client, &existing, facet).await?; return Ok(existing.id); } @@ -658,15 +701,13 @@ async fn push_preprocessor_template( client: &ApiClient, project_id: &str, template: &PreprocessorTemplate, - if_exists: IfExistsMode, + force: bool, ) -> Result { validate_preprocessor_template(template)?; if let Some(existing) = get_function_by_slug(client, project_id, &template.slug).await? { ensure_function_type(&existing, "preprocessor", &template.slug)?; - match if_exists { - IfExistsMode::Error => bail!(existing_preprocessor_error(&template.slug)), - IfExistsMode::Ignore => return Ok(existing), - IfExistsMode::Replace => {} + if !force { + bail!(existing_preprocessor_error(&template.slug)); } } let request = UpsertFunctionRequest { @@ -811,7 +852,83 @@ impl PreprocessorTemplate { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::net::TcpListener; + + use actix_web::{web, App, HttpResponse, HttpServer}; + use braintrust_sdk_rust::LoginState; + use super::*; + use crate::auth::LoginContext; + + struct MockFunctionServer { + base_url: String, + handle: actix_web::dev::ServerHandle, + } + + impl MockFunctionServer { + async fn start(preprocessor_exists: bool) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind mock server"); + let address = listener.local_addr().expect("mock server address"); + let base_url = format!("http://{address}"); + let state = web::Data::new(preprocessor_exists); + let server = HttpServer::new(move || { + App::new() + .app_data(state.clone()) + .route("/v1/function", web::get().to(mock_list_functions)) + }) + .workers(1) + .listen(listener) + .expect("listen on mock server") + .run(); + let handle = server.handle(); + tokio::spawn(server); + + Self { base_url, handle } + } + + fn client(&self) -> ApiClient { + let login = LoginState::new(); + login.set( + "test-key".to_string(), + "test-org-id".to_string(), + "test-org".to_string(), + self.base_url.clone(), + "https://app.example.com".to_string(), + ); + ApiClient::new(&LoginContext { + login, + api_url: self.base_url.clone(), + app_url: "https://app.example.com".to_string(), + profile: None, + }) + .expect("build API client") + } + + async fn stop(&self) { + self.handle.stop(true).await; + } + } + + async fn mock_list_functions( + preprocessor_exists: web::Data, + query: web::Query>, + ) -> HttpResponse { + let objects = if **preprocessor_exists + && query.get("slug").map(String::as_str) == Some("test-preprocessor") + { + vec![serde_json::json!({ + "id": "fake-preprocessor-id", + "name": "Test preprocessor", + "slug": "test-preprocessor", + "function_type": "preprocessor", + "function_data": {"type": "code"} + })] + } else { + Vec::new() + }; + HttpResponse::Ok().json(serde_json::json!({"objects": objects})) + } fn facet_template() -> FacetTemplate { FacetTemplate { @@ -937,6 +1054,42 @@ mod tests { validate_template(&template).expect("portable reference"); } + #[tokio::test] + async fn preflight_rejects_missing_unbundled_preprocessor() { + let server = MockFunctionServer::start(false).await; + let client = server.client(); + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": "test-preprocessor" + }); + + let err = preflight_preprocessor_dependency(&client, "fake-project-id", &template, false) + .await + .expect_err("missing dependency"); + server.stop().await; + + assert!(err.to_string().contains("facet 'test-facet'")); + assert!(err.to_string().contains("test-preprocessor")); + assert!(err.to_string().contains("neither bundled")); + } + + #[tokio::test] + async fn preflight_accepts_unbundled_preprocessor_from_target() { + let server = MockFunctionServer::start(true).await; + let client = server.client(); + let mut template = facet_template(); + template.function_data["preprocessor"] = serde_json::json!({ + "type": "function", + "slug": "test-preprocessor" + }); + + preflight_preprocessor_dependency(&client, "fake-project-id", &template, false) + .await + .expect("target preprocessor"); + server.stop().await; + } + #[test] fn accepts_bundled_project_preprocessor() { let mut template = facet_template(); diff --git a/src/active_observability_template/mod.rs b/src/active_observability_template/mod.rs index 570ca228..34af32ed 100644 --- a/src/active_observability_template/mod.rs +++ b/src/active_observability_template/mod.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use clap::{Args, Subcommand, ValueEnum}; +use clap::{Args, Subcommand}; use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect}; use serde::{Deserialize, Serialize}; @@ -89,14 +89,14 @@ struct PushArgs { )] topics_automation: Option, - /// Behavior when a facet, preprocessor, or Loop automation already exists + /// Replace existing facets, bundled preprocessors, topic maps, and Loop automations #[arg( - long = "if-exists", - env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_IF_EXISTS", - value_enum, - default_value = "error" + long, + env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_FORCE", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new() )] - if_exists: IfExistsMode, + force: bool, /// Skip the confirmation prompt #[arg( @@ -123,23 +123,6 @@ impl PushArgs { } } -#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] -pub(super) enum IfExistsMode { - Error, - Replace, - Ignore, -} - -impl IfExistsMode { - fn as_str(self) -> &'static str { - match self { - Self::Error => "error", - Self::Replace => "replace", - Self::Ignore => "ignore", - } - } -} - #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum TemplateKind { @@ -254,12 +237,29 @@ fn select_resources( .with_prompt("Select facets and Loop automations to include") .items(&labels) .defaults(&defaults) + .report(false) .interact_on(&term) .context("failed to select active observability resources")?; + term.write_line(&selected_resources_report(&labels, &selected)) + .context("failed to report selected active observability resources")?; Ok(filter_resources(facets, automations, &selected)) } +fn selected_resources_report(labels: &[String], selected: &[usize]) -> String { + if selected.is_empty() { + return "Selected no facets or Loop automations".to_string(); + } + + let resources = selected + .iter() + .filter_map(|index| labels.get(*index)) + .map(|label| format!(" {label}")) + .collect::>() + .join("\n"); + format!("Selected facets and Loop automations:\n{resources}") +} + fn filter_resources( facets: Vec, automations: Vec, @@ -301,7 +301,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { &ctx.project.id, &template, args.topics_automation.as_deref(), - args.if_exists, + args.force, ), ) .await?; @@ -311,7 +311,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { ctx.client.org_name(), &ctx.project.name, &template, - args.if_exists, + args.force, ); let term = ui::prompt_term().ok_or_else(|| anyhow::anyhow!("interactive mode requires TTY"))?; @@ -332,7 +332,7 @@ async fn push(base: BaseArgs, args: PushArgs) -> Result<()> { &ctx.project.id, &template, args.topics_automation.as_deref(), - args.if_exists, + args.force, ), ) .await @@ -387,23 +387,22 @@ async fn push_resources( project_id: &str, template: &ActiveObservabilityTemplate, topics_automation: Option<&str>, - if_exists: IfExistsMode, + force: bool, ) -> Result<(Vec, Vec)> { let mut pushed_facets = Vec::with_capacity(template.facets.len()); let mut pushed_preprocessors = HashSet::new(); for facet in &template.facets { - let preprocessor_if_exists = match facet.preprocessor_slug() { - Some(slug) if !pushed_preprocessors.insert(slug) => IfExistsMode::Ignore, - _ => if_exists, - }; + let push_preprocessor = facet + .preprocessor_slug() + .is_some_and(|slug| pushed_preprocessors.insert(slug)); pushed_facets.push( facet::push_template( client, project_id, facet, topics_automation, - if_exists, - preprocessor_if_exists, + force, + push_preprocessor, ) .await .with_context(|| format!("failed to push facet '{}'", facet.name()))?, @@ -413,7 +412,7 @@ async fn push_resources( let mut pushed_automations = Vec::with_capacity(template.automations.len()); for automation in &template.automations { pushed_automations.push( - automation::push_template(client, project_id, automation, if_exists) + automation::push_template(client, project_id, automation, force) .await .with_context(|| { format!("failed to push Loop automation '{}'", automation.name()) @@ -429,13 +428,13 @@ async fn preflight_resources( project_id: &str, template: &ActiveObservabilityTemplate, topics_automation: Option<&str>, - if_exists: IfExistsMode, + force: bool, ) -> Result<()> { for facet in &template.facets { - facet::preflight_push(client, project_id, facet, topics_automation, if_exists).await?; + facet::preflight_push(client, project_id, facet, topics_automation, force).await?; } for automation in &template.automations { - automation::preflight_push(client, project_id, automation, if_exists).await?; + automation::preflight_push(client, project_id, automation, force).await?; } Ok(()) } @@ -444,7 +443,7 @@ fn push_confirmation_prompt( org_name: &str, project_name: &str, template: &ActiveObservabilityTemplate, - if_exists: IfExistsMode, + force: bool, ) -> String { let facet_labels = template .facets @@ -465,9 +464,13 @@ fn push_confirmation_prompt( "Loop automations", &automation_labels, ); + let replacement = if force { + " and replace existing matching resources" + } else { + "" + }; format!( - "Push {facets} and {automations}, including required preprocessors and Topics wiring, to {org_name}/{project_name} (--if-exists {})", - if_exists.as_str(), + "Push {facets} and {automations}, including required preprocessors and Topics wiring, to {org_name}/{project_name}{replacement}" ) } @@ -650,6 +653,23 @@ mod tests { assert_eq!(automations[0].name(), "test-loop"); } + #[test] + fn selection_report_uses_one_line_per_resource() { + let labels = vec![ + "Facet Test facet".to_string(), + "Automation Test automation".to_string(), + ]; + + assert_eq!( + selected_resources_report(&labels, &[0, 1]), + "Selected facets and Loop automations:\n Facet Test facet\n Automation Test automation" + ); + assert_eq!( + selected_resources_report(&labels, &[]), + "Selected no facets or Loop automations" + ); + } + #[test] fn rejects_duplicate_resource_identity() { let mut value = active_observability_template_json(); @@ -761,7 +781,7 @@ mod tests { file_positional: Some("active-observability-template.json".to_string()), file_flag: None, topics_automation: None, - if_exists: IfExistsMode::Error, + force: false, yes: false, }; assert_eq!( @@ -773,7 +793,7 @@ mod tests { file_positional: Some("active-observability-template.json".to_string()), file_flag: Some("other.json".to_string()), topics_automation: None, - if_exists: IfExistsMode::Error, + force: false, yes: false, }; assert!(conflicting.file().is_err()); @@ -785,12 +805,11 @@ mod tests { serde_json::from_value(active_observability_template_json()) .expect("active observability template"); - let prompt = - push_confirmation_prompt("test-org", "test-project", &template, IfExistsMode::Replace); + let prompt = push_confirmation_prompt("test-org", "test-project", &template, true); assert!(prompt.contains("1 facet (test-facet)")); assert!(prompt.contains("1 Loop automation (test-loop)")); assert!(prompt.contains("test-org/test-project")); - assert!(prompt.contains("--if-exists replace")); + assert!(prompt.contains("replace existing matching resources")); } } diff --git a/src/main.rs b/src/main.rs index 63f19fdc..303852b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -738,11 +738,33 @@ mod tests { "--file", "active-observability-template.json", ], + vec![ + "bt", + "active-observability-template", + "push", + "active-observability-template.json", + "--force", + ], ] { Cli::try_parse_from(args).expect("active-observability-template command should parse"); } } + #[test] + fn active_observability_template_push_rejects_if_exists() { + let err = Cli::try_parse_from([ + "bt", + "active-observability-template", + "push", + "active-observability-template.json", + "--if-exists", + "replace", + ]) + .expect_err("--if-exists should not be exposed"); + + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + #[test] fn individual_active_observability_resource_commands_are_not_exposed() { for command in ["automation", "automations", "facet", "facets"] { From 13f28b9280ae46a9f255396d9da801fbf11f85a4 Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Wed, 26 Aug 2026 11:14:06 -0700 Subject: [PATCH 13/13] fix topics automation backfill --- .../automation.rs | 106 ++++- src/active_observability_template/facet.rs | 427 ++++++++++++++++-- src/active_observability_template/mod.rs | 82 +++- src/topics/api.rs | 14 + 4 files changed, 563 insertions(+), 66 deletions(-) diff --git a/src/active_observability_template/automation.rs b/src/active_observability_template/automation.rs index d7eb7a08..ee543aa9 100644 --- a/src/active_observability_template/automation.rs +++ b/src/active_observability_template/automation.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use anyhow::{anyhow, bail, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -36,17 +38,47 @@ struct UpsertAutomationRequest<'a> { pub(super) async fn list_templates( client: &ApiClient, project_id: &str, -) -> Result> { +) -> Result<(Vec, HashMap)> { let path = format!("/v1/project_automation?project_id={}", encode(project_id)); let automations: Vec = pagination::list_all(client, &path, "automations").await?; + let topic_map_automation_names = topic_map_automation_names(&automations)?; let mut templates = automations .into_iter() .filter(|automation| is_loop_config(&automation.config)) .map(template_from_remote) .collect::>>()?; templates.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(templates) + Ok((templates, topic_map_automation_names)) +} + +fn topic_map_automation_names(automations: &[RemoteAutomation]) -> Result> { + let mut names = HashMap::new(); + for automation in automations.iter().filter(|automation| { + automation.config.get("event_type").and_then(Value::as_str) == Some("topic") + }) { + for topic_map_id in automation + .config + .get("topic_map_functions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("function")) + .filter(|function| function.get("type").and_then(Value::as_str) == Some("function")) + .filter_map(|function| function.get("id").and_then(Value::as_str)) + { + if let Some(existing) = names.insert(topic_map_id.to_string(), automation.name.clone()) + { + if existing != automation.name { + bail!( + "topic map function '{topic_map_id}' belongs to multiple Topics automations ('{existing}' and '{}'); each portable facet must map to one Topics automation", + automation.name + ); + } + } + } + } + Ok(names) } pub(super) async fn push_template( @@ -195,6 +227,10 @@ impl AutomationTemplate { pub(super) fn name(&self) -> &str { &self.name } + + pub(super) fn is_active(&self) -> bool { + self.config.get("status").and_then(Value::as_str) != Some("paused") + } } #[cfg(test)] @@ -243,6 +279,72 @@ mod tests { assert!(value["config"].get("actions").is_none()); } + #[test] + fn loop_automation_is_active_unless_explicitly_paused() { + let active = template_from_remote(remote_loop()).expect("active automation"); + assert!(active.is_active()); + + let mut implicit = remote_loop(); + implicit.config.as_object_mut().unwrap().remove("status"); + let implicit = template_from_remote(implicit).expect("implicit active automation"); + assert!(implicit.is_active()); + + let mut paused = remote_loop(); + paused.config["status"] = Value::String("paused".to_string()); + let paused = template_from_remote(paused).expect("paused automation"); + assert!(!paused.is_active()); + } + + #[test] + fn collects_topic_map_automation_names() { + let topics = RemoteAutomation { + id: "fake-topics-automation-id".to_string(), + name: "Topics".to_string(), + description: None, + config: serde_json::json!({ + "event_type": "topic", + "topic_map_functions": [ + {"function": {"type": "function", "id": "fake-active-topic-map-id"}}, + {"function": {"type": "global", "name": "global-topic-map"}} + ] + }), + }; + + let names = topic_map_automation_names(&[topics, remote_loop()]).expect("topic mappings"); + + assert_eq!( + names, + HashMap::from([("fake-active-topic-map-id".to_string(), "Topics".to_string())]) + ); + } + + #[test] + fn rejects_topic_maps_attached_to_multiple_automations() { + let config = serde_json::json!({ + "event_type": "topic", + "topic_map_functions": [ + {"function": {"type": "function", "id": "fake-topic-map-id"}} + ] + }); + let first = RemoteAutomation { + id: "fake-topics-automation-id-1".to_string(), + name: "Topics A".to_string(), + description: None, + config: config.clone(), + }; + let second = RemoteAutomation { + id: "fake-topics-automation-id-2".to_string(), + name: "Topics B".to_string(), + description: None, + config, + }; + + let err = topic_map_automation_names(&[first, second]) + .expect_err("ambiguous topic map membership"); + + assert!(err.to_string().contains("multiple Topics automations")); + } + #[test] fn push_ignores_template_destinations_and_preserves_target_destinations() { let source_config = serde_json::json!({ diff --git a/src/active_observability_template/facet.rs b/src/active_observability_template/facet.rs index a3bd312e..7518f8dc 100644 --- a/src/active_observability_template/facet.rs +++ b/src/active_observability_template/facet.rs @@ -1,3 +1,5 @@ +use std::collections::{HashMap, HashSet}; + use anyhow::{anyhow, bail, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -29,6 +31,8 @@ pub(super) struct FacetTemplate { name: String, slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] + topics_automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] preprocessor: Option, @@ -82,24 +86,40 @@ struct RemoteProjectAutomation { } struct TopicsSetup { - automation: RemoteProjectAutomation, + automation: Option, + automation_name: String, embedding_model: String, } +const DEFAULT_TOPICS_AUTOMATION_DESCRIPTION: &str = + "Automatically extract facets and classify logs using topic maps"; const DEFAULT_TOPICS_EMBEDDING_MODEL: &str = "brain-embedding-1"; +const DEFAULT_TOPICS_WINDOW_SECONDS: i64 = 24 * 60 * 60; +const DEFAULT_TOPICS_RERUN_SECONDS: i64 = 24 * 60 * 60; +const DEFAULT_TOPICS_RELABEL_OVERLAP_SECONDS: i64 = 60 * 60; +const DEFAULT_TOPICS_IDLE_SECONDS: i64 = 10 * 60; -pub(super) async fn list_templates( +pub(super) async fn list_remote_functions( client: &ApiClient, project_id: &str, +) -> Result> { + let path = format!("/v1/function?project_id={}", encode(project_id)); + pagination::list_all(client, &path, "functions").await +} + +pub(super) async fn templates_from_remote( + client: &ApiClient, + functions: Vec, + topic_map_automation_names: &HashMap, ) -> Result> { let mut templates = Vec::new(); - let path = format!("/v1/function?project_id={}", encode(project_id)); - let functions: Vec = pagination::list_all(client, &path, "functions").await?; for facet in functions - .into_iter() + .iter() .filter(|function| function.function_type.as_deref() == Some("facet")) { - templates.push(template_from_remote(client, facet).await?); + let topics_automation = + topics_automation_for_facet(facet, &functions, topic_map_automation_names)?; + templates.push(template_from_remote(client, facet.clone(), topics_automation).await?); } templates.sort_by(|left, right| { left.name @@ -109,6 +129,55 @@ pub(super) async fn list_templates( Ok(templates) } +fn topics_automation_for_facet( + facet: &RemoteFunction, + functions: &[RemoteFunction], + topic_map_automation_names: &HashMap, +) -> Result> { + let mut automation_names = HashSet::new(); + for topic_map in functions.iter().filter(|function| { + topic_map_automation_names.contains_key(&function.id) + && function.function_type.as_deref() == Some("classifier") + && function.function_data.get("type").and_then(Value::as_str) == Some("topic_map") + }) { + let source_facet_id = topic_map + .function_data + .get("source_facet_function") + .and_then(Value::as_object) + .filter(|source| source.get("type").and_then(Value::as_str) == Some("function")) + .and_then(|source| source.get("id")) + .and_then(Value::as_str); + let matches = source_facet_id.map_or_else( + || { + topic_map + .function_data + .get("source_facet") + .and_then(Value::as_str) + .is_some_and(|source| source == facet.name || source == facet.slug) + }, + |source_id| source_id == facet.id, + ); + if matches { + automation_names.insert( + topic_map_automation_names + .get(&topic_map.id) + .expect("filtered to mapped topic maps") + .clone(), + ); + } + } + if automation_names.len() > 1 { + let mut names = automation_names.into_iter().collect::>(); + names.sort(); + bail!( + "facet '{}' belongs to multiple Topics automations ({}); each portable facet must map to one Topics automation", + facet.name, + names.join(", ") + ); + } + Ok(automation_names.into_iter().next()) +} + pub(super) async fn push_template( client: &ApiClient, project_id: &str, @@ -123,7 +192,15 @@ pub(super) async fn push_template( if existing.is_some() && !force { bail!(existing_facet_error(&template.slug)); } - let topics_setup = resolve_topics_setup(client, project_id, topics_automation).await?; + let (topics_automation, create_topics_automation) = + topics_automation_target(template, topics_automation); + let topics_setup = resolve_topics_setup( + client, + project_id, + topics_automation, + create_topics_automation, + ) + .await?; if push_preprocessor { if let Some(preprocessor) = template.preprocessor.as_ref() { push_preprocessor_template(client, project_id, preprocessor, force) @@ -169,9 +246,10 @@ pub(super) async fn push_template( pushed.name ) })?; - attach_facet_to_topics_automation( + attach_facet_to_topics( client, - &topics_setup.automation, + project_id, + topics_setup, &pushed.id, &topic_map_id, ) @@ -208,10 +286,31 @@ pub(super) async fn preflight_push( bail!(existing_topic_map_error(&existing.slug)); } } - resolve_topics_setup(client, project_id, topics_automation).await?; + let (topics_automation, create_topics_automation) = + topics_automation_target(template, topics_automation); + resolve_topics_setup( + client, + project_id, + topics_automation, + create_topics_automation, + ) + .await?; Ok(()) } +fn topics_automation_target<'a>( + template: &'a FacetTemplate, + override_selector: Option<&'a str>, +) -> (Option<&'a str>, bool) { + match override_selector { + Some(selector) => (Some(selector), false), + None => ( + template.topics_automation.as_deref(), + template.topics_automation.is_some(), + ), + } +} + async fn preflight_preprocessor_dependency( client: &ApiClient, project_id: &str, @@ -325,41 +424,42 @@ async fn resolve_topics_setup( client: &ApiClient, project_id: &str, selector: Option<&str>, + create_if_missing: bool, ) -> Result { - let automations = list_topic_automations(client, project_id).await?; - let automation = select_topic_automation(automations, selector)?; - let embedding_model = embedding_model_for_automation(client, &automation).await?; + let automations = list_project_automations(client, project_id).await?; + let automation = select_topic_automation(automations, selector, create_if_missing)?; + let embedding_model = if let Some(automation) = automation.as_ref() { + embedding_model_for_automation(client, automation).await? + } else { + DEFAULT_TOPICS_EMBEDDING_MODEL.to_string() + }; + let automation_name = automation + .as_ref() + .map(|automation| automation.name.clone()) + .or_else(|| selector.map(str::to_string)) + .expect("a creatable Topics automation has a template name"); Ok(TopicsSetup { automation, + automation_name, embedding_model, }) } -async fn list_topic_automations( +async fn list_project_automations( client: &ApiClient, project_id: &str, ) -> Result> { let path = format!("/v1/project_automation?project_id={}", encode(project_id)); let automations: Vec = - pagination::list_all(client, &path, "Topics automations").await?; - Ok(automations - .into_iter() - .filter(|automation| { - automation.config.get("event_type").and_then(Value::as_str) == Some("topic") - }) - .collect()) + pagination::list_all(client, &path, "project automations").await?; + Ok(automations) } fn select_topic_automation( automations: Vec, selector: Option<&str>, -) -> Result { - if automations.is_empty() { - bail!( - "Topics is not enabled in the target project; run `bt topics config enable` before pushing a new facet" - ); - } - + create_if_missing: bool, +) -> Result> { if let Some(selector) = selector { let selector = selector.trim(); if selector.is_empty() { @@ -368,17 +468,38 @@ fn select_topic_automation( let mut matches = automations .into_iter() .filter(|automation| automation.id == selector || automation.name == selector); - let found = matches.next().ok_or_else(|| { - anyhow!( - "Topics automation '{selector}' was not found in the target project; use its exact name or ID" - ) - })?; + let found = matches.next(); if matches.next().is_some() { bail!( "multiple Topics automations named '{selector}' were found; use an automation ID with --topics-automation" ); } - return Ok(found); + let Some(found) = found else { + if create_if_missing { + return Ok(None); + } + bail!( + "Topics automation '{selector}' was not found in the target project; use its exact name or ID" + ); + }; + if found.config.get("event_type").and_then(Value::as_str) != Some("topic") { + bail!( + "automation '{selector}' already exists but is not a Topics automation; rename the template mapping to avoid a different automation type" + ); + } + return Ok(Some(found)); + } + + let automations = automations + .into_iter() + .filter(|automation| { + automation.config.get("event_type").and_then(Value::as_str) == Some("topic") + }) + .collect::>(); + if automations.is_empty() { + bail!( + "Topics is not enabled in the target project; run `bt topics config enable` before pushing a legacy facet without a Topics automation mapping" + ); } if automations.len() > 1 { @@ -386,7 +507,9 @@ fn select_topic_automation( "multiple Topics automations were found in the target project; select one with --topics-automation " ); } - Ok(automations.into_iter().next().expect("checked non-empty")) + Ok(Some( + automations.into_iter().next().expect("checked non-empty"), + )) } async fn embedding_model_for_automation( @@ -538,12 +661,23 @@ fn topic_map_insert_request( }) } -async fn attach_facet_to_topics_automation( +async fn attach_facet_to_topics( client: &ApiClient, - automation: &RemoteProjectAutomation, + project_id: &str, + topics_setup: TopicsSetup, facet_id: &str, topic_map_id: &str, ) -> Result<()> { + let Some(automation) = topics_setup.automation else { + return create_topics_automation( + client, + project_id, + &topics_setup.automation_name, + facet_id, + topic_map_id, + ) + .await; + }; let config = topics_config_with_functions(&automation.config, facet_id, topic_map_id)?; let request = serde_json::json!({ "id": automation.id, @@ -557,6 +691,59 @@ async fn attach_facet_to_topics_automation( Ok(()) } +async fn create_topics_automation( + client: &ApiClient, + project_id: &str, + name: &str, + facet_id: &str, + topic_map_id: &str, +) -> Result<()> { + let request = new_topics_automation_request(project_id, name, facet_id, topic_map_id); + let created: RemoteProjectAutomation = client + .put("/v1/project_automation", &request) + .await + .with_context(|| format!("failed to create Topics automation '{name}'"))?; + crate::topics::api::seed_new_topic_automation_cursors( + client, + project_id, + &created.id, + &created.config, + ) + .await + .with_context(|| format!("failed to start Topics automation '{name}'"))?; + Ok(()) +} + +fn new_topics_automation_request( + project_id: &str, + name: &str, + facet_id: &str, + topic_map_id: &str, +) -> Value { + serde_json::json!({ + "project_id": project_id, + "name": name, + "description": DEFAULT_TOPICS_AUTOMATION_DESCRIPTION, + "config": { + "event_type": "topic", + "sampling_rate": 1.0, + "facet_functions": [ + {"type": "function", "id": facet_id} + ], + "topic_map_functions": [{ + "function": {"type": "function", "id": topic_map_id} + }], + "scope": { + "type": "trace", + "idle_seconds": DEFAULT_TOPICS_IDLE_SECONDS + }, + "rerun_seconds": DEFAULT_TOPICS_RERUN_SECONDS, + "relabel_overlap_seconds": DEFAULT_TOPICS_RELABEL_OVERLAP_SECONDS, + "backfill_time_range": format!("{DEFAULT_TOPICS_WINDOW_SECONDS}s") + } + }) +} + fn topics_config_with_functions( config: &Value, facet_id: &str, @@ -626,7 +813,11 @@ async fn get_preprocessor_by_slug( Ok(function) } -async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Result { +async fn template_from_remote( + client: &ApiClient, + remote: RemoteFunction, + topics_automation: Option, +) -> Result { if remote.function_type.as_deref() != Some("facet") { bail!("function '{}' is not a facet", remote.name); } @@ -635,6 +826,7 @@ async fn template_from_remote(client: &ApiClient, remote: RemoteFunction) -> Res let template = FacetTemplate { name: remote.name, slug: remote.slug, + topics_automation, description: remote.description, preprocessor, function_data, @@ -771,6 +963,16 @@ pub(super) fn validate_template(template: &FacetTemplate) -> Result<()> { if template.slug.trim().is_empty() { bail!("facet template slug must not be empty"); } + if let Some(topics_automation) = template.topics_automation.as_deref() { + if topics_automation.trim().is_empty() { + bail!("facet template Topics automation name must not be empty"); + } + if topics_automation != topics_automation.trim() { + bail!( + "facet template Topics automation name must not have leading or trailing whitespace" + ); + } + } if template.function_data.get("type").and_then(Value::as_str) != Some("facet") { bail!("facet template function_data.type must be 'facet'"); } @@ -829,6 +1031,10 @@ impl FacetTemplate { &self.name } + pub(super) fn is_active(&self) -> bool { + self.topics_automation.is_some() + } + pub(super) fn slug(&self) -> &str { &self.slug } @@ -934,6 +1140,7 @@ mod tests { FacetTemplate { name: "test-facet".to_string(), slug: "test-facet".to_string(), + topics_automation: Some("Topics".to_string()), description: Some("A synthetic facet".to_string()), preprocessor: None, function_data: serde_json::json!({ @@ -1025,6 +1232,7 @@ mod tests { assert_eq!(value["name"], "test-facet"); assert_eq!(value["slug"], "test-facet"); + assert_eq!(value["topics_automation"], "Topics"); assert!(value.get("kind").is_none()); assert!(value.get("schema_version").is_none()); assert!(value.get("id").is_none()); @@ -1033,6 +1241,63 @@ mod tests { assert!(value.get("function_type").is_none()); } + #[test] + fn maps_facets_to_their_topics_automation() { + let facet = remote_facet(); + let mut topic_map = remote_topic_map(); + topic_map.function_data["source_facet"] = Value::String("Stale facet name".to_string()); + topic_map.function_data["source_facet_function"] = serde_json::json!({ + "type": "function", + "id": facet.id + }); + + let mapping = HashMap::from([(topic_map.id.clone(), "Topics A".to_string())]); + assert_eq!( + topics_automation_for_facet(&facet, &[topic_map.clone()], &mapping) + .expect("facet mapping"), + Some("Topics A".to_string()) + ); + + let mut legacy_topic_map = topic_map.clone(); + legacy_topic_map + .function_data + .as_object_mut() + .unwrap() + .remove("source_facet_function"); + legacy_topic_map.function_data["source_facet"] = Value::String(facet.name.clone()); + assert_eq!( + topics_automation_for_facet(&facet, &[legacy_topic_map], &mapping) + .expect("legacy facet mapping"), + Some("Topics A".to_string()) + ); + + assert_eq!( + topics_automation_for_facet(&facet, &[topic_map], &HashMap::new()) + .expect("inactive facet"), + None + ); + } + + #[test] + fn rejects_a_facet_mapped_to_multiple_topics_automations() { + let facet = remote_facet(); + let mut first_topic_map = remote_topic_map(); + first_topic_map.function_data["source_facet_function"]["id"] = + Value::String(facet.id.clone()); + let mut second_topic_map = first_topic_map.clone(); + second_topic_map.id = "fake-topic-map-id-2".to_string(); + let mapping = HashMap::from([ + (first_topic_map.id.clone(), "Topics A".to_string()), + (second_topic_map.id.clone(), "Topics B".to_string()), + ]); + + let err = + topics_automation_for_facet(&facet, &[first_topic_map, second_topic_map], &mapping) + .expect_err("ambiguous facet mapping"); + + assert!(err.to_string().contains("Topics A, Topics B")); + } + #[test] fn validates_facet_shape() { validate_template(&facet_template()).expect("valid facet"); @@ -1043,6 +1308,30 @@ mod tests { assert!(err.to_string().contains("function_data.type")); } + #[test] + fn validates_topics_automation_mapping() { + let mut template = facet_template(); + template.topics_automation = Some(" ".to_string()); + + let err = validate_template(&template).expect_err("empty Topics mapping"); + + assert!(err.to_string().contains("Topics automation name")); + } + + #[test] + fn command_line_topics_override_takes_precedence_over_template_mapping() { + let template = facet_template(); + + assert_eq!( + topics_automation_target(&template, Some("Override Topics")), + (Some("Override Topics"), false) + ); + assert_eq!( + topics_automation_target(&template, None), + (Some("Topics"), true) + ); + } + #[test] fn accepts_portable_project_preprocessor_reference() { let mut template = facet_template(); @@ -1135,15 +1424,19 @@ mod tests { #[test] fn requires_topics_before_creating_a_facet() { - let err = select_topic_automation(Vec::new(), None).expect_err("Topics required"); + let err = select_topic_automation(Vec::new(), None, false).expect_err("Topics required"); assert!(err.to_string().contains("bt topics config enable")); } #[test] fn selects_the_only_topics_automation_or_an_explicit_id() { - let selected = - select_topic_automation(vec![topic_automation("fake-topics-id", "Topics")], None) - .expect("single Topics automation"); + let selected = select_topic_automation( + vec![topic_automation("fake-topics-id", "Topics")], + None, + false, + ) + .expect("single Topics automation") + .expect("existing Topics automation"); assert_eq!(selected.id, "fake-topics-id"); let selected = select_topic_automation( @@ -1152,11 +1445,35 @@ mod tests { topic_automation("fake-topics-id-2", "Topics"), ], Some("fake-topics-id-2"), + false, ) - .expect("explicit Topics automation ID"); + .expect("explicit Topics automation ID") + .expect("existing Topics automation"); assert_eq!(selected.id, "fake-topics-id-2"); } + #[test] + fn allows_a_template_mapping_to_create_a_missing_topics_automation() { + let selected = select_topic_automation(Vec::new(), Some("Topics A"), true) + .expect("creatable Topics automation"); + + assert!(selected.is_none()); + } + + #[test] + fn does_not_replace_a_non_topics_automation_with_the_mapped_name() { + let mut automation = topic_automation("fake-loop-id", "Topics A"); + automation.config = serde_json::json!({ + "event_type": "windowed", + "loop": {"prompt": "Review traces"} + }); + + let err = select_topic_automation(vec![automation], Some("Topics A"), true) + .expect_err("different automation type"); + + assert!(err.to_string().contains("not a Topics automation")); + } + #[test] fn requires_a_selector_for_multiple_topics_automations() { let err = select_topic_automation( @@ -1165,11 +1482,35 @@ mod tests { topic_automation("fake-topics-id-2", "Topics B"), ], None, + false, ) .expect_err("selector required"); assert!(err.to_string().contains("--topics-automation")); } + #[test] + fn new_topics_automation_uses_product_defaults_and_initial_facet_mapping() { + let request = new_topics_automation_request( + "fake-project-id", + "Topics A", + "fake-facet-id", + "fake-topic-map-id", + ); + + assert_eq!(request["name"], "Topics A"); + assert_eq!(request["config"]["event_type"], "topic"); + assert_eq!( + request["config"]["facet_functions"][0]["id"], + "fake-facet-id" + ); + assert_eq!( + request["config"]["topic_map_functions"][0]["function"]["id"], + "fake-topic-map-id" + ); + assert_eq!(request["config"]["scope"]["type"], "trace"); + assert_eq!(request["config"]["backfill_time_range"], "86400s"); + } + #[test] fn topic_map_uses_the_pushed_facet_identity() { let request = topic_map_insert_request( diff --git a/src/active_observability_template/mod.rs b/src/active_observability_template/mod.rs index 34af32ed..7247d250 100644 --- a/src/active_observability_template/mod.rs +++ b/src/active_observability_template/mod.rs @@ -23,7 +23,6 @@ use self::facet::{FacetTemplate, RemoteFunction}; Examples: bt active-observability-template pull --output active-observability-template.json bt active-observability-template push active-observability-template.json --org test-org --project test-project - bt active-observability-template push active-observability-template.json --topics-automation Topics bt active-observability-template push https://example.com/active-observability-template.json bt active-observability-template pull | bt active-observability-template push - --project test-project ")] @@ -36,12 +35,14 @@ pub(crate) struct ActiveObservabilityTemplateArgs { enum ActiveObservabilityTemplateCommand { /// Pull facets and Loop automations into one portable active observability template /// - /// In an interactive terminal, all resources are selected by default. Use Space to - /// exclude resources and Enter to confirm. Use --no-input to include everything - /// without prompting. Project-specific preprocessors used by selected facets are - /// included automatically. Automation destination actions are excluded. + /// In an interactive terminal, active resources are selected by default and inactive + /// resources remain available but unchecked. Use Space to change the selection and + /// Enter to confirm. Use --no-input to include everything without prompting. + /// Project-specific preprocessors used by selected facets are included automatically. + /// Each facet's Topics automation mapping is preserved. Automation destination actions + /// are excluded. Pull(PullArgs), - /// Push facets and Loop automations from an active observability template + /// Push facets and Loop automations, creating mapped Topics automations when needed Push(PushArgs), } @@ -81,7 +82,7 @@ struct PushArgs { )] file_flag: Option, - /// Topics automation name or ID to attach facets to + /// Override every facet's template mapping with an existing Topics automation name or ID #[arg( long, env = "BT_ACTIVE_OBSERVABILITY_TEMPLATE_PUSH_TOPICS_AUTOMATION", @@ -149,13 +150,17 @@ pub(crate) async fn run(base: BaseArgs, args: ActiveObservabilityTemplateArgs) - async fn pull(base: BaseArgs, args: PullArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, true).await?; - let (facets, automations) = with_spinner("Loading active observability template...", async { - tokio::try_join!( - facet::list_templates(&ctx.client, &ctx.project.id), - automation::list_templates(&ctx.client, &ctx.project.id), - ) - }) - .await?; + let (remote_functions, (automations, topic_map_automation_names)) = + with_spinner("Loading active observability template...", async { + tokio::try_join!( + facet::list_remote_functions(&ctx.client, &ctx.project.id), + automation::list_templates(&ctx.client, &ctx.project.id), + ) + }) + .await?; + let facets = + facet::templates_from_remote(&ctx.client, remote_functions, &topic_map_automation_names) + .await?; let (facets, automations) = if !base.json && ui::is_interactive() && ui::can_prompt() { select_resources(facets, automations)? } else { @@ -224,14 +229,16 @@ fn select_resources( let labels = facets .iter() - .map(|facet| format!("Facet {}", facet.name())) - .chain( - automations - .iter() - .map(|automation| format!("Automation {}", automation.name())), - ) + .map(|facet| resource_selection_label("Facet", facet.name(), facet.is_active())) + .chain(automations.iter().map(|automation| { + resource_selection_label("Automation", automation.name(), automation.is_active()) + })) + .collect::>(); + let defaults = facets + .iter() + .map(FacetTemplate::is_active) + .chain(automations.iter().map(AutomationTemplate::is_active)) .collect::>(); - let defaults = vec![true; labels.len()]; let term = ui::prompt_term().ok_or_else(|| anyhow::anyhow!("interactive mode requires TTY"))?; let selected = MultiSelect::with_theme(&ColorfulTheme::default()) .with_prompt("Select facets and Loop automations to include") @@ -246,6 +253,11 @@ fn select_resources( Ok(filter_resources(facets, automations, &selected)) } +fn resource_selection_label(kind: &str, name: &str, active: bool) -> String { + let status = if active { "" } else { " (inactive)" }; + format!("{kind:<12}{name}{status}") +} + fn selected_resources_report(labels: &[String], selected: &[usize]) -> String { if selected.is_empty() { return "Selected no facets or Loop automations".to_string(); @@ -582,6 +594,7 @@ mod tests { "facets": [{ "name": "test-facet", "slug": "test-facet", + "topics_automation": "Topics", "function_data": { "type": "facet", "prompt": "Classify the trace" @@ -627,6 +640,21 @@ mod tests { assert_eq!(template.automations.len(), 1); } + #[test] + fn accepts_legacy_facets_without_a_topics_automation_mapping() { + let mut value = active_observability_template_json(); + value["facets"][0] + .as_object_mut() + .expect("facet object") + .remove("topics_automation"); + + let template: ActiveObservabilityTemplate = + serde_json::from_value(value).expect("legacy active observability template"); + + validate_template(&template).expect("valid legacy template"); + assert!(!template.facets[0].is_active()); + } + #[test] fn defaults_missing_resource_arrays_to_empty() { let template: ActiveObservabilityTemplate = serde_json::from_value(serde_json::json!({ @@ -670,6 +698,18 @@ mod tests { ); } + #[test] + fn inactive_resources_are_labeled_in_the_picker() { + assert_eq!( + resource_selection_label("Facet", "Test facet", true), + "Facet Test facet" + ); + assert_eq!( + resource_selection_label("Automation", "Test automation", false), + "Automation Test automation (inactive)" + ); + } + #[test] fn rejects_duplicate_resource_identity() { let mut value = active_observability_template_json(); diff --git a/src/topics/api.rs b/src/topics/api.rs index 736276ee..f8ca4c7d 100644 --- a/src/topics/api.rs +++ b/src/topics/api.rs @@ -957,6 +957,20 @@ async fn seed_topic_automation_cursors( }) } +pub(crate) async fn seed_new_topic_automation_cursors( + client: &ApiClient, + project_id: &str, + automation_id: &str, + config: &Value, +) -> Result<()> { + let automation_row = serde_json::json!({ + "id": automation_id, + "config": config, + }); + seed_topic_automation_cursors(client, project_id, &automation_row, None).await?; + Ok(()) +} + fn filter_or_resolve_topic_automation_rows( rows: Vec, automation_id: Option<&str>,