Skip to content
Merged
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
16 changes: 3 additions & 13 deletions crates/relayburn-cli/src/commands/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,6 @@ fn parse_fidelity(s: &str) -> Result<FidelityClass> {
}
}

fn fidelity_class_str(cls: FidelityClass) -> &'static str {
match cls {
FidelityClass::Full => "full",
FidelityClass::UsageOnly => "usage-only",
FidelityClass::AggregateOnly => "aggregate-only",
FidelityClass::CostOnly => "cost-only",
FidelityClass::Partial => "partial",
}
}

/// Normalize `--since` exactly like the TS CLI's `parseSinceArg` does:
///
/// - Relative ranges (`7d`, `24h`, `4w`, `30m`) → `now - delta` rendered
Expand Down Expand Up @@ -670,7 +660,7 @@ fn compute_excluded(summary: &FidelitySummary, minimum: FidelityClass) -> Exclud
}
let need = FIDELITY_ORDER
.iter()
.position(|c| *c == fidelity_class_str(minimum))
.position(|c| *c == minimum.wire_str())
.unwrap_or(0);
for (i, key) in FIDELITY_ORDER.iter().enumerate() {
if i >= need {
Expand Down Expand Up @@ -756,7 +746,7 @@ fn build_json(
"totals": Value::Object(totals),
"cells": cells,
"fidelity": {
"minimum": fidelity_class_str(minimum),
"minimum": minimum.wire_str(),
"excluded": {
"total": excluded.total,
"aggregateOnly": excluded.aggregate_only,
Expand Down Expand Up @@ -1164,7 +1154,7 @@ fn format_excluded_note(excluded: &ExcludedBreakdown, minimum: FidelityClass) ->
format!(
"excluded {} {noun} below {} fidelity{breakdown}",
format_int(excluded.total),
fidelity_class_str(minimum)
minimum.wire_str()
)
}

Expand Down
27 changes: 3 additions & 24 deletions crates/relayburn-cli/src/commands/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use relayburn_sdk::{
hotspots as sdk_hotspots, ingest_all, normalize_since, AttributionMethod, BashAggregation,
BashVerbAggregation, FileAggregation, HotspotsAttributionResult, HotspotsGroupBy,
HotspotsOptions, HotspotsResult, HotspotsSessionTotal, Ledger, LedgerOpenOptions, Query,
SourceKind, SubagentAggregation,
SubagentAggregation,
};
use serde_json::{json, Map, Value};

Expand Down Expand Up @@ -232,8 +232,7 @@ fn describe_excluded_turns(
continue;
}
out.excluded += 1;
let source = source_label(t.source);
let entry = out.sources.entry(source.to_string()).or_default();
let entry = out.sources.entry(t.source.wire_str().to_string()).or_default();
entry.count += 1;
if !f.coverage.has_tool_calls {
entry.missing.insert("tool-call records".to_string(), ());
Expand All @@ -243,31 +242,11 @@ fn describe_excluded_turns(
}
entry
.granularities
.insert(granularity_label(f.granularity).to_string(), ());
.insert(f.granularity.wire_str().to_string(), ());
}
Ok(out)
}

fn source_label(s: SourceKind) -> &'static str {
match s {
SourceKind::ClaudeCode => "claude-code",
SourceKind::Codex => "codex",
SourceKind::Opencode => "opencode",
SourceKind::AnthropicApi => "anthropic-api",
SourceKind::OpenaiApi => "openai-api",
SourceKind::GeminiApi => "gemini-api",
}
}

fn granularity_label(g: relayburn_sdk::UsageGranularity) -> &'static str {
match g {
relayburn_sdk::UsageGranularity::PerTurn => "per-turn",
relayburn_sdk::UsageGranularity::PerMessage => "per-message",
relayburn_sdk::UsageGranularity::PerSessionAggregate => "per-session-aggregate",
relayburn_sdk::UsageGranularity::CostOnly => "cost-only",
}
}

fn emit_json(result: &HotspotsResult) {
let mut value = hotspots_result_to_json(result);
coerce_whole_f64_to_int(&mut value);
Expand Down
23 changes: 2 additions & 21 deletions crates/relayburn-cli/src/commands/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ fn fidelity_summary_to_json(s: &FidelitySummary) -> Value {
FidelityClass::Partial,
] {
by_class.insert(
fidelity_class_key(class).to_string(),
class.wire_str().to_string(),
json!(*s.by_class.get(&class).unwrap_or(&0)),
);
}
Expand All @@ -526,7 +526,7 @@ fn fidelity_summary_to_json(s: &FidelitySummary) -> Value {
relayburn_sdk::UsageGranularity::CostOnly,
] {
by_granularity.insert(
granularity_key(g).to_string(),
g.wire_str().to_string(),
json!(*s.by_granularity.get(&g).unwrap_or(&0)),
);
}
Expand Down Expand Up @@ -558,25 +558,6 @@ fn fidelity_summary_to_json(s: &FidelitySummary) -> Value {
Value::Object(out)
}

fn fidelity_class_key(c: FidelityClass) -> &'static str {
match c {
FidelityClass::Full => "full",
FidelityClass::UsageOnly => "usage-only",
FidelityClass::AggregateOnly => "aggregate-only",
FidelityClass::CostOnly => "cost-only",
FidelityClass::Partial => "partial",
}
}

fn granularity_key(g: relayburn_sdk::UsageGranularity) -> &'static str {
match g {
relayburn_sdk::UsageGranularity::PerTurn => "per-turn",
relayburn_sdk::UsageGranularity::PerMessage => "per-message",
relayburn_sdk::UsageGranularity::PerSessionAggregate => "per-session-aggregate",
relayburn_sdk::UsageGranularity::CostOnly => "cost-only",
}
}

