From f0d30180d729b140c120cd6a972fa1a980fa8e0e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 16:10:24 -0400 Subject: [PATCH] add summary envelopes and ghost-surface text matching --- CHANGELOG.md | 3 + .../relayburn-cli/src/commands/summary/mod.rs | 1 + crates/relayburn-sdk-node/src/lib.rs | 131 +++++++++ .../src/analyze/ghost_surface_inputs.rs | 4 +- .../relayburn-sdk/src/query_verbs/compare.rs | 11 +- .../relayburn-sdk/src/query_verbs/hotspots.rs | 49 +++- crates/relayburn-sdk/src/query_verbs/mod.rs | 38 ++- .../relayburn-sdk/src/query_verbs/sessions.rs | 3 +- .../src/query_verbs/summary/compute.rs | 6 +- .../src/query_verbs/summary/mod.rs | 189 ++++++++++++- crates/relayburn-sdk/src/query_verbs/tests.rs | 261 +++++++++++++++++- packages/sdk-node/CHANGELOG.md | 2 + packages/sdk-node/src/binding.cjs | 24 +- packages/sdk-node/src/binding.d.ts | 3 + packages/sdk-node/src/index.cjs | 3 + packages/sdk-node/src/index.d.ts | 142 ++++++++++ packages/sdk-node/src/index.js | 12 + packages/sdk-node/test/conformance.test.js | 29 ++ packages/sdk-node/test/no-onlog.test.js | 16 +- 19 files changed, 893 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea625cb..5ef6659f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `relayburn-sdk` now exposes SDK-owned summary report/time-series envelopes with schema/version, capability, and normalized window metadata for app and presenter consumers. +- `burn hotspots --findings` ghost-surface results now account for user-text slash-command invocations, so active Claude/Codex commands and prompts are no longer flagged as unused when they do not appear as tool calls. + ## [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/commands/summary/mod.rs b/crates/relayburn-cli/src/commands/summary/mod.rs index 28c5babf..e12f7477 100644 --- a/crates/relayburn-cli/src/commands/summary/mod.rs +++ b/crates/relayburn-cli/src/commands/summary/mod.rs @@ -313,6 +313,7 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result { session: args.session, project: args.project, since: args.since, + until: None, workflow: args.workflow, tags: if tag_filter.is_empty() { None diff --git a/crates/relayburn-sdk-node/src/lib.rs b/crates/relayburn-sdk-node/src/lib.rs index bbfe033f..7156cadd 100644 --- a/crates/relayburn-sdk-node/src/lib.rs +++ b/crates/relayburn-sdk-node/src/lib.rs @@ -93,6 +93,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use napi::bindgen_prelude::{BigInt, Error as NapiError, Result as NapiResult, ToNapiValue}; use napi::sys; use napi_derive::napi; +use serde::Deserialize; use serde_json::Value as JsonValue; use relayburn_sdk as sdk; @@ -221,6 +222,27 @@ const BIGINT_FIELDS: &[&str] = &[ "filesWithRecommendations", "totalRecommendations", "tokensPerSession", + // SDK-owned summary report envelopes + "bucketSeconds", + "seconds", + "turnCount", + "totalTokens", + "unpricedTurns", + "turns", + "known", + "missing", + "count", + "calls", + "invocations", + "estimatedTokensSaved", + "endTurn", + "maxTokens", + "pauseTurn", + "stopSequence", + "toolUse", + "refusal", + "silent", + "none", // hotspots aggregations "callCount", "distinctCommands", @@ -644,6 +666,8 @@ pub struct SummaryOptions { /// ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range /// (`24h`, `7d`, `4w`, `2m`). pub since: Option, + /// Inclusive upper bound. Accepts the same ISO/relative grammar as `since`. + pub until: Option, pub tags: Option>, pub group_by_tag: Option, pub ledger_home: Option, @@ -767,6 +791,7 @@ pub fn summary(opts: Option) -> Result { session: None, project: None, since: None, + until: None, tags: None, group_by_tag: None, ledger_home: None, @@ -775,6 +800,7 @@ pub fn summary(opts: Option) -> Result { session: opts.session, project: opts.project, since: opts.since, + until: opts.until, tags: opts .tags .map(|tags| tags.into_iter().collect::>()), @@ -784,6 +810,111 @@ pub fn summary(opts: Option) -> Result { sdk::summary(raw).map(Summary::from).map_err(sdk_err) } +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SummaryTimeseriesEnvelopeOptions { + #[serde(default)] + bucket_seconds: Option, + #[serde(default)] + session: Option, + #[serde(default)] + project: Option, + #[serde(default)] + since: Option, + #[serde(default)] + until: Option, + #[serde(default)] + workflow: Option, + #[serde(default)] + tags: Option>, + #[serde(default)] + group_by_tag: Option, + #[serde(default)] + agent: Option, + #[serde(default)] + providers: Option>, + #[serde(default)] + mode: sdk::SummaryReportMode, + #[serde(default)] + include_quality: bool, + #[serde(default)] + ledger_home: Option, +} + +impl SummaryTimeseriesEnvelopeOptions { + fn into_parts(self) -> Result<(sdk::SummaryReportOptions, u64), BurnError> { + let bucket_seconds = self + .bucket_seconds + .filter(|n| *n > 0) + .ok_or_else(|| invalid_arg("summaryTimeseries requires a positive bucketSeconds"))?; + Ok(( + sdk::SummaryReportOptions { + session: self.session, + project: self.project, + since: self.since, + until: self.until, + workflow: self.workflow, + tags: self.tags, + group_by_tag: self.group_by_tag, + agent: self.agent, + providers: self.providers, + mode: self.mode, + include_quality: self.include_quality, + ledger_home: self.ledger_home, + }, + bucket_seconds, + )) + } +} + +fn summary_report_options_from_value( + opts: Option, +) -> Result { + opts.map(serde_json::from_value) + .transpose() + .map_err(|e| invalid_arg(format!("invalid summaryReport options: {e}")))? + .map(Ok) + .unwrap_or_else(|| Ok(sdk::SummaryReportOptions::default())) +} + +/// Version/capability handshake for SDK-owned report contracts. +#[napi(ts_return_type = "import('./index').ReportCapabilities")] +pub fn capabilities() -> Result { + let value = serde_json::to_value(sdk::report_capabilities()) + .map_err(|e| NapiError::new(SDK_ERROR_CODE, format!("serialize capabilities: {e}")))?; + Ok(BigIntPromoting(value)) +} + +/// SDK-owned summary report envelope. The result shape is documented as +/// `SummaryReportEnvelope` in the Node facade types. +#[napi( + js_name = "summaryReport", + ts_return_type = "import('./index').SummaryReportEnvelope" +)] +pub fn summary_report(opts: Option) -> Result { + let raw = summary_report_options_from_value(opts)?; + let result = sdk::summary_report_envelope(raw).map_err(sdk_err)?; + let value = serde_json::to_value(&result) + .map_err(|e| NapiError::new(SDK_ERROR_CODE, format!("serialize summary_report: {e}")))?; + Ok(BigIntPromoting(value)) +} + +/// SDK-owned bucketed summary time-series envelope. +#[napi( + js_name = "summaryTimeseries", + ts_return_type = "import('./index').SummaryTimeseriesEnvelope" +)] +pub fn summary_timeseries(opts: JsonValue) -> Result { + let parsed: SummaryTimeseriesEnvelopeOptions = serde_json::from_value(opts) + .map_err(|e| invalid_arg(format!("invalid summaryTimeseries options: {e}")))?; + let (raw, bucket_seconds) = parsed.into_parts()?; + let result = sdk::summary_timeseries_envelope(raw, bucket_seconds).map_err(sdk_err)?; + let value = serde_json::to_value(&result).map_err(|e| { + NapiError::new(SDK_ERROR_CODE, format!("serialize summary_timeseries: {e}")) + })?; + Ok(BigIntPromoting(value)) +} + // --------------------------------------------------------------------------- // session_cost // --------------------------------------------------------------------------- diff --git a/crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs b/crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs index 2befb21d..8ef24a89 100644 --- a/crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs +++ b/crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs @@ -10,8 +10,8 @@ //! //! The optional `userTurnTextBySession` map (used by Claude / //! Codex slash-command miners) is sourced from the ledger's content -//! sidecar in the production CLI; loading it lives in the CLI layer -//! rather than here so the analyze crate stays I/O-free. +//! sidecar in the query layer; loading it lives outside this module so the +//! analyze crate stays I/O-free. use std::collections::{HashMap, HashSet}; diff --git a/crates/relayburn-sdk/src/query_verbs/compare.rs b/crates/relayburn-sdk/src/query_verbs/compare.rs index 7b689151..5a7e9bd4 100644 --- a/crates/relayburn-sdk/src/query_verbs/compare.rs +++ b/crates/relayburn-sdk/src/query_verbs/compare.rs @@ -85,6 +85,7 @@ impl LedgerHandle { opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + None, )?; let mut enrichment = BTreeMap::new(); if let Some(workflow) = opts.workflow { @@ -164,6 +165,7 @@ impl LedgerHandle { opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + None, )?; let mut enrichment = BTreeMap::new(); if let Some(workflow) = opts.workflow { @@ -185,8 +187,13 @@ impl LedgerHandle { } let pricing = load_pricing(None); - let Some((buckets, per_bucket)) = - super::partition_into_buckets(turns, q.since.as_deref(), bucket_secs, |t| &t.turn.ts)? + let Some((buckets, per_bucket)) = super::partition_into_buckets( + turns, + q.since.as_deref(), + q.until.as_deref(), + bucket_secs, + |t| &t.turn.ts, + )? else { return Ok(CompareTimeseries { bucket_secs, diff --git a/crates/relayburn-sdk/src/query_verbs/hotspots.rs b/crates/relayburn-sdk/src/query_verbs/hotspots.rs index 494a02dc..0516b2a4 100644 --- a/crates/relayburn-sdk/src/query_verbs/hotspots.rs +++ b/crates/relayburn-sdk/src/query_verbs/hotspots.rs @@ -194,6 +194,7 @@ impl LedgerHandle { opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + None, )?; if let Some(workflow) = opts.workflow.as_ref() { let mut enrichment = q.enrichment.unwrap_or_default(); @@ -282,6 +283,7 @@ fn run_hotspots_attribution( let side_q = Query { session_id: q.session_id.clone(), since: q.since.clone(), + until: q.until.clone(), enrichment: q.enrichment.clone(), ..Default::default() }; @@ -491,6 +493,7 @@ fn run_hotspots_findings( let side_q = Query { session_id: q.session_id.clone(), since: q.since.clone(), + until: q.until.clone(), enrichment: q.enrichment.clone(), ..Default::default() }; @@ -545,7 +548,7 @@ fn run_hotspots_findings( } if wanted_set.contains("ghost-surface") { - let inputs = build_ghost_surface_inputs(turns, pricing, None); + let inputs = build_hotspots_ghost_surface_inputs(handle, turns, pricing, &side_q); let ghosts = detect_ghost_surface(&inputs); let options = GhostSurfaceFindingOptions::default(); for g in ghosts { @@ -572,6 +575,50 @@ fn run_hotspots_findings( }) } +pub(super) fn build_hotspots_ghost_surface_inputs( + handle: &LedgerHandle, + turns: &[TurnRecord], + pricing: &PricingTable, + q: &Query, +) -> crate::analyze::ghost_surface::GhostSurfaceInputs { + let user_turn_text_by_session = load_ghost_surface_user_text_by_session(handle, turns, q); + build_ghost_surface_inputs(turns, pricing, Some(user_turn_text_by_session)) +} + +fn load_ghost_surface_user_text_by_session( + handle: &LedgerHandle, + turns: &[TurnRecord], + q: &Query, +) -> HashMap>> { + let session_sources: HashSet<(SourceKind, String)> = turns + .iter() + .map(|turn| (turn.source, turn.session_id.clone())) + .collect(); + let mut out: HashMap>> = HashMap::new(); + for (source, session_id) in session_sources { + let records = match handle.inner.query_content(&Query { + session_id: Some(session_id.clone()), + source: Some(source), + since: q.since.clone(), + until: q.until.clone(), + ..Default::default() + }) { + Ok(records) => records, + Err(_) => continue, + }; + let texts: Vec = records + .into_iter() + .filter(|record| record.role == ContentRole::User && record.kind == ContentKind::Text) + .filter_map(|record| record.text) + .filter(|text| !text.is_empty()) + .collect(); + if !texts.is_empty() { + out.entry(source).or_default().insert(session_id, texts); + } + } + out +} + fn fidelity_summary_to_value(s: &FidelitySummary) -> serde_json::Value { // Mirror the TS shape: { total, byClass, byGranularity, missingCoverage, // unknown }. The analyze type doesn't derive Serialize so build it here. diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index cf58e658..59321012 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -39,8 +39,8 @@ use crate::analyze::{ }; use crate::ledger::{EnrichedTurn, Enrichment, Query}; use crate::reader::{ - parse_bash_command, resolve_project, BashParse, FidelityClass, RelationshipType, SourceKind, - StopReason, TurnRecord, UsageGranularity, UserTurnRecord, + parse_bash_command, resolve_project, BashParse, ContentKind, ContentRole, FidelityClass, + RelationshipType, SourceKind, StopReason, TurnRecord, UsageGranularity, UserTurnRecord, }; // Re-exported only for the `tests` submodule, which reaches these names // through `use super::*`. The non-test query-verb code no longer references @@ -73,7 +73,15 @@ use crate::{Ledger, LedgerHandle, LedgerOpenOptions}; /// /// Garbage inputs error out; `None` / empty inputs return `Ok(None)`. pub fn normalize_since(since: Option<&str>) -> Result> { - let Some(raw) = since else { + normalize_time_bound("since", since) +} + +fn normalize_until(until: Option<&str>) -> Result> { + normalize_time_bound("until", until) +} + +fn normalize_time_bound(label: &str, value: Option<&str>) -> Result> { + let Some(raw) = value else { return Ok(None); }; if raw.is_empty() { @@ -96,7 +104,7 @@ pub fn normalize_since(since: Option<&str>) -> Result> { if let Some(canonical) = normalize_iso_to_utc_z(raw) { return Ok(Some(canonical)); } - anyhow::bail!("invalid since: {raw} (expected ISO timestamp or relative range like 7d)"); + anyhow::bail!("invalid {label}: {raw} (expected ISO timestamp or relative range like 7d)"); } fn parse_relative(s: &str) -> Option<(u64, char)> { @@ -409,6 +417,7 @@ pub(crate) fn ensure_bucket_span(anchor: i64, end: i64, bucket_secs: u64) -> Res pub(crate) fn partition_into_buckets( items: Vec, since: Option<&str>, + until: Option<&str>, bucket_secs: u64, ts_of: impl Fn(&T) -> &str, ) -> Result>)>> { @@ -418,9 +427,14 @@ pub(crate) fn partition_into_buckets( ) else { return Ok(None); }; - let now = system_now_secs() as i64; - ensure_bucket_span(anchor, now, bucket_secs)?; - let buckets = Buckets::new(anchor, now, bucket_secs); + let end = until + .and_then(iso_z_to_epoch_secs) + // `Query::until` is inclusive. Buckets are `[start, end)`, so include + // the final until second by making the partition end exclusive. + .map(|secs| secs.saturating_add(1)) + .unwrap_or_else(|| system_now_secs() as i64); + ensure_bucket_span(anchor, end, bucket_secs)?; + let buckets = Buckets::new(anchor, end, bucket_secs); let mut per_bucket: Vec> = (0..buckets.len()).map(|_| Vec::new()).collect(); for t in items { let Some(ep) = iso_z_to_epoch_secs(ts_of(&t)) else { @@ -437,7 +451,12 @@ pub(crate) fn partition_into_buckets( // Shared helpers — query construction + hotspots coverage gate // --------------------------------------------------------------------------- -fn build_query(session: Option<&str>, project: Option<&str>, since: Option<&str>) -> Result { +fn build_query( + session: Option<&str>, + project: Option<&str>, + since: Option<&str>, + until: Option<&str>, +) -> Result { let mut q = Query::default(); if let Some(s) = session { q.session_id = Some(s.to_string()); @@ -448,6 +467,9 @@ fn build_query(session: Option<&str>, project: Option<&str>, since: Option<&str> if let Some(since_norm) = normalize_since(since)? { q.since = Some(since_norm); } + if let Some(until_norm) = normalize_until(until)? { + q.until = Some(until_norm); + } Ok(q) } diff --git a/crates/relayburn-sdk/src/query_verbs/sessions.rs b/crates/relayburn-sdk/src/query_verbs/sessions.rs index 1241a718..32162b07 100644 --- a/crates/relayburn-sdk/src/query_verbs/sessions.rs +++ b/crates/relayburn-sdk/src/query_verbs/sessions.rs @@ -115,6 +115,7 @@ impl LedgerHandle { opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + None, )?; Ok(self.inner.query_inferences(&q)?) } @@ -194,7 +195,7 @@ impl LedgerHandle { /// every other read verb already trusts). pub fn sessions_list(&self, opts: SessionsListOptions) -> Result { let limit = opts.limit.unwrap_or(SESSIONS_LIST_DEFAULT_LIMIT); - let q = build_query(None, opts.project.as_deref(), opts.since.as_deref())?; + let q = build_query(None, opts.project.as_deref(), opts.since.as_deref(), None)?; let turns = collect_turns(self, &q)?; let pricing = load_pricing(None); diff --git a/crates/relayburn-sdk/src/query_verbs/summary/compute.rs b/crates/relayburn-sdk/src/query_verbs/summary/compute.rs index 0d174201..54095b7e 100644 --- a/crates/relayburn-sdk/src/query_verbs/summary/compute.rs +++ b/crates/relayburn-sdk/src/query_verbs/summary/compute.rs @@ -30,6 +30,7 @@ pub(crate) fn build_summary_report_query(opts: &SummaryReportOptions) -> Result< opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + opts.until.as_deref(), )?; if let Some(tag) = opts.group_by_tag.as_deref() { validate_tag_key(tag, "groupByTag")?; @@ -235,8 +236,8 @@ pub(crate) fn compute_summary_subagent_counts( /// into. Returns `None` when `opts` carries no scoping filters, which /// preserves the original "scan every reachable session" behavior for /// the bare `burn summary` invocation. Returns `Some(set)` when any -/// filter (`session`, `project`, `since`, `workflow`, `tags`, `agent`, -/// `providers`) is active — `set` is the session ids that survived +/// filter (`session`, `project`, `since`, `until`, `workflow`, `tags`, +/// `agent`, `providers`) is active — `set` is the session ids that survived /// every filter, derived from the already-filtered `turns` slice. /// /// Plumbing the filter via the filtered turn set (instead of e.g. @@ -251,6 +252,7 @@ pub(crate) fn summary_subagent_session_filter( let has_filter = opts.session.is_some() || opts.project.is_some() || opts.since.is_some() + || opts.until.is_some() || opts.workflow.is_some() || opts.agent.is_some() || opts.tags.as_ref().map(|t| !t.is_empty()).unwrap_or(false) diff --git a/crates/relayburn-sdk/src/query_verbs/summary/mod.rs b/crates/relayburn-sdk/src/query_verbs/summary/mod.rs index cf692e26..832f08b5 100644 --- a/crates/relayburn-sdk/src/query_verbs/summary/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/summary/mod.rs @@ -10,6 +10,7 @@ pub struct SummaryOptions { pub session: Option, pub project: Option, pub since: Option, + pub until: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -145,6 +146,7 @@ impl LedgerHandle { opts.session.as_deref(), opts.project.as_deref(), opts.since.as_deref(), + opts.until.as_deref(), )?; if let Some(tags) = opts.tags.clone() { validate_tags(&tags)?; @@ -327,6 +329,7 @@ pub struct SummaryReportOptions { pub session: Option, pub project: Option, pub since: Option, + pub until: Option, pub workflow: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option, @@ -559,7 +562,148 @@ pub struct SummaryTimeseries { pub buckets: Vec, } +pub const SUMMARY_REPORT_SCHEMA_NAME: &str = "relayburn.report.summary.v1"; +pub const SUMMARY_TIMESERIES_SCHEMA_NAME: &str = "relayburn.report.summaryTimeseries.v1"; +pub const REPORT_SCHEMA_VERSION: u32 = 1; + +const REPORT_CAPABILITIES: &[&str] = &[ + "summaryReportEnvelope", + "summaryTimeseriesEnvelope", + "summaryUntil", + "summaryProviderGrouping", + "summaryTagGrouping", + "summaryQuality", + "summaryRelationships", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReportSchemaVersion { + pub name: String, + pub version: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReportCapabilities { + pub package_name: String, + pub package_version: String, + pub features: Vec, +} + +impl ReportCapabilities { + pub fn current() -> Self { + Self { + package_name: env!("CARGO_PKG_NAME").to_string(), + package_version: env!("CARGO_PKG_VERSION").to_string(), + features: REPORT_CAPABILITIES + .iter() + .map(|s| (*s).to_string()) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReportBucket { + pub seconds: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReportWindow { + pub since: Option, + pub until: Option, + pub bucket: Option, +} + +impl ReportWindow { + fn from_query(q: &Query, bucket_secs: Option) -> Self { + Self { + since: q.since.clone(), + until: q.until.clone(), + bucket: bucket_secs.map(|seconds| ReportBucket { seconds }), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SummaryReportEnvelope { + pub schema: ReportSchemaVersion, + pub capabilities: ReportCapabilities, + pub window: ReportWindow, + pub report: SummaryReport, +} + +impl SummaryReportEnvelope { + fn new(q: &Query, report: SummaryReport) -> Self { + Self { + schema: ReportSchemaVersion { + name: SUMMARY_REPORT_SCHEMA_NAME.to_string(), + version: REPORT_SCHEMA_VERSION, + }, + capabilities: ReportCapabilities::current(), + window: ReportWindow::from_query(q, None), + report, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SummaryTimeseriesEnvelope { + pub schema: ReportSchemaVersion, + pub capabilities: ReportCapabilities, + pub window: ReportWindow, + pub timeseries: SummaryTimeseries, +} + +impl SummaryTimeseriesEnvelope { + fn new(q: &Query, bucket_secs: u64, timeseries: SummaryTimeseries) -> Self { + Self { + schema: ReportSchemaVersion { + name: SUMMARY_TIMESERIES_SCHEMA_NAME.to_string(), + version: REPORT_SCHEMA_VERSION, + }, + capabilities: ReportCapabilities::current(), + window: ReportWindow::from_query(q, Some(bucket_secs)), + timeseries, + } + } +} + +pub fn report_capabilities() -> ReportCapabilities { + ReportCapabilities::current() +} + impl LedgerHandle { + pub fn summary_report_envelope( + &self, + opts: SummaryReportOptions, + ) -> Result { + let q = build_summary_report_query(&opts)?; + let mut normalized_opts = opts; + normalized_opts.since = q.since.clone(); + normalized_opts.until = q.until.clone(); + let report = self.summary_report(normalized_opts)?; + Ok(SummaryReportEnvelope::new(&q, report)) + } + + pub fn summary_timeseries_envelope( + &self, + opts: SummaryReportOptions, + bucket_secs: u64, + ) -> Result { + let q = build_summary_report_query(&opts)?; + let mut normalized_opts = opts; + normalized_opts.since = q.since.clone(); + normalized_opts.until = q.until.clone(); + let timeseries = self.summary_timeseries(normalized_opts, bucket_secs)?; + Ok(SummaryTimeseriesEnvelope::new(&q, bucket_secs, timeseries)) + } + /// Time-bucketed cost/usage totals (the `--bucket` form of the default /// grouped summary). Fetches the `--since` window once, then partitions the /// turns by `ts` into `bucket_secs`-wide buckets and aggregates each — a @@ -602,8 +746,13 @@ impl LedgerHandle { ); let turns = summary_turns_from_enriched(&enriched); - let Some((buckets, per_bucket)) = - super::partition_into_buckets(turns, q.since.as_deref(), bucket_secs, |t| &t.ts)? + let Some((buckets, per_bucket)) = super::partition_into_buckets( + turns, + q.since.as_deref(), + q.until.as_deref(), + bucket_secs, + |t| &t.ts, + )? else { return Ok(SummaryTimeseries { bucket_secs, @@ -823,6 +972,42 @@ pub fn summary_report(opts: SummaryReportOptions) -> Result { }) } +pub fn summary_report_envelope(opts: SummaryReportOptions) -> Result { + let handle = open_with(opts.ledger_home.as_deref())?; + handle.summary_report_envelope(SummaryReportOptions { + ledger_home: None, + ..opts + }) +} + +pub fn summary_timeseries( + opts: SummaryReportOptions, + bucket_secs: u64, +) -> Result { + let handle = open_with(opts.ledger_home.as_deref())?; + handle.summary_timeseries( + SummaryReportOptions { + ledger_home: None, + ..opts + }, + bucket_secs, + ) +} + +pub fn summary_timeseries_envelope( + opts: SummaryReportOptions, + bucket_secs: u64, +) -> Result { + let handle = open_with(opts.ledger_home.as_deref())?; + handle.summary_timeseries_envelope( + SummaryReportOptions { + ledger_home: None, + ..opts + }, + bucket_secs, + ) +} + pub fn summary_fidelity_summary_to_value(s: &FidelitySummary) -> serde_json::Value { let mut by_class = serde_json::Map::new(); for class in [ diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index 1b6782b6..1d3fa1c1 100644 --- a/crates/relayburn-sdk/src/query_verbs/tests.rs +++ b/crates/relayburn-sdk/src/query_verbs/tests.rs @@ -6,7 +6,11 @@ use super::summary::{ SummaryRelationshipMatch, }; use super::*; -use crate::reader::{RelationshipSourceKind, ToolCall, Usage, UserTurnBlock, UserTurnBlockKind}; +use crate::reader::{ + ContentKind, ContentRecord, ContentRole, RelationshipSourceKind, ToolCall, Usage, + UserTurnBlock, UserTurnBlockKind, +}; +use std::path::PathBuf; use tempfile::TempDir; fn fixture_handle() -> (TempDir, LedgerHandle) { @@ -99,6 +103,69 @@ fn fixture_handle() -> (TempDir, LedgerHandle) { (dir, handle) } +fn ghost_surface_fixture_root() -> PathBuf { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests") + .join("fixtures") + .join("ghost-surface") +} + +fn ghost_surface_turn(source: SourceKind, session_id: &str, message_id: &str) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.into(), + session_path: None, + message_id: message_id.into(), + turn_index: 0, + ts: "2026-04-23T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: Some("/tmp/proj".into()), + project_key: None, + usage: Usage { + input: 1000, + output: 100, + reasoning: 0, + cache_read: 1000, + cache_create_5m: 0, + cache_create_1h: 0, + }, + tool_calls: Vec::new(), + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } +} + +fn user_content( + source: SourceKind, + session_id: &str, + message_id: &str, + text: &str, +) -> ContentRecord { + ContentRecord { + v: 1, + source, + session_id: session_id.into(), + message_id: message_id.into(), + ts: "2026-04-23T00:00:00.000Z".into(), + role: ContentRole::User, + kind: ContentKind::Text, + text: Some(text.into()), + tool_use: None, + tool_result: None, + } +} + #[test] fn normalize_since_relative_emits_canonical_mmm_z() { // Relative ranges must carry the `.000Z` suffix so the cutoff lex-sorts @@ -266,6 +333,67 @@ fn summary_report_grouped_owns_rows_and_stable_fidelity_shape() { assert!(summary_fidelity_summary_to_value(&grouped.fidelity)["byClass"].is_object()); } +#[test] +fn summary_report_envelope_carries_schema_capabilities_and_window() { + let (_dir, handle) = fixture_handle(); + let envelope = handle + .summary_report_envelope(SummaryReportOptions { + since: Some("2026-04-23T00:00:00Z".into()), + until: Some("2026-04-23T00:00:30Z".into()), + ..SummaryReportOptions::default() + }) + .expect("summary report envelope"); + + assert_eq!(envelope.schema.name, SUMMARY_REPORT_SCHEMA_NAME); + assert_eq!(envelope.schema.version, REPORT_SCHEMA_VERSION); + assert_eq!(envelope.capabilities.package_name, "relayburn-sdk"); + assert!(envelope + .capabilities + .features + .contains(&"summaryReportEnvelope".to_string())); + assert_eq!( + envelope.window.since.as_deref(), + Some("2026-04-23T00:00:00.000Z") + ); + assert_eq!( + envelope.window.until.as_deref(), + Some("2026-04-23T00:00:30.000Z") + ); + assert!(envelope.window.bucket.is_none()); + + let SummaryReport::Grouped(grouped) = envelope.report else { + panic!("expected grouped report"); + }; + assert_eq!(grouped.turn_count, 1); +} + +#[test] +fn summary_timeseries_envelope_applies_until_and_declares_bucket() { + let (_dir, handle) = fixture_handle(); + let envelope = handle + .summary_timeseries_envelope( + SummaryReportOptions { + since: Some("2026-04-23T00:00:00Z".into()), + until: Some("2026-04-23T00:00:30Z".into()), + mode: SummaryReportMode::Grouped { by_provider: false }, + ..SummaryReportOptions::default() + }, + 60, + ) + .expect("summary timeseries envelope"); + + assert_eq!(envelope.schema.name, SUMMARY_TIMESERIES_SCHEMA_NAME); + assert_eq!(envelope.window.bucket.as_ref().map(|b| b.seconds), Some(60)); + assert_eq!( + envelope.window.until.as_deref(), + Some("2026-04-23T00:00:30.000Z") + ); + assert_eq!(envelope.timeseries.bucket_secs, 60); + assert_eq!(envelope.timeseries.buckets.len(), 1); + assert_eq!(envelope.timeseries.buckets[0].turn_count, 1); + assert_eq!(envelope.timeseries.buckets[0].total_tokens, 1_500); +} + /// Acceptance test for issue #437: a turn carrying `stop_reason: /// "max_tokens"` surfaces in the summary outcome counts. Mixes a /// `max_tokens` turn with a `none` turn (no field on the row) to @@ -598,6 +726,13 @@ fn summary_subagent_session_filter_treats_every_filter_as_scoping() { ..SummaryReportOptions::default() }, ), + ( + "until", + SummaryReportOptions { + until: Some("2026-04-23T00:00:30.000Z".into()), + ..SummaryReportOptions::default() + }, + ), ( "workflow", SummaryReportOptions { @@ -1052,6 +1187,130 @@ fn hotspots_group_by_findings_honors_patterns_filter() { } } +#[test] +fn hotspots_ghost_surface_inputs_deghost_claude_command_from_user_text() { + let (_dir, mut handle) = fixture_handle(); + let turn = ghost_surface_turn(SourceKind::ClaudeCode, "ghost-claude", "ghost-claude-turn"); + handle + .raw_mut() + .append_turns(std::slice::from_ref(&turn)) + .unwrap(); + handle + .raw_mut() + .append_content(&[user_content( + SourceKind::ClaudeCode, + "ghost-claude", + "ghost-claude-user", + "/openspec-apply\nApply the latest proposal.", + )]) + .unwrap(); + + let pricing = load_pricing(None); + let mut inputs = super::hotspots::build_hotspots_ghost_surface_inputs( + &handle, + &[turn], + &pricing, + &Query::default(), + ); + let root = ghost_surface_fixture_root(); + inputs.claude_home = Some(root.join("claude")); + inputs.codex_home = Some(root.join("missing-codex")); + inputs.opencode_projects = Some(vec![root.join("missing-opencode")]); + + let ghosts = crate::analyze::ghost_surface::detect_ghost_surface(&inputs); + let claude_ghosts: Vec<_> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + assert!( + !claude_ghosts + .iter() + .any(|g| g.path.ends_with("openspec-apply.md")), + "Claude openspec-apply should be de-ghosted by user text" + ); + assert!( + claude_ghosts + .iter() + .any(|g| g.path.ends_with("openspec-archive.md")), + "unused Claude command should remain ghost" + ); +} + +#[test] +fn hotspots_ghost_surface_inputs_deghost_codex_command_from_user_text() { + let (_dir, mut handle) = fixture_handle(); + let turn = ghost_surface_turn(SourceKind::Codex, "ghost-codex", "ghost-codex-turn"); + handle + .raw_mut() + .append_turns(std::slice::from_ref(&turn)) + .unwrap(); + handle + .raw_mut() + .append_content(&[user_content( + SourceKind::Codex, + "ghost-codex", + "ghost-codex-user", + "/openspec-apply\nApply the latest proposal please.", + )]) + .unwrap(); + + let pricing = load_pricing(None); + let mut inputs = super::hotspots::build_hotspots_ghost_surface_inputs( + &handle, + &[turn], + &pricing, + &Query::default(), + ); + let root = ghost_surface_fixture_root(); + inputs.claude_home = Some(root.join("missing-claude")); + inputs.codex_home = Some(root.join("codex")); + inputs.opencode_projects = Some(vec![root.join("missing-opencode")]); + + let ghosts = crate::analyze::ghost_surface::detect_ghost_surface(&inputs); + let codex_ghosts: Vec<_> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Codex) + .collect(); + assert!( + !codex_ghosts + .iter() + .any(|g| g.path.ends_with("openspec-apply.md")), + "Codex openspec-apply should be de-ghosted by user text" + ); + assert!( + codex_ghosts + .iter() + .any(|g| g.path.ends_with("openspec-archive.md")), + "unused Codex command should remain ghost" + ); +} + +#[test] +fn hotspots_ghost_surface_inputs_fall_back_when_content_missing() { + let (_dir, handle) = fixture_handle(); + let turn = ghost_surface_turn(SourceKind::Codex, "ghost-empty", "ghost-empty-turn"); + let pricing = load_pricing(None); + let mut inputs = super::hotspots::build_hotspots_ghost_surface_inputs( + &handle, + &[turn], + &pricing, + &Query::default(), + ); + let root = ghost_surface_fixture_root(); + inputs.claude_home = Some(root.join("missing-claude")); + inputs.codex_home = Some(root.join("codex")); + inputs.opencode_projects = Some(vec![root.join("missing-opencode")]); + + assert!(inputs.user_turn_text_by_session.is_none()); + let ghosts = crate::analyze::ghost_surface::detect_ghost_surface(&inputs); + assert!( + ghosts + .iter() + .any(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")), + "missing content should preserve tool-call-only ghost behavior" + ); +} + #[test] fn hotspots_session_filter_narrows_to_session() { let (_dir, handle) = fixture_handle(); diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md index 76e4a3bc..5d6e266b 100644 --- a/packages/sdk-node/CHANGELOG.md +++ b/packages/sdk-node/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added `summaryReport()`, `summaryTimeseries()`, and `capabilities()` APIs for the SDK-owned versioned summary report contract. + ## [4.0.0] - 2026-06-23 - `compare()` cost figures now use canonical decimal rounding (`toFixed` semantics) instead of float-multiply rounding, so cells/totals can shift by one in the last reported digit at exact ties. diff --git a/packages/sdk-node/src/binding.cjs b/packages/sdk-node/src/binding.cjs index 61afac5d..9ae7e557 100644 --- a/packages/sdk-node/src/binding.cjs +++ b/packages/sdk-node/src/binding.cjs @@ -46,12 +46,16 @@ let loadError = null; function tryRequire(specifier, localFile) { // Prefer the optional-dep platform package; fall back to a sibling .node // that `napi build --release` drops next to this loader during local dev. - const localPath = localFile ? join(__dirname, '..', localFile) : null; - if (localPath && existsSync(localPath)) { - try { - return require(localPath); - } catch (e) { - loadError = e; + const localPaths = localFile + ? [join(__dirname, localFile), join(__dirname, '..', localFile)] + : []; + for (const localPath of localPaths) { + if (existsSync(localPath)) { + try { + return require(localPath); + } catch (e) { + loadError = e; + } } } try { @@ -63,13 +67,13 @@ function tryRequire(specifier, localFile) { } if (platform === 'darwin' && arch === 'arm64') { - nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'relayburn-sdk.darwin-arm64.node'); + nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'index.darwin-arm64.node'); } else if (platform === 'darwin' && arch === 'x64') { - nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'relayburn-sdk.darwin-x64.node'); + nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'index.darwin-x64.node'); } else if (platform === 'linux' && arch === 'arm64' && !isMusl()) { - nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'relayburn-sdk.linux-arm64-gnu.node'); + nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'index.linux-arm64-gnu.node'); } else if (platform === 'linux' && arch === 'x64' && !isMusl()) { - nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'relayburn-sdk.linux-x64-gnu.node'); + nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'index.linux-x64-gnu.node'); } if (!nativeBinding) { diff --git a/packages/sdk-node/src/binding.d.ts b/packages/sdk-node/src/binding.d.ts index 3fc04911..b7318fe7 100644 --- a/packages/sdk-node/src/binding.d.ts +++ b/packages/sdk-node/src/binding.d.ts @@ -14,6 +14,9 @@ export declare class Ledger { export declare function ingest(opts?: unknown): Promise; export declare function summary(opts?: unknown): Promise; +export declare function summaryReport(opts?: unknown): unknown; +export declare function summaryTimeseries(opts: unknown): unknown; +export declare function capabilities(): unknown; export declare function sessionCost(opts?: unknown): Promise; export declare function fingerprint(opts?: unknown): Promise; export declare function overhead(opts?: unknown): Promise; diff --git a/packages/sdk-node/src/index.cjs b/packages/sdk-node/src/index.cjs index df5f1419..0ac583b9 100644 --- a/packages/sdk-node/src/index.cjs +++ b/packages/sdk-node/src/index.cjs @@ -87,6 +87,9 @@ module.exports = { Ledger, ingest: async (opts) => coerceBigInts(await binding.ingest(opts)), summary: async (opts) => coerceBigInts(await binding.summary(opts)), + summaryReport: async (opts) => coerceBigInts(await binding.summaryReport(opts)), + summaryTimeseries: async (opts) => coerceBigInts(await binding.summaryTimeseries(opts)), + capabilities: () => coerceBigInts(binding.capabilities()), sessionCost: async (opts) => coerceBigInts(await binding.sessionCost(opts)), fingerprint: async (opts) => coerceBigInts(await binding.fingerprint(opts)), overhead: async (opts) => coerceBigInts(await binding.overhead(opts)), diff --git a/packages/sdk-node/src/index.d.ts b/packages/sdk-node/src/index.d.ts index 77881e00..264dc4f3 100644 --- a/packages/sdk-node/src/index.d.ts +++ b/packages/sdk-node/src/index.d.ts @@ -84,6 +84,8 @@ export interface SummaryOptions { project?: string; /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */ since?: string; + /** Inclusive upper bound. Accepts the same ISO/relative grammar as `since`. */ + until?: string; /** Folded enrichment tag filters; every key/value pair must match. */ tags?: Record; /** Group summary costs/tokens by this folded enrichment tag key. */ @@ -120,6 +122,146 @@ export declare function summary(opts?: SummaryOptions): Promise<{ unpricedModels?: string[]; }> +export interface ReportSchemaVersion { + name: 'relayburn.report.summary.v1' | 'relayburn.report.summaryTimeseries.v1' | string; + version: number; +} + +export interface ReportCapabilities { + packageName: string; + packageVersion: string; + features: string[]; +} + +export interface ReportWindow { + since: string | null; + until: string | null; + bucket: { seconds: number | bigint } | null; +} + +export type SummaryGroupBy = 'model' | 'provider' | 'tag'; +export type SummaryReportMode = + | { kind: 'grouped'; byProvider?: boolean } + | { kind: 'byTool' } + | { kind: 'bySubagentType' } + | { kind: 'byRelationship'; subagent?: boolean } + | { kind: 'subagentTree'; sessionId?: string }; + +export interface SummaryReportOptions { + session?: string; + project?: string; + since?: string; + until?: string; + workflow?: string; + tags?: Record; + groupByTag?: string; + agent?: string; + providers?: string[]; + mode?: SummaryReportMode; + includeQuality?: boolean; + ledgerHome?: string; +} + +export interface SummaryCostBreakdown { + model: string; + total: number; + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheCreate: number; +} + +export interface SummaryUsage { + input: number | bigint; + output: number | bigint; + reasoning: number | bigint; + cacheRead: number | bigint; + cacheCreate5m: number | bigint; + cacheCreate1h: number | bigint; +} + +export interface SummaryAggregateRow { + label: string; + turns: number | bigint; + usage: SummaryUsage; + cost: SummaryCostBreakdown; + coverage: unknown; +} + +export interface StopReasonCounts { + endTurn: number | bigint; + maxTokens: number | bigint; + pauseTurn: number | bigint; + stopSequence: number | bigint; + toolUse: number | bigint; + refusal: number | bigint; + silent: number | bigint; + none: number | bigint; +} + +export interface SummaryGroupedReport { + groupBy: SummaryGroupBy; + tagKey?: string; + tagValues?: Array; + turnCount: number | bigint; + rows: SummaryAggregateRow[]; + totalCost: SummaryCostBreakdown; + fidelity: unknown; + perCellFidelity: unknown; + replacementSavings: unknown; + stopReasons: StopReasonCounts; + subagents?: unknown; + quality?: unknown; + unpricedTurns: number | bigint; + unpricedModels?: string[]; +} + +export type SummaryReportPayload = + | { grouped: SummaryGroupedReport } + | { byTool: unknown } + | { bySubagentType: unknown } + | { relationship: unknown } + | { subagentTree: unknown }; + +export interface SummaryReportEnvelope { + schema: ReportSchemaVersion; + capabilities: ReportCapabilities; + window: ReportWindow; + report: SummaryReportPayload; +} + +export interface SummaryTimeseriesOptions extends SummaryReportOptions { + /** Positive bucket size in seconds. */ + bucketSeconds: number; +} + +export interface SummaryBucket { + start: string; + end: string; + turnCount: number | bigint; + totalTokens: number | bigint; + totalCost: SummaryCostBreakdown; + groupBy: SummaryGroupBy; + rows: SummaryAggregateRow[]; +} + +export interface SummaryTimeseries { + bucketSeconds: number | bigint; + buckets: SummaryBucket[]; +} + +export interface SummaryTimeseriesEnvelope { + schema: ReportSchemaVersion; + capabilities: ReportCapabilities; + window: ReportWindow; + timeseries: SummaryTimeseries; +} + +export declare function summaryReport(opts?: SummaryReportOptions): Promise +export declare function summaryTimeseries(opts: SummaryTimeseriesOptions): Promise +export declare function capabilities(): ReportCapabilities + export interface SessionCostOptions { /** Session id to total. Omit for `{ note: 'no session id provided' }`. */ session?: string; diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js index 95bc9771..3d3d7960 100644 --- a/packages/sdk-node/src/index.js +++ b/packages/sdk-node/src/index.js @@ -100,6 +100,18 @@ export async function summary(opts) { return coerceBigInts(await binding.summary(opts)); } +export async function summaryReport(opts) { + return coerceBigInts(await binding.summaryReport(opts)); +} + +export async function summaryTimeseries(opts) { + return coerceBigInts(await binding.summaryTimeseries(opts)); +} + +export function capabilities() { + return coerceBigInts(binding.capabilities()); +} + export async function sessionCost(opts) { return coerceBigInts(await binding.sessionCost(opts)); } diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 56b68722..5425fff6 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -62,6 +62,9 @@ test('sdk facade exposes the expected verb set', async (t) => { 'Ledger', 'ingest', 'summary', + 'summaryReport', + 'summaryTimeseries', + 'capabilities', 'sessionCost', 'fingerprint', 'overhead', @@ -171,6 +174,32 @@ test('2.x extension verbs return stable shapes against the fixture ledger', asyn const stampRows = await sdk.exportStamps({ ledgerHome }); assert.ok(Array.isArray(stampRows)); + const capabilities = sdk.capabilities(); + assert.equal(capabilities.packageName, 'relayburn-sdk'); + assert.ok(capabilities.features.includes('summaryReportEnvelope')); + + const summaryReport = await sdk.summaryReport({ + ledgerHome, + since: '2026-04-23T00:00:00Z', + until: '2026-04-24T00:00:00Z', + }); + assert.equal(summaryReport.schema.name, 'relayburn.report.summary.v1'); + assert.equal(summaryReport.schema.version, 1); + assert.equal(summaryReport.window.since, '2026-04-23T00:00:00.000Z'); + assert.equal(summaryReport.window.until, '2026-04-24T00:00:00.000Z'); + assert.ok(summaryReport.report.grouped); + + const summaryTimeseries = await sdk.summaryTimeseries({ + ledgerHome, + since: '2026-04-23T00:00:00Z', + until: '2026-04-24T00:00:00Z', + bucketSeconds: 3600, + }); + assert.equal(summaryTimeseries.schema.name, 'relayburn.report.summaryTimeseries.v1'); + assert.equal(summaryTimeseries.window.bucket.seconds, 3600); + assert.equal(summaryTimeseries.timeseries.bucketSeconds, 3600); + assert.ok(Array.isArray(summaryTimeseries.timeseries.buckets)); + const excluded = sdk.computeCompareExcluded( { total: 10, diff --git a/packages/sdk-node/test/no-onlog.test.js b/packages/sdk-node/test/no-onlog.test.js index 3bbae985..6f428717 100644 --- a/packages/sdk-node/test/no-onlog.test.js +++ b/packages/sdk-node/test/no-onlog.test.js @@ -11,7 +11,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -49,8 +50,13 @@ test('verbs tolerate a stray onLog property at runtime', async (t) => { } // Cast through any-shape to bypass the now-stricter option types: the // contract under test is the runtime forgiveness, not the TS shape. - const stray = { onLog: () => {} }; - await assert.doesNotReject(() => sdk.summary(stray)); - await assert.doesNotReject(() => sdk.sessionCost(stray)); - await assert.doesNotReject(() => sdk.hotspots(stray)); + const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-onlog-')); + try { + const stray = { ledgerHome, onLog: () => {} }; + await assert.doesNotReject(() => sdk.summary(stray)); + await assert.doesNotReject(() => sdk.sessionCost(stray)); + await assert.doesNotReject(() => sdk.hotspots(stray)); + } finally { + rmSync(ledgerHome, { recursive: true, force: true }); + } });