diff --git a/bt-daemon/src/command_output.rs b/bt-daemon/src/command_output.rs index 3039a4f..990c1f3 100644 --- a/bt-daemon/src/command_output.rs +++ b/bt-daemon/src/command_output.rs @@ -4,7 +4,7 @@ //! the output shape to this crate so every front-end reports daemon commands //! consistently and JSON mode never falls back to human prose. -use crate::wire::{SessionRoute, StatusResult}; +use crate::wire::{SessionRoute, StatusResult, TraceDestination}; use serde::Serialize; use std::path::PathBuf; @@ -67,6 +67,17 @@ pub struct StopCommandOutput { pub stopped: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct ImportSummary { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_span_id: Option, + pub span_count: usize, + pub finalized: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct AuthDiagnostic { pub status: String, @@ -105,6 +116,7 @@ pub enum TraceCommandOutput { Enable(SetupCommandOutput), Disable(SetupCommandOutput), Stop(StopCommandOutput), + Import { summaries: Vec }, } impl TraceCommandOutput { @@ -133,6 +145,10 @@ impl TraceCommandOutput { Self::Stop(StopCommandOutput { running, stopped }) } + pub fn import(summaries: Vec) -> Self { + Self::Import { summaries } + } + pub fn disable( source: impl Into, display_name: impl Into, @@ -205,7 +221,48 @@ impl TraceCommandOutput { )), Self::Stop(stop) if stop.stopped => Ok("Tracing daemon stopped.".into()), Self::Stop(_) => Ok("No tracing daemon is running.".into()), + Self::Import { summaries } => Ok(summaries + .iter() + .map(|summary| { + let destination = summary + .destination + .as_ref() + .map(render_destination) + .unwrap_or_else(|| "the configured destination".into()); + let root = summary + .root_span_id + .as_deref() + .map(|id| format!(", root span {id}")) + .unwrap_or_default(); + format!( + "Imported session {} to {}: {} spans{}.", + summary.session_id, destination, summary.span_count, root + ) + }) + .collect::>() + .join("\n")), + } + } +} + +fn render_destination(destination: &TraceDestination) -> String { + match destination { + TraceDestination::ProjectLogs { + project_id, + project_name, + } => project_name + .as_ref() + .map(|name| format!("project {name}")) + .or_else(|| project_id.as_ref().map(|id| format!("project {id}"))) + .unwrap_or_else(|| "project logs".into()), + TraceDestination::Experiment { experiment_id } => { + format!("experiment {experiment_id}") } + TraceDestination::ParentSpan { components } => components + .span_id + .as_ref() + .map(|id| format!("parent span {id}")) + .unwrap_or_else(|| "a parent span".into()), } } @@ -289,6 +346,30 @@ mod tests { ); } + #[test] + fn import_summary_has_stable_human_and_json_output() { + let output = TraceCommandOutput::import(vec![ImportSummary { + session_id: "session-1".into(), + destination: Some(TraceDestination::ProjectLogs { + project_id: None, + project_name: Some("Agents".into()), + }), + root_span_id: Some("root-1".into()), + span_count: 3, + finalized: true, + }]); + assert_eq!( + output.render(OutputFormat::Human).unwrap(), + "Imported session session-1 to project Agents: 3 spans, root span root-1." + ); + let value: serde_json::Value = + serde_json::from_str(&output.render(OutputFormat::Json).unwrap()).unwrap(); + assert_eq!(value["command"], "import"); + assert_eq!(value["summaries"][0]["session_id"], "session-1"); + assert_eq!(value["summaries"][0]["span_count"], 3); + assert_eq!(value["summaries"][0]["finalized"], true); + } + #[test] fn human_output_preserves_existing_messages() { assert_eq!( diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index e5d4180..34b784c 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -33,8 +33,8 @@ mod transport; pub mod wire; pub use client::HostInfo; pub use command_output::{ - AuthDiagnostic, DoctorCommandOutput, OutputFormat, SetupCommandOutput, StatusCommandOutput, - StopCommandOutput, TraceCommandOutput, + AuthDiagnostic, DoctorCommandOutput, ImportSummary, OutputFormat, SetupCommandOutput, + StatusCommandOutput, StopCommandOutput, TraceCommandOutput, }; pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions}; pub use setup::{run_disable, run_enable, run_setup}; @@ -49,7 +49,7 @@ pub use translate::{ }; use anyhow::Context; -use braintrust_sdk_rust::SpanComponents; +use braintrust_sdk_rust::{SpanComponents, SpanObjectType}; use clap::{Args, ValueEnum}; use std::ffi::OsString; use std::path::PathBuf; @@ -160,11 +160,50 @@ pub struct ImportArgs { pub all: bool, /// Destination object reference, such as `project_logs:` or /// `experiment:`. - #[arg(long, value_name = "DESTINATION", conflicts_with = "parent")] + #[arg( + long, + value_name = "DESTINATION", + conflicts_with_all = ["parent", "parent_span_id", "parent_root_span_id", "parent_object_type", "parent_object_id", "parent_project"] + )] pub destination: Option, - /// Attach the imported session below an exported Braintrust span. - #[arg(long, value_name = "SPAN_COMPONENTS", conflicts_with = "destination")] + /// Attach below the opaque value returned by a Braintrust SDK span.export(). + #[arg( + long, + value_name = "SPAN_EXPORT", + conflicts_with_all = ["destination", "parent_span_id", "parent_root_span_id", "parent_object_type", "parent_object_id", "parent_project"] + )] pub parent: Option, + /// Attach below this span ID. Also requires --parent-root-span-id and + /// --parent-object-type, plus --parent-object-id or --parent-project. + #[arg( + long, + value_name = "SPAN_ID", + requires_all = ["parent_root_span_id", "parent_object_type"], + conflicts_with_all = ["destination", "parent"] + )] + pub parent_span_id: Option, + /// Root span ID for --parent-span-id. + #[arg(long, value_name = "ROOT_SPAN_ID", requires = "parent_span_id")] + pub parent_root_span_id: Option, + /// Braintrust object type for --parent-span-id. + #[arg(long, value_enum, requires = "parent_span_id")] + pub parent_object_type: Option, + /// Braintrust object ID for --parent-span-id. + #[arg( + long, + value_name = "OBJECT_ID", + requires = "parent_span_id", + conflicts_with = "parent_project" + )] + pub parent_object_id: Option, + /// Braintrust project name for a project-logs parent. + #[arg( + long, + value_name = "PROJECT", + requires = "parent_span_id", + conflicts_with = "parent_object_id" + )] + pub parent_project: Option, /// Keep following the transcript until Ctrl-C, importing new turns as the /// coding-agent session grows. #[arg(long, conflicts_with = "all")] @@ -181,6 +220,31 @@ pub enum ImportSource { Claude, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ParentObjectType { + Experiment, + #[value(name = "project_logs", alias = "project-logs")] + ProjectLogs, + #[value(name = "playground_logs", alias = "playground-logs")] + PlaygroundLogs, +} + +impl From for SpanObjectType { + fn from(value: ParentObjectType) -> Self { + match value { + ParentObjectType::Experiment => Self::Experiment, + ParentObjectType::ProjectLogs => Self::ProjectLogs, + ParentObjectType::PlaygroundLogs => Self::PlaygroundLogs, + } + } +} + +impl ImportArgs { + pub fn has_destination_override(&self) -> bool { + self.destination.is_some() || self.parent.is_some() || self.parent_span_id.is_some() + } +} + /// Arguments for launching a coding agent with invocation-local live hooks. #[derive(Debug, Clone, Args)] #[command(trailing_var_arg = true)] @@ -485,12 +549,11 @@ pub async fn run_import( args: ImportArgs, opts: ServeOptions, mut config: Option, -) -> anyhow::Result<()> { +) -> anyhow::Result> { validate_import_selection(&args)?; - let destination = args - .parent + let destination = import_parent_components(&args)? .map(|components| wire::TraceDestination::ParentSpan { components }) - .or(args.destination); + .or_else(|| args.destination.clone()); apply_import_destination(&mut config, destination)?; let files = transcript_import::resolve_transcripts(&args.session_ids, args.all, args.source)?; if args.attach { @@ -506,9 +569,86 @@ fn validate_import_selection(args: &ImportArgs) -> anyhow::Result<()> { if args.attach && args.session_ids.len() != 1 { anyhow::bail!("--attach requires exactly one session id"); } + import_parent_components(args)?; Ok(()) } +fn import_parent_components(args: &ImportArgs) -> anyhow::Result> { + if let Some(parent) = &args.parent { + parent + .to_parent_span_info() + .map_err(|error| anyhow::anyhow!("invalid --parent value: {error}"))?; + return Ok(Some(parent.clone())); + } + + let Some(span_id) = args.parent_span_id.as_deref() else { + return Ok(None); + }; + let span_id = span_id.trim(); + if span_id.is_empty() { + anyhow::bail!("--parent-span-id must not be empty"); + } + let root_span_id = args + .parent_root_span_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--parent-span-id requires --parent-root-span-id"))?; + let root_span_id = root_span_id.trim(); + if root_span_id.is_empty() { + anyhow::bail!("--parent-root-span-id must not be empty"); + } + let object_type = args + .parent_object_type + .ok_or_else(|| anyhow::anyhow!("--parent-span-id requires --parent-object-type"))?; + + let object_id = args + .parent_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let parent_project = args + .parent_project + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let compute_object_metadata_args = match object_type { + ParentObjectType::ProjectLogs if object_id.is_none() => { + let project = parent_project.ok_or_else(|| { + anyhow::anyhow!( + "project_logs parent requires --parent-object-id or --parent-project" + ) + })?; + Some(serde_json::Map::from_iter([( + "project_name".to_string(), + serde_json::Value::String(project.to_string()), + )])) + } + ParentObjectType::ProjectLogs => None, + _ if parent_project.is_some() => { + anyhow::bail!("--parent-project is only valid with --parent-object-type project_logs") + } + _ if object_id.is_none() => { + anyhow::bail!("this parent object type requires --parent-object-id") + } + _ => None, + }; + + let components = SpanComponents { + object_type: object_type.into(), + object_id, + compute_object_metadata_args, + row_id: None, + span_id: Some(span_id.to_string()), + root_span_id: Some(root_span_id.to_string()), + span_parents: None, + propagated_event: None, + }; + components + .to_parent_span_info() + .map_err(|error| anyhow::anyhow!("invalid parent identifiers: {error}"))?; + Ok(Some(components)) +} + /// Launch a coding agent with inherited stdio and inject Braintrust hooks for /// this invocation, without requiring the tracing plugin to be installed or /// enabled globally. @@ -522,6 +662,17 @@ pub async fn run_traced( "managed run requires a trace destination; select a project, object destination, or parent span" ); } + if args.source == RunSource::Codex + && args.agent_args.iter().any(|arg| { + let arg = arg.to_string_lossy(); + arg == "--dangerously-bypass-hook-trust" + || arg.starts_with("--dangerously-bypass-hook-trust=") + }) + { + anyhow::bail!( + "bt trace run codex cannot be combined with --dangerously-bypass-hook-trust; managed tracing preserves Codex hook trust. Remove the flag, or run Codex directly" + ); + } let (executable_env, default_executable) = match args.source { RunSource::Codex => ("CODEX_BIN", "codex"), RunSource::Claude => ("CLAUDE_BIN", "claude"), @@ -818,7 +969,7 @@ pub async fn import_transcript( opts: ServeOptions, config: Option, attach: bool, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let mut tail = transcript_import::TranscriptTail::new(file.to_path_buf(), source); let mut processor = ImportProcessor::new(opts, config); let shutdown = tokio::signal::ctrl_c(); @@ -849,8 +1000,9 @@ pub async fn import_transcripts( source: ImportSource, opts: ServeOptions, config: Option, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let mut processor = ImportProcessor::new(opts, config); + let mut summaries = Vec::new(); for file in files { let mut tail = transcript_import::TranscriptTail::new(file.clone(), source); let entries = tail @@ -862,10 +1014,13 @@ pub async fn import_transcripts( .collect::>(); processor.process(entries).await?; for session_id in session_ids { - processor.finish_session(&session_id).await?; + if let Some(summary) = processor.finish_session(&session_id).await? { + summaries.push(summary); + } } } - processor.finish().await + summaries.extend(processor.finish().await?); + Ok(summaries) } struct ImportLive { @@ -873,6 +1028,9 @@ struct ImportLive { sink: Box, ctx: SessionCtx, pending_ops: usize, + span_ids: std::collections::HashSet, + root_span_id: Option, + destination: Option, } struct ImportProcessor { @@ -913,6 +1071,15 @@ impl ImportProcessor { config: None, }, pending_ops: 0, + span_ids: std::collections::HashSet::new(), + root_span_id: self + .config + .as_ref() + .and_then(|config| config.attached_span_ids().1), + destination: self + .config + .as_ref() + .and_then(|config| config.destination.clone()), }, ); self.sessions.get_mut(&sid).unwrap() @@ -939,6 +1106,15 @@ impl ImportProcessor { // for every native turn boundary. const FLUSH_OPS: usize = 500; for chunk in ops.chunks(FLUSH_OPS) { + for op in chunk { + let row = match op { + SpanOp::Insert(row) | SpanOp::Merge(row) => row, + }; + live.span_ids.insert(row.span_id.clone()); + if live.root_span_id.is_none() && !row.root_span_id.is_empty() { + live.root_span_id = Some(row.root_span_id.clone()); + } + } live.sink.emit(chunk).await?; live.pending_ops += chunk.len(); if live.pending_ops >= FLUSH_OPS { @@ -951,22 +1127,36 @@ impl ImportProcessor { Ok(()) } - async fn finish(self) -> anyhow::Result<()> { - for (_sid, mut live) in self.sessions { + async fn finish(self) -> anyhow::Result> { + let mut summaries = Vec::new(); + for (sid, mut live) in self.sessions { let ops = live.translator.flush(&live.ctx)?; Self::emit_translator_batches(&mut live, ops).await?; live.sink.flush().await?; + summaries.push(Self::summary(sid, live)); } - Ok(()) + summaries.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + Ok(summaries) } - async fn finish_session(&mut self, session_id: &str) -> anyhow::Result<()> { + async fn finish_session(&mut self, session_id: &str) -> anyhow::Result> { let Some(mut live) = self.sessions.remove(session_id) else { - return Ok(()); + return Ok(None); }; let ops = live.translator.flush(&live.ctx)?; Self::emit_translator_batches(&mut live, ops).await?; - live.sink.flush().await + live.sink.flush().await?; + Ok(Some(Self::summary(session_id.to_string(), live))) + } + + fn summary(session_id: String, live: ImportLive) -> ImportSummary { + ImportSummary { + session_id, + destination: live.destination, + root_span_id: live.root_span_id, + span_count: live.span_ids.len(), + finalized: true, + } } } @@ -1137,6 +1327,66 @@ mod tests { assert!(ImportCli::try_parse_from(["test", "codex", "session-a", "--all"]).is_err()); } + #[test] + fn import_parent_accepts_complete_cli_identifiers_without_guessing() { + let args = ImportCli::try_parse_from([ + "test", + "codex", + "session-a", + "--parent-span-id", + "span-1", + "--parent-root-span-id", + "root-1", + "--parent-object-type", + "project_logs", + "--parent-project", + "Agents", + ]) + .unwrap() + .args; + + let components = import_parent_components(&args).unwrap().unwrap(); + assert_eq!(components.object_type, SpanObjectType::ProjectLogs); + assert_eq!(components.span_id.as_deref(), Some("span-1")); + assert_eq!(components.root_span_id.as_deref(), Some("root-1")); + assert_eq!( + components + .compute_object_metadata_args + .as_ref() + .and_then(|value| value.get("project_name")) + .and_then(serde_json::Value::as_str), + Some("Agents") + ); + } + + #[test] + fn import_parent_rejects_incomplete_or_conflicting_identifiers() { + assert!(ImportCli::try_parse_from([ + "test", + "codex", + "session-a", + "--parent-span-id", + "span-1", + ]) + .is_err()); + assert!(ImportCli::try_parse_from([ + "test", + "codex", + "session-a", + "--destination", + "project_logs:project-id", + "--parent-span-id", + "span-1", + "--parent-root-span-id", + "root-1", + "--parent-object-type", + "project_logs", + "--parent-project", + "Agents", + ]) + .is_err()); + } + #[test] fn attach_requires_one_explicit_session() { let args = ImportArgs { @@ -1145,6 +1395,11 @@ mod tests { all: false, destination: None, parent: None, + parent_span_id: None, + parent_root_span_id: None, + parent_object_type: None, + parent_object_id: None, + parent_project: None, attach: true, additional_metadata: None, }; @@ -1236,6 +1491,31 @@ mod tests { assert!(error.to_string().contains("requires a trace destination")); } + #[tokio::test] + async fn codex_managed_run_rejects_global_hook_trust_bypass_before_launch() { + let error = run_traced( + RunArgs { + source: RunSource::Codex, + additional_metadata: None, + agent_args: vec![OsString::from("--dangerously-bypass-hook-trust")], + }, + test_run_hook_command(), + SessionRoute { + destination: Some(wire::TraceDestination::ProjectLogs { + project_id: Some("project-id".into()), + project_name: None, + }), + ..SessionRoute::default() + }, + ) + .await + .unwrap_err(); + + assert!(error + .to_string() + .contains("cannot be combined with --dangerously-bypass-hook-trust")); + } + #[test] fn codex_managed_run_injects_live_hooks() { let args = managed_run_args(RunSource::Codex, &test_run_hook_command()).unwrap(); diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 64ba8e7..d8d52e1 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -284,9 +284,17 @@ async fn main() { Command::Import(args) => { let data_dir = paths::data_dir(None); let opts = debug_serve_options(VERSION, &data_dir); - if let Err(e) = run_import(args, opts, None).await { - eprintln!("bt-daemon import: {e}"); - std::process::exit(1); + match run_import(args, opts, None).await { + Ok(summaries) => println!( + "{}", + TraceCommandOutput::import(summaries) + .render(OutputFormat::from(cli.json)) + .unwrap() + ), + Err(e) => { + eprintln!("bt-daemon import: {e}"); + std::process::exit(1); + } } } Command::Run { route, args } => { diff --git a/bt-daemon/src/paths.rs b/bt-daemon/src/paths.rs index 27691ec..98b2bed 100644 --- a/bt-daemon/src/paths.rs +++ b/bt-daemon/src/paths.rs @@ -1,6 +1,7 @@ //! Socket and data-directory resolution. Both `serve` and `hook` must agree on //! the defaults, so the logic lives here. See `docs/protocol.md`. +use std::ffi::OsStr; use std::path::{Path, PathBuf}; /// Env override for the socket path (also settable via `--socket`). @@ -94,7 +95,7 @@ pub fn agent_settings_path(source: &str, explicit: Option<&Path>) -> PathBuf { } match source { "codex" => home().join(".codex").join("braintrust.json"), - "claude" | "claude-code" => home().join(".claude").join("braintrust.json"), + "claude" | "claude-code" => claude_config_dir().join("braintrust.json"), "opencode" => std::env::var_os("XDG_CONFIG_HOME") .filter(|path| !path.is_empty()) .map(PathBuf::from) @@ -110,6 +111,24 @@ pub fn agent_settings_path(source: &str, explicit: Option<&Path>) -> PathBuf { } } +/// Claude Code's shared settings file. Braintrust never mutates this file; +/// setup only inspects it for obsolete tracing-specific environment entries. +pub(crate) fn claude_settings_path() -> PathBuf { + claude_config_dir().join("settings.json") +} + +fn claude_config_dir() -> PathBuf { + let home = home(); + claude_config_dir_from(std::env::var_os("CLAUDE_CONFIG_DIR").as_deref(), &home) +} + +fn claude_config_dir_from(config_dir: Option<&OsStr>, home: &Path) -> PathBuf { + config_dir + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")) +} + /// Resolve Antigravity's native configuration directory. pub(crate) fn antigravity_config_dir() -> PathBuf { if let Some(path) = std::env::var_os(ANTIGRAVITY_CONFIG_DIR_ENV) { @@ -164,4 +183,19 @@ mod tests { ); } } + + #[test] + fn claude_config_dir_override_selects_the_active_settings_directory() { + assert_eq!( + claude_config_dir_from( + Some(OsStr::new("/tmp/custom-claude")), + Path::new("/home/test") + ), + Path::new("/tmp/custom-claude") + ); + assert_eq!( + claude_config_dir_from(Some(OsStr::new("")), Path::new("/home/test")), + Path::new("/home/test/.claude") + ); + } } diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index 83959dc..8258eaf 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -19,6 +19,7 @@ const CLAUDE_PLUGIN: &str = "trace-claude-code@braintrust-claude-plugin"; const OPENCODE_PLUGIN: &str = "@braintrust/trace-opencode@^1"; const PI_PLUGIN: &str = "npm:@braintrust/pi-extension@^1"; const ANTIGRAVITY_PLUGIN: &str = "braintrust-antigravity-tracing"; +const LEGACY_CLAUDE_TRACING_ENV_KEYS: [&str; 2] = ["BRAINTRUST_CC_PROJECT", "BRAINTRUST_CC_DEBUG"]; #[cfg(unix)] const ANTIGRAVITY_PLUGIN_SOURCE: &str = "https://github.com/braintrustdata/braintrust-antigravity-plugin"; @@ -216,6 +217,42 @@ fn setup_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { } } +fn legacy_claude_tracing_env_keys(path: &Path) -> anyhow::Result> { + let raw = match std::fs::read(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect Claude settings: {}", path.display())) + } + }; + let value: Value = serde_json::from_slice(&raw) + .with_context(|| format!("Claude settings are not valid JSON: {}", path.display()))?; + let env = value.get("env").and_then(Value::as_object); + Ok(LEGACY_CLAUDE_TRACING_ENV_KEYS + .into_iter() + .filter(|key| env.is_some_and(|env| env.contains_key(*key))) + .collect()) +} + +fn warn_legacy_claude_tracing_env() { + let path = paths::claude_settings_path(); + let keys = match legacy_claude_tracing_env_keys(&path) { + Ok(keys) => keys, + Err(error) => { + eprintln!("warning: could not inspect legacy Claude tracing settings: {error}"); + return; + } + }; + if !keys.is_empty() { + eprintln!( + "warning: obsolete Braintrust tracing settings in {}: {}; remove these keys from env. BRAINTRUST_API_KEY is left unchanged because the Braintrust MCP plugin may use it.", + path.display(), + keys.join(", ") + ); + } +} + fn disable_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { let plugins = runner.json("claude", &["plugin", "list", "--json"])?; if claude_plugin(&plugins).is_some() { @@ -520,6 +557,7 @@ pub fn run_enable(args: EnableArgs, route: SessionRoute) -> anyhow::Result { setup_claude(&mut runner)?; + warn_legacy_claude_tracing_env(); ("claude", "Claude Code") } SetupAgent::OpenCode => { @@ -739,6 +777,27 @@ mod tests { assert!(update < enable); } + #[test] + fn claude_legacy_env_diagnostic_is_read_only_and_preserves_api_key() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings.json"); + let contents = r#"{ + "env": { + "BRAINTRUST_CC_PROJECT": "old-project", + "BRAINTRUST_CC_DEBUG": "1", + "BRAINTRUST_API_KEY": "still-used-by-mcp", + "OTHER": "value" + } + }"#; + std::fs::write(&path, contents).unwrap(); + + assert_eq!( + legacy_claude_tracing_env_keys(&path).unwrap(), + ["BRAINTRUST_CC_PROJECT", "BRAINTRUST_CC_DEBUG"] + ); + assert_eq!(std::fs::read_to_string(path).unwrap(), contents); + } + #[test] fn opencode_reconciles_the_published_plugin_and_preserves_config() { let temp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 84498a3..1438ec2 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -367,15 +367,15 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul let mut route = host .services .resolve_route(RouteRequirements { - destination_required: import_args.destination.is_none() - && import_args.parent.is_none(), + destination_required: !import_args.has_destination_override(), interactive_auth: true, persistent_auth: false, }) .await?; apply_additional_metadata(&mut route, import_args.additional_metadata.as_deref())?; let config = session_config(&host, &route).await?; - run_import(import_args, serve_options(&host), Some(config)).await + let summaries = run_import(import_args, serve_options(&host), Some(config)).await?; + print_output(TraceCommandOutput::import(summaries), host.output_format) } TraceCommand::Run(run_args) => { let mut route = resolve_command_route( @@ -628,6 +628,11 @@ mod tests { all: false, destination, parent: None, + parent_span_id: None, + parent_root_span_id: None, + parent_object_type: None, + parent_object_id: None, + parent_project: None, attach: false, additional_metadata: None, }; diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index f402a48..c286a58 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -1,3 +1,5 @@ +use braintrust_sdk_rust::{SpanComponents, SpanObjectType}; +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig, TraceDestination}; use bt_daemon::{ import_transcript, import_transcripts, DebugSinkFactory, ImportSource, Registry, ServeOptions, }; @@ -69,6 +71,58 @@ fn inserted(rows: &[Value], span_type: &str) -> usize { .count() } +#[tokio::test] +async fn attached_import_summary_reports_the_effective_parent_root() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("attached.jsonl"); + write_jsonl( + &transcript, + &[ + json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"codex-attached","cwd":"/tmp/demo"}}), + json!({"timestamp":"2026-01-01T00:00:02Z","type":"turn_context","payload":{"model":"gpt-test"}}), + json!({"timestamp":"2026-01-01T00:00:03Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}), + json!({"timestamp":"2026-01-01T00:00:04Z","type":"event_msg","payload":{"type":"task_complete","last_agent_message":"done"}}), + ], + ); + let parent_root = "0123456789abcdef0123456789abcdef"; + let config = SessionConfig { + auth: BackendAuth { + token: "test-token".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }, + destination: Some(TraceDestination::ParentSpan { + components: SpanComponents { + object_type: SpanObjectType::ProjectLogs, + object_id: Some("project-id".into()), + compute_object_metadata_args: None, + row_id: None, + span_id: Some("0123456789abcdef".into()), + root_span_id: Some(parent_root.into()), + span_parents: None, + propagated_event: None, + }, + }), + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + }; + + let summaries = import_transcript( + &transcript, + ImportSource::Codex, + options(&tmp.path().join("spans")), + Some(config), + false, + ) + .await + .unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].root_span_id.as_deref(), Some(parent_root)); +} + #[tokio::test] async fn imports_multiple_transcripts_in_one_invocation() { let tmp = tempfile::tempdir().unwrap(); @@ -85,7 +139,7 @@ async fn imports_multiple_transcripts_in_one_invocation() { } let output = tmp.path().join("spans"); - import_transcripts( + let summaries = import_transcripts( &[first, second], ImportSource::Claude, options(&output), @@ -94,6 +148,17 @@ async fn imports_multiple_transcripts_in_one_invocation() { .await .unwrap(); + assert_eq!( + summaries + .iter() + .map(|summary| summary.session_id.as_str()) + .collect::>(), + ["claude-first", "claude-second"] + ); + assert!(summaries.iter().all(|summary| { + summary.span_count == 3 && summary.root_span_id.is_some() && summary.finalized + })); + for session_id in ["claude-first", "claude-second"] { let rows = rows(&output.join(format!("{session_id}.ndjson"))); assert_eq!(inserted(&rows, "task"), 2, "session and one turn");