|
let mut value = hotspots_result_to_json(result); |
|
coerce_whole_f64_to_int(&mut value); |
|
let mut out = serde_json::to_string_pretty(&value).unwrap_or_default(); |
|
out.push('\n'); |
|
print!("{}", out); |
|
} |
|
|
|
fn hotspots_result_to_json(result: &HotspotsResult) -> Value { |
|
match result { |
|
HotspotsResult::Attribution(a) => attribution_to_json(a), |
|
HotspotsResult::Bash { |
|
rows, |
|
refused, |
|
refusal_reason, |
|
} => json!({ |
|
"rows": rows.iter().map(bash_to_json).collect::<Vec<_>>(), |
|
"refused": refused, |
|
"refusalReason": refusal_reason, |
|
}), |
|
HotspotsResult::BashVerb { |
|
rows, |
|
refused, |
|
refusal_reason, |
|
} => json!({ |
|
"rows": rows.iter().map(bash_verb_to_json).collect::<Vec<_>>(), |
|
"refused": refused, |
|
"refusalReason": refusal_reason, |
|
}), |
|
HotspotsResult::File { |
|
rows, |
|
refused, |
|
refusal_reason, |
|
} => json!({ |
|
"rows": rows.iter().map(file_to_json).collect::<Vec<_>>(), |
|
"refused": refused, |
|
"refusalReason": refusal_reason, |
|
}), |
|
HotspotsResult::Subagent { |
|
rows, |
|
refused, |
|
refusal_reason, |
|
} => json!({ |
|
"rows": rows.iter().map(subagent_to_json).collect::<Vec<_>>(), |
|
"refused": refused, |
|
"refusalReason": refusal_reason, |
|
}), |
|
HotspotsResult::Findings { findings, summary } => json!({ |
|
"findings": findings, |
|
"summary": summary, |
|
}), |
|
} |
|
} |
|
|
|
fn attribution_to_json(a: &HotspotsAttributionResult) -> Value { |
|
let mut out = Map::new(); |
|
out.insert("turnsAnalyzed".into(), json!(a.turns_analyzed)); |
|
out.insert("grandTotal".into(), json!(a.grand_total)); |
|
out.insert("attributedTotal".into(), json!(a.attributed_total)); |
|
out.insert("unattributedTotal".into(), json!(a.unattributed_total)); |
|
out.insert("attributionDegraded".into(), json!(a.attribution_degraded)); |
|
out.insert( |
|
"sessions".into(), |
|
Value::Array(a.sessions.iter().map(session_total_to_json).collect()), |
|
); |
|
out.insert( |
|
"files".into(), |
|
Value::Array(a.files.iter().map(file_to_json).collect()), |
|
); |
|
out.insert( |
|
"bashVerbs".into(), |
|
Value::Array(a.bash_verbs.iter().map(bash_verb_to_json).collect()), |
|
); |
|
out.insert( |
|
"bash".into(), |
|
Value::Array(a.bash.iter().map(bash_to_json).collect()), |
|
); |
|
out.insert( |
|
"subagents".into(), |
|
Value::Array(a.subagents.iter().map(subagent_to_json).collect()), |
|
); |
|
out.insert( |
|
"fidelity".into(), |
|
json!({ |
|
"analyzed": a.fidelity.analyzed, |
|
"excluded": a.fidelity.excluded, |
|
"summary": reorder_fidelity_summary(&a.fidelity.summary), |
|
"refused": a.fidelity.refused, |
|
}), |
|
); |
|
if let Some(refused) = a.refused { |
|
out.insert("refused".into(), json!(refused)); |
|
} |
|
if let Some(reason) = a.refusal_reason.as_ref() { |
|
out.insert("refusalReason".into(), json!(reason)); |
|
} |
|
Value::Object(out) |
|
} |
|
|
|
fn session_total_to_json(s: &HotspotsSessionTotal) -> Value { |
|
json!({ |
|
"sessionId": s.session_id, |
|
"grandCost": s.grand_cost, |
|
"attributedCost": s.attributed_cost, |
|
"unattributedCost": s.unattributed_cost, |
|
"attributionMethod": attribution_method_key(s.attribution_method), |
|
}) |
|
} |
|
|
|
/// Re-order the SDK-emitted fidelity summary so the JSON keys match the |
|
/// TS-CLI snapshot ordering. The SDK builds `byClass` / |
|
/// `byGranularity` / `missingCoverage` from `HashMap`s so iteration |
|
/// order is non-deterministic; we reach into the `Value`, pull out the |
|
/// numbers, and reassemble the object in the canonical order the TS |
|
/// implementation uses (which is also the iteration order of the |
|
/// upstream enum). |
|
fn reorder_fidelity_summary(summary: &Value) -> Value { |
Current status
Still valid for
burn summary; partially resolved forburn hotspots.Checked against
origin/mainate0c8fa0eefe3ffde27debe5afc2db4ca956bba86after #367, #369, and #370.burn summaryis still the main layering issue.crates/relayburn-cli/src/commands/summary.rsopens the ledger, runs ingest, queries raw turns, applies presenter-local filters, and composes the output from SDK primitives rather than from one richer SDK-owned summary result. The default grouped path still callssummarize_fidelity,summarize_replacement_savings,aggregate_by_provider, and a CLI-localaggregate_by_model:burn/crates/relayburn-cli/src/commands/summary.rs
Lines 228 to 251 in e0c8fa0
burn/crates/relayburn-cli/src/commands/summary.rs
Line 474 in e0c8fa0
burn/crates/relayburn-cli/src/commands/summary.rs
Lines 1765 to 1820 in e0c8fa0
relayburn_sdk::summaryis still a slim embedding API shape (total_tokens,total_cost,turn_count,by_tool,by_model, optionalreplacement_savings) and does not expose the composed view thatburn summary --json/ human rendering needs:burn/crates/relayburn-sdk/src/query_verbs.rs
Lines 234 to 241 in e0c8fa0
hotspotsis less affected now. Compute hotspots per-source coverage gap in SDK #370 moved the per-source excluded-turn breakdown into the SDK viaHotspotsFidelityBlock::excluded_by_source, closing Rust perf: hotspots presenter walks the ledger twice per invocation #342. The CLI no longer performs the second ledger walk originally called out here.hotspotsstill has some presenter-side shape work (hotspots_result_to_json,reorder_fidelity_summary) for TS-compatible JSON ordering, but the major duplicated compute from the original issue has been addressed:burn/crates/relayburn-cli/src/commands/hotspots.rs
Lines 170 to 285 in e0c8fa0
burn mcp-servernow exists (relayburn-cli: burn ingest + burn mcp-server (#248 D8, closes #210) #319), but Rustsummary/hotspotsMCP tools are still follow-ups. The duplication risk remains if those tools need the same CLI-composed summary shape.Problem
The Rust-port design rule still applies:
Today, the summary command violates that rule more than it did when this issue was opened: #367 implemented broader Rust summary parity, and #369 added tag filtering, but the composition still lives in
crates/relayburn-cli/src/commands/summary.rs.Proposed direction
Move the current
burn summarycompute/composition into an SDK-owned typed result. This can be either a compatible widening ofrelayburn_sdk::summaryor an additive richer verb/result type if semver makes widening the existing publicSummarystruct wrong.The SDK-owned result should cover the composed data the CLI currently builds:
Then reduce
commands/summary.rsto flag parsing, the CLI-specific ingest-before-render behavior, one SDK call, and render/JSON formatting.For
hotspots, keep this issue limited to the remaining shape-ordering cleanup unless new duplicated compute appears. The concrete second-ledger-walk problem is already handled by #370 / #342.Also audit the broad public re-exports added for the original CLI assembly path. Keep low-level primitives public only if they are intentionally part of the embedding API; otherwise prefer the richer SDK result as the supported surface.
Constraints
relayburn-sdkis still0.0.0" note is stale. The workspace is now2.3.0, so changing public Rust structs is no longer automatically non-breaking. Use a semver-compatible path or schedule any breaking shape change deliberately.relayburn-sdk-nodebindings still need to pass. If TypeScript@relayburn/sdkkeeps the slimsummary()shape, compare a compatible subset or expose the richer shape behind a separate result/verb.References
burn ingest+burn mcp-server: relayburn-cli: burn ingest + burn mcp-server (#248 D8, closes #210) #319