Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

- `burn --json` commands and stdout `burn stamps export` streams now exit quietly when a downstream pipe closes early instead of reporting the pipe closure as a generic error.
- Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers.
- `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00.

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repository = "https://github.com/AgentWorkforce/burn"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = "1.0.140"
uuid = { version = "1", features = ["v4"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "time"] }
anyhow = "1"
Expand Down
33 changes: 28 additions & 5 deletions crates/relayburn-cli/src/commands/stamps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use std::fs::File;
use std::io::{self, BufWriter, Write};

use anyhow::Context;
use relayburn_sdk::{ExportStampsOptions, Ledger, LedgerOpenOptions};

use crate::cli::{GlobalArgs, StampsArgs};
Expand All @@ -20,6 +21,12 @@ use crate::render::progress::TaskProgress;
/// Default output is stdout ("-")
const DEFAULT_OUT: &str = "-";

#[derive(Clone, Copy)]
enum OutputTarget {
Stdout,
File,
}

pub fn run(globals: &GlobalArgs, args: StampsArgs) -> i32 {
match args.command {
crate::cli::StampsSubcommand::Export(export_args) => run_export(globals, export_args),
Expand Down Expand Up @@ -54,10 +61,14 @@ fn run_export(globals: &GlobalArgs, args: crate::cli::StampsExportArgs) -> i32 {
let out_path = args.out.as_deref().unwrap_or(DEFAULT_OUT);
let result = if out_path == "-" {
let stdout = io::stdout();
write_jsonl(&mut BufWriter::new(stdout.lock()), iter)
write_jsonl(
&mut BufWriter::new(stdout.lock()),
iter,
OutputTarget::Stdout,
)
} else {
match File::create(out_path) {
Ok(file) => write_jsonl(&mut BufWriter::new(file), iter),
Ok(file) => write_jsonl(&mut BufWriter::new(file), iter, OutputTarget::File),
Err(err) => Err(anyhow::anyhow!("failed to open output file: {}", err)),
}
};
Expand All @@ -81,18 +92,30 @@ fn run_export(globals: &GlobalArgs, args: crate::cli::StampsExportArgs) -> i32 {
fn write_jsonl<W: Write, I: IntoIterator<Item = serde_json::Value>>(
writer: &mut W,
iter: I,
target: OutputTarget,
) -> anyhow::Result<usize> {
let mut count: usize = 0;
for val in iter {
serde_json::to_writer(&mut *writer, &val)
.map_err(|err| anyhow::anyhow!("failed to serialize stamp: {}", err))?;
.map_err(crate::render::json::serde_error_to_io)
.map_err(|err| mark_stdout(err, target))
.context("failed to serialize stamp")?;
writer
.write_all(b"\n")
.map_err(|err| anyhow::anyhow!("failed to write stamp: {}", err))?;
.map_err(|err| mark_stdout(err, target))
.context("failed to write stamp")?;
count += 1;
}
writer
.flush()
.map_err(|err| anyhow::anyhow!("failed to flush output: {}", err))?;
.map_err(|err| mark_stdout(err, target))
.context("failed to flush output")?;
Ok(count)
}

fn mark_stdout(err: io::Error, target: OutputTarget) -> io::Error {
match target {
OutputTarget::Stdout => crate::render::json::stdout_error(err),
OutputTarget::File => err,
}
}
2 changes: 1 addition & 1 deletion crates/relayburn-cli/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ fn unknown_channel_error() -> anyhow::Error {
fn print_json(value: &serde_json::Value) -> std::io::Result<()> {
use std::io::Write;
let mut out = std::io::stdout().lock();
serde_json::to_writer(&mut out, value).map_err(std::io::Error::other)?;
serde_json::to_writer(&mut out, value).map_err(crate::render::json::serde_error_to_io)?;
out.write_all(b"\n")
}

Expand Down
60 changes: 54 additions & 6 deletions crates/relayburn-cli/src/render/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
//! stack. Always falls through to a generic `2` exit code with the
//! `Display` form of the error on stderr.
//! - `std::io::Error` — broken pipe / write-to-stdout failures from
//! the rendering helpers. Mapped to exit code `2`, EPIPE silenced
//! (matches Unix tools-as-citizen conventions).
//! the rendering helpers. EPIPE exits quietly with code `0` (matching
//! Unix pipeline conventions); other I/O failures map to code `2`.
//!
//! Every helper here writes to stderr in human mode and writes a
//! `{"error": "..."}` envelope to stdout in `--json` mode, then returns
Expand All @@ -25,6 +25,7 @@

#![allow(dead_code)]

use std::any::Any;
use std::io::{self, Write};

use serde_json::json;
Expand Down Expand Up @@ -53,10 +54,32 @@ pub fn report_ledger_error(err: &LedgerError, globals: &GlobalArgs) -> i32 {
/// Map any other error (anyhow, io, etc.) to a stderr message + exit
/// code. Use this when the error comes from a non-SDK boundary or when
/// the command handler chose to propagate as `anyhow::Error`.
pub fn report_error<E: std::fmt::Display>(err: &E, globals: &GlobalArgs) -> i32 {
pub fn report_error<E: std::fmt::Display + 'static>(err: &E, globals: &GlobalArgs) -> i32 {
if is_broken_pipe(err) {
return 0;
}
report(globals, &err.to_string(), EXIT_GENERIC_ERROR)
}

/// Recognize stdout pipes closed by an early-exiting consumer. JSON writers
/// mark those errors before returning either a direct `io::Error` or an
/// `anyhow::Error` that retains it in its chain. File/FIFO errors lack that
/// marker and remain failures.
fn is_broken_pipe<E: 'static>(err: &E) -> bool {
let err = err as &dyn Any;
if let Some(err) = err.downcast_ref::<io::Error>() {
return crate::render::json::is_stdout_broken_pipe(err);
}
if let Some(err) = err.downcast_ref::<anyhow::Error>() {
return err.chain().any(|cause| {
cause
.downcast_ref::<io::Error>()
.is_some_and(crate::render::json::is_stdout_broken_pipe)
});
}
false
}

/// `not yet implemented` exit path used by every command stub in this
/// scaffold PR. Keeps the message format consistent across the
/// subcommands so the smoke test can assert on it without each command
Expand Down Expand Up @@ -98,9 +121,13 @@ fn report(globals: &GlobalArgs, message: &str, code: i32) -> i32 {
fn write_json_envelope(value: &serde_json::Value) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer(&mut handle, value).map_err(io::Error::other)?;
handle.write_all(b"\n")?;
handle.flush()
serde_json::to_writer(&mut handle, value)
.map_err(crate::render::json::serde_error_to_io)
.map_err(crate::render::json::stdout_error)?;
Comment thread
willwashburn marked this conversation as resolved.
handle
.write_all(b"\n")
.map_err(crate::render::json::stdout_error)?;
handle.flush().map_err(crate::render::json::stdout_error)
}

#[cfg(test)]
Expand Down Expand Up @@ -144,6 +171,27 @@ mod tests {
assert_eq!(report_error(&err, &human_globals()), EXIT_GENERIC_ERROR);
}

#[test]
fn broken_pipe_exits_zero_without_reporting() {
let direct = crate::render::json::stdout_error(io::Error::from(io::ErrorKind::BrokenPipe));
assert_eq!(report_error(&direct, &human_globals()), 0);

let wrapped = anyhow::Error::from(crate::render::json::stdout_error(io::Error::from(
io::ErrorKind::BrokenPipe,
)));
assert_eq!(report_error(&wrapped, &json_globals()), 0);
}

#[test]
fn non_stdout_broken_pipe_remains_an_error() {
let err = io::Error::from(io::ErrorKind::BrokenPipe);
assert_eq!(report_error(&err, &human_globals()), EXIT_GENERIC_ERROR);

let wrapped = anyhow::Error::from(io::Error::from(io::ErrorKind::BrokenPipe))
.context("failed to write export file");
assert_eq!(report_error(&wrapped, &human_globals()), EXIT_GENERIC_ERROR);
}

#[test]
fn ledger_error_uses_exit_three() {
let err = LedgerError::Other("ledger boom".into());
Expand Down
71 changes: 68 additions & 3 deletions crates/relayburn-cli/src/render/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
//! whole-valued `f64`s print as bare integers) should run their value
//! through [`crate::render::format::coerce_whole_f64_to_int`] first.

use std::error::Error;
use std::fmt;
use std::io::{self, Write};

use serde::Serialize;
Expand All @@ -20,21 +22,84 @@ use serde::Serialize;
pub fn render_json<T: Serialize + ?Sized>(value: &T) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, value).map_err(io::Error::other)?;
handle.write_all(b"\n")?;
handle.flush()
write_json_pretty(&mut handle, value).map_err(stdout_error)?;
handle.write_all(b"\n").map_err(stdout_error)?;
handle.flush().map_err(stdout_error)
}

fn write_json_pretty<W: Write, T: Serialize + ?Sized>(writer: &mut W, value: &T) -> io::Result<()> {
serde_json::to_writer_pretty(writer, value).map_err(serde_error_to_io)
}

#[derive(Debug)]
struct StdoutError(io::Error);

impl fmt::Display for StdoutError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}

impl Error for StdoutError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}

/// Mark an I/O error as originating from the process stdout renderer while
/// retaining its kind. The marker lets shared reporting distinguish a normal
/// early-closing pipeline from an EPIPE raised by a file or FIFO writer.
pub(crate) fn stdout_error(err: io::Error) -> io::Error {
let kind = err.kind();
io::Error::new(kind, StdoutError(err))
}

pub(crate) fn is_stdout_broken_pipe(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::BrokenPipe
&& err
.get_ref()
.is_some_and(|source| source.is::<StdoutError>())
}

/// Convert a serde failure back to an I/O error without erasing the error
/// kind reported by the writer. In particular, callers rely on
/// `BrokenPipe` to treat an early-closing pipeline as a successful exit.
pub(crate) fn serde_error_to_io(err: serde_json::Error) -> io::Error {
match err.io_error_kind() {
Some(kind) => io::Error::new(kind, err),
None => io::Error::other(err),
}
}

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

struct BrokenPipeWriter;

impl Write for BrokenPipeWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}

fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

// Smoke test: the helper should accept anything `Serialize` and
// not panic. Real I/O assertions live in the integration smoke
// test under `tests/smoke.rs` which drives the binary end-to-end.
#[test]
fn render_json_accepts_arbitrary_serialize_input() {
assert!(render_json(&json!({ "ok": true, "rows": [1, 2, 3] })).is_ok());
}

#[test]
fn json_writer_preserves_broken_pipe_kind() {
let err = write_json_pretty(&mut BrokenPipeWriter, &json!({ "ok": true }))
.expect_err("writer should close early");
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
}
Loading
Loading