diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea625cb..0d61b8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/relayburn-cli/src/cli.rs b/crates/relayburn-cli/src/cli.rs index 31275a63..45e42c06 100644 --- a/crates/relayburn-cli/src/cli.rs +++ b/crates/relayburn-cli/src/cli.rs @@ -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, /// 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, /// 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, #[command(subcommand)] @@ -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, + + /// 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, + + /// Narrow to a single overhead-file kind. + #[arg(long, value_enum, value_name = "KIND")] + pub kind: Option, + /// Number of recommendations per file. Defaults to 3. #[arg(long, value_name = "N")] pub top: Option, @@ -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, + + /// 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, + /// Restrict to a single session id. When unset, every session in the /// ledger window contributes. #[arg(long, value_name = "ID")] diff --git a/crates/relayburn-cli/src/commands/overhead.rs b/crates/relayburn-cli/src/commands/overhead.rs index 04e08b6a..08f87af6 100644 --- a/crates/relayburn-cli/src/commands/overhead.rs +++ b/crates/relayburn-cli/src/commands/overhead.rs @@ -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(name: &str, parent: Option, child: Option) -> io::Result> { + 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, @@ -171,6 +212,28 @@ fn resolve_project(project: Option<&Path>) -> PathBuf { } } +fn resolve_deltas_project(project: &Path) -> PathBuf { + 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", @@ -368,10 +431,18 @@ fn format_line_range(start: u64, end: u64) -> String { // `burn overhead deltas` (#432) // --------------------------------------------------------------------------- -fn run_deltas(globals: &GlobalArgs, since: Option, args: OverheadDeltasArgs) -> i32 { +fn run_deltas( + globals: &GlobalArgs, + project: Option, + since: Option, + 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()), + since, top: args.top, min_delta: args.min_delta, owner: args.owner.into(), @@ -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`). -/// Unrecognized inputs fall through to `None` — the SDK then applies the -/// 24h default. -fn parse_since_duration(s: &str) -> Option { - 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 @@ -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); + } } diff --git a/crates/relayburn-cli/tests/smoke.rs b/crates/relayburn-cli/tests/smoke.rs index 46178f5e..a7a0c1c7 100644 --- a/crates/relayburn-cli/tests/smoke.rs +++ b/crates/relayburn-cli/tests/smoke.rs @@ -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 "), "{stdout}"); + assert!(stdout.contains("--since "), "{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() diff --git a/crates/relayburn-sdk/src/analyze.rs b/crates/relayburn-sdk/src/analyze.rs index d1917272..72699457 100644 --- a/crates/relayburn-sdk/src/analyze.rs +++ b/crates/relayburn-sdk/src/analyze.rs @@ -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, }; diff --git a/crates/relayburn-sdk/src/analyze/context_delta.rs b/crates/relayburn-sdk/src/analyze/context_delta.rs index 4a691131..d3942f80 100644 --- a/crates/relayburn-sdk/src/analyze/context_delta.rs +++ b/crates/relayburn-sdk/src/analyze/context_delta.rs @@ -51,10 +51,8 @@ //! approximation; downstream consumers should treat the number as //! advisory. -use std::collections::HashMap; -use std::time::Duration; - use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use crate::analyze::pricing::PricingTable; use crate::analyze::span_tree::{AttrValue, SpanKind, SpanNode, TurnSpanTree}; @@ -221,10 +219,16 @@ pub struct ContextDeltaOpts { /// When set, narrow to a single session. When `None`, every session /// in the ledger window contributes. pub session: Option, - /// Time window (relative — `Duration::from_secs(24 * 3600)` by - /// default). Sessions whose latest activity falls before - /// `now - since` are skipped. - pub since: Option, + /// Project filter. Matches the ledger's `project` or `project_key` value; + /// path inputs also try their canonical spelling and resolved Git project + /// key so ledgers containing a raw cwd, symlink-resolved cwd, or stable + /// repository key remain queryable. When `None`, every project contributes. + pub project: Option, + /// Inclusive lower bound for the current inference in each delta. + /// Accepts a relative range (`24h`, `7d`, `4w`, `2m`) or ISO timestamp; + /// invalid values return an error. The preceding baseline inference may + /// be older than the cutoff. Defaults to all time. + pub since: Option, /// Output cap. Defaults to 20. pub top: Option, /// Hide deltas below this threshold. Defaults to 1000 tokens — the @@ -245,10 +249,6 @@ impl ContextDeltaOpts { pub fn effective_min_delta(&self) -> u64 { self.min_delta.unwrap_or(1000) } - - pub fn effective_since(&self) -> Duration { - self.since.unwrap_or(Duration::from_secs(24 * 3600)) - } } // --------------------------------------------------------------------------- @@ -267,11 +267,17 @@ impl ContextDeltaOpts { /// `curr` inference's model. Models the pricing table doesn't recognize /// charge `0.0` (matching the rest of the analyze surface, which never /// surfaces costs it can't price). -pub(crate) fn deltas_for_session( +/// Time-filtered form used by the ledger verb after it has normalized the +/// user-facing `since` expression. A delta is retained when its current +/// inference is on/after `since_ms`. Inferences with an unknown timestamp +/// (`start_ms == 0`) remain eligible, matching ledger query semantics for +/// records whose timestamp is unavailable. +pub(crate) fn deltas_for_session_since( trees: &[TurnSpanTree], compactions: &[CompactionEvent], pricing: &PricingTable, opts: &ContextDeltaOpts, + since_ms: Option, ) -> Vec { if trees.is_empty() { return Vec::new(); @@ -298,6 +304,12 @@ pub(crate) fn deltas_for_session( for (pair_idx, window) in inf_indices.windows(2).enumerate() { let prev_pos = window[0]; let curr_pos = window[1]; + if let Some(cutoff) = since_ms { + let curr_start = timeline[curr_pos].start_ms; + if curr_start != 0 && curr_start < cutoff { + continue; + } + } let TimelineKind::Inference { context_tokens: prev_ctx, .. diff --git a/crates/relayburn-sdk/src/analyze/context_delta_tests.rs b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs index 3939eeec..c35326b0 100644 --- a/crates/relayburn-sdk/src/analyze/context_delta_tests.rs +++ b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs @@ -73,7 +73,7 @@ fn bash_blowup_surfaces_as_top_delta_with_bash_driver() { let tree = turn_tree("sess-1", "msg-1", root); let pricing = crate::analyze::pricing::load_builtin_pricing(); let opts = ContextDeltaOpts::default(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + let deltas = deltas_for_session_since(&[tree], &[], &pricing, &opts, None); assert_eq!(deltas.len(), 1, "one pairwise delta expected"); let d = &deltas[0]; assert_eq!(d.session_id, "sess-1"); @@ -142,7 +142,7 @@ fn compaction_replaces_negative_delta() { min_delta: Some(0), ..ContextDeltaOpts::default() }; - let deltas = deltas_for_session(&[tree], &[compaction], &pricing, &opts); + let deltas = deltas_for_session_since(&[tree], &[compaction], &pricing, &opts, None); assert_eq!(deltas.len(), 1); let d = &deltas[0]; assert_eq!(d.delta_tokens, 0, "compaction clamps to 0"); @@ -200,7 +200,7 @@ fn subagent_isolation_main_rail_excludes_subagent_results() { min_delta: Some(0), ..ContextDeltaOpts::default() }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + let deltas = deltas_for_session_since(&[tree], &[], &pricing, &opts, None); // We expect one main-rail delta and one subagent-rail delta. let main_delta = deltas @@ -245,13 +245,60 @@ fn single_inference_yields_no_delta() { root.children.push(inf1); let tree = turn_tree("sess-1", "msg-1", root); let pricing = crate::analyze::pricing::load_builtin_pricing(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + let deltas = + deltas_for_session_since(&[tree], &[], &pricing, &ContextDeltaOpts::default(), None); assert!( deltas.is_empty(), "single inference must not emit a pairwise delta" ); } +#[test] +fn since_filters_on_current_inference_and_keeps_older_baseline() { + let mut inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + inf1.start_ms = 100; + let mut inf2 = make_inf("req-2", "claude-sonnet-4-6", 3000, 0, 0); + inf2.start_ms = 200; + let mut inf3 = make_inf("req-3", "claude-sonnet-4-6", 6000, 0, 0); + inf3.start_ms = 300; + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.extend([inf1, inf2, inf3]); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + + let deltas = deltas_for_session_since(&[tree], &[], &pricing, &opts, Some(200)); + assert_eq!( + deltas.len(), + 2, + "the pair ending exactly at the cutoff qualifies" + ); + assert!(deltas.iter().any(|delta| { + delta.prior_context_tokens == 1000 && delta.current_context_tokens == 3000 + })); +} + +#[test] +fn since_keeps_delta_when_current_timestamp_is_unknown() { + let mut inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + inf1.start_ms = 100; + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 3000, 0, 0); + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.extend([inf1, inf2]); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + + let deltas = deltas_for_session_since(&[tree], &[], &pricing, &opts, Some(10_000)); + assert_eq!(deltas.len(), 1); +} + /// `min_delta` filters out small jumps. #[test] fn min_delta_filters_small_jumps() { @@ -266,7 +313,8 @@ fn min_delta_filters_small_jumps() { let tree = turn_tree("sess-1", "msg-1", root); let pricing = crate::analyze::pricing::load_builtin_pricing(); // Default min_delta is 1000; 500 < 1000 → filtered out. - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + let deltas = + deltas_for_session_since(&[tree], &[], &pricing, &ContextDeltaOpts::default(), None); assert!(deltas.is_empty(), "500 token jump must be filtered"); // Lower the threshold to 100 → row appears. @@ -274,11 +322,12 @@ fn min_delta_filters_small_jumps() { min_delta: Some(100), ..ContextDeltaOpts::default() }; - let deltas = deltas_for_session( + let deltas = deltas_for_session_since( &[turn_tree("sess-1", "msg-1", root_with_two_infs(1000, 1500))], &[], &pricing, &opts, + None, ); assert_eq!(deltas.len(), 1); assert_eq!(deltas[0].delta_tokens, 500); @@ -313,7 +362,7 @@ fn top_caps_output() { min_delta: Some(0), ..ContextDeltaOpts::default() }; - let all = deltas_for_session(std::slice::from_ref(&tree), &[], &pricing, &opts); + let all = deltas_for_session_since(std::slice::from_ref(&tree), &[], &pricing, &opts, None); assert_eq!(all.len(), 4); // Cap at 2 → only the top 2 deltas. @@ -322,7 +371,7 @@ fn top_caps_output() { top: Some(2), ..ContextDeltaOpts::default() }; - let top2 = deltas_for_session(&[tree], &[], &pricing, &opts); + let top2 = deltas_for_session_since(&[tree], &[], &pricing, &opts, None); assert_eq!(top2.len(), 2); } @@ -356,7 +405,7 @@ fn owner_filter_main_excludes_subagent_rail() { owner: OwnerFilter::Main, ..ContextDeltaOpts::default() }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + let deltas = deltas_for_session_since(&[tree], &[], &pricing, &opts, None); for d in &deltas { assert_eq!( d.owner_rail, diff --git a/crates/relayburn-sdk/src/query_verbs/flow.rs b/crates/relayburn-sdk/src/query_verbs/flow.rs index 394714b9..305cd0bc 100644 --- a/crates/relayburn-sdk/src/query_verbs/flow.rs +++ b/crates/relayburn-sdk/src/query_verbs/flow.rs @@ -389,16 +389,6 @@ fn is_schema_missing(err: &crate::ledger::LedgerError) -> bool { msg.contains("no such table") || msg.contains("no such column") } -/// Convert a relative `Duration` window into a canonical -/// `now - duration` ISO-8601 timestamp suitable for a [`Query::since`] -/// filter. Centralized so the deltas seed-query mirrors the same -/// `format_iso_z_ms` shape the rest of the SDK emits. -pub(crate) fn duration_to_since_iso(d: std::time::Duration) -> String { - let now = system_now_secs(); - let when = now.saturating_sub(d.as_secs()) as i64; - format_iso_z_ms(when, 0) -} - /// Lex key for sorting cross-session [`ContextDelta`] rows by owner_rail /// when other tie-breakers are equal. Mirrors the per-session helper in /// `analyze::context_delta`. @@ -409,6 +399,40 @@ fn owner_rail_str(rail: &OwnerRail) -> (&str, &str) { } } +/// Project spellings that may already exist in the ledger. Ingest preserves +/// the harness cwd verbatim, so historical rows can contain a symlinked path +/// while callers may supply that raw path or its canonical target. Turns from +/// another checkout can instead share the Git-derived project key. Query each +/// available spelling without discarding the literal value the caller provided. +fn project_filter_variants(project: Option<&str>) -> Vec> { + let Some(raw) = project else { + return vec![None]; + }; + let mut variants = vec![Some(raw.to_string())]; + if let Some(project_key) = Path::new(raw) + .is_dir() + .then(|| resolve_project(raw).project_key) + .flatten() + { + if !variants + .iter() + .any(|variant| variant.as_deref() == Some(&project_key)) + { + variants.push(Some(project_key)); + } + } + if let Ok(canonical) = std::fs::canonicalize(raw) { + let canonical = canonical.to_string_lossy().into_owned(); + if !variants + .iter() + .any(|variant| variant.as_deref() == Some(&canonical)) + { + variants.push(Some(canonical)); + } + } + variants +} + impl LedgerHandle { /// Per-inference context-window deltas. /// @@ -421,41 +445,51 @@ impl LedgerHandle { /// isolation). /// /// When [`ContextDeltaOpts::session`] is `Some`, only that session is - /// scanned. When `None`, every session in the ledger that has activity - /// inside the [`ContextDeltaOpts::since`] window contributes — sessions - /// whose latest activity falls outside the window are skipped before any - /// span trees get loaded. The same window is then applied to the - /// returned [`Vec`] cap. + /// scanned. [`ContextDeltaOpts::project`] narrows sessions by their + /// ledger project/project-key. [`ContextDeltaOpts::since`] accepts the + /// shared relative-or-ISO grammar and is an inclusive lower bound on each + /// delta's current inference. Its preceding baseline inference may be + /// older. Deltas whose current inference has no timestamp remain eligible. pub fn context_delta(&self, opts: ContextDeltaOpts) -> Result> { let pricing = load_pricing(None); + let normalized_since = normalize_since(opts.since.as_deref())?; + let since_ms = normalized_since + .as_deref() + .and_then(crate::util::time::parse_iso_ms); - // Build the seed `since` filter from `opts.since`. We always have a - // sensible `effective_since()` default, but only apply it when the - // caller actually passed a value — when `None`, scan every session. - // (Honoring the default would change historic behavior for callers - // that relied on "no since = all time".) - let seed_since: Option = opts.since.map(duration_to_since_iso); - let session_query = Query { - since: seed_since.clone(), - ..Default::default() - }; - - let session_ids: Vec = match opts.session.clone() { - Some(id) => vec![id], - None => { - // Enumerate sessions that have activity inside the - // `since` window. Walking only the matching `turns` - // rows keeps this cheap on large ledgers — we never - // load span trees for sessions that already missed - // the filter. - let mut ids: BTreeSet = BTreeSet::new(); - let all = self.inner.query_turns(&session_query)?; - for enriched in all { + // First narrow by project/session, then select sessions with a turn or + // inference in the window. We intentionally inspect timestamps in + // memory instead of pushing `since` into SQL: the ledger query omits + // unknown timestamps, while deltas preserve those rows by contract. + let timestamp_passes = |ms: i64| since_ms.is_none_or(|cutoff| ms == 0 || ms >= cutoff); + let mut ids: BTreeSet = BTreeSet::new(); + for project in project_filter_variants(opts.project.as_deref()) { + let session_query = Query { + project, + session_id: opts.session.clone(), + ..Default::default() + }; + for enriched in self.inner.query_turns(&session_query)? { + let ms = crate::util::time::parse_iso_ms(&enriched.turn.ts).unwrap_or(0); + if timestamp_passes(ms) { ids.insert(enriched.turn.session_id); } - ids.into_iter().collect() } - }; + if since_ms.is_some() { + match self.inner.query_inferences(&session_query) { + Ok(inferences) => { + for inference in inferences { + if timestamp_passes(inference.start_ms) { + ids.insert(inference.session_id); + } + } + } + Err(err) if is_schema_missing(&err) => {} + Err(err) => return Err(err.into()), + } + } + } + let session_ids: Vec = ids.into_iter().collect(); let mut out: Vec = Vec::new(); for session_id in session_ids { @@ -467,7 +501,8 @@ impl LedgerHandle { session_id: Some(session_id.clone()), ..Default::default() })?; - let per_session = deltas_for_session(&trees, &compactions, &pricing, &opts); + let per_session = + deltas_for_session_since(&trees, &compactions, &pricing, &opts, since_ms); out.extend(per_session); } diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index cf58e658..ef41ecf8 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -20,7 +20,7 @@ use crate::analyze::{ aggregate_by_bash, aggregate_by_bash_verb, aggregate_by_file, aggregate_by_mcp_server, aggregate_by_provider, aggregate_by_subagent, aggregate_subagent_type_stats, attribute_hotspots, attribute_overhead, build_compare_table, build_ghost_surface_inputs, - build_subagent_tree, build_trim_recommendations, cost_for_turn, deltas_for_session, + build_subagent_tree, build_trim_recommendations, cost_for_turn, deltas_for_session_since, detect_ghost_surface, detect_patterns, detect_tool_call_patterns, detect_tool_output_bloat, find_overhead_files, findings_from_patterns, ghost_surface_to_finding, has_minimum_fidelity, load_claude_settings, load_overhead_file, load_pricing, project_claude_settings_path, @@ -82,12 +82,13 @@ pub fn normalize_since(since: Option<&str>) -> Result> { if let Some((n, unit)) = parse_relative(raw) { let secs_back = match unit { - 'h' => n * 3_600, - 'd' => n * 86_400, - 'w' => n * 7 * 86_400, - 'm' => n * 30 * 86_400, + '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!(), - }; + } + .ok_or_else(|| anyhow::anyhow!("invalid since: {raw} (relative range is too large)"))?; let now = system_now_secs(); let when = now.saturating_sub(secs_back) as i64; return Ok(Some(format_iso_z_ms(when, 0))); diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index 1b6782b6..56306882 100644 --- a/crates/relayburn-sdk/src/query_verbs/tests.rs +++ b/crates/relayburn-sdk/src/query_verbs/tests.rs @@ -196,6 +196,11 @@ fn normalize_since_rejects_garbage() { assert!(normalize_since(Some("2026-05-06T00:00:00+9")).is_err()); } +#[test] +fn normalize_since_rejects_overflowing_relative_range() { + assert!(normalize_since(Some("18446744073709551615h")).is_err()); +} + #[test] fn normalize_since_returns_none_for_empty() { assert!(normalize_since(None).unwrap().is_none()); @@ -1786,17 +1791,6 @@ fn compute_summary_replacement_savings_none_when_no_replacement_tools() { assert!(result.replacement_savings.is_none()); } -#[test] -fn duration_to_since_iso_emits_canonical_zulu_ms() { - let iso = super::duration_to_since_iso(std::time::Duration::from_secs(60)); - // Shape only — actual value depends on system clock. We assert - // the canonical lower-bound shape `YYYY-MM-DDTHH:MM:SS.mmmZ` - // that `Query::since` lex-compares against ledger rows. - assert_eq!(iso.len(), 24, "{iso}"); - assert!(iso.ends_with(".000Z")); - assert!(iso.contains('T')); -} - /// Regression for the `since`-is-ignored bug: when `opts.since` is /// `Some`, sessions whose latest turn is older than the window must /// not appear in the deltas output. With a 1-second window and @@ -1809,7 +1803,7 @@ fn context_delta_since_filter_excludes_old_sessions() { use crate::analyze::ContextDeltaOpts; let (_dir, handle) = multi_session_handle(); let opts = ContextDeltaOpts { - since: Some(std::time::Duration::from_secs(1)), + since: Some("1h".into()), ..ContextDeltaOpts::default() }; let deltas = handle.context_delta(opts).expect("context_delta"); @@ -1820,6 +1814,248 @@ fn context_delta_since_filter_excludes_old_sessions() { ); } +fn context_delta_filter_handle(alpha_project: &str) -> (TempDir, LedgerHandle) { + context_delta_filter_handle_with_key(alpha_project, None) +} + +fn context_delta_filter_handle_with_key( + alpha_project: &str, + alpha_project_key: Option<&str>, +) -> (TempDir, LedgerHandle) { + let dir = tempfile::tempdir().unwrap(); + let opts = LedgerOpenOptions::with_home(dir.path()); + let mut handle = Ledger::open(opts).expect("open ledger"); + let turn = |session: &str, + project: &str, + project_key: Option<&str>, + index: u64, + ts: &str, + message_id: &str, + input: u64| { + TurnRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session.into(), + session_path: None, + message_id: message_id.into(), + turn_index: index, + ts: ts.into(), + model: "claude-sonnet-4-6".into(), + project: Some(project.into()), + project_key: project_key.map(str::to_owned), + usage: Usage { + input, + output: 0, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + tool_calls: vec![], + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } + }; + handle + .raw_mut() + .append_turns(&[ + turn( + "sess-alpha", + alpha_project, + alpha_project_key, + 0, + "2026-04-20T10:00:00.000Z", + "alpha-1", + 1_000, + ), + turn( + "sess-alpha", + alpha_project, + alpha_project_key, + 1, + "2026-04-21T10:00:00.000Z", + "alpha-2", + 3_000, + ), + turn( + "sess-alpha", + alpha_project, + alpha_project_key, + 2, + "2026-07-20T10:00:00.000Z", + "alpha-3", + 6_000, + ), + turn( + "sess-beta", + "/tmp/proj-beta", + None, + 0, + "2026-07-19T10:00:00.000Z", + "beta-1", + 2_000, + ), + turn( + "sess-beta", + "/tmp/proj-beta", + None, + 1, + "2026-07-20T11:00:00.000Z", + "beta-2", + 7_000, + ), + ]) + .expect("append turns"); + (dir, handle) +} + +#[test] +fn context_delta_project_filter_actually_filters_sessions() { + let (_dir, handle) = context_delta_filter_handle("/tmp/proj-alpha"); + let deltas = handle + .context_delta(ContextDeltaOpts { + project: Some("/tmp/proj-alpha".into()), + min_delta: Some(0), + top: Some(20), + ..ContextDeltaOpts::default() + }) + .expect("context_delta"); + assert!(!deltas.is_empty()); + assert!(deltas.iter().all(|delta| delta.session_id == "sess-alpha")); +} + +#[test] +fn context_delta_project_path_matches_resolved_git_project_key() { + let project = tempfile::tempdir().expect("project root"); + let git_dir = project.path().join(".git"); + std::fs::create_dir_all(&git_dir).expect("create git dir"); + std::fs::write( + git_dir.join("config"), + "[remote \"origin\"]\n\turl = git@github.com:example/context-deltas.git\n", + ) + .expect("write git config"); + let nested = project.path().join("crates").join("sdk"); + std::fs::create_dir_all(&nested).expect("create nested project path"); + + let (_dir, handle) = context_delta_filter_handle_with_key( + "/different/checkout/subdirectory", + Some("github.com/example/context-deltas"), + ); + let deltas = handle + .context_delta(ContextDeltaOpts { + project: Some(nested.to_string_lossy().into_owned()), + min_delta: Some(0), + ..ContextDeltaOpts::default() + }) + .expect("project-key context_delta"); + + assert!(!deltas.is_empty()); + assert!(deltas.iter().all(|delta| delta.session_id == "sess-alpha")); + + let nonexistent = nested.join("does-not-exist"); + let deltas = handle + .context_delta(ContextDeltaOpts { + project: Some(nonexistent.to_string_lossy().into_owned()), + min_delta: Some(0), + ..ContextDeltaOpts::default() + }) + .expect("nonexistent project context_delta"); + assert!( + deltas.is_empty(), + "a nonexistent path must not widen to its parent repository key" + ); +} + +#[cfg(unix)] +#[test] +fn context_delta_project_filter_matches_literal_and_canonical_symlink_paths() { + 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"); + let raw = link.to_string_lossy().into_owned(); + let canonical = std::fs::canonicalize(&link) + .expect("canonical project") + .to_string_lossy() + .into_owned(); + assert_ne!(raw, canonical, "fixture must exercise distinct spellings"); + + let (_raw_dir, raw_handle) = context_delta_filter_handle(&raw); + let raw_match = raw_handle + .context_delta(ContextDeltaOpts { + project: Some(raw.clone()), + min_delta: Some(0), + ..ContextDeltaOpts::default() + }) + .expect("literal project match"); + assert!(!raw_match.is_empty()); + assert!(raw_match + .iter() + .all(|delta| delta.session_id == "sess-alpha")); + + let (_canonical_dir, canonical_handle) = context_delta_filter_handle(&canonical); + let canonical_match = canonical_handle + .context_delta(ContextDeltaOpts { + project: Some(raw), + min_delta: Some(0), + ..ContextDeltaOpts::default() + }) + .expect("canonical project alias match"); + assert!(!canonical_match.is_empty()); + assert!(canonical_match + .iter() + .all(|delta| delta.session_id == "sess-alpha")); +} + +#[test] +fn context_delta_iso_since_filters_rows_and_matches_session_mode() { + let (_dir, handle) = context_delta_filter_handle("/tmp/proj-alpha"); + let since = Some("2026-07-01T00:00:00Z".into()); + let all = handle + .context_delta(ContextDeltaOpts { + project: Some("/tmp/proj-alpha".into()), + since: since.clone(), + min_delta: Some(0), + top: Some(20), + ..ContextDeltaOpts::default() + }) + .expect("all-session context_delta"); + let one = handle + .context_delta(ContextDeltaOpts { + session: Some("sess-alpha".into()), + since, + min_delta: Some(0), + top: Some(20), + ..ContextDeltaOpts::default() + }) + .expect("session context_delta"); + + assert_eq!(all, one); + assert_eq!(all.len(), 1, "the April delta must be filtered out"); + assert_eq!(all[0].turn_id, "alpha-3"); + assert_eq!(all[0].prior_context_tokens, 3_000); + assert_eq!(all[0].current_context_tokens, 6_000); +} + +#[test] +fn context_delta_invalid_since_returns_error() { + let (_dir, handle) = context_delta_filter_handle("/tmp/proj-alpha"); + let err = handle + .context_delta(ContextDeltaOpts { + since: Some("last-tuesday-ish".into()), + ..ContextDeltaOpts::default() + }) + .expect_err("invalid since must not widen to all time"); + assert!(err.to_string().contains("invalid since"), "{err:#}"); +} + fn multi_session_handle() -> (TempDir, LedgerHandle) { let dir = tempfile::tempdir().unwrap(); let opts = LedgerOpenOptions::with_home(dir.path());