diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 4d664cbb502..c82767c4034 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -460,6 +460,7 @@ dependencies = [ "baml_query_btel", "baml_release", "baml_sdkgen_types", + "baml_test_support", "baml_tests", "baml_type", "baml_version", @@ -471,6 +472,7 @@ dependencies = [ "bex_project", "bex_vm_types", "borsh", + "bridge_cffi", "btel_bcs", "btel_file", "btel_recorder", @@ -1190,7 +1192,6 @@ dependencies = [ "async-trait", "baml_db", "baml_env", - "baml_project_root", "baml_test_support", "baml_type", "baml_type_runtime", @@ -1879,10 +1880,12 @@ name = "btel_settings" version = "0.0.0-beta" dependencies = [ "baml_env", + "baml_project_root", "borsh", "serde", "sha2", "strsim", + "tempfile", "thiserror 2.0.18", "toml 0.8.23", "tracing", diff --git a/baml_language/ENV_VARS.md b/baml_language/ENV_VARS.md index 57c779f5c96..6b1fb146e09 100644 --- a/baml_language/ENV_VARS.md +++ b/baml_language/ENV_VARS.md @@ -34,7 +34,7 @@ Windows keeps an empty `login/cache/.lock` beside each credential | Variable | What it does | |---|---| -| `BAML_HOME` | Move all BAML state (CI workspace, container, read-only home). | +| `BAML_HOME` | Move user state (CI workspace, container, read-only home), including per-project local trace stores; see below. Empty means unset. | | `BAML_TOOLCHAIN` | Which toolchain `baml` runs: a channel, a version, or a path to a local `baml-cli`. Default `canary`. | | `BAML_MANIFEST_BASE_URL` | Release manifest mirror, used by the wrapper and the bridges. | | `BAML_BUILD_CACHE` | `false` turns off the build cache. | @@ -42,7 +42,7 @@ Windows keeps an empty `login/cache/.lock` beside each credential | `BAML_LOG` | Level for `log.*` output from `baml run` and `baml test` (same as `--log`): `off`, `error`, `warn`, `info` (default), `debug`, `trace`. | | `BAML_TELEMETRY` | Runtime tracing level: `off`, `low`, `medium` (default), `high`. | | `BOUNDARY_API_URL` | Boundary API gateway for login, cloud queries and telemetry. Overrides `[boundary].api_url`; default `https://api.prod.bcs.boundaryml.com`. | -| `BOUNDARY_API_KEY` | Non-interactive Boundary credential for cloud queries and telemetry. Takes precedence over saved user login. | +| `BOUNDARY_API_KEY` | Non-interactive Boundary credential for cloud queries and telemetry. Takes precedence over saved user login. Set to `local` to record and query locally even when a saved cloud login exists. | | `BOUNDARY_PROJECT` | Cloud target as `org_handle/project_name`. Overrides `[boundary].project`; query's `--project` flag takes precedence. | | `BAML_CLI_ALLOW_DIRECT` | Hides the "don't run baml-cli directly" warning. The wrapper sets it. | | `BAML_FEEDBACK_HOST` | Where `baml feedback` sends reports. Staging and tests. | @@ -80,3 +80,44 @@ Go, Rust, C++, Java, C# and TypeScript load a native library. Python and Swift l `NAPI_RS_NATIVE_LIBRARY_PATH`, `NAPI_RS_FORCE_WASI` and `NAPI_RS_ENFORCE_VERSION_CHECK` belong to the generated napi-rs loader, not to us. Not listed: test, benchmark and CI-only variables, Ruby, and the old `engine/`. + +## Shared local trace store + +SDK bridge recordings, playground runs, `baml run` / `baml test`, and `baml query --local` share a fixed per-project store at `/`. `BAML_HOME` is the BAML state directory itself, defaulting to `~/.baml`; no extra `.baml` is appended. No trace-store setting is needed or supported in `baml.toml`, and recording does not create `/.baml`. + +`` is the project directory's name (sanitized and limited to 64 characters), followed by a hyphen and the first 16 hexadecimal digits of the SHA-256 of its canonical filesystem path. Different checkouts with the same name have separate stores; symlinked paths to the same checkout share a store. Moving a project changes its store identity. Each store contains `btel/recordings/`, shared `btel/cas/` blobs, and the disposable `btel/query.sqlite` index. Store resolution reads no manifest settings and creates no directories until recording or indexing needs them. If neither a nonempty `BAML_HOME` nor an OS home directory is available, the base falls back to `.baml` relative to the working directory. + +The CLI uses its resolved project root, including an explicit project passed to `--from`. The SDK bridge discovers the project enclosing the host process's working directory using the same `baml.toml` / `baml_src/` markers as the CLI. Use an absolute `BAML_HOME` when launching processes from different directories; a relative value is resolved against each process's working directory. An unwritable store disables recording with a diagnostic rather than silently writing into another store. + +Use `BOUNDARY_API_KEY=local` to select local recording and query even with a saved Boundary login; `baml query --local` selects local reads without changing how your application records. The fixed location does not enable recording that the runtime telemetry policy has disabled. SDK hosts launched outside a project and packed executables use `/btel` directly. Packed executables do not adopt a project's store when launched inside it. + +For example, launch your computer-use demo from its project and query its recordings with the same environment. CLI entrypoints use this store too (`baml run main` below assumes the project defines `main`): + +```sh +cd /path/to/demo-computer-use +export BOUNDARY_API_KEY=local +export BAML_TELEMETRY=high +# Run your application using baml_bridge from this project or a child directory. +baml run main +baml query --local 'SELECT COUNT(*) FROM processes' +``` + +`baml query --from` also accepts an existing state directory, a directory containing `recordings/` and `cas/`, or the `recordings/` directory itself. A recognized traces directory selects local querying directly, even with cloud credentials set. It takes precedence over project discovery and `BAML_HOME`; it cannot be combined with cloud `--project` or `--environment` selectors. An explicit project root uses its fixed project store even if it contains unrelated `btel/` or `recordings/` directories. These commands all read the same store: + +```sh +# Replace the example suffix with the hash of your project's canonical path. +store="${BAML_HOME:-$HOME/.baml}/demo-computer-use-" +baml query --from "$store" 'SELECT COUNT(*) FROM processes' +baml query --from "$store/btel" 'SELECT COUNT(*) FROM processes' +baml query --from "$store/btel/recordings" 'SELECT COUNT(*) FROM processes' +# Query recordings from packed programs or SDK hosts without a project: +baml query --from "${BAML_HOME:-$HOME/.baml}" 'SELECT COUNT(*) FROM processes' +``` + +Compatibility: existing recordings are not moved or combined automatically. Older `/.baml` stores and formerly configured stores remain readable by passing their state directory explicitly to `baml query --from`. `[tracing].store` no longer selects a destination; remove the obsolete table from `baml.toml`. + +### Querying large stores + +The first query builds an index of the selected store; later refreshes reconcile completed files using metadata and decode new or changed files. Refresh still lists every recording and stats completed files, so a global store accumulating many projects has more discovery and reconciliation work than a project store. SQL `LIMIT` and `--max-wall` constrain query execution, not this preceding refresh. `--format json` exposes `outcome.refresh` counters/timings and `outcome.query` timings to distinguish those costs. + +The default per-project stores limit how much unrelated history each refresh visits. After building the index, `baml query --from /path/to/store --no-refresh 'SELECT COUNT(*) FROM processes'` skips file reconciliation and queries the existing index; it does not include recordings added since the last refresh. Queries can still scan indexed rows and report extents for all recordings, and captured-value expressions can read CAS blobs. This path/configuration change does not claim a speedup for SQL over the same global dataset; global-store query performance is being investigated separately. diff --git a/baml_language/crates/baml_cli/Cargo.toml b/baml_language/crates/baml_cli/Cargo.toml index ae857b9a825..fb2b6f3d0ea 100644 --- a/baml_language/crates/baml_cli/Cargo.toml +++ b/baml_language/crates/baml_cli/Cargo.toml @@ -86,7 +86,9 @@ internal = [ ] skip-integ-tests = [ ] [dev-dependencies] +baml_test_support = { workspace = true } baml_tests = { workspace = true } +bridge_cffi = { workspace = true } btel_bcs = { workspace = true } btel_file = { workspace = true } btel_recorder = { workspace = true } diff --git a/baml_language/crates/baml_cli/src/query_command.rs b/baml_language/crates/baml_cli/src/query_command.rs index 4fe165c2c2a..841aa109440 100644 --- a/baml_language/crates/baml_cli/src/query_command.rs +++ b/baml_language/crates/baml_cli/src/query_command.rs @@ -1,6 +1,6 @@ -//! `baml query`: SQL over the project's local Btel recordings. +//! `baml query`: SQL over local Btel recordings or the Boundary cloud. //! -//! The first query creates `/.baml/btel/query.sqlite`; each query +//! The first local query creates `query.sqlite` in the selected Btel root; each query //! then applies only newly completed recording files before running. Rows //! and a terminal outcome (indexed extents, evidence diagnostics) are //! printed; JSON keeps diagnostics out of row data. @@ -12,7 +12,9 @@ use std::{io::Read as _, path::PathBuf, time::Duration}; -use baml_query_btel::{Budgets, Error, Index, IndexOptions, QueryRequest, Status, catalog, format}; +use baml_query_btel::{ + Budgets, Error, Index, IndexOptions, QueryRequest, SourceLayout, Status, catalog, format, +}; use clap::{Parser, ValueEnum}; use crate::project_load::find_project_root_from; @@ -56,7 +58,9 @@ pub struct QueryArgs { #[arg(long, conflicts_with = "local")] pub environment: Option, - /// Project directory (defaults to the current directory's project). + /// Project or traces directory: a project store under `BAML_HOME`, a legacy `.baml`, + /// btel directory, or recordings directory. A traces directory selects local + /// querying directly. Defaults to the shared local store for this project. #[arg(long, value_name = "PATH")] pub from: Option, @@ -165,6 +169,7 @@ impl QueryArgs { crate::cloud_query::run(self, endpoint, credential, source, project, sql) } + /// Select a source, refresh local files when requested, and render rows and outcomes. pub fn run(&self) -> anyhow::Result { if self.schema { return self.print_schema(); @@ -178,14 +183,38 @@ impl QueryArgs { Some(sql) => sql.to_owned(), None => anyhow::bail!("provide a SELECT statement, or --schema to list relations"), }; - let root = match find_project_root_from(self.from.as_deref())? { - Some(root) => root, + // Trace folders beat enclosing projects and saved cloud credentials. + // An explicitly selected project root still uses its own configuration, + // even if it contains unrelated directories named btel or recordings. + let from = self + .from + .as_deref() + .map(|from| { + std::fs::canonicalize(from) + .map_err(|error| anyhow::anyhow!("--from {}: {error}", from.display())) + }) + .transpose()?; + let project_root = find_project_root_from(from.as_deref())?; + let trace_layout = from + .as_deref() + .filter(|from| { + !from.join(baml_db::BAML_TOML).is_file() + && !from.join(baml_db::BAML_SRC_DIR).is_dir() + }) + .and_then(SourceLayout::from_trace_directory); + let local = self.local || trace_layout.is_some(); + anyhow::ensure!( + trace_layout.is_none() || (self.project.is_none() && self.environment.is_none()), + "--from selects a local traces directory; use a project directory for a cloud query" + ); + let root = match project_root.as_ref() { + Some(root) => root.clone(), None => match &self.from { Some(from) => from.clone(), None => std::env::current_dir()?, }, }; - let api_key = if self.local { + let api_key = if local { None } else { baml_env::string_var("BOUNDARY_API_KEY")? @@ -195,7 +224,7 @@ impl QueryArgs { !local_from_env || (self.project.is_none() && self.environment.is_none()), "BOUNDARY_API_KEY=local selects local recordings; unset BOUNDARY_API_KEY to use --project or --environment for a cloud query" ); - if !self.local && !local_from_env { + if !local && !local_from_env { let settings = crate::cloud_config::Boundary::read(&root)?; let endpoint = settings.endpoint().map_err(|error| { bcs_api::diagnostics::Context { @@ -259,7 +288,19 @@ impl QueryArgs { }, explain: self.explain, }; - let outcome = Index::for_project(&root, options).and_then(|mut index| { + let layout = match trace_layout { + Some(layout) => layout, + None => { + let store = btel_settings::local_store::resolve( + project_root.as_deref().or(self.from.as_deref()), + baml_env::os_var("BAML_HOME"), + std::env::home_dir(), + )?; + SourceLayout::for_btel(store.join("btel")) + } + }; + let recordings = layout.recordings.clone(); + let outcome = Index::open(layout, options).and_then(|mut index| { if self.no_refresh { index.query(&request) } else { @@ -287,8 +328,8 @@ impl QueryArgs { } if result.outcome.source_missing && self.format == QueryFormat::Table { eprintln!( - "note: no recordings at {}; run BAML code in this project to record some", - root.join(".baml/btel/recordings").display() + "note: no recordings at {}; run BAML code with this local store to record some", + recordings.display() ); } Ok(match result.outcome.status { diff --git a/baml_language/crates/baml_cli/src/runtime_telemetry.rs b/baml_language/crates/baml_cli/src/runtime_telemetry.rs index 7358bc112c3..b7cccec6824 100644 --- a/baml_language/crates/baml_cli/src/runtime_telemetry.rs +++ b/baml_language/crates/baml_cli/src/runtime_telemetry.rs @@ -15,6 +15,7 @@ pub(crate) fn create_engine( create_engine_with_context(program, argv, project_root, sources, Default::default()) } +/// Create the CLI engine with its launch context and shared local-store fallback. pub(crate) fn create_engine_with_context( program: bex_vm_types::Program, argv: Vec, @@ -37,7 +38,7 @@ pub(crate) fn create_engine_with_context( boundary.api_url.as_deref(), ) .unwrap_or_else(|| { - TelemetryRecording::local_files( + TelemetryRecording::project_files( project_root, btel_settings::publisher::RecordingConfig::default(), ) diff --git a/baml_language/crates/baml_cli/tests/boundary_telemetry_e2e.rs b/baml_language/crates/baml_cli/tests/boundary_telemetry_e2e.rs index e54ac6633af..b0d06cbc141 100644 --- a/baml_language/crates/baml_cli/tests/boundary_telemetry_e2e.rs +++ b/baml_language/crates/baml_cli/tests/boundary_telemetry_e2e.rs @@ -373,13 +373,22 @@ async fn initial_connection_failures_warn_without_changing_execution() { ); } +/// The local sentinel must keep both recording and querying away from the cloud. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn local_sentinel_records_and_queries_locally_without_boundary_requests() { let temp = fixture("function main() -> int { 7 }\n"); let server = MockServer::start().await; let output = execute_case(temp.path(), &server.uri(), "local", &["run", "main"]); assert!(output.status.success(), "{output:?}"); - assert!(temp.path().join(RECORDINGS).exists()); + // Local writers and readers now honor this fixture's BAML_HOME. + let store = btel_settings::local_store::resolve( + Some(temp.path()), + Some(temp.path().join("home").into()), + None, + ) + .unwrap(); + assert!(store.join("btel/recordings").exists()); + assert!(!temp.path().join(RECORDINGS).exists()); let output = execute_case( temp.path(), &server.uri(), diff --git a/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs b/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs index ca11ebddca2..9f10bba70f1 100644 --- a/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs +++ b/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs @@ -788,6 +788,7 @@ async fn wrapper_build_embeds_named_api_key_for_both_release_variants() { } } +/// Packed wrapper recordings stay in the user store and keep boundary spans value-free. #[test] fn wrapper_command_boundaries_record_spans_without_values() { let built = common::ensure_built(); @@ -843,7 +844,7 @@ fn wrapper_command_boundaries_record_spans_without_values() { let query = |sql: &str| { let output = Command::new(&built.baml_cli) .args(["query", "--local", "--format", "json", "--from"]) - .arg(&home) + .arg(home.join(".baml")) .arg(sql) .env("BAML_HOME", home.join(".baml")) .env("BAML_CLI_ALLOW_DIRECT", "1") diff --git a/baml_language/crates/baml_cli/tests/query_e2e.rs b/baml_language/crates/baml_cli/tests/query_e2e.rs index 397c49115eb..82afd8ee884 100644 --- a/baml_language/crates/baml_cli/tests/query_e2e.rs +++ b/baml_language/crates/baml_cli/tests/query_e2e.rs @@ -20,11 +20,15 @@ function main() -> int { } "; +/// Run with isolated local state so saved credentials cannot change the data source. fn cli(project: &Path, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_baml-cli")) .args(args) .current_dir(project) .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .env("BAML_HOME", project.join(".baml")) + .env("BOUNDARY_API_KEY", "local") + .env("DO_NOT_TRACK", "1") .env_remove("BAML_TELEMETRY") .envs(envs.iter().copied()) .output() @@ -204,6 +208,7 @@ fn fresh_query_processes_index_once_and_then_only_new_files() { assert_eq!(calls["outcome"]["status"], "complete"); } +/// Schema, SQL rejection, and stdin framing must also work with an empty local store. #[test] fn schema_invalid_sql_and_stdin() { let project = tempfile::tempdir().unwrap(); @@ -231,6 +236,9 @@ fn schema_invalid_sql_and_stdin() { .args(["query", "--local", "--format", "jsonl", "-"]) .current_dir(project.path()) .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .env("BAML_HOME", project.path().join(".baml")) + .env("BOUNDARY_API_KEY", "local") + .env("DO_NOT_TRACK", "1") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() diff --git a/baml_language/crates/baml_cli/tests/run_context_e2e.rs b/baml_language/crates/baml_cli/tests/run_context_e2e.rs index 07244d8b5fc..6e77e671e3b 100644 --- a/baml_language/crates/baml_cli/tests/run_context_e2e.rs +++ b/baml_language/crates/baml_cli/tests/run_context_e2e.rs @@ -11,6 +11,7 @@ use std::{ const CONTEXT: &str = r#"{"distinct_id":"pipe-user","metadata":{"phase":"launch"}}"#; +/// Keep context-query evidence isolated while sharing only the build-cache home. fn project() -> tempfile::TempDir { let project = tempfile::tempdir().unwrap(); common::write_project( @@ -51,6 +52,7 @@ fn piped_cli_with_env_context( .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") .env("BAML_CLI_ALLOW_DIRECT", "1") .env("BAML_TELEMETRY", "high") + .env("BOUNDARY_API_KEY", "local") .env("BAML_HOME", common::shared_baml_home()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/baml_language/crates/baml_cli/tests/shared_trace_store_e2e.rs b/baml_language/crates/baml_cli/tests/shared_trace_store_e2e.rs new file mode 100644 index 00000000000..f4d3f41890f --- /dev/null +++ b/baml_language/crates/baml_cli/tests/shared_trace_store_e2e.rs @@ -0,0 +1,313 @@ +//! The real bridge writes, the CLI writes, and fresh CLI processes read one store. +use std::{path::Path, process::Command}; + +use bridge_cffi::baml_bridge::cffi::{ + BamlOutboundResult, CallFunctionArgs, InvocationOptions, baml_outbound_result, + call_function_args::CallTarget, +}; +use prost::Message as _; +use serde_json::{Value, json}; + +/// Run a local-only CLI subprocess without using the developer's cloud login. +fn cli(cwd: &Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_baml-cli")) + .current_dir(cwd) + .args(args) + .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .env("BAML_CLI_ALLOW_DIRECT", "1") + .env("BOUNDARY_API_KEY", "local") + .env("DO_NOT_TRACK", "1") + .output() + .unwrap() +} + +/// Count recorded processes through the public query command and JSON output. +fn query(cwd: &Path, from: Option<&Path>) -> Value { + let mut args = vec![ + "query", + "--format", + "json", + "SELECT COUNT(*) FROM processes", + ]; + if let Some(from) = from { + args.extend(["--from", from.to_str().unwrap()]); + } + let output = cli(cwd, &args); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +/// Bridge and CLI recordings must remain visible through every supported query source. +#[tokio::test] +async fn bridge_cli_and_query_share_the_resolved_store() { + const STORE: &str = "BAML_TEST_SHARED_STORE"; + let Some(store) = std::env::var_os(STORE) else { + // Isolate the bridge singleton, cwd, auth, and configuration per scenario. + for scenario in ["baml_src", "manifest", "home", "generated"] { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + let nested = project.join("baml_src/nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + project.join("baml_src/main.baml"), + "function main() -> int { 7 }", + ) + .unwrap(); + // Source-only and manifest projects use the same fixed layout. + if scenario != "baml_src" { + std::fs::write( + project.join("baml.toml"), + "[package]\nname = 'shared-store'\n", + ) + .unwrap(); + } + let home = tmp.path().join("user"); + let baml_home = + matches!(scenario, "home" | "generated").then(|| tmp.path().join("state")); + let state = btel_settings::local_store::resolve( + Some(&project), + baml_home.clone().map(Into::into), + Some(home.clone()), + ) + .unwrap(); + std::fs::create_dir(&home).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "bridge_cli_and_query_share_the_resolved_store", + "--nocapture", + ]) + .current_dir(nested) + .env(STORE, &state) + .env("BAML_TEST_SHARED_PROJECT", &project) + .env( + "BAML_TEST_SHARED_ARTIFACT", + if scenario == "generated" { + "generated" + } else { + "legacy" + }, + ) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("BOUNDARY_API_KEY", "local") + .env("BAML_TELEMETRY", "high") + .env("DO_NOT_TRACK", "1") + .env_remove("BOUNDARY_PROJECT") + .env_remove("BOUNDARY_API_URL") + .env_remove("BAML_HOME"); + if let Some(baml_home) = baml_home { + child.env("BAML_HOME", baml_home); + } + let output = child.output().unwrap(); + assert!( + output.status.success(), + "{scenario}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!project.join(".baml").exists()); + assert!(!tmp.path().join("state/btel").exists()); + } + return; + }; + let store = std::path::PathBuf::from(store); + let project = std::path::PathBuf::from(std::env::var_os("BAML_TEST_SHARED_PROJECT").unwrap()); + let cwd = std::env::current_dir().unwrap(); + bridge_cffi::register_bridge(bridge_cffi::BridgeInfo { + language: bridge_cffi::BridgeLanguage::Python, + bridge_runtime_name: "shared-store-test".into(), + bridge_runtime_version: baml_version::CANONICAL_VERSION.into(), + toolchain_version: baml_version::CANONICAL_VERSION.into(), + }) + .unwrap(); + let program = baml_test_support::compile_source("function main() -> int { 7 }"); + let artifact = baml_artifact::encode(baml_artifact::ArtifactKind::Program, &program).unwrap(); + let blob = baml_artifact::encode_embedded(&artifact); + // Exercise both legacy blobs and the generated SDK metadata path. + let metadata = + (std::env::var("BAML_TEST_SHARED_ARTIFACT").unwrap() == "generated").then(|| { + let policy = btel_settings::artifact::ArtifactTelemetry::default(); + let codegen = toml::Table::from_iter([ + ("metadata_version".into(), 1.into()), + ("telemetry".into(), toml::Value::try_from(&policy).unwrap()), + ( + "toolchain".into(), + toml::Value::Table(toml::Table::from_iter([( + "version".into(), + baml_version::CANONICAL_VERSION.into(), + )])), + ), + ]); + toml::to_string(&toml::Table::from_iter([( + "__baml_codegen".into(), + toml::Value::Table(codegen), + )])) + .unwrap() + }); + bridge_cffi::initialize_runtime_from_blob(blob.as_bytes(), metadata.as_deref()).unwrap(); + let args = CallFunctionArgs { + call_id: bridge_cffi::new_function_call_id(), + call_target: Some(CallTarget::FunctionName("main".into())), + invocation: Some(InvocationOptions::default()), + ..Default::default() + } + .encode_to_vec(); + let request = bridge_cffi::decode_invocation_request(&args).unwrap(); + let response = bridge_cffi::execute_invocation(request).await; + let response = BamlOutboundResult::decode(response.as_slice()).unwrap(); + assert!(matches!( + response.result, + Some(baml_outbound_result::Result::Ok(_)) + )); + bridge_cffi::shutdown_runtime(None).await.unwrap(); + assert!(store.join("btel/recordings").is_dir()); + let written = query(&cwd, None); + assert_eq!(written["rows"], json!([[1]])); + + // CLI --from resolves its project independently of the launch directory. + let run = cli( + store.parent().unwrap(), + &["run", "main", "--from", project.to_str().unwrap()], + ); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + // Project roots retain their fixed store even when unrelated child + // directories look like traces. Actual trace folders below it still work. + for name in ["recordings", "btel"] { + std::fs::create_dir_all(project.join(name)).unwrap(); + assert_eq!(query(&cwd, Some(&project))["rows"], json!([[2]])); + } + // A query outside a project cannot mix in a project's recordings. + assert_eq!(query(project.parent().unwrap(), None)["rows"], json!([[0]])); + // Another checkout with the same directory name has its own store. + let other = project.parent().unwrap().join("other/app"); + std::fs::create_dir_all(other.join("baml_src")).unwrap(); + assert_eq!(query(&cwd, Some(&other))["rows"], json!([[0]])); + + // Trace directories override both enclosing projects and cloud credentials. + // Point BAML_HOME elsewhere too: explicit --from must be authoritative. + for from in [ + store.clone(), + store.join("btel"), + store.join("btel/recordings"), + ] { + let output = Command::new(env!("CARGO_BIN_EXE_baml-cli")) + .current_dir(&cwd) + .args([ + "query", + "--from", + from.to_str().unwrap(), + "--format", + "json", + "SELECT COUNT(*) FROM processes", + ]) + .env("BOUNDARY_API_KEY", "bdry_secret_should_not_contact_cloud") + .env("BAML_HOME", store.join("other-home")) + .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["rows"], json!([[2]])); + assert_eq!(result["outcome"]["refresh"]["files_decoded"], 0); + } +} + +/// An explicit empty store bypasses project discovery and rejects cloud selectors. +#[test] +fn empty_trace_folder_bypasses_enclosing_project_store() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("baml.toml"), "[package]\nname = 'app'\n").unwrap(); + let traces = tmp.path().join(".baml"); + std::fs::create_dir(&traces).unwrap(); + let result = query(tmp.path(), Some(&traces)); + assert_eq!(result["rows"], json!([[0]])); + assert_eq!(result["outcome"]["source_missing"], true); + assert!(!traces.join("btel").exists()); + let output = cli( + tmp.path(), + &[ + "query", + "--from", + traces.to_str().unwrap(), + "--project", + "org/project", + "SELECT 1", + ], + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("local traces directory")); +} + +/// Disabled source execution must not create a project trace store. +#[test] +fn telemetry_off_does_not_create_a_project_trace_store() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("baml_src")).unwrap(); + std::fs::write( + tmp.path().join("baml_src/main.baml"), + "function main() -> int { 7 }", + ) + .unwrap(); + std::fs::write(tmp.path().join("baml.toml"), "[package]\nname = 'app'\n").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_baml-cli")) + .current_dir(tmp.path()) + .args(["run", "main"]) + .env("BAML_TELEMETRY", "off") + .env("BOUNDARY_API_KEY", "local") + .env("BAML_HOME", tmp.path().join("home")) + .env("DO_NOT_TRACK", "1") + .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!tmp.path().join(".baml").exists()); + let store = btel_settings::local_store::resolve( + Some(tmp.path()), + Some(tmp.path().join("home").into()), + None, + ) + .unwrap(); + assert!(!store.exists()); +} + +/// Missing source paths must identify both the flag and the supplied directory. +#[test] +fn missing_from_path_is_named_in_the_error() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("missing-traces"); + let output = cli( + tmp.path(), + // Reach query's source resolver rather than the preceding skill check. + &[ + "--agent-skill-check", + "off", + "query", + "--from", + missing.to_str().unwrap(), + "SELECT 1", + ], + ); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("--from"), "{error}"); + assert!(error.contains(missing.to_str().unwrap()), "{error}"); + assert!(!missing.exists()); +} diff --git a/baml_language/crates/baml_cli/tests/telemetry_recording_e2e.rs b/baml_language/crates/baml_cli/tests/telemetry_recording_e2e.rs index 9111c3141cf..46778b5d23a 100644 --- a/baml_language/crates/baml_cli/tests/telemetry_recording_e2e.rs +++ b/baml_language/crates/baml_cli/tests/telemetry_recording_e2e.rs @@ -36,8 +36,9 @@ fn run(command: &mut Command, success: bool) { ); } -fn assert_recordings(root: &Path, count: usize) { - let directories: Vec<_> = std::fs::read_dir(root.join(RECORDINGS)) +/// Validate completed recording contents at an explicit recordings directory. +fn assert_recordings_in(recordings: &Path, count: usize) { + let directories: Vec<_> = std::fs::read_dir(recordings) .unwrap() .map(|entry| entry.unwrap().path()) .collect(); @@ -54,6 +55,11 @@ fn assert_recordings(root: &Path, count: usize) { } } +/// Validate recordings in the conventional user-home layout used by packed programs. +fn assert_recordings(root: &Path, count: usize) { + assert_recordings_in(&root.join(RECORDINGS), count); +} + fn home(root: &Path) -> std::path::PathBuf { let home = root.join("home"); std::fs::create_dir_all(home.join(".baml")).unwrap(); @@ -65,6 +71,7 @@ fn home(root: &Path) -> std::path::PathBuf { home } +/// A project store beneath `BAML_HOME` must survive nested launches and bytecode-cache hits. #[test] fn cli_uses_project_root_for_cold_cached_nested_expression_and_test_execution() { let temp = tempfile::tempdir().unwrap(); @@ -79,6 +86,9 @@ testset "smoke" { test "ok" { assert.is_true(true) } } "#, ); let home = home(temp.path()); + let store = + btel_settings::local_store::resolve(Some(&project), Some(home.join(".baml").into()), None) + .unwrap(); let nested = project.join("baml_src/nested"); std::fs::create_dir(&nested).unwrap(); // --from must win over both cwd and the baml_src subdirectory. Repeat to @@ -90,31 +100,31 @@ testset "smoke" { test "ok" { assert.is_true(true) } } .arg(&project), true, ); - assert_recordings(&project, count); + assert_recordings_in(&store.join("btel/recordings"), count); } run(cli(&nested, &home).args(["run", "main"]), true); - assert_recordings(&project, 3); + assert_recordings_in(&store.join("btel/recordings"), 3); run( cli(temp.path(), &home) .args(["run", "-e", "1 + 2", "--from"]) .arg(&project), true, ); - assert_recordings(&project, 4); + assert_recordings_in(&store.join("btel/recordings"), 4); run( cli(temp.path(), &home) .args(["test", "--from"]) .arg(&project), true, ); - assert_recordings(&project, 5); + assert_recordings_in(&store.join("btel/recordings"), 5); run( cli(temp.path(), &home) .args(["run", "fail", "--from"]) .arg(&project), false, ); - assert_recordings(&project, 6); + assert_recordings_in(&store.join("btel/recordings"), 6); run( cli(temp.path(), &home) .args(["run", "main", "--from"]) @@ -122,9 +132,10 @@ testset "smoke" { test "ok" { assert.is_true(true) } } .env("BAML_TELEMETRY", "off"), true, ); - assert_recordings(&project, 6); + assert_recordings_in(&store.join("btel/recordings"), 6); assert!(!temp.path().join(RECORDINGS).exists()); assert!(!nested.join(RECORDINGS).exists()); + assert!(!project.join(".baml").exists()); assert!(!home.join(RECORDINGS).exists()); } @@ -314,7 +325,7 @@ fn packed_http_bodies_false_keeps_bodies_out_of_network_spans() { let query = |sql: &str| { let output = cli(temp.path(), &home) .args(["query", "--local", "--format", "json", "--from"]) - .arg(&home) + .arg(home.join(".baml")) .arg(sql) .output() .unwrap(); diff --git a/baml_language/crates/baml_db/src/manifest.rs b/baml_language/crates/baml_db/src/manifest.rs index 896ba22d1d3..b284ceb279d 100644 --- a/baml_language/crates/baml_db/src/manifest.rs +++ b/baml_language/crates/baml_db/src/manifest.rs @@ -83,7 +83,8 @@ pub struct TestProfileManifest { /// Top-level keys owned outside the compiler's manifest model, preserved in /// [`BamlToml::unknown`]. /// The wrapper reads `toolchain` to choose the executable; the CLI's cloud -/// configuration reads `boundary`. Matching is by top-level key name. +/// configuration reads `boundary`; artifact telemetry reads `pack` and `bridge`. +/// Matching is by top-level key name. const KNOWN_UNHANDLED_TOP_LEVEL_KEYS: &[&str] = &["toolchain", "boundary", "pack", "bridge"]; #[derive(Debug, Deserialize)] @@ -379,6 +380,17 @@ mod tests { assert!(warns.iter().any(|w| w.contains("nmae")), "got: {warns:?}"); } + /// Removed trace-store configuration is no longer a recognized manifest key. + #[test] + fn tracing_settings_are_unknown() { + let manifest = parse("[package]\nname = 'app'\n[tracing]\nstore = '.baml'\n").unwrap(); + assert!( + unknown_field_warnings(&manifest) + .iter() + .any(|warning| warning.contains("tracing")) + ); + } + #[test] fn parses_generator_section_with_spans() { let m = parse( diff --git a/baml_language/crates/baml_lsp_server/src/engine.rs b/baml_language/crates/baml_lsp_server/src/engine.rs index 52a77a76a70..a437ea95954 100644 --- a/baml_language/crates/baml_lsp_server/src/engine.rs +++ b/baml_language/crates/baml_lsp_server/src/engine.rs @@ -533,7 +533,7 @@ impl RuntimeRegistry { } } -/// `recording_root` is the project root whose `.baml/btel` receives this +/// `recording_root` identifies the project store beneath `BAML_HOME` for this /// engine's telemetry recording (`BAML_TELEMETRY=off` disables it); `None` /// records nothing. pub fn construct_engine_candidate( @@ -549,7 +549,7 @@ pub fn construct_engine_candidate( Vec::new(), Some(bex_project::runtime_compiler()), btel_settings::clock::DEFAULT_MODE, - bex_engine::TelemetryRecording::local_files( + bex_engine::TelemetryRecording::project_files( root, btel_settings::publisher::RecordingConfig::default(), ) diff --git a/baml_language/crates/baml_lsp_server/src/playground_btel.rs b/baml_language/crates/baml_lsp_server/src/playground_btel.rs index a74ef5e0913..50e468da344 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_btel.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_btel.rs @@ -20,7 +20,7 @@ use std::{ sync::{Arc, Mutex, TryLockError}, }; -use baml_query_btel::{Index, IndexOptions, QueryRequest, QueryResult, SqlParam}; +use baml_query_btel::{Index, IndexOptions, QueryRequest, QueryResult, SourceLayout, SqlParam}; use serde_json::{Value, json}; /// Rows the list shows; the newest root calls first. Runtime GC pauses are @@ -147,14 +147,24 @@ impl PlaygroundTelemetry { Ok(path) } + /// Reuse one query index per project at the shared recording destination. fn index(&self, project: &Path) -> Result>>, TelemetryError> { if let Some(index) = self.indexes.lock().expect("index table").get(project) { return Ok(Some(Arc::clone(index))); } // Opening can wait on another process's index lock, so other projects // must not wait on the table meanwhile. - let index = Index::for_project(project, IndexOptions::default()) - .map_err(|e| TelemetryError::failed(e.to_string()))?; + let store = btel_settings::local_store::resolve( + Some(project), + baml_env::os_var("BAML_HOME"), + std::env::home_dir(), + ) + .map_err(|e| TelemetryError::failed(e.to_string()))?; + let index = Index::open( + SourceLayout::for_btel(store.join("btel")), + IndexOptions::default(), + ) + .map_err(|e| TelemetryError::failed(e.to_string()))?; // Nothing recorded yet: do not cache an empty in-memory index, so the // first recording is found by the next request. if index.source_missing() { diff --git a/baml_language/crates/baml_lsp_server/src/playground_btel/tests.rs b/baml_language/crates/baml_lsp_server/src/playground_btel/tests.rs index a115251fc6c..a009be3a774 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_btel/tests.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_btel/tests.rs @@ -21,7 +21,7 @@ function main(name: string) -> string { } "#; -/// The playground's own engine construction, recording to the project. +/// The playground's own engine construction, recording to the project store. fn engine(project: &Path) -> Arc { engine_for(project, SOURCE) } @@ -67,6 +67,7 @@ async fn run( .await } +/// Live refresh finds new playground recordings and preserves their captured values. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recorded_playground_runs_appear_on_refresh_with_captured_values() { let workspace = tempfile::tempdir().unwrap(); @@ -262,7 +263,18 @@ function main(name: string) -> string { let execution = main["executionId"].as_str().unwrap().to_owned(); // The request is recorded, under the call that made it. - let mut index = Index::for_project(&project, IndexOptions::default()).unwrap(); + let store = btel_settings::local_store::resolve( + Some(&project), + baml_env::os_var("BAML_HOME"), + std::env::home_dir(), + ) + .unwrap(); + assert!(!project.join(".baml").exists()); + let mut index = Index::open( + SourceLayout::for_btel(store.join("btel")), + IndexOptions::default(), + ) + .unwrap(); let network = index .refresh_and_query(&QueryRequest { sql: "SELECT span_id, parent_span_id, is_complete FROM span_announcements diff --git a/baml_language/crates/baml_lsp_server/src/playground_seam.rs b/baml_language/crates/baml_lsp_server/src/playground_seam.rs index 6b596869940..5eb3752f5d8 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_seam.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_seam.rs @@ -728,7 +728,7 @@ impl PlaygroundSeam { // against this root: the process serves every project at once and // never changes directory. let sys_ops = Arc::new(self.platform.for_root(root)); - // Playground runs record to the project's `.baml/btel`, like `baml run`. + // Playground runs use the project store beneath BAML_HOME, like `baml run`. let recording_root = root.to_path_buf(); let candidate = tokio::task::spawn_blocking(move || { construct_engine_candidate(*program, sys_ops, revision, Some(&recording_root)) diff --git a/baml_language/crates/baml_query_btel/src/lib.rs b/baml_language/crates/baml_query_btel/src/lib.rs index 3ffb4e67146..01e3bfef779 100644 --- a/baml_language/crates/baml_query_btel/src/lib.rs +++ b/baml_language/crates/baml_query_btel/src/lib.rs @@ -27,10 +27,8 @@ mod usage; use std::{path::Path, sync::Arc, time::Duration}; -use btel_reader::{ - cas::{CasLimits, CasStore}, - layout::SourceLayout, -}; +use btel_reader::cas::{CasLimits, CasStore}; +pub use btel_reader::layout::SourceLayout; pub use ingest::{RefreshMetrics, RefreshOptions}; pub use query::{Budgets, Outcome, QueryRequest, QueryResult, Status}; use rusqlite::Connection; diff --git a/baml_language/crates/bex_engine/Cargo.toml b/baml_language/crates/bex_engine/Cargo.toml index 56d62cb0a67..72e35b12866 100644 --- a/baml_language/crates/bex_engine/Cargo.toml +++ b/baml_language/crates/bex_engine/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true [dependencies] baml_env = { workspace = true } -baml_project_root = { workspace = true } baml_type = { workspace = true } baml_type_runtime = { workspace = true } bex_events = { workspace = true } diff --git a/baml_language/crates/bex_engine/src/telemetry.rs b/baml_language/crates/bex_engine/src/telemetry.rs index 4a19585bce3..a896a1a8060 100644 --- a/baml_language/crates/bex_engine/src/telemetry.rs +++ b/baml_language/crates/bex_engine/src/telemetry.rs @@ -37,6 +37,9 @@ enum Destination { recordings: PathBuf, cas: PathBuf, }, + ProjectFiles { + root: PathBuf, + }, UserFiles { project_from_cwd: bool, }, @@ -414,6 +417,16 @@ impl TelemetryRecording { } } + /// Use the project store beneath `BAML_HOME` (default `~/.baml`). + /// The project path is resolved only when enabled recording starts. + pub fn project_files(project_root: impl Into, config: RecordingConfig) -> Self { + let mut recording = Self::user_files(config); + recording.destination = Destination::ProjectFiles { + root: project_root.into(), + }; + recording + } + /// Override the root: recordings use `/` and shared /// CAS uses `/cas/v4`. No I/O or worker /// starts until engine construction; `BAML_TELEMETRY=off` ignores this output. @@ -453,12 +466,9 @@ impl TelemetryRecording { } } - /// When recording to the BAML home without an explicit `BAML_HOME`, prefer - /// the BAML project enclosing the working directory, where - /// `baml query --local` reads. Falls back to the BAML home when there is no - /// project or its directory cannot be created. For SDK hosts, which run - /// from their project; a packed program launched elsewhere must not write - /// into an unrelated project. + /// Resolve the shared local store using the project enclosing the working + /// directory. For SDK hosts; packed programs keep using the BAML home so + /// launching one elsewhere does not adopt an unrelated project's settings. #[must_use] pub fn preferring_cwd_project(mut self) -> Self { if let Destination::UserFiles { project_from_cwd } = &mut self.destination { @@ -498,6 +508,7 @@ impl TelemetryRecording { Arc::clone(&self.exit) } + /// Capture process metadata and local sources while omitting them from cloud uploads. fn process( &self, launch_context: &btel_types::context::Context, @@ -522,7 +533,9 @@ impl TelemetryRecording { // Cloud source uploads are a separate feature; local recordings retain their code. sources: (matches!( self.destination, - Destination::LocalFiles { .. } | Destination::UserFiles { .. } + Destination::LocalFiles { .. } + | Destination::ProjectFiles { .. } + | Destination::UserFiles { .. } ) && !self.sources.is_empty()) .then(|| { let pool = btel_snapshot::SnapshotPool::unbounded(btel_snapshot::Limits::default()); @@ -537,6 +550,7 @@ impl TelemetryRecording { } } + /// Resolve the selected destination and construct its recording processor and delivery. pub(crate) fn start( self, source_snapshot: Option<[u8; 32]>, @@ -554,7 +568,7 @@ impl TelemetryRecording { self.config .validate_transport(&transport) .map_err(|error| EngineError::Other(error.to_owned()))?; - let roots = match self.destination { + let (root, cas) = match self.destination { Destination::InvalidConfiguration(diagnostic) => { return Err(EngineError::CloudAuthorization(diagnostic)); } @@ -584,42 +598,34 @@ impl TelemetryRecording { .and_then(Result::ok) .as_deref(), std::env::home_dir(), - ), - Destination::LocalFiles { recordings, cas } => vec![(recordings, cas)], + ) + .map_err(|error| EngineError::Other(error.to_string()))?, + Destination::ProjectFiles { root } => { + let store = btel_settings::local_store::resolve( + Some(&root), + baml_env::os_var("BAML_HOME"), + std::env::home_dir(), + ) + .map_err(|error| EngineError::Other(error.to_string()))?; + let btel = store.join("btel"); + (btel.join("recordings"), btel.join("cas")) + } + Destination::LocalFiles { recordings, cas } => (recordings, cas), }; let mut delivery = None; let runtime = btel_processor::TelemetryRuntime::with_publisher_factory(transport, |control| { - let mut writer = Err(std::io::Error::other("no telemetry directory")); - let last = roots.len().saturating_sub(1); - for (index, (root, cas)) in roots.into_iter().enumerate() { - let failure = control.clone(); - // The CAS is first written with the first snapshot, after a - // fallback is no longer possible. - let cas_ready = if index < last { - cas_accepts_writes(&cas, self.id) - } else { - Ok(()) - }; - writer = cas_ready.and_then(|()| { - btel_file::LocalDelivery::create_with_cas( - &root, - &cas, - self.id, - btel_file::LocalDeliveryConfig::default(), - move |error| { - failure.disable(btel_processor::RuntimeError(error.to_string())); - tracing::warn!(%error, "telemetry recording disabled"); - }, - ) - }); - match &writer { - Ok(_) => break, - Err(error) => { - tracing::debug!(%error, root = %root.display(), "telemetry directory unavailable"); - } - } - } + let failure = control.clone(); + let writer = btel_file::LocalDelivery::create_with_cas( + &root, + &cas, + self.id, + btel_file::LocalDeliveryConfig::default(), + move |error| { + failure.disable(btel_processor::RuntimeError(error.to_string())); + tracing::warn!(%error, "telemetry recording disabled"); + }, + ); let builder = RecordingBuilder::new(self.id, self.config) .map_err(std::io::Error::other)? .with_source_snapshot(source_snapshot) @@ -756,32 +762,15 @@ impl TelemetryRecording { } } -/// Recording and CAS directories for [`TelemetryRecording::user_files`], most -/// preferred first. `cwd` is set only when the recording prefers the project -/// enclosing it. +/// Resolve the same store as CLI writers and readers, without silently +/// falling back to another directory when the selected store is unwritable. fn user_file_roots( baml_home: Option, cwd: Option<&Path>, home_dir: Option, -) -> Vec<(PathBuf, PathBuf)> { - use btel_settings::local_files::{CAS_DIRECTORY, RECORDINGS_DIRECTORY}; - let baml_home = baml_home.filter(|value| !value.is_empty()); - let project = cwd - .filter(|_| baml_home.is_none()) - .and_then(baml_project_root::find_baml_project_root); - let btel = baml_env::baml_home_from(baml_home, home_dir).join("btel"); - project - .map(|root| (root.join(RECORDINGS_DIRECTORY), root.join(CAS_DIRECTORY))) - .into_iter() - .chain([(btel.join("recordings"), btel.join("cas"))]) - .collect() -} - -fn cas_accepts_writes(cas: &Path, id: RecordingId) -> std::io::Result<()> { - std::fs::create_dir_all(cas)?; - let probe = btel_file::recording_directory(cas, id); - std::fs::create_dir(&probe)?; - std::fs::remove_dir(&probe) +) -> std::io::Result<(PathBuf, PathBuf)> { + let btel = btel_settings::local_store::resolve_for_cwd(cwd, baml_home, home_dir)?.join("btel"); + Ok((btel.join("recordings"), btel.join("cas"))) } fn artifact_recovery(policy: &ArtifactTelemetry) -> RecordingOverride { diff --git a/baml_language/crates/bex_engine/src/telemetry/user_files_tests.rs b/baml_language/crates/bex_engine/src/telemetry/user_files_tests.rs index a6ae6ec4873..faa5a62e1a5 100644 --- a/baml_language/crates/bex_engine/src/telemetry/user_files_tests.rs +++ b/baml_language/crates/bex_engine/src/telemetry/user_files_tests.rs @@ -9,19 +9,19 @@ use btel_types::context::Context; use super::{TelemetryRecording, user_file_roots}; +/// Recording and CAS roots inside a selected state directory. fn btel(root: &Path) -> (PathBuf, PathBuf) { (root.join("btel/recordings"), root.join("btel/cas")) } -fn project(root: &Path) -> (PathBuf, PathBuf) { - ( - root.join(".baml/btel/recordings"), - root.join(".baml/btel/cas"), - ) +/// Resolve the expected project destination with an injectable home. +fn project(root: &Path, baml_home: Option, home: Option) -> (PathBuf, PathBuf) { + btel(&btel_settings::local_store::resolve(Some(root), baml_home.map(Into::into), home).unwrap()) } +/// SDK discovery uses the enclosing project identity beneath the user home. #[test] -fn project_enclosing_cwd_precedes_home() { +fn project_enclosing_cwd_is_namespaced_under_home() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("app"); let nested = root.join("src/deep"); @@ -30,23 +30,25 @@ fn project_enclosing_cwd_precedes_home() { let home = dir.path().join("home"); assert_eq!( - user_file_roots(None, Some(&nested), Some(home.clone())), - vec![project(&root), btel(&home.join(".baml"))] + user_file_roots(None, Some(&nested), Some(home.clone())).unwrap(), + project(&root, None, Some(home)) ); } +/// An explicit BAML home still isolates each project in its own child store. #[test] -fn baml_home_overrides_project() { +fn baml_home_contains_the_project_store() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("baml.toml"), "").unwrap(); let baml_home = dir.path().join("state"); assert_eq!( - user_file_roots(Some(baml_home.clone().into()), Some(dir.path()), None), - vec![btel(&baml_home)] + user_file_roots(Some(baml_home.clone().into()), Some(dir.path()), None).unwrap(), + project(dir.path(), Some(baml_home), None) ); } +/// An empty environment override preserves the default user-home project store. #[test] fn empty_baml_home_is_unset() { let dir = tempfile::tempdir().unwrap(); @@ -54,26 +56,57 @@ fn empty_baml_home_is_unset() { let home = dir.path().join("home"); assert_eq!( - user_file_roots(Some("".into()), Some(dir.path()), Some(home.clone())), - vec![project(dir.path()), btel(&home.join(".baml"))] + user_file_roots(Some("".into()), Some(dir.path()), Some(home.clone())).unwrap(), + project(dir.path(), None, Some(home)) ); } +/// Unassociated SDK hosts and packed programs keep the user store. #[test] fn no_project_uses_home() { let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); assert_eq!( - user_file_roots(None, Some(dir.path()), Some(home.clone())), - vec![btel(&home.join(".baml"))] + user_file_roots(None, Some(dir.path()), Some(home.clone())).unwrap(), + btel(&home.join(".baml")) ); assert_eq!( - user_file_roots(None, None, Some(home.clone())), - vec![btel(&home.join(".baml"))] + user_file_roots(None, None, Some(home.clone())).unwrap(), + btel(&home.join(".baml")) ); } +/// A blocked destination disables delivery instead of selecting an unrelated home. +#[test] +fn an_unwritable_selected_store_does_not_redirect_recordings() { + const CHILD: &str = "BAML_TEST_BLOCKED_STORE"; + if let Some(root) = baml_env::os_var(CHILD) { + let root = PathBuf::from(root); + let (runtime, delivery) = + TelemetryRecording::project_files(&root, RecordingConfig::default()) + .start(None, Arc::default(), &Context::default()) + .unwrap(); + assert!(delivery.is_none(), "must not open a different store"); + assert!(runtime.finish().is_err()); + return; + } + let dir = tempfile::tempdir().unwrap(); + // A regular file blocks directory creation even for privileged test runners. + let blocked = dir.path().join("blocked"); + std::fs::write(&blocked, "not a directory").unwrap(); + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "telemetry::user_files_tests::an_unwritable_selected_store_does_not_redirect_recordings", "--nocapture"]) + .env(CHILD, dir.path()) + .env("BAML_HOME", blocked) + .env("HOME", dir.path().join("user")) + .env("USERPROFILE", dir.path().join("user")) + .output().unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(!dir.path().join(".baml").exists()); + assert!(!dir.path().join("user").exists()); +} + /// Starts real recordings in child processes whose working directory is a /// project, since the destination depends on process-wide state. #[test] @@ -131,7 +164,7 @@ fn recording_starts_in_cwd_project() { ); }; - let recordings = root.join(".baml/btel/recordings"); + let recordings = project(&root, None, Some(home.clone())).0; let fallback = home.join(".baml/btel/recordings"); std::fs::create_dir_all(&recordings).unwrap(); std::fs::create_dir_all(&fallback).unwrap(); @@ -139,25 +172,10 @@ fn recording_starts_in_cwd_project() { // Packed programs keep recording to the user store. run(&fallback, true); - // An unwritable project store falls back to the user store. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let readonly = std::fs::Permissions::from_mode(0o555); - std::fs::set_permissions(&recordings, readonly).unwrap(); - let writable = std::fs::File::create(recordings.join("probe")).is_ok(); - if !writable { - run(&fallback, false); - } - std::fs::set_permissions(&recordings, std::fs::Permissions::from_mode(0o755)).unwrap(); - - // So does a writable project store whose CAS is not. - let cas = root.join(".baml/btel/cas"); - std::fs::create_dir_all(&cas).unwrap(); - std::fs::set_permissions(&cas, std::fs::Permissions::from_mode(0o555)).unwrap(); - if !writable { - run(&fallback, false); - } - std::fs::set_permissions(&cas, std::fs::Permissions::from_mode(0o755)).unwrap(); - } + // Manifest settings cannot redirect recordings away from the fixed project store. + std::fs::write(root.join("baml.toml"), "[tracing]\nstore = 'custom'\n").unwrap(); + run(&recordings, false); + run(&fallback, true); + assert!(!root.join("custom").exists()); + assert!(!root.join(".baml").exists()); } diff --git a/baml_language/crates/btel_reader/src/layout.rs b/baml_language/crates/btel_reader/src/layout.rs index 6ce65b12270..46ac1c35eac 100644 --- a/baml_language/crates/btel_reader/src/layout.rs +++ b/baml_language/crates/btel_reader/src/layout.rs @@ -1,11 +1,11 @@ -//! Where one project's recordings and CAS blobs live. The caller resolves -//! the project; nothing here searches for it. +//! Where a store's recordings and CAS blobs live. The caller resolves +//! project configuration; explicit trace directories need no project. use std::path::{Path, PathBuf}; use btel_settings::local_files::{CAS_DIRECTORY, RECORDINGS_DIRECTORY}; use btel_snapshot::CasId; -/// `/.baml/btel`: `recordings//.btel` plus shared CAS. +/// A Btel root: `recordings//.btel` plus shared CAS. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SourceLayout { /// The directory holding `recordings/` and `cas/`, and derived state. @@ -16,6 +16,37 @@ pub struct SourceLayout { } impl SourceLayout { + /// Open the directory containing `recordings/`, `cas/` and the query index. + pub fn for_btel(root: impl Into) -> Self { + let root = root.into(); + Self { + recordings: root.join("recordings"), + cas: root.join("cas"), + root, + } + } + + /// Recognize an explicitly selected trace directory before project walk-up. + /// Accept a state directory (`.baml` or `BAML_HOME`), its `btel` child, + /// or the `recordings` directory. Never scan recording contents or the CAS. + /// The caller must prefer an explicitly selected project root over these + /// shapes: a project may own unrelated `btel` or `recordings` directories. + pub fn from_trace_directory(path: &Path) -> Option { + if !path.is_dir() { + return None; + } + if path.join("btel").is_dir() || path.file_name().is_some_and(|name| name == ".baml") { + return Some(Self::for_btel(path.join("btel"))); + } + if path.join("recordings").is_dir() || path.file_name().is_some_and(|name| name == "btel") { + return Some(Self::for_btel(path)); + } + if path.file_name().is_some_and(|name| name == "recordings") { + return path.parent().map(Self::for_btel); + } + None + } + pub fn for_project(project_root: &Path) -> Self { let recordings = project_root.join(RECORDINGS_DIRECTORY); let cas = project_root.join(CAS_DIRECTORY); @@ -34,3 +65,43 @@ impl SourceLayout { btel_file::cas_path(&self.cas, id) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Trace-directory recognition must not require files or create a query index. + #[test] + fn explicit_trace_directories_keep_their_layout_even_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join(".baml"); + std::fs::create_dir(&state).unwrap(); + let expected = SourceLayout::for_project(tmp.path()); + assert_eq!( + SourceLayout::from_trace_directory(&state), + Some(expected.clone()) + ); + std::fs::create_dir(state.join("btel")).unwrap(); + assert_eq!( + SourceLayout::from_trace_directory(&state.join("btel")), + Some(expected.clone()) + ); + std::fs::create_dir(state.join("btel/recordings")).unwrap(); + assert_eq!( + SourceLayout::from_trace_directory(&state.join("btel/recordings")), + Some(expected.clone()) + ); + assert_eq!(SourceLayout::from_trace_directory(tmp.path()), None); + assert_eq!( + SourceLayout::from_trace_directory(&state.join("missing")), + None + ); + let custom = tmp.path().join("traces"); + std::fs::rename(state, &custom).unwrap(); + assert_eq!( + SourceLayout::from_trace_directory(&custom), + Some(SourceLayout::for_btel(custom.join("btel"))) + ); + assert!(!expected.root.join("query.sqlite").exists()); + } +} diff --git a/baml_language/crates/btel_settings/Cargo.toml b/baml_language/crates/btel_settings/Cargo.toml index 47d2b8f6d1b..1b9ab0b74a5 100644 --- a/baml_language/crates/btel_settings/Cargo.toml +++ b/baml_language/crates/btel_settings/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [dependencies] baml_env = { workspace = true } +baml_project_root = { workspace = true } borsh = { workspace = true } serde = { workspace = true } sha2 = { workspace = true } @@ -26,3 +27,6 @@ strsim = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/baml_language/crates/btel_settings/src/lib.rs b/baml_language/crates/btel_settings/src/lib.rs index 71116631ee1..cba9b3777ae 100644 --- a/baml_language/crates/btel_settings/src/lib.rs +++ b/baml_language/crates/btel_settings/src/lib.rs @@ -77,6 +77,8 @@ pub mod encoding; pub mod identity; pub mod layout; pub mod local_files; +#[cfg(not(target_arch = "wasm32"))] +pub mod local_store; pub mod mode; pub mod network; pub mod overage; diff --git a/baml_language/crates/btel_settings/src/local_files.rs b/baml_language/crates/btel_settings/src/local_files.rs index a0c269f5706..c6f50688043 100644 --- a/baml_language/crates/btel_settings/src/local_files.rs +++ b/baml_language/crates/btel_settings/src/local_files.rs @@ -1,8 +1,8 @@ //! Local delivery settings. File output is enabled explicitly per engine. use std::num::NonZeroUsize; -/// Standard recording directory relative to the CLI-resolved BAML project root. -/// User-level recordings instead resolve `btel/recordings` beneath the BAML home. +/// Conventional recording directory relative to a BAML project root. +/// Configured stores instead resolve `btel/recordings` beneath the state directory. /// Each recording gets its own ID directory beneath this path. pub const RECORDINGS_DIRECTORY: &str = ".baml/btel/recordings"; @@ -25,7 +25,7 @@ impl Default for LocalDeliveryConfig { } /// CAS directory relative to the BAML project root, shared across recordings. -/// User-level recordings instead resolve `btel/cas` beneath the BAML home. +/// Configured stores instead resolve `btel/cas` beneath the state directory. /// Local delivery appends the blob format version before the digest shards. pub const CAS_DIRECTORY: &str = ".baml/btel/cas"; /// Writer buffering for scalar-heavy snapshot blobs; does not constrain capture size. diff --git a/baml_language/crates/btel_settings/src/local_store.rs b/baml_language/crates/btel_settings/src/local_store.rs new file mode 100644 index 00000000000..67302a004c4 --- /dev/null +++ b/baml_language/crates/btel_settings/src/local_store.rs @@ -0,0 +1,176 @@ +//! Shared local trace-store resolution for native writers and readers. +use std::{ + ffi::OsString, + io, + path::{Path, PathBuf}, +}; + +use sha2::{Digest as _, Sha256}; + +/// Resolve the state directory containing `btel/recordings` and `btel/cas`. +/// Project stores live at `/-`; without +/// `BAML_HOME`, the base is `~/.baml`. The canonical project path identifies +/// the store, so aliases share recordings and separate checkouts stay isolated. +/// Without a project, use the BAML home itself (including packed programs). +/// This never reads manifest settings, creates directories, or falls back to +/// a different store when the selected destination is unwritable. +pub fn resolve( + project: Option<&Path>, + baml_home: Option, + home_dir: Option, +) -> io::Result { + let home = baml_env::baml_home_from(baml_home, home_dir); + let Some(project) = project else { + return Ok(home); + }; + let project = std::fs::canonicalize(project).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "{}: cannot resolve trace-store project: {error}", + project.display() + ), + ) + })?; + let name: String = project + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("project") + .chars() + .take(64) + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + let hash = format!( + "{:x}", + Sha256::digest(project.as_os_str().as_encoded_bytes()) + ); + Ok(home.join(format!("{name}-{}", &hash[..16]))) +} + +/// SDK hosts discover the project enclosing their working directory. Packed +/// applications pass no cwd so they do not adopt an unrelated project's store. +pub fn resolve_for_cwd( + cwd: Option<&Path>, + baml_home: Option, + home_dir: Option, +) -> io::Result { + let project = cwd.and_then(baml_project_root::find_baml_project_root); + resolve(project.as_deref(), baml_home, home_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Project identity and nested discovery select one store without manifest configuration or writes. + #[test] + fn project_stores_live_under_baml_home_and_are_discovered_from_nested_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + let nested = project.join("baml_src/deep"); + std::fs::create_dir_all(&nested).unwrap(); + let home = tmp.path().join("user"); + let state = tmp.path().join("state"); + let default = resolve(Some(&project), None, Some(home.clone())).unwrap(); + assert_eq!(default.parent().unwrap(), home.join(".baml")); + assert!( + default + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("app-") + ); + assert_eq!( + resolve(Some(&project), Some("".into()), Some(home.clone())).unwrap(), + default + ); + let configured = resolve(Some(&project), Some(state.clone().into()), None).unwrap(); + assert_eq!(configured, state.join(default.file_name().unwrap())); + assert_eq!( + resolve_for_cwd(Some(&nested), Some(state.into()), None).unwrap(), + configured + ); + // Trace paths do not depend on manifest contents, including old settings. + std::fs::write( + project.join("baml.toml"), + "[tracing]\nstore = 'elsewhere'\n", + ) + .unwrap(); + assert_eq!( + resolve(Some(&project), None, Some(home.clone())).unwrap(), + default + ); + assert_eq!( + resolve_for_cwd(Some(&nested), None, Some(home)).unwrap(), + default + ); + assert!(!default.exists()); + assert!(!project.join(".baml").exists()); + } + + /// Canonical paths separate checkouts while equivalent path spellings share a store. + #[test] + fn separate_checkouts_with_the_same_name_have_separate_stores() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first/app"); + let second = tmp.path().join("second/app"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + let home = Some(tmp.path().join("state").into()); + let store = resolve(Some(&first), home.clone(), None).unwrap(); + assert_ne!(store, resolve(Some(&second), home.clone(), None).unwrap()); + assert_eq!( + store, + resolve(Some(&first.join("../app")), home, None).unwrap() + ); + } + + #[cfg(unix)] + /// A symlink alias must not create another history for the same checkout. + #[test] + fn symlinked_project_uses_the_same_store() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + let alias = tmp.path().join("alias"); + std::fs::create_dir(&project).unwrap(); + std::os::unix::fs::symlink(&project, &alias).unwrap(); + let home = Some(tmp.path().join("state").into()); + assert_eq!( + resolve(Some(&project), home.clone(), None).unwrap(), + resolve(Some(&alias), home, None).unwrap() + ); + } + + /// Packed and unassociated hosts retain the user store, including existing home fallbacks. + #[test] + fn no_project_uses_baml_home_itself() { + let home = PathBuf::from("user"); + assert_eq!( + resolve_for_cwd(None, None, Some(home.clone())).unwrap(), + home.join(".baml") + ); + assert_eq!( + resolve(None, Some("state".into()), Some(home)).unwrap(), + PathBuf::from("state") + ); + assert_eq!(resolve(None, None, None).unwrap(), PathBuf::from(".baml")); + } + + /// Project-resolution errors identify the requested path and never create a fallback store. + #[test] + fn missing_project_reports_the_path_instead_of_selecting_a_different_store() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("missing"); + let error = + resolve(Some(&missing), Some(tmp.path().join("state").into()), None).unwrap_err(); + assert!(error.to_string().contains(missing.to_str().unwrap())); + assert!(!tmp.path().join("state").exists()); + } +} diff --git a/baml_language/sdk_tests/crates/python_pydantic2/llm_functions/customizable/test_stream_tracing.py b/baml_language/sdk_tests/crates/python_pydantic2/llm_functions/customizable/test_stream_tracing.py index f77f21f550a..9cb796d003a 100644 --- a/baml_language/sdk_tests/crates/python_pydantic2/llm_functions/customizable/test_stream_tracing.py +++ b/baml_language/sdk_tests/crates/python_pydantic2/llm_functions/customizable/test_stream_tracing.py @@ -225,6 +225,7 @@ async def test_stream_precancelled_admission_preserves_reservation(): # The ordinary SDK CI does not build the query CLI. Run these audits wherever # baml-cli is built, or set BAML_AUDIT_CLI to a CLI from the same checkout. def _audit_cli(): + """Use a compatible query CLI, skipping inherited binaries from older checkouts.""" import os from pathlib import Path @@ -235,15 +236,18 @@ def _audit_cli(): cli = Path(os.environ.get("BAML_AUDIT_CLI", workspace / "target/debug/baml-cli")) if not cli.is_file(): pytest.skip("Build baml_cli to run the native stream recording audit") - # A CLI older than this checkout's blob format can't read what the SDK - # records here: one left over from another checkout skips, not fails. - blob_format = workspace / "crates/btel_settings/src/snapshot.rs" + # A CLI older than the blob format or store resolver cannot read what + # this SDK records. Warm CI runners can retain an older query binary. + reader_sources = [ + workspace / "crates/btel_settings/src/snapshot.rs", + workspace / "crates/btel_settings/src/local_store.rs", + ] if ( "BAML_AUDIT_CLI" not in os.environ - and cli.stat().st_mtime < blob_format.stat().st_mtime + and cli.stat().st_mtime < max(path.stat().st_mtime for path in reader_sources) ): pytest.skip( - "target/debug/baml-cli predates this checkout's blob format; " + "target/debug/baml-cli predates this checkout's trace format or layout; " "rebuild baml_cli or set BAML_AUDIT_CLI" ) return cli @@ -251,6 +255,7 @@ def _audit_cli(): # SDK_PARITY_LINT(skip): native Python recording lands where `baml query --local` reads def test_recording_lands_in_cwd_project(tmp_path): + """SDK and CLI discover the same project store beneath the user home.""" import json import os from pathlib import Path @@ -291,6 +296,9 @@ def test_recording_lands_in_cwd_project(tmp_path): ) assert child.returncode == 0, child.stderr assert not (home / ".baml/btel/recordings").exists() + assert not (project / ".baml").exists() + stores = list((home / ".baml").glob("app-*/btel/recordings")) + assert len(stores) == 1 and any(stores[0].iterdir()), child.stderr result = subprocess.run( [ @@ -316,6 +324,7 @@ def test_recording_lands_in_cwd_project(tmp_path): # SDK_PARITY_LINT(skip): native Python recording ancestry, duration, values and step-count audit def test_stream_recording_parent_chain(tmp_path): + """Recorded stream ancestry and values remain queryable without a project.""" import json import os from pathlib import Path @@ -328,9 +337,10 @@ def test_stream_recording_parent_chain(tmp_path): import baml_sdk marker = "stream-audit-" + uuid.uuid4().hex - # Local queries are project-relative: the child records beneath - # `/.baml`, its BAML_HOME, and the query reads from there. + # With no project enclosing cwd, the child records in BAML_HOME itself. + # Query that state directory explicitly rather than adopting a project. query_root = tmp_path / "config" + query_root.mkdir() env = { key: value for key, value in os.environ.items() @@ -349,6 +359,7 @@ def test_stream_recording_parent_chain(tmp_path): ) child = subprocess.run( [sys.executable, str(sdk_root / "stream_recording_cases.py"), marker], + cwd=query_root, env=env, capture_output=True, text=True, @@ -375,7 +386,7 @@ def test_stream_recording_parent_chain(tmp_path): "query", "--local", "--from", - str(query_root), + str(query_root / ".baml"), "--format", "json", sql,