fn build_per_cell_fidelity(rows: &[UsageCostAggregateRow], by_provider: bool) -> Value {
let cells: Vec<Value> = rows
.iter()
Expand Down
10 changes: 3 additions & 7 deletions crates/relayburn-sdk/src/analyze/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,13 +531,9 @@ pub fn edit_heavy_to_finding(session: &EditHeavySession) -> WasteFinding {
} else {
raw_severity
};
// SourceKind serializes via serde to kebab-case; render its discriminant
// through serde_json so the detail string matches TS's `${session.source}`
// (which uses the same string set).
let source_str = match serde_json::to_value(session.source) {
Ok(serde_json::Value::String(s)) => s,
_ => String::new(),
};
// Render the source's kebab-case label so the detail string matches TS's
// `${session.source}` (which uses the same string set).
let source_str = session.source.wire_str();
WasteFinding {
kind: "edit-heavy".to_string(),
severity,
Expand Down
7 changes: 2 additions & 5 deletions crates/relayburn-sdk/src/analyze/overhead.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,8 @@ pub fn attribute_overhead(input: AttributeOverheadInput<'_>) -> OverheadAttribut
}

pub fn describe_applies_to(applies_to: &[SourceKind]) -> String {
let mut as_strs: Vec<String> = applies_to
.iter()
.map(|s| serde_json::to_value(s).ok().and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default())
.collect();
as_strs.sort();
let mut as_strs: Vec<&'static str> = applies_to.iter().map(SourceKind::wire_str).collect();
as_strs.sort_unstable();
as_strs.join(", ")
}

Expand Down
52 changes: 25 additions & 27 deletions crates/relayburn-sdk/src/ledger/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,47 +36,56 @@ fn short_sha256(input: &str) -> String {

/// `sha256("source|sessionId|messageId")[..16]`.
pub fn turn_id_fingerprint(t: &TurnRecord) -> String {
let source = serde_plain(&t.source);
short_sha256(&format!("{}|{}|{}", source, t.session_id, t.message_id))
short_sha256(&format!(
"{}|{}|{}",
t.source.wire_str(),
t.session_id,
t.message_id
))
}

/// `sha256("source|sessionId|ts")[..16]` — compactions are unique per
/// session/timestamp.
pub fn compaction_id_fingerprint(e: &CompactionEvent) -> String {
let source = serde_plain(&e.source);
short_sha256(&format!("{}|{}|{}", source, e.session_id, e.ts))
short_sha256(&format!(
"{}|{}|{}",
e.source.wire_str(),
e.session_id,
e.ts
))
}

/// `sha256("source|sessionId|relationshipType|relatedSessionId|agentId|parentToolUseId")[..16]`.
pub fn relationship_id_fingerprint(r: &SessionRelationshipRecord) -> String {
let source = serde_plain(&r.source);
let relationship_type = serde_plain(&r.relationship_type);
let parts = [
source,
r.session_id.clone(),
relationship_type,
r.related_session_id.clone().unwrap_or_default(),
r.agent_id.clone().unwrap_or_default(),
r.parent_tool_use_id.clone().unwrap_or_default(),
r.source.wire_str(),
r.session_id.as_str(),
r.relationship_type.wire_str(),
r.related_session_id.as_deref().unwrap_or(""),
r.agent_id.as_deref().unwrap_or(""),
r.parent_tool_use_id.as_deref().unwrap_or(""),
];
short_sha256(&parts.join("|"))
}

/// `sha256("source|sessionId|toolUseId|eventIndex")[..16]`.
pub fn tool_result_event_id_fingerprint(r: &ToolResultEventRecord) -> String {
let source = serde_plain(&r.source);
short_sha256(&format!(
"{}|{}|{}|{}",
source, r.session_id, r.tool_use_id, r.event_index
r.source.wire_str(),
r.session_id,
r.tool_use_id,
r.event_index
))
}

/// `sha256("source|sessionId|userUuid")[..16]`.
pub fn user_turn_id_fingerprint(r: &UserTurnRecord) -> String {
let source = serde_plain(&r.source);
short_sha256(&format!(
"{}|{}|{}",
source, r.session_id, r.user_uuid
r.source.wire_str(),
r.session_id,
r.user_uuid
))
}

Expand Down Expand Up @@ -112,17 +121,6 @@ pub fn content_blob_fingerprint(body: &[u8]) -> String {
hex::encode(digest)[..FINGERPRINT_LEN].to_string()
}

/// `serde_plain` shim — stringify an enum the same way it'd appear on the
/// wire (`"claude-code"`, `"subagent"`, etc.) so id fingerprints are
/// stable against TS-side hashing for the same record shape.
fn serde_plain<T: serde::Serialize>(value: &T) -> String {
let v = serde_json::to_value(value).expect("serializable enum");
match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
10 changes: 2 additions & 8 deletions crates/relayburn-sdk/src/ledger/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,16 +261,10 @@ fn relationship_passes(r: &SessionRelationshipRecord, q: &Query) -> bool {
// `Query.source` is a `SourceKind` (the harness identity);
// `r.source` is a `RelationshipSourceKind` (a superset that
// also covers `spawn-env`, `native-claude`, etc.). Compare via
// their serialized kebab-case forms so a `source = "claude-code"`
// their kebab-case wire labels so a `source = "claude-code"`
// filter matches both enums identically — same semantics as the
// TS adapter, which compared the raw strings.
let want = serde_json::to_value(source)
.ok()
.and_then(|v| v.as_str().map(str::to_string));
let have = serde_json::to_value(r.source)
.ok()
.and_then(|v| v.as_str().map(str::to_string));
if want != have {
if source.wire_str() != r.source.wire_str() {
return false;
}
}
Expand Down
35 changes: 9 additions & 26 deletions crates/relayburn-sdk/src/ledger/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,6 @@ fn now_iso() -> String {
format!("ts:{:020}.{:09}", secs, nanos_part)
}

fn source_str<T: serde::Serialize>(v: &T) -> Result<String> {
let value = serde_json::to_value(v)?;
Ok(match value {
serde_json::Value::String(s) => s,
other => other.to_string(),
})
}

pub(crate) fn append_turns(conn: &mut Connection, turns: &[TurnRecord]) -> Result<usize> {
if turns.is_empty() {
return Ok(0);
Expand All @@ -67,10 +59,9 @@ pub(crate) fn append_turns(conn: &mut Connection, turns: &[TurnRecord]) -> Resul
if already.is_some() {
continue;
}
let source = source_str(&t.source)?;
let json = serde_json::to_string(t)?;
let changed = insert.execute(params![
source,
t.source.wire_str(),
t.session_id,
t.message_id,
t.ts,
Expand Down Expand Up @@ -105,9 +96,8 @@ pub(crate) fn append_compactions(
)?;
for e in events {
let id = compaction_id_fingerprint(e);
let source = source_str(&e.source)?;
let json = serde_json::to_string(e)?;
let changed = insert.execute(params![id, source, e.session_id, e.ts, json])?;
let changed = insert.execute(params![id, e.source.wire_str(), e.session_id, e.ts, json])?;
if changed > 0 {
appended += 1;
}
Expand Down Expand Up @@ -135,15 +125,13 @@ pub(crate) fn append_relationships(
)?;
for r in records {
let id = relationship_id_fingerprint(r);
let source = source_str(&r.source)?;
let relationship_type = source_str(&r.relationship_type)?;
let json = serde_json::to_string(r)?;
let changed = insert.execute(params![
id,
source,
r.source.wire_str(),
r.session_id,
r.related_session_id,
relationship_type,
r.relationship_type.wire_str(),
r.ts,
json,
])?;
Expand Down Expand Up @@ -173,11 +161,10 @@ pub(crate) fn append_tool_result_events(
)?;
for r in records {
let id = tool_result_event_id_fingerprint(r);
let source = source_str(&r.source)?;
let json = serde_json::to_string(r)?;
let changed = insert.execute(params![
id,
source,
r.source.wire_str(),
r.session_id,
r.tool_use_id,
r.event_index as i64,
Expand Down Expand Up @@ -210,10 +197,9 @@ pub(crate) fn append_user_turns(
)?;
for r in records {
let id = user_turn_id_fingerprint(r);
let source = source_str(&r.source)?;
let json = serde_json::to_string(r)?;
let changed = insert.execute(params![
id, source, r.session_id, r.user_uuid, r.ts, json,
id, r.source.wire_str(), r.session_id, r.user_uuid, r.ts, json,
])?;
if changed > 0 {
appended += 1;
Expand Down Expand Up @@ -250,8 +236,6 @@ pub(crate) fn append_stamp(conn: &mut Connection, stamp: &Stamp) -> Result<()> {
])?;
if let Some(rel) = synthesized {
let id = relationship_id_fingerprint(&rel);
let source = source_str(&rel.source)?;
let relationship_type = source_str(&rel.relationship_type)?;
let json = serde_json::to_string(&rel)?;
tx.prepare(
"INSERT OR IGNORE INTO relationships
Expand All @@ -261,10 +245,10 @@ pub(crate) fn append_stamp(conn: &mut Connection, stamp: &Stamp) -> Result<()> {
)?
.execute(params![
id,
source,
rel.source.wire_str(),
rel.session_id,
rel.related_session_id,
relationship_type,
rel.relationship_type.wire_str(),
rel.ts,
json,
])?;
Expand Down Expand Up @@ -294,12 +278,11 @@ pub(crate) fn append_content(
if !is_valid_session_id(&r.session_id) {
return Err(LedgerError::InvalidSessionId(r.session_id.clone()));
}
let source = source_str(&r.source)?;
let body = serde_json::to_string(r)?;
let body_bytes = body.as_bytes();
let hash = content_blob_fingerprint(body_bytes);
let changed = insert.execute(params![
source,
r.source.wire_str(),
r.session_id,
r.message_id,
hash,
Expand Down
Loading
Loading