Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Stable, host-independent output contracts for user-facing trace commands.
//!
//! Embedders such as `bt` own their global `--json` flag, but should delegate
//! 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::StatusResult;
use serde::Serialize;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Human,
Json,
}

impl From<bool> for OutputFormat {
fn from(json: bool) -> Self {
if json {
Self::Json
} else {
Self::Human
}
}
}

#[derive(Debug, Clone, Serialize)]
pub struct StatusCommandOutput {
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uptime_ms: Option<u64>,
pub sessions: Vec<crate::wire::SessionStatus>,
}

impl From<Option<StatusResult>> for StatusCommandOutput {
fn from(status: Option<StatusResult>) -> Self {
match status {
Some(status) => Self {
running: true,
daemon_version: Some(status.daemon_version),
uptime_ms: Some(status.uptime_ms),
sessions: status.sessions,
},
None => Self {
running: false,
daemon_version: None,
uptime_ms: None,
sessions: Vec::new(),
},
}
}
}

#[derive(Debug, Clone, Serialize)]
pub struct SetupCommandOutput {
pub source: String,
pub display_name: String,
pub settings_path: PathBuf,
pub restart_required: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct StopCommandOutput {
pub running: bool,
pub stopped: bool,
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum TraceCommandOutput {
Status(StatusCommandOutput),
Setup(SetupCommandOutput),
Stop(StopCommandOutput),
}

impl TraceCommandOutput {
pub fn status(status: Option<StatusResult>) -> Self {
Self::Status(status.into())
}

pub fn setup(
source: impl Into<String>,
display_name: impl Into<String>,
settings_path: impl Into<PathBuf>,
) -> Self {
Self::Setup(SetupCommandOutput {
source: source.into(),
display_name: display_name.into(),
settings_path: settings_path.into(),
restart_required: true,
})
}

pub fn stop(running: bool, stopped: bool) -> Self {
Self::Stop(StopCommandOutput { running, stopped })
}

pub fn render(&self, format: OutputFormat) -> anyhow::Result<String> {
match format {
OutputFormat::Json => Ok(serde_json::to_string(self)?),
OutputFormat::Human => self.render_human(),
}
}

fn render_human(&self) -> anyhow::Result<String> {
match self {
Self::Status(status) if !status.running => Ok("bt-daemon is not running".into()),
Self::Status(status) => Ok(serde_json::to_string_pretty(&StatusResult {
daemon_version: status.daemon_version.clone().unwrap_or_default(),
uptime_ms: status.uptime_ms.unwrap_or_default(),
sessions: status.sessions.clone(),
})?),
Self::Setup(setup) => Ok(format!(
"The Braintrust tracing plugin is installed for {} and configured in {}.\nRestart the coding agent to load the tracing plugin.",
setup.display_name,
setup.settings_path.display()
)),
Self::Stop(stop) if stop.stopped => Ok("Tracing daemon stopped.".into()),
Self::Stop(_) => Ok("No tracing daemon is running.".into()),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn absent_status_is_machine_readable_in_json_mode() {
let output = TraceCommandOutput::status(None);
let rendered = output.render(OutputFormat::Json).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["command"], "status");
assert_eq!(value["running"], false);
assert_eq!(value["sessions"], serde_json::json!([]));
assert!(value.get("daemon_version").is_none());
assert!(!rendered.contains("not running"));
}

#[test]
fn setup_json_contains_stable_selection_fields_without_prose() {
let output = TraceCommandOutput::setup(
"opencode",
"OpenCode",
PathBuf::from("/tmp/opencode/braintrust.json"),
);
let rendered = output.render(OutputFormat::Json).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["command"], "setup");
assert_eq!(value["source"], "opencode");
assert_eq!(value["restart_required"], true);
assert!(!rendered.contains("installed for"));
}

#[test]
fn stop_json_reports_idempotent_and_successful_shutdowns() {
let absent: serde_json::Value = serde_json::from_str(
&TraceCommandOutput::stop(false, false)
.render(OutputFormat::Json)
.unwrap(),
)
.unwrap();
assert_eq!(
absent,
serde_json::json!({
"command": "stop",
"running": false,
"stopped": false
})
);

let stopped: serde_json::Value = serde_json::from_str(
&TraceCommandOutput::stop(true, true)
.render(OutputFormat::Json)
.unwrap(),
)
.unwrap();
assert_eq!(
stopped,
serde_json::json!({
"command": "stop",
"running": true,
"stopped": true
})
);
}

#[test]
fn human_output_preserves_existing_messages() {
assert_eq!(
TraceCommandOutput::status(None)
.render(OutputFormat::Human)
.unwrap(),
"bt-daemon is not running"
);
assert_eq!(
TraceCommandOutput::stop(false, false)
.render(OutputFormat::Human)
.unwrap(),
"No tracing daemon is running."
);
}
}
4 changes: 4 additions & 0 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
pub mod paths;

mod client;
mod command_output;
mod dispatch;
mod ids;
mod journal;
Expand All @@ -25,6 +26,9 @@ mod transport;

pub mod wire;
pub use client::HostInfo;
pub use command_output::{
OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput,
};
pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions};
pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory};
pub use translate::{
Expand Down
14 changes: 8 additions & 6 deletions bt-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use bt_daemon::wire::{AuthSelection, BackendAuth, SessionRoute, TraceDestination
use bt_daemon::{
braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, run_traced,
AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, DebugSinkFactory, HookArgs,
HostInfo, ImportArgs, Registry, RunArgs, RunHookCommand, ServeArgs, ServeOptions, StatusArgs,
HostInfo, ImportArgs, OutputFormat, Registry, RunArgs, RunHookCommand, ServeArgs, ServeOptions,
StatusArgs, TraceCommandOutput,
};
use clap::{Args, Parser, Subcommand};
use std::ffi::OsString;
Expand Down Expand Up @@ -72,6 +73,9 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
about = "Braintrust coding-agent tracing daemon (standalone test binary)"
)]
struct Cli {
/// Output user-facing command results as JSON.
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
Expand Down Expand Up @@ -212,11 +216,9 @@ async fn main() {
std::process::exit(0);
}
Command::Status(args) => match run_status(args).await {
Ok(Some(status)) => {
println!("{}", serde_json::to_string_pretty(&status).unwrap());
}
Ok(None) => {
println!("bt-daemon is not running");
Ok(status) => {
let output = TraceCommandOutput::status(status);
println!("{}", output.render(OutputFormat::from(cli.json)).unwrap());
}
Err(e) => {
eprintln!("bt-daemon status: {e}");
Expand Down
31 changes: 31 additions & 0 deletions bt-daemon/tests/command_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#![cfg(feature = "cli")]

use std::process::Command;

#[test]
fn standalone_status_json_is_valid_when_daemon_is_absent() {
#[cfg(unix)]
let temp = tempfile::tempdir().unwrap();
#[cfg(unix)]
let socket = temp.path().join("missing.sock");
#[cfg(windows)]
let socket = std::path::PathBuf::from(format!(
r"\\.\pipe\missing-bt-daemon-{}",
uuid::Uuid::new_v4()
));

let output = Command::new(env!("CARGO_BIN_EXE_bt-daemon"))
.args(["status", "--json", "--socket"])
.arg(socket)
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["command"], "status");
assert_eq!(value["running"], false);
assert_eq!(value["sessions"], serde_json::json!([]));
}
Loading