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

## [Unreleased]

- **BREAKING (`relayburn-sdk`):** `ContextDeltaOpts::since` is now a relative-range or ISO-timestamp string instead of `Duration`; context-delta queries also accept `project` and apply the cutoff to returned deltas in both all-session and session-specific modes.
- `burn overhead deltas` now honors `--project`, accepts relative or ISO `--since` values with explicit errors for invalid input, and rejects the unsupported `--kind` flag.
- `--since` parsing now rejects overflowing relative ranges with an error instead of panicking.

## [4.0.0] - 2026-06-23

- **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged.
Expand Down
31 changes: 28 additions & 3 deletions crates/relayburn-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,16 +325,16 @@ pub struct CompareArgs {
pub struct OverheadArgs {
/// Project root to scan for overhead files (CLAUDE.md, .claude/CLAUDE.md,
/// AGENTS.md). Defaults to the current working directory.
#[arg(long, value_name = "PATH", global = true)]
#[arg(long, value_name = "PATH")]
pub project: Option<PathBuf>,

/// Time window to attribute over: a relative range (`24h`, `7d`,
/// `4w`, `2m`) or an ISO timestamp. Defaults to all time.
#[arg(long, value_name = "RANGE", global = true)]
#[arg(long, value_name = "RANGE")]
pub since: Option<String>,

/// Narrow to a single overhead-file kind.
#[arg(long, value_enum, value_name = "KIND", global = true)]
#[arg(long, value_enum, value_name = "KIND")]
pub kind: Option<OverheadKind>,

#[command(subcommand)]
Expand Down Expand Up @@ -375,6 +375,20 @@ pub enum OverheadAction {
/// `burn overhead trim` flags layered on top of [`OverheadArgs`].
#[derive(Debug, ClapArgs)]
pub struct OverheadTrimArgs {
/// Project root to scan for overhead files. Defaults to the current
/// working directory.
#[arg(long, value_name = "PATH")]
pub project: Option<PathBuf>,

/// Time window to attribute over: a relative range (`24h`, `7d`,
/// `4w`, `2m`) or an ISO timestamp. Defaults to all time.
#[arg(long, value_name = "RANGE")]
pub since: Option<String>,

/// Narrow to a single overhead-file kind.
#[arg(long, value_enum, value_name = "KIND")]
pub kind: Option<OverheadKind>,

/// Number of recommendations per file. Defaults to 3.
#[arg(long, value_name = "N")]
pub top: Option<u64>,
Expand All @@ -383,6 +397,17 @@ pub struct OverheadTrimArgs {
/// `burn overhead deltas` flags layered on top of [`OverheadArgs`].
#[derive(Debug, ClapArgs)]
pub struct OverheadDeltasArgs {
/// Restrict ledger sessions to this project. Relative paths are resolved
/// from the current working directory. Defaults to all projects.
#[arg(long, value_name = "PATH")]
pub project: Option<PathBuf>,

/// Inclusive lower bound for the current inference in each delta: a
/// relative range (`24h`, `7d`, `4w`, `2m`) or an ISO timestamp. The
/// preceding baseline inference may be older. Defaults to all time.
#[arg(long, value_name = "RANGE")]
pub since: Option<String>,

/// Restrict to a single session id. When unset, every session in the
/// ledger window contributes.
#[arg(long, value_name = "ID")]
Expand Down
150 changes: 116 additions & 34 deletions crates/relayburn-cli/src/commands/overhead.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,54 @@ use crate::render::progress::TaskProgress;
pub fn run(globals: &GlobalArgs, args: OverheadArgs) -> i32 {
match args.action {
Some(OverheadAction::Trim(trim)) => {
run_trim(globals, args.project, args.since, args.kind, trim.top)
let project = match merge_scoped_flag("--project", args.project, trim.project) {
Ok(value) => value,
Err(err) => return report_error(&err, globals),
};
let since = match merge_scoped_flag("--since", args.since, trim.since) {
Ok(value) => value,
Err(err) => return report_error(&err, globals),
};
let kind = match merge_scoped_flag("--kind", args.kind, trim.kind) {
Ok(value) => value,
Err(err) => return report_error(&err, globals),
};
run_trim(globals, project, since, kind, trim.top)
}
Some(OverheadAction::Deltas(deltas)) => {
if args.kind.is_some() {
let err = io::Error::new(
io::ErrorKind::InvalidInput,
"--kind is not supported by `burn overhead deltas`",
);
return report_error(&err, globals);
}
let project = match merge_scoped_flag("--project", args.project, deltas.project.clone())
{
Ok(value) => value,
Err(err) => return report_error(&err, globals),
};
let since = match merge_scoped_flag("--since", args.since, deltas.since.clone()) {
Ok(value) => value,
Err(err) => return report_error(&err, globals),
};
run_deltas(globals, project, since, deltas)
}
Some(OverheadAction::Deltas(deltas)) => run_deltas(globals, args.since, deltas),
None => run_report(globals, args.project, args.since, args.kind),
}
}

fn merge_scoped_flag<T>(name: &str, parent: Option<T>, child: Option<T>) -> io::Result<Option<T>> {
match (parent, child) {
(Some(_), Some(_)) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} was provided both before and after the overhead subcommand"),
)),
(Some(value), None) | (None, Some(value)) => Ok(Some(value)),
(None, None) => Ok(None),
}
}

fn run_report(
globals: &GlobalArgs,
project: Option<PathBuf>,
Expand Down Expand Up @@ -171,6 +212,28 @@ fn resolve_project(project: Option<&Path>) -> PathBuf {
}
}

fn resolve_deltas_project(project: &Path) -> PathBuf {
Comment thread
willwashburn marked this conversation as resolved.
if project.is_absolute() {
project.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| {
let mut resolved = PathBuf::new();
for component in cwd.join(project).components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
resolved.pop();
}
other => resolved.push(other.as_os_str()),
}
}
resolved
})
.unwrap_or_else(|_| project.to_path_buf())
}
}

