use std::collections::BTreeMap;
use chrono::{DateTime, FixedOffset};
use crate::core::audit_trail::{AuditEntry, AuditEventType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Aggregation {
pub entries: usize,
pub tool_calls: usize,
pub blocked: usize,
pub redacted: usize,
pub other_security: usize,
pub by_event: Vec<(String, usize)>,
pub by_tool_blocked: Vec<(String, usize)>,
pub anchor_prev_hash: String,
pub head_hash: String,
}
const MAX_TOOL_ROWS: usize = 12;
pub fn event_label(ev: &AuditEventType) -> &'static str {
match ev {
AuditEventType::ToolCall => "tool_call",
AuditEventType::ToolDenied => "tool_denied",
AuditEventType::PathJailViolation => "path_jail_violation",
AuditEventType::BudgetExceeded => "budget_exceeded",
AuditEventType::CrossProjectAccess => "cross_project_access",
AuditEventType::RateLimited => "rate_limited",
AuditEventType::SecurityViolation => "security_violation",
AuditEventType::RoleChanged => "role_changed",
AuditEventType::SecretDetected => "secret_detected",
AuditEventType::AgentRegistered => "agent_registered",
AuditEventType::AgentSuspended => "agent_suspended",
AuditEventType::AgentResumed => "agent_resumed",
AuditEventType::AgentDecommissioned => "agent_decommissioned",
}
}
pub fn aggregate(
from: DateTime<FixedOffset>,
to: DateTime<FixedOffset>,
) -> Result<Aggregation, String> {
let trail_path = crate::core::data_dir::lean_ctx_data_dir()
.map_err(|e| format!("data dir: {e}"))?
.join("audit")
.join("trail.jsonl");
let raw = match std::fs::read_to_string(&trail_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(empty()),
Err(e) => return Err(format!("read {}: {e}", trail_path.display())),
};
aggregate_str(&raw, from, to)
}
pub fn aggregate_str(
raw: &str,
from: DateTime<FixedOffset>,
to: DateTime<FixedOffset>,
) -> Result<Aggregation, String> {
let mut agg = empty();
let mut events: BTreeMap<String, usize> = BTreeMap::new();
let mut tools: BTreeMap<String, usize> = BTreeMap::new();
let mut anchor: Option<String> = None;
for line in raw.lines() {
for value in serde_json::Deserializer::from_str(line)
.into_iter::<serde_json::Value>()
.flatten()
{
let Ok(entry) = serde_json::from_value::<AuditEntry>(value) else {
continue;
};
let Ok(ts) = DateTime::parse_from_rfc3339(&entry.timestamp) else {
continue;
};
if ts < from || ts > to {
continue;
}
if anchor.is_none() {
anchor = Some(entry.prev_hash.clone());
}
agg.head_hash.clone_from(&entry.entry_hash);
agg.entries += 1;
*events
.entry(event_label(&entry.event_type).to_string())
.or_default() += 1;
match entry.event_type {
AuditEventType::ToolCall => agg.tool_calls += 1,
AuditEventType::ToolDenied => {
agg.blocked += 1;
*tools.entry(entry.tool.clone()).or_default() += 1;
}
AuditEventType::SecretDetected => agg.redacted += 1,
_ => agg.other_security += 1,
}
}
}
agg.anchor_prev_hash = anchor.unwrap_or_else(|| "genesis".to_string());
agg.by_event = events.into_iter().collect();
agg.by_tool_blocked = top_rows(tools);
Ok(agg)
}
fn empty() -> Aggregation {
Aggregation {
entries: 0,
tool_calls: 0,
blocked: 0,
redacted: 0,
other_security: 0,
by_event: Vec::new(),
by_tool_blocked: Vec::new(),
anchor_prev_hash: "genesis".to_string(),
head_hash: String::new(),
}
}
fn top_rows(map: BTreeMap<String, usize>) -> Vec<(String, usize)> {
let mut rows: Vec<(String, usize)> = map.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
rows.truncate(MAX_TOOL_ROWS);
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(s: &str) -> DateTime<FixedOffset> {
DateTime::parse_from_rfc3339(s).unwrap()
}
fn line(ts: &str, event: &str, tool: &str, prev: &str, hash: &str) -> String {
format!(
r#"{{"timestamp":"{ts}","agent_id":"a","tool":"{tool}","action":null,"input_hash":"x","output_tokens":0,"role":"r","event_type":"{event}","prev_hash":"{prev}","entry_hash":"{hash}"}}"#
)
}
#[test]
fn counts_blocked_and_redacted_in_window() {
let raw = [
line(
"2026-06-01T10:00:00+00:00",
"tool_call",
"ctx_read",
"genesis",
"h1",
),
line(
"2026-06-01T11:00:00+00:00",
"tool_denied",
"ctx_url_read",
"h1",
"h2",
),
line(
"2026-06-01T12:00:00+00:00",
"secret_detected",
"ctx_read",
"h2",
"h3",
),
line(
"2026-06-01T13:00:00+00:00",
"tool_denied",
"ctx_url_read",
"h3",
"h4",
),
]
.join("\n");
let agg = aggregate_str(
&raw,
ts("2026-06-01T00:00:00+00:00"),
ts("2026-06-02T00:00:00+00:00"),
)
.unwrap();
assert_eq!(agg.entries, 4);
assert_eq!(agg.blocked, 2);
assert_eq!(agg.redacted, 1);
assert_eq!(agg.tool_calls, 1);
assert_eq!(agg.anchor_prev_hash, "genesis");
assert_eq!(agg.head_hash, "h4");
assert_eq!(agg.by_tool_blocked, vec![("ctx_url_read".to_string(), 2)]);
}
#[test]
fn excludes_entries_outside_window() {
let raw = [
line(
"2026-05-01T10:00:00+00:00",
"tool_denied",
"ctx_url_read",
"genesis",
"h1",
),
line(
"2026-06-15T10:00:00+00:00",
"tool_denied",
"ctx_url_read",
"h1",
"h2",
),
]
.join("\n");
let agg = aggregate_str(
&raw,
ts("2026-06-01T00:00:00+00:00"),
ts("2026-07-01T00:00:00+00:00"),
)
.unwrap();
assert_eq!(agg.entries, 1);
assert_eq!(agg.blocked, 1);
assert_eq!(agg.anchor_prev_hash, "h1");
assert_eq!(agg.head_hash, "h2");
}
#[test]
fn empty_window_is_ok_not_error() {
let raw = line(
"2026-01-01T10:00:00+00:00",
"tool_denied",
"ctx_url_read",
"genesis",
"h1",
);
let agg = aggregate_str(
&raw,
ts("2026-06-01T00:00:00+00:00"),
ts("2026-07-01T00:00:00+00:00"),
)
.unwrap();
assert_eq!(agg, super::empty());
}
#[test]
fn tolerates_two_objects_on_one_line() {
let a = line(
"2026-06-01T10:00:00+00:00",
"tool_denied",
"ctx_url_read",
"genesis",
"h1",
);
let b = line(
"2026-06-01T10:00:01+00:00",
"secret_detected",
"ctx_read",
"h1",
"h2",
);
let raw = format!("{a}{b}"); let agg = aggregate_str(
&raw,
ts("2026-06-01T00:00:00+00:00"),
ts("2026-06-02T00:00:00+00:00"),
)
.unwrap();
assert_eq!(agg.entries, 2);
assert_eq!(agg.blocked, 1);
assert_eq!(agg.redacted, 1);
}
}