fn kind_to_str(k: crate::cli::OverheadKind) -> &'static str {
match k {
crate::cli::OverheadKind::ClaudeMd => "claude-md",
Expand Down Expand Up @@ -368,10 +431,18 @@ fn format_line_range(start: u64, end: u64) -> String {
// `burn overhead deltas` (#432)
// ---------------------------------------------------------------------------

fn run_deltas(globals: &GlobalArgs, since: Option<String>, args: OverheadDeltasArgs) -> i32 {
fn run_deltas(
globals: &GlobalArgs,
project: Option<PathBuf>,
since: Option<String>,
args: OverheadDeltasArgs,
) -> i32 {
let opts = ContextDeltaOpts {
session: args.session.clone(),
since: since.as_deref().and_then(parse_since_duration),
project: project
.as_deref()
.map(|path| resolve_deltas_project(path).to_string_lossy().into_owned()),
Comment thread
willwashburn marked this conversation as resolved.
since,
top: args.top,
min_delta: args.min_delta,
owner: args.owner.into(),
Expand Down Expand Up @@ -468,36 +539,6 @@ fn render_human_deltas(deltas: &[ContextDelta], explain: bool) -> io::Result<()>
Ok(())
}

/// Parse the CLI's relative-range `--since` form (`24h`, `7d`, `4w`, `2m`)
/// into a [`std::time::Duration`]. ISO-timestamp forms are accepted by the
/// SDK's `normalize_since` elsewhere, but the deltas verb only takes a
/// relative window today (`ContextDeltaOpts::since: Option<Duration>`).
/// Unrecognized inputs fall through to `None` — the SDK then applies the
/// 24h default.
fn parse_since_duration(s: &str) -> Option<std::time::Duration> {
if s.is_empty() {
return None;
}
let bytes = s.as_bytes();
let unit = *bytes.last()? as char;
if !matches!(unit, 'h' | 'd' | 'w' | 'm') {
return None;
}
let num = &s[..s.len() - 1];
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u64 = num.parse().ok()?;
let secs = match unit {
'h' => n.checked_mul(3_600)?,
'd' => n.checked_mul(86_400)?,
'w' => n.checked_mul(7 * 86_400)?,
'm' => n.checked_mul(30 * 86_400)?,
_ => unreachable!(),
};
Some(std::time::Duration::from_secs(secs))
}

fn short_turn_label(turn_id: &str) -> String {
// Turn ids on Claude are `msg-...` UUIDs; trim to a short prefix
// for the table. Keep the original for JSON output. Use
Expand Down Expand Up @@ -656,4 +697,45 @@ mod tests {
assert_eq!(format_signed_tokens(0), "0");
assert!(format_signed_tokens(5_000).starts_with('+'));
}

#[test]
fn duplicate_scoped_flag_is_an_error() {
let err = merge_scoped_flag("--since", Some("7d"), Some("1d"))
.expect_err("duplicate flag must not pick a winner");
assert!(err.to_string().contains("both before and after"));
}

#[test]
fn resolve_deltas_project_absolutizes_without_resolving_symlinks() {
let dir = tempfile::Builder::new()
.prefix("relayburn-project-")
.tempdir_in(".")
.expect("temp project");
let input = Path::new(dir.path().file_name().expect("temp project name"));
assert!(!input.is_absolute());
assert_eq!(
resolve_deltas_project(input),
std::env::current_dir().expect("cwd").join(input)
);
assert_eq!(
resolve_deltas_project(Path::new(".")),
std::env::current_dir().expect("cwd")
);
}

#[cfg(unix)]
#[test]
fn resolve_deltas_project_preserves_absolute_symlink_spelling() {
use std::os::unix::fs::symlink;

let target = tempfile::tempdir().expect("project target");
let links = tempfile::tempdir().expect("symlink parent");
let link = links.path().join("project-link");
symlink(target.path(), &link).expect("project symlink");
assert_ne!(
link,
std::fs::canonicalize(&link).expect("canonical project")
);
assert_eq!(resolve_deltas_project(&link), link);
}
}
94 changes: 94 additions & 0 deletions crates/relayburn-cli/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,100 @@ fn overhead_trim_help_exits_zero_with_non_empty_stdout() {
);
}

#[test]
fn overhead_deltas_help_only_advertises_supported_shared_flags() {
let output = burn()
.args(["overhead", "deltas", "--help"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).expect("help should be valid UTF-8");
assert!(stdout.contains("--project <PATH>"), "{stdout}");
assert!(stdout.contains("--since <RANGE>"), "{stdout}");
assert!(!stdout.contains("--kind"), "{stdout}");
assert!(stdout.contains("Defaults to all projects"), "{stdout}");
assert!(
stdout.contains("preceding baseline inference may be older"),
"{stdout}"
);
}

#[test]
fn overhead_trim_keeps_post_subcommand_shared_flags() {
let ledger = tempfile::TempDir::new().expect("temp ledger");
let project = tempfile::TempDir::new().expect("temp project");
burn()
.args([
"--ledger-path",
ledger.path().to_str().unwrap(),
"overhead",
"trim",
"--project",
project.path().to_str().unwrap(),
"--since",
"7d",
"--kind",
"claude-md",
])
.assert()
.failure()
.stderr(predicate::str::contains(
"no claude-md overhead files found",
));
}

#[test]
fn overhead_deltas_invalid_since_errors_and_iso_works() {
let invalid_ledger = tempfile::TempDir::new().expect("temp ledger");
burn()
.args([
"--ledger-path",
invalid_ledger.path().to_str().unwrap(),
"overhead",
"deltas",
"--since",
"not-a-range",
])
.assert()
.failure()
.stderr(predicate::str::contains("invalid since"));

let iso_ledger = tempfile::TempDir::new().expect("temp ledger");
burn()
.args([
"--ledger-path",
iso_ledger.path().to_str().unwrap(),
"overhead",
"deltas",
"--since",
"2026-07-01T00:00:00Z",
])
.assert()
.success();
}

#[test]
fn overhead_deltas_rejects_kind_and_duplicate_since() {
burn()
.args(["overhead", "deltas", "--kind", "claude-md"])
.assert()
.failure()
.stderr(predicate::str::contains("unexpected argument '--kind'"));

burn()
.args(["overhead", "--kind", "claude-md", "deltas"])
.assert()
.failure()
.stderr(predicate::str::contains("--kind is not supported"));

burn()
.args(["overhead", "--since", "7d", "deltas", "--since", "1d"])
.assert()
.failure()
.stderr(predicate::str::contains("both before and after"));
}

#[test]
fn update_toggle_auto_update_help_exits_zero_with_non_empty_stdout() {
let output = burn()
Expand Down
2 changes: 1 addition & 1 deletion crates/relayburn-sdk/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub use claude_md::{MarkdownSection, SessionClaudeMdCost};
// public (the CLI uses it as the default `--min-sample`).
pub use compare::DEFAULT_MIN_SAMPLE;
pub(crate) use compare::{build_compare_table, CompareOptions, CompareTable};
pub(crate) use context_delta::deltas_for_session;
pub(crate) use context_delta::deltas_for_session_since;
pub use context_delta::{
ContextDelta, ContextDeltaOpts, InterveningStep, OwnerFilter, OwnerRail, ReminderSource,
};
Expand Down
Loading
Loading