1use crate::merkle::{MerkleChain, VerificationResult};
8use crate::safety::AuditEvent;
9use crate::types::{CostEstimate, RiskLevel, TokenUsage};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::Path;
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct TraceEvent {
24 pub sequence: usize,
26 pub timestamp: DateTime<Utc>,
28 pub kind: TraceEventKind,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum TraceEventKind {
36 TaskStarted {
37 task_id: Uuid,
38 goal: String,
39 },
40 TaskCompleted {
41 task_id: Uuid,
42 success: bool,
43 iterations: usize,
44 },
45 ToolRequested {
46 tool: String,
47 risk_level: RiskLevel,
48 args_summary: String,
49 },
50 ToolApproved {
51 tool: String,
52 },
53 ToolDenied {
54 tool: String,
55 reason: String,
56 },
57 ApprovalRequested {
58 tool: String,
59 context: String,
60 },
61 ApprovalDecision {
62 tool: String,
63 approved: bool,
64 },
65 ToolExecuted {
66 tool: String,
67 success: bool,
68 duration_ms: u64,
69 output_preview: String,
70 },
71 LlmCall {
72 model: String,
73 input_tokens: usize,
74 output_tokens: usize,
75 cost: f64,
76 },
77 StatusChange {
78 from: String,
79 to: String,
80 },
81 Error {
82 message: String,
83 },
84}
85
86impl TraceEventKind {
87 pub fn from_audit_event(event: &AuditEvent) -> Self {
90 match event {
91 AuditEvent::ActionRequested {
92 tool,
93 risk_level,
94 description,
95 } => TraceEventKind::ToolRequested {
96 tool: tool.clone(),
97 risk_level: *risk_level,
98 args_summary: description.clone(),
99 },
100 AuditEvent::ActionApproved { tool } => {
101 TraceEventKind::ToolApproved { tool: tool.clone() }
102 }
103 AuditEvent::ActionDenied { tool, reason } => TraceEventKind::ToolDenied {
104 tool: tool.clone(),
105 reason: reason.clone(),
106 },
107 AuditEvent::ActionExecuted {
108 tool,
109 success,
110 duration_ms,
111 } => TraceEventKind::ToolExecuted {
112 tool: tool.clone(),
113 success: *success,
114 duration_ms: *duration_ms,
115 output_preview: String::new(),
116 },
117 AuditEvent::ApprovalRequested { tool, context } => TraceEventKind::ApprovalRequested {
118 tool: tool.clone(),
119 context: context.clone(),
120 },
121 AuditEvent::ApprovalDecision { tool, approved } => TraceEventKind::ApprovalDecision {
122 tool: tool.clone(),
123 approved: *approved,
124 },
125 }
126 }
127
128 fn type_tag(&self) -> &'static str {
130 match self {
131 TraceEventKind::TaskStarted { .. } => "task_started",
132 TraceEventKind::TaskCompleted { .. } => "task_completed",
133 TraceEventKind::ToolRequested { .. } => "tool_requested",
134 TraceEventKind::ToolApproved { .. } => "tool_approved",
135 TraceEventKind::ToolDenied { .. } => "tool_denied",
136 TraceEventKind::ApprovalRequested { .. } => "approval_requested",
137 TraceEventKind::ApprovalDecision { .. } => "approval_decision",
138 TraceEventKind::ToolExecuted { .. } => "tool_executed",
139 TraceEventKind::LlmCall { .. } => "llm_call",
140 TraceEventKind::StatusChange { .. } => "status_change",
141 TraceEventKind::Error { .. } => "error",
142 }
143 }
144
145 fn tool_name(&self) -> Option<&str> {
147 match self {
148 TraceEventKind::ToolRequested { tool, .. }
149 | TraceEventKind::ToolApproved { tool }
150 | TraceEventKind::ToolDenied { tool, .. }
151 | TraceEventKind::ApprovalRequested { tool, .. }
152 | TraceEventKind::ApprovalDecision { tool, .. }
153 | TraceEventKind::ToolExecuted { tool, .. } => Some(tool),
154 _ => None,
155 }
156 }
157
158 fn summary(&self) -> String {
160 match self {
161 TraceEventKind::TaskStarted { goal, .. } => format!("Task started: {}", goal),
162 TraceEventKind::TaskCompleted {
163 success,
164 iterations,
165 ..
166 } => {
167 let tag = if *success { "SUCCESS" } else { "FAILED" };
168 format!("Task completed [{}] after {} iterations", tag, iterations)
169 }
170 TraceEventKind::ToolRequested {
171 tool, risk_level, ..
172 } => format!("Tool requested: {} (risk: {})", tool, risk_level),
173 TraceEventKind::ToolApproved { tool } => format!("Tool approved: {}", tool),
174 TraceEventKind::ToolDenied { tool, reason } => {
175 format!("Tool denied: {} — {}", tool, reason)
176 }
177 TraceEventKind::ApprovalRequested { tool, .. } => {
178 format!("Approval requested for: {}", tool)
179 }
180 TraceEventKind::ApprovalDecision { tool, approved } => {
181 let decision = if *approved { "approved" } else { "denied" };
182 format!("Approval decision for {}: {}", tool, decision)
183 }
184 TraceEventKind::ToolExecuted {
185 tool,
186 success,
187 duration_ms,
188 ..
189 } => {
190 let tag = if *success { "OK" } else { "ERR" };
191 format!("Tool executed: {} [{}] ({}ms)", tool, tag, duration_ms)
192 }
193 TraceEventKind::LlmCall {
194 model,
195 input_tokens,
196 output_tokens,
197 cost,
198 } => format!(
199 "LLM call: {} ({}/{} tokens, ${:.4})",
200 model, input_tokens, output_tokens, cost
201 ),
202 TraceEventKind::StatusChange { from, to } => {
203 format!("Status: {} -> {}", from, to)
204 }
205 TraceEventKind::Error { message } => format!("Error: {}", message),
206 }
207 }
208
209 fn csv_details(&self) -> (String, String) {
211 match self {
212 TraceEventKind::TaskStarted { goal, .. } => (String::new(), goal.clone()),
213 TraceEventKind::TaskCompleted {
214 success,
215 iterations,
216 ..
217 } => (
218 String::new(),
219 format!("success={} iterations={}", success, iterations),
220 ),
221 TraceEventKind::ToolRequested {
222 tool,
223 risk_level,
224 args_summary,
225 } => (
226 tool.clone(),
227 format!("risk={} args={}", risk_level, args_summary),
228 ),
229 TraceEventKind::ToolApproved { tool } => (tool.clone(), String::new()),
230 TraceEventKind::ToolDenied { tool, reason } => (tool.clone(), reason.clone()),
231 TraceEventKind::ApprovalRequested { tool, context } => (tool.clone(), context.clone()),
232 TraceEventKind::ApprovalDecision { tool, approved } => {
233 (tool.clone(), format!("approved={}", approved))
234 }
235 TraceEventKind::ToolExecuted {
236 tool,
237 success,
238 duration_ms,
239 output_preview,
240 } => (
241 tool.clone(),
242 format!(
243 "success={} duration_ms={} output={}",
244 success, duration_ms, output_preview
245 ),
246 ),
247 TraceEventKind::LlmCall {
248 model,
249 input_tokens,
250 output_tokens,
251 cost,
252 } => (
253 String::new(),
254 format!(
255 "model={} in={} out={} cost={:.6}",
256 model, input_tokens, output_tokens, cost
257 ),
258 ),
259 TraceEventKind::StatusChange { from, to } => {
260 (String::new(), format!("{} -> {}", from, to))
261 }
262 TraceEventKind::Error { message } => (String::new(), message.clone()),
263 }
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ExecutionTrace {
274 pub trace_id: Uuid,
275 pub session_id: Uuid,
276 pub task_id: Uuid,
277 pub goal: String,
278 pub started_at: DateTime<Utc>,
279 pub completed_at: Option<DateTime<Utc>>,
280 pub success: Option<bool>,
281 pub iterations: usize,
282 pub events: Vec<TraceEvent>,
283 pub total_usage: TokenUsage,
284 pub total_cost: CostEstimate,
285}
286
287impl ExecutionTrace {
288 pub fn new(session_id: Uuid, task_id: Uuid, goal: impl Into<String>) -> Self {
290 let goal = goal.into();
291 let now = Utc::now();
292 let mut trace = Self {
293 trace_id: Uuid::new_v4(),
294 session_id,
295 task_id,
296 goal: goal.clone(),
297 started_at: now,
298 completed_at: None,
299 success: None,
300 iterations: 0,
301 events: Vec::new(),
302 total_usage: TokenUsage::default(),
303 total_cost: CostEstimate::default(),
304 };
305 trace.push_event(TraceEventKind::TaskStarted { task_id, goal });
307 trace
308 }
309
310 pub fn push_event(&mut self, kind: TraceEventKind) {
312 let seq = self.events.len();
313 self.events.push(TraceEvent {
314 sequence: seq,
315 timestamp: Utc::now(),
316 kind,
317 });
318 }
319
320 pub fn complete(&mut self, success: bool) {
322 self.completed_at = Some(Utc::now());
323 self.success = Some(success);
324 self.push_event(TraceEventKind::TaskCompleted {
325 task_id: self.task_id,
326 success,
327 iterations: self.iterations,
328 });
329 }
330
331 pub fn duration_ms(&self) -> Option<u64> {
333 self.completed_at.map(|end| {
334 let dur = end - self.started_at;
335 dur.num_milliseconds().max(0) as u64
336 })
337 }
338
339 pub fn tool_events(&self) -> Vec<&TraceEvent> {
341 self.events
342 .iter()
343 .filter(|e| {
344 matches!(
345 e.kind,
346 TraceEventKind::ToolRequested { .. }
347 | TraceEventKind::ToolApproved { .. }
348 | TraceEventKind::ToolDenied { .. }
349 | TraceEventKind::ToolExecuted { .. }
350 )
351 })
352 .collect()
353 }
354
355 pub fn error_events(&self) -> Vec<&TraceEvent> {
357 self.events
358 .iter()
359 .filter(|e| matches!(e.kind, TraceEventKind::Error { .. }))
360 .collect()
361 }
362
363 pub fn llm_events(&self) -> Vec<&TraceEvent> {
365 self.events
366 .iter()
367 .filter(|e| matches!(e.kind, TraceEventKind::LlmCall { .. }))
368 .collect()
369 }
370}
371
372#[derive(Debug, Clone, thiserror::Error)]
378pub enum AuditError {
379 #[error("serialization failed: {0}")]
380 SerializationFailed(String),
381 #[error("io error: {0}")]
382 IoError(String),
383 #[error("store is empty")]
384 EmptyStore,
385 #[error("trace not found: {0}")]
386 TraceNotFound(Uuid),
387}
388
389pub struct AuditStore {
395 traces: Vec<ExecutionTrace>,
396 max_traces: usize,
397 merkle_chain: Option<MerkleChain>,
398}
399
400impl AuditStore {
401 pub fn new() -> Self {
403 Self {
404 traces: Vec::new(),
405 max_traces: 1000,
406 merkle_chain: None,
407 }
408 }
409
410 pub fn with_merkle_chain() -> Self {
412 Self {
413 traces: Vec::new(),
414 max_traces: 1000,
415 merkle_chain: Some(MerkleChain::new()),
416 }
417 }
418
419 pub fn add_trace(&mut self, trace: ExecutionTrace) {
423 if let Some(ref mut chain) = self.merkle_chain
425 && let Ok(serialized) = serde_json::to_vec(&trace)
426 {
427 chain.append(&serialized);
428 }
429 if self.traces.len() >= self.max_traces {
430 self.traces.remove(0);
431 }
432 self.traces.push(trace);
433 }
434
435 pub fn verify_integrity(&self) -> Option<VerificationResult> {
439 self.merkle_chain.as_ref().map(|chain| chain.verify_chain())
440 }
441
442 pub fn merkle_root_hash(&self) -> Option<String> {
444 self.merkle_chain
445 .as_ref()
446 .and_then(|chain| chain.root_hash().map(|h| h.to_string()))
447 }
448
449 pub fn merkle_chain(&self) -> Option<&MerkleChain> {
451 self.merkle_chain.as_ref()
452 }
453
454 pub fn traces(&self) -> &[ExecutionTrace] {
456 &self.traces
457 }
458
459 pub fn get_trace(&self, trace_id: Uuid) -> Option<&ExecutionTrace> {
461 self.traces.iter().find(|t| t.trace_id == trace_id)
462 }
463
464 pub fn query(&self, query: &AuditQuery) -> Vec<&ExecutionTrace> {
466 self.traces.iter().filter(|t| query.matches(t)).collect()
467 }
468
469 pub fn latest(&self, n: usize) -> Vec<&ExecutionTrace> {
471 let start = self.traces.len().saturating_sub(n);
472 self.traces[start..].iter().collect()
473 }
474
475 pub fn save(&self, path: &Path) -> Result<(), AuditError> {
477 let json = serde_json::to_string_pretty(&self.traces)
478 .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
479
480 if let Some(parent) = path.parent() {
481 std::fs::create_dir_all(parent).map_err(|e| AuditError::IoError(e.to_string()))?;
482 }
483
484 std::fs::write(path, json).map_err(|e| AuditError::IoError(e.to_string()))?;
485 Ok(())
486 }
487
488 pub fn load(path: &Path) -> Result<Self, AuditError> {
490 let json = std::fs::read_to_string(path).map_err(|e| AuditError::IoError(e.to_string()))?;
491
492 let traces: Vec<ExecutionTrace> = serde_json::from_str(&json)
493 .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
494
495 Ok(Self {
496 max_traces: 1000,
497 traces,
498 merkle_chain: None,
499 })
500 }
501
502 pub fn load_with_merkle(path: &Path) -> Result<Self, AuditError> {
505 let mut store = Self::load(path)?;
506 let mut chain = MerkleChain::new();
507 for trace in &store.traces {
508 if let Ok(serialized) = serde_json::to_vec(trace) {
509 chain.append(&serialized);
510 }
511 }
512 store.merkle_chain = Some(chain);
513 Ok(store)
514 }
515
516 pub fn len(&self) -> usize {
518 self.traces.len()
519 }
520
521 pub fn is_empty(&self) -> bool {
523 self.traces.is_empty()
524 }
525}
526
527impl Default for AuditStore {
528 fn default() -> Self {
529 Self::new()
530 }
531}
532
533#[derive(Debug, Clone, Default)]
547pub struct AuditQuery {
548 pub session_id: Option<Uuid>,
549 pub task_id: Option<Uuid>,
550 pub tool_name: Option<String>,
551 pub risk_level_min: Option<RiskLevel>,
552 pub success_only: Option<bool>,
553 pub since: Option<DateTime<Utc>>,
554 pub until: Option<DateTime<Utc>>,
555}
556
557impl AuditQuery {
558 pub fn new() -> Self {
559 Self::default()
560 }
561
562 pub fn for_session(mut self, id: Uuid) -> Self {
563 self.session_id = Some(id);
564 self
565 }
566
567 pub fn for_task(mut self, id: Uuid) -> Self {
568 self.task_id = Some(id);
569 self
570 }
571
572 pub fn for_tool(mut self, name: impl Into<String>) -> Self {
573 self.tool_name = Some(name.into());
574 self
575 }
576
577 pub fn min_risk(mut self, level: RiskLevel) -> Self {
578 self.risk_level_min = Some(level);
579 self
580 }
581
582 pub fn successful(mut self) -> Self {
583 self.success_only = Some(true);
584 self
585 }
586
587 pub fn failed(mut self) -> Self {
588 self.success_only = Some(false);
589 self
590 }
591
592 pub fn since(mut self, dt: DateTime<Utc>) -> Self {
593 self.since = Some(dt);
594 self
595 }
596
597 pub fn until(mut self, dt: DateTime<Utc>) -> Self {
598 self.until = Some(dt);
599 self
600 }
601
602 fn matches(&self, trace: &ExecutionTrace) -> bool {
605 if let Some(sid) = self.session_id
606 && trace.session_id != sid
607 {
608 return false;
609 }
610 if let Some(tid) = self.task_id
611 && trace.task_id != tid
612 {
613 return false;
614 }
615 if let Some(ref tool) = self.tool_name {
616 let has_tool = trace
617 .events
618 .iter()
619 .any(|e| e.kind.tool_name() == Some(tool.as_str()));
620 if !has_tool {
621 return false;
622 }
623 }
624 if let Some(min_risk) = self.risk_level_min {
625 let has_risk = trace.events.iter().any(|e| {
626 if let TraceEventKind::ToolRequested { risk_level, .. } = &e.kind {
627 *risk_level >= min_risk
628 } else {
629 false
630 }
631 });
632 if !has_risk {
633 return false;
634 }
635 }
636 if let Some(want_success) = self.success_only {
637 match trace.success {
638 Some(s) if s == want_success => {}
639 _ => return false,
640 }
641 }
642 if let Some(since) = self.since
643 && trace.started_at < since
644 {
645 return false;
646 }
647 if let Some(until) = self.until
648 && trace.started_at > until
649 {
650 return false;
651 }
652 true
653 }
654}
655
656pub struct AuditExporter;
663
664impl AuditExporter {
665 pub fn to_json(traces: &[&ExecutionTrace]) -> Result<String, AuditError> {
667 serde_json::to_string_pretty(traces)
668 .map_err(|e| AuditError::SerializationFailed(e.to_string()))
669 }
670
671 pub fn to_jsonl(traces: &[&ExecutionTrace]) -> Result<String, AuditError> {
673 let mut buf = String::new();
674 for trace in traces {
675 let line = serde_json::to_string(trace)
676 .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
677 buf.push_str(&line);
678 buf.push('\n');
679 }
680 Ok(buf)
681 }
682
683 pub fn to_text(traces: &[&ExecutionTrace]) -> String {
685 let mut buf = String::new();
686 for trace in traces {
687 buf.push_str(&format!(
688 "Trace {} | Task: {}\n",
689 trace.trace_id, trace.goal
690 ));
691 buf.push_str(&format!(
692 "Started: {} | Completed: {} | Duration: {}ms\n",
693 trace.started_at.to_rfc3339(),
694 trace
695 .completed_at
696 .map(|t| t.to_rfc3339())
697 .unwrap_or_else(|| "in-progress".to_string()),
698 trace
699 .duration_ms()
700 .map(|d| d.to_string())
701 .unwrap_or_else(|| "N/A".to_string()),
702 ));
703 buf.push_str(&format!(
704 "Iterations: {} | Tokens: {}/{} | Cost: ${:.4}\n",
705 trace.iterations,
706 trace.total_usage.input_tokens,
707 trace.total_usage.output_tokens,
708 trace.total_cost.total(),
709 ));
710 buf.push_str("Events:\n");
711 for event in &trace.events {
712 buf.push_str(&format!(
713 " [{}] {} {}\n",
714 event.sequence,
715 event.timestamp.to_rfc3339(),
716 event.kind.summary()
717 ));
718 }
719 buf.push_str("---\n");
720 }
721 buf
722 }
723
724 pub fn to_csv(traces: &[&ExecutionTrace]) -> String {
728 let mut buf = String::from("trace_id,sequence,timestamp,event_type,tool,details\n");
729 for trace in traces {
730 for event in &trace.events {
731 let (tool, details) = event.kind.csv_details();
732 buf.push_str(&format!(
733 "{},{},{},{},{},{}\n",
734 trace.trace_id,
735 event.sequence,
736 event.timestamp.to_rfc3339(),
737 event.kind.type_tag(),
738 csv_escape(&tool),
739 csv_escape(&details),
740 ));
741 }
742 }
743 buf
744 }
745}
746
747fn csv_escape(value: &str) -> String {
750 if value.contains(',') || value.contains('"') || value.contains('\n') {
751 let escaped = value.replace('"', "\"\"");
752 format!("\"{}\"", escaped)
753 } else {
754 value.to_string()
755 }
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct ToolUsageSummary {
765 pub tool_counts: HashMap<String, usize>,
766 pub tool_success_rates: HashMap<String, f64>,
767 pub tool_avg_duration_ms: HashMap<String, f64>,
768 pub most_used: Option<String>,
769 pub most_denied: Option<String>,
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct ModelCostEntry {
775 pub calls: usize,
776 pub total_tokens: usize,
777 pub total_cost: f64,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct CostBreakdown {
783 pub total_cost: f64,
784 pub total_tokens: usize,
785 pub by_model: HashMap<String, ModelCostEntry>,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct Pattern {
791 pub kind: PatternKind,
792 pub description: String,
793 pub occurrences: usize,
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(rename_all = "snake_case")]
799pub enum PatternKind {
800 FrequentDenial,
801 ApprovalBottleneck,
802 HighCostTool,
803 RepeatedError,
804 SlowTool,
805}
806
807pub struct Analytics;
809
810impl Analytics {
811 pub fn tool_usage_summary(traces: &[&ExecutionTrace]) -> ToolUsageSummary {
813 let mut counts: HashMap<String, usize> = HashMap::new();
814 let mut successes: HashMap<String, usize> = HashMap::new();
815 let mut exec_counts: HashMap<String, usize> = HashMap::new();
816 let mut durations: HashMap<String, Vec<u64>> = HashMap::new();
817 let mut denials: HashMap<String, usize> = HashMap::new();
818
819 for trace in traces {
820 for event in &trace.events {
821 match &event.kind {
822 TraceEventKind::ToolRequested { tool, .. } => {
823 *counts.entry(tool.clone()).or_insert(0) += 1;
824 }
825 TraceEventKind::ToolExecuted {
826 tool,
827 success,
828 duration_ms,
829 ..
830 } => {
831 *exec_counts.entry(tool.clone()).or_insert(0) += 1;
832 if *success {
833 *successes.entry(tool.clone()).or_insert(0) += 1;
834 }
835 durations
836 .entry(tool.clone())
837 .or_default()
838 .push(*duration_ms);
839 }
840 TraceEventKind::ToolDenied { tool, .. } => {
841 *denials.entry(tool.clone()).or_insert(0) += 1;
842 }
843 _ => {}
844 }
845 }
846 }
847
848 let tool_success_rates: HashMap<String, f64> = exec_counts
849 .iter()
850 .map(|(tool, &total)| {
851 let ok = *successes.get(tool).unwrap_or(&0);
852 let rate = if total > 0 {
853 ok as f64 / total as f64
854 } else {
855 0.0
856 };
857 (tool.clone(), rate)
858 })
859 .collect();
860
861 let tool_avg_duration_ms: HashMap<String, f64> = durations
862 .iter()
863 .map(|(tool, durs)| {
864 let avg = if durs.is_empty() {
865 0.0
866 } else {
867 durs.iter().sum::<u64>() as f64 / durs.len() as f64
868 };
869 (tool.clone(), avg)
870 })
871 .collect();
872
873 let most_used = counts
874 .iter()
875 .max_by_key(|&(_, &c)| c)
876 .map(|(t, _)| t.clone());
877
878 let most_denied = denials
879 .iter()
880 .max_by_key(|&(_, &c)| c)
881 .map(|(t, _)| t.clone());
882
883 ToolUsageSummary {
884 tool_counts: counts,
885 tool_success_rates,
886 tool_avg_duration_ms,
887 most_used,
888 most_denied,
889 }
890 }
891
892 pub fn cost_breakdown(traces: &[&ExecutionTrace]) -> CostBreakdown {
894 let mut by_model: HashMap<String, ModelCostEntry> = HashMap::new();
895 let mut total_cost = 0.0_f64;
896 let mut total_tokens = 0_usize;
897
898 for trace in traces {
899 for event in &trace.events {
900 if let TraceEventKind::LlmCall {
901 model,
902 input_tokens,
903 output_tokens,
904 cost,
905 } = &event.kind
906 {
907 let tokens = input_tokens + output_tokens;
908 total_cost += cost;
909 total_tokens += tokens;
910
911 let entry = by_model.entry(model.clone()).or_insert(ModelCostEntry {
912 calls: 0,
913 total_tokens: 0,
914 total_cost: 0.0,
915 });
916 entry.calls += 1;
917 entry.total_tokens += tokens;
918 entry.total_cost += cost;
919 }
920 }
921 }
922
923 CostBreakdown {
924 total_cost,
925 total_tokens,
926 by_model,
927 }
928 }
929
930 pub fn detect_patterns(traces: &[&ExecutionTrace]) -> Vec<Pattern> {
933 let mut patterns = Vec::new();
934
935 let mut denial_counts: HashMap<String, usize> = HashMap::new();
937 let mut approval_counts: HashMap<String, usize> = HashMap::new();
939 let mut durations: HashMap<String, Vec<u64>> = HashMap::new();
941 let mut error_counts: HashMap<String, usize> = HashMap::new();
943 let mut tool_costs: HashMap<String, f64> = HashMap::new();
945
946 for trace in traces {
947 for event in &trace.events {
948 match &event.kind {
949 TraceEventKind::ToolDenied { tool, .. } => {
950 *denial_counts.entry(tool.clone()).or_insert(0) += 1;
951 }
952 TraceEventKind::ApprovalRequested { tool, .. } => {
953 *approval_counts.entry(tool.clone()).or_insert(0) += 1;
954 }
955 TraceEventKind::ToolExecuted {
956 tool, duration_ms, ..
957 } => {
958 durations
959 .entry(tool.clone())
960 .or_default()
961 .push(*duration_ms);
962 }
963 TraceEventKind::Error { message } => {
964 *error_counts.entry(message.clone()).or_insert(0) += 1;
965 }
966 TraceEventKind::LlmCall { cost, .. } => {
967 *tool_costs.entry("_llm".to_string()).or_insert(0.0) += cost;
970 }
971 _ => {}
972 }
973 }
974 }
975
976 let threshold = 3_usize;
978
979 for (tool, count) in &denial_counts {
980 if *count >= threshold {
981 patterns.push(Pattern {
982 kind: PatternKind::FrequentDenial,
983 description: format!("Tool '{}' was denied {} times", tool, count),
984 occurrences: *count,
985 });
986 }
987 }
988
989 for (tool, count) in &approval_counts {
990 if *count >= threshold {
991 patterns.push(Pattern {
992 kind: PatternKind::ApprovalBottleneck,
993 description: format!("Tool '{}' required approval {} times", tool, count),
994 occurrences: *count,
995 });
996 }
997 }
998
999 let slow_threshold_ms = 5000_u64;
1000 for (tool, durs) in &durations {
1001 let slow = durs.iter().filter(|&&d| d >= slow_threshold_ms).count();
1002 if slow >= threshold {
1003 patterns.push(Pattern {
1004 kind: PatternKind::SlowTool,
1005 description: format!(
1006 "Tool '{}' was slow (>={}ms) {} times",
1007 tool, slow_threshold_ms, slow
1008 ),
1009 occurrences: slow,
1010 });
1011 }
1012 }
1013
1014 for (message, count) in &error_counts {
1015 if *count >= threshold {
1016 let preview = if message.len() > 60 {
1017 format!("{}...", &message[..60])
1018 } else {
1019 message.clone()
1020 };
1021 patterns.push(Pattern {
1022 kind: PatternKind::RepeatedError,
1023 description: format!("Error '{}' occurred {} times", preview, count),
1024 occurrences: *count,
1025 });
1026 }
1027 }
1028
1029 let cost_threshold = 1.0_f64;
1030 for (label, cost) in &tool_costs {
1031 if *cost >= cost_threshold {
1032 patterns.push(Pattern {
1033 kind: PatternKind::HighCostTool,
1034 description: format!("'{}' accumulated ${:.4} in costs", label, cost),
1035 occurrences: 1,
1036 });
1037 }
1038 }
1039
1040 patterns
1041 }
1042
1043 pub fn success_rate(traces: &[&ExecutionTrace]) -> f64 {
1047 let completed: Vec<_> = traces.iter().filter(|t| t.success.is_some()).collect();
1048 if completed.is_empty() {
1049 return 0.0;
1050 }
1051 let ok = completed.iter().filter(|t| t.success == Some(true)).count();
1052 ok as f64 / completed.len() as f64
1053 }
1054
1055 pub fn avg_iterations(traces: &[&ExecutionTrace]) -> f64 {
1057 if traces.is_empty() {
1058 return 0.0;
1059 }
1060 let total: usize = traces.iter().map(|t| t.iterations).sum();
1061 total as f64 / traces.len() as f64
1062 }
1063}
1064
1065#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use chrono::Duration;
1073
1074 fn make_trace(goal: &str) -> ExecutionTrace {
1076 let session = Uuid::new_v4();
1077 let task = Uuid::new_v4();
1078 let mut trace = ExecutionTrace::new(session, task, goal);
1079 trace.iterations = 3;
1080 trace
1081 }
1082
1083 fn make_trace_with_tools() -> ExecutionTrace {
1085 let mut trace = make_trace("test task");
1086 trace.push_event(TraceEventKind::ToolRequested {
1087 tool: "file_read".into(),
1088 risk_level: RiskLevel::ReadOnly,
1089 args_summary: "reading main.rs".into(),
1090 });
1091 trace.push_event(TraceEventKind::ToolApproved {
1092 tool: "file_read".into(),
1093 });
1094 trace.push_event(TraceEventKind::ToolExecuted {
1095 tool: "file_read".into(),
1096 success: true,
1097 duration_ms: 42,
1098 output_preview: "fn main() ...".into(),
1099 });
1100 trace.push_event(TraceEventKind::LlmCall {
1101 model: "claude-opus-4-5-20251101".into(),
1102 input_tokens: 1000,
1103 output_tokens: 500,
1104 cost: 0.05,
1105 });
1106 trace.push_event(TraceEventKind::Error {
1107 message: "timeout".into(),
1108 });
1109 trace
1110 }
1111
1112 #[test]
1114 fn test_trace_event_creation() {
1115 let mut trace = make_trace("goal");
1116 assert_eq!(trace.events.len(), 1);
1118 assert_eq!(trace.events[0].sequence, 0);
1119
1120 trace.push_event(TraceEventKind::StatusChange {
1121 from: "idle".into(),
1122 to: "thinking".into(),
1123 });
1124 assert_eq!(trace.events[1].sequence, 1);
1125
1126 trace.push_event(TraceEventKind::Error {
1127 message: "oops".into(),
1128 });
1129 assert_eq!(trace.events[2].sequence, 2);
1130 }
1131
1132 #[test]
1134 fn test_execution_trace_new() {
1135 let session = Uuid::new_v4();
1136 let task = Uuid::new_v4();
1137 let trace = ExecutionTrace::new(session, task, "my goal");
1138
1139 assert_eq!(trace.session_id, session);
1140 assert_eq!(trace.task_id, task);
1141 assert_eq!(trace.goal, "my goal");
1142 assert!(trace.completed_at.is_none());
1143 assert!(trace.success.is_none());
1144 assert_eq!(trace.iterations, 0);
1145 assert_eq!(trace.total_usage.total(), 0);
1146 assert!((trace.total_cost.total() - 0.0).abs() < f64::EPSILON);
1147 assert_eq!(trace.events.len(), 1);
1149 assert!(matches!(
1150 &trace.events[0].kind,
1151 TraceEventKind::TaskStarted { goal, .. } if goal == "my goal"
1152 ));
1153 }
1154
1155 #[test]
1157 fn test_execution_trace_push_event() {
1158 let mut trace = make_trace("push test");
1159 let initial_len = trace.events.len();
1160
1161 trace.push_event(TraceEventKind::ToolRequested {
1162 tool: "shell_exec".into(),
1163 risk_level: RiskLevel::Execute,
1164 args_summary: "cargo test".into(),
1165 });
1166 assert_eq!(trace.events.len(), initial_len + 1);
1167 assert_eq!(trace.events.last().unwrap().sequence, initial_len);
1168 }
1169
1170 #[test]
1172 fn test_execution_trace_complete() {
1173 let mut trace = make_trace("complete test");
1174 assert!(trace.completed_at.is_none());
1175 assert!(trace.success.is_none());
1176
1177 trace.complete(true);
1178 assert!(trace.completed_at.is_some());
1179 assert_eq!(trace.success, Some(true));
1180
1181 let last = trace.events.last().unwrap();
1183 assert!(matches!(
1184 &last.kind,
1185 TraceEventKind::TaskCompleted { success: true, .. }
1186 ));
1187 }
1188
1189 #[test]
1191 fn test_execution_trace_duration() {
1192 let mut trace = make_trace("duration test");
1193 assert!(trace.duration_ms().is_none());
1195
1196 trace.complete(true);
1197 let dur = trace.duration_ms().unwrap();
1199 assert!(dur < 5000); }
1201
1202 #[test]
1204 fn test_execution_trace_tool_events() {
1205 let trace = make_trace_with_tools();
1206 let tool_evts = trace.tool_events();
1207 assert_eq!(tool_evts.len(), 3);
1209 assert!(matches!(
1210 &tool_evts[0].kind,
1211 TraceEventKind::ToolRequested { .. }
1212 ));
1213 }
1214
1215 #[test]
1217 fn test_execution_trace_error_events() {
1218 let trace = make_trace_with_tools();
1219 let errs = trace.error_events();
1220 assert_eq!(errs.len(), 1);
1221 assert!(matches!(&errs[0].kind, TraceEventKind::Error { message } if message == "timeout"));
1222 }
1223
1224 #[test]
1226 fn test_execution_trace_llm_events() {
1227 let trace = make_trace_with_tools();
1228 let llm = trace.llm_events();
1229 assert_eq!(llm.len(), 1);
1230 assert!(matches!(
1231 &llm[0].kind,
1232 TraceEventKind::LlmCall { model, .. } if model == "claude-opus-4-5-20251101"
1233 ));
1234 }
1235
1236 #[test]
1238 fn test_audit_store_add_and_get() {
1239 let mut store = AuditStore::new();
1240 let trace = make_trace("store test");
1241 let id = trace.trace_id;
1242
1243 store.add_trace(trace);
1244 assert_eq!(store.len(), 1);
1245 assert!(!store.is_empty());
1246
1247 let found = store.get_trace(id).unwrap();
1248 assert_eq!(found.goal, "store test");
1249
1250 assert!(store.get_trace(Uuid::new_v4()).is_none());
1252 }
1253
1254 #[test]
1256 fn test_audit_store_capacity() {
1257 let mut store = AuditStore {
1258 traces: Vec::new(),
1259 max_traces: 3,
1260 merkle_chain: None,
1261 };
1262
1263 for i in 0..5 {
1264 store.add_trace(make_trace(&format!("trace {}", i)));
1265 }
1266
1267 assert_eq!(store.len(), 3);
1268 let goals: Vec<&str> = store.traces().iter().map(|t| t.goal.as_str()).collect();
1270 assert_eq!(goals, vec!["trace 2", "trace 3", "trace 4"]);
1271 }
1272
1273 #[test]
1275 fn test_audit_store_latest() {
1276 let mut store = AuditStore::new();
1277 for i in 0..5 {
1278 store.add_trace(make_trace(&format!("trace {}", i)));
1279 }
1280
1281 let latest = store.latest(2);
1282 assert_eq!(latest.len(), 2);
1283 assert_eq!(latest[0].goal, "trace 3");
1284 assert_eq!(latest[1].goal, "trace 4");
1285
1286 let all = store.latest(100);
1288 assert_eq!(all.len(), 5);
1289 }
1290
1291 #[test]
1293 fn test_audit_store_query_by_session() {
1294 let session_a = Uuid::new_v4();
1295 let session_b = Uuid::new_v4();
1296
1297 let mut store = AuditStore::new();
1298
1299 let mut t1 = make_trace("a1");
1300 t1.session_id = session_a;
1301 let mut t2 = make_trace("b1");
1302 t2.session_id = session_b;
1303 let mut t3 = make_trace("a2");
1304 t3.session_id = session_a;
1305
1306 store.add_trace(t1);
1307 store.add_trace(t2);
1308 store.add_trace(t3);
1309
1310 let results = store.query(&AuditQuery::new().for_session(session_a));
1311 assert_eq!(results.len(), 2);
1312 assert!(results.iter().all(|t| t.session_id == session_a));
1313 }
1314
1315 #[test]
1317 fn test_audit_store_query_by_tool() {
1318 let mut store = AuditStore::new();
1319
1320 let mut t1 = make_trace("with tool");
1321 t1.push_event(TraceEventKind::ToolRequested {
1322 tool: "file_read".into(),
1323 risk_level: RiskLevel::ReadOnly,
1324 args_summary: "src/main.rs".into(),
1325 });
1326
1327 let t2 = make_trace("no tool");
1328
1329 store.add_trace(t1);
1330 store.add_trace(t2);
1331
1332 let results = store.query(&AuditQuery::new().for_tool("file_read"));
1333 assert_eq!(results.len(), 1);
1334 assert_eq!(results[0].goal, "with tool");
1335 }
1336
1337 #[test]
1339 fn test_audit_store_query_by_risk() {
1340 let mut store = AuditStore::new();
1341
1342 let mut low = make_trace("low risk");
1343 low.push_event(TraceEventKind::ToolRequested {
1344 tool: "ls".into(),
1345 risk_level: RiskLevel::ReadOnly,
1346 args_summary: ".".into(),
1347 });
1348
1349 let mut high = make_trace("high risk");
1350 high.push_event(TraceEventKind::ToolRequested {
1351 tool: "rm".into(),
1352 risk_level: RiskLevel::Destructive,
1353 args_summary: "tmp/".into(),
1354 });
1355
1356 store.add_trace(low);
1357 store.add_trace(high);
1358
1359 let results = store.query(&AuditQuery::new().min_risk(RiskLevel::Execute));
1360 assert_eq!(results.len(), 1);
1361 assert_eq!(results[0].goal, "high risk");
1362 }
1363
1364 #[test]
1366 fn test_audit_store_query_success_only() {
1367 let mut store = AuditStore::new();
1368
1369 let mut ok = make_trace("good");
1370 ok.complete(true);
1371
1372 let mut fail = make_trace("bad");
1373 fail.complete(false);
1374
1375 store.add_trace(ok);
1376 store.add_trace(fail);
1377
1378 let successes = store.query(&AuditQuery::new().successful());
1379 assert_eq!(successes.len(), 1);
1380 assert_eq!(successes[0].goal, "good");
1381
1382 let failures = store.query(&AuditQuery::new().failed());
1383 assert_eq!(failures.len(), 1);
1384 assert_eq!(failures[0].goal, "bad");
1385 }
1386
1387 #[test]
1389 fn test_audit_store_query_time_range() {
1390 let mut store = AuditStore::new();
1391
1392 let now = Utc::now();
1393 let one_hour_ago = now - Duration::hours(1);
1394 let two_hours_ago = now - Duration::hours(2);
1395
1396 let mut old = make_trace("old");
1397 old.started_at = two_hours_ago;
1398
1399 let mut recent = make_trace("recent");
1400 recent.started_at = now;
1401
1402 store.add_trace(old);
1403 store.add_trace(recent);
1404
1405 let results = store.query(&AuditQuery::new().since(one_hour_ago));
1406 assert_eq!(results.len(), 1);
1407 assert_eq!(results[0].goal, "recent");
1408
1409 let results = store.query(&AuditQuery::new().until(one_hour_ago));
1410 assert_eq!(results.len(), 1);
1411 assert_eq!(results[0].goal, "old");
1412 }
1413
1414 #[test]
1416 fn test_audit_store_save_load_roundtrip() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let path = dir.path().join("audit.json");
1419
1420 let mut store = AuditStore::new();
1421 let mut trace = make_trace_with_tools();
1422 trace.complete(true);
1423 let id = trace.trace_id;
1424 store.add_trace(trace);
1425
1426 store.save(&path).unwrap();
1427 assert!(path.exists());
1428
1429 let loaded = AuditStore::load(&path).unwrap();
1430 assert_eq!(loaded.len(), 1);
1431 let t = loaded.get_trace(id).unwrap();
1432 assert_eq!(t.goal, "test task");
1433 }
1434
1435 #[test]
1437 fn test_exporter_json() {
1438 let trace = make_trace_with_tools();
1439 let refs = vec![&trace];
1440
1441 let json = AuditExporter::to_json(&refs).unwrap();
1442 assert!(json.contains("test task"));
1443 assert!(json.contains("file_read"));
1444
1445 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1447 assert!(parsed.is_array());
1448 }
1449
1450 #[test]
1452 fn test_exporter_jsonl() {
1453 let t1 = make_trace("one");
1454 let t2 = make_trace("two");
1455 let refs = vec![&t1, &t2];
1456
1457 let jsonl = AuditExporter::to_jsonl(&refs).unwrap();
1458 let lines: Vec<&str> = jsonl.trim().split('\n').collect();
1459 assert_eq!(lines.len(), 2);
1460
1461 for line in &lines {
1463 serde_json::from_str::<serde_json::Value>(line).unwrap();
1464 }
1465 }
1466
1467 #[test]
1469 fn test_exporter_text() {
1470 let mut trace = make_trace_with_tools();
1471 trace.total_usage = TokenUsage {
1472 input_tokens: 1000,
1473 output_tokens: 500,
1474 };
1475 trace.total_cost = CostEstimate {
1476 input_cost: 0.01,
1477 output_cost: 0.03,
1478 };
1479 trace.complete(true);
1480 let refs = vec![&trace];
1481
1482 let text = AuditExporter::to_text(&refs);
1483 assert!(text.contains("Trace"));
1484 assert!(text.contains("test task"));
1485 assert!(text.contains("Tokens: 1000/500"));
1486 assert!(text.contains("$0.0400"));
1487 assert!(text.contains("Events:"));
1488 assert!(text.contains("---"));
1489 }
1490
1491 #[test]
1493 fn test_exporter_csv() {
1494 let trace = make_trace_with_tools();
1495 let refs = vec![&trace];
1496
1497 let csv = AuditExporter::to_csv(&refs);
1498 let lines: Vec<&str> = csv.trim().split('\n').collect();
1499
1500 assert!(lines[0].starts_with("trace_id,sequence,timestamp,event_type,tool,details"));
1502 assert!(lines.len() > 1);
1503 assert!(lines[1].contains("task_started"));
1505 }
1506
1507 #[test]
1509 fn test_analytics_tool_usage() {
1510 let trace = make_trace_with_tools();
1511 let refs = vec![&trace];
1512
1513 let summary = Analytics::tool_usage_summary(&refs);
1514 assert_eq!(summary.tool_counts.get("file_read"), Some(&1));
1515 assert_eq!(summary.most_used, Some("file_read".to_string()));
1516
1517 let rate = summary.tool_success_rates.get("file_read").unwrap();
1518 assert!((rate - 1.0).abs() < f64::EPSILON);
1519
1520 let avg = summary.tool_avg_duration_ms.get("file_read").unwrap();
1521 assert!((avg - 42.0).abs() < f64::EPSILON);
1522 }
1523
1524 #[test]
1526 fn test_analytics_cost_breakdown() {
1527 let mut trace = make_trace("cost test");
1528 trace.push_event(TraceEventKind::LlmCall {
1529 model: "gpt-4".into(),
1530 input_tokens: 1000,
1531 output_tokens: 500,
1532 cost: 0.06,
1533 });
1534 trace.push_event(TraceEventKind::LlmCall {
1535 model: "gpt-4".into(),
1536 input_tokens: 2000,
1537 output_tokens: 1000,
1538 cost: 0.12,
1539 });
1540 trace.push_event(TraceEventKind::LlmCall {
1541 model: "claude-sonnet".into(),
1542 input_tokens: 500,
1543 output_tokens: 200,
1544 cost: 0.02,
1545 });
1546
1547 let refs = vec![&trace];
1548 let breakdown = Analytics::cost_breakdown(&refs);
1549
1550 assert!((breakdown.total_cost - 0.20).abs() < 1e-9);
1551 assert_eq!(breakdown.total_tokens, 1000 + 500 + 2000 + 1000 + 500 + 200);
1552
1553 let gpt4 = breakdown.by_model.get("gpt-4").unwrap();
1554 assert_eq!(gpt4.calls, 2);
1555 assert!((gpt4.total_cost - 0.18).abs() < 1e-9);
1556
1557 let claude = breakdown.by_model.get("claude-sonnet").unwrap();
1558 assert_eq!(claude.calls, 1);
1559 }
1560
1561 #[test]
1563 fn test_analytics_detect_patterns() {
1564 let mut trace = make_trace("pattern test");
1565
1566 for _ in 0..4 {
1568 trace.push_event(TraceEventKind::ToolDenied {
1569 tool: "rm_rf".into(),
1570 reason: "too dangerous".into(),
1571 });
1572 }
1573
1574 for _ in 0..3 {
1576 trace.push_event(TraceEventKind::ApprovalRequested {
1577 tool: "deploy".into(),
1578 context: "production".into(),
1579 });
1580 }
1581
1582 for _ in 0..3 {
1584 trace.push_event(TraceEventKind::Error {
1585 message: "connection reset".into(),
1586 });
1587 }
1588
1589 let refs = vec![&trace];
1590 let patterns = Analytics::detect_patterns(&refs);
1591
1592 let kinds: Vec<_> = patterns.iter().map(|p| &p.kind).collect();
1593 assert!(
1594 kinds
1595 .iter()
1596 .any(|k| matches!(k, PatternKind::FrequentDenial))
1597 );
1598 assert!(
1599 kinds
1600 .iter()
1601 .any(|k| matches!(k, PatternKind::ApprovalBottleneck))
1602 );
1603 assert!(
1604 kinds
1605 .iter()
1606 .any(|k| matches!(k, PatternKind::RepeatedError))
1607 );
1608 }
1609
1610 #[test]
1612 fn test_analytics_success_rate() {
1613 let mut t1 = make_trace("ok");
1614 t1.complete(true);
1615 let mut t2 = make_trace("ok2");
1616 t2.complete(true);
1617 let mut t3 = make_trace("fail");
1618 t3.complete(false);
1619 let t4 = make_trace("in-progress");
1620
1621 let refs = vec![&t1, &t2, &t3, &t4];
1622 let rate = Analytics::success_rate(&refs);
1623 assert!((rate - 2.0 / 3.0).abs() < 1e-9);
1625 }
1626
1627 #[test]
1629 fn test_analytics_avg_iterations() {
1630 let mut t1 = make_trace("a");
1631 t1.iterations = 5;
1632 let mut t2 = make_trace("b");
1633 t2.iterations = 10;
1634 let mut t3 = make_trace("c");
1635 t3.iterations = 0;
1636
1637 let refs = vec![&t1, &t2, &t3];
1638 let avg = Analytics::avg_iterations(&refs);
1639 assert!((avg - 5.0).abs() < 1e-9);
1640 }
1641
1642 #[test]
1644 fn test_trace_event_kind_from_audit_event() {
1645 let requested = AuditEvent::ActionRequested {
1646 tool: "file_write".into(),
1647 risk_level: RiskLevel::Write,
1648 description: "writing config".into(),
1649 };
1650 let kind = TraceEventKind::from_audit_event(&requested);
1651 assert!(matches!(
1652 kind,
1653 TraceEventKind::ToolRequested {
1654 ref tool,
1655 risk_level: RiskLevel::Write,
1656 ..
1657 } if tool == "file_write"
1658 ));
1659
1660 let approved = AuditEvent::ActionApproved {
1661 tool: "file_write".into(),
1662 };
1663 let kind = TraceEventKind::from_audit_event(&approved);
1664 assert!(matches!(kind, TraceEventKind::ToolApproved { ref tool } if tool == "file_write"));
1665
1666 let denied = AuditEvent::ActionDenied {
1667 tool: "rm".into(),
1668 reason: "denied".into(),
1669 };
1670 let kind = TraceEventKind::from_audit_event(&denied);
1671 assert!(matches!(kind, TraceEventKind::ToolDenied { ref tool, .. } if tool == "rm"));
1672
1673 let executed = AuditEvent::ActionExecuted {
1674 tool: "grep".into(),
1675 success: true,
1676 duration_ms: 99,
1677 };
1678 let kind = TraceEventKind::from_audit_event(&executed);
1679 assert!(
1680 matches!(kind, TraceEventKind::ToolExecuted { ref tool, success: true, duration_ms: 99, .. } if tool == "grep")
1681 );
1682
1683 let approval_req = AuditEvent::ApprovalRequested {
1684 tool: "deploy".into(),
1685 context: "prod".into(),
1686 };
1687 let kind = TraceEventKind::from_audit_event(&approval_req);
1688 assert!(
1689 matches!(kind, TraceEventKind::ApprovalRequested { ref tool, ref context } if tool == "deploy" && context == "prod")
1690 );
1691
1692 let decision = AuditEvent::ApprovalDecision {
1693 tool: "deploy".into(),
1694 approved: false,
1695 };
1696 let kind = TraceEventKind::from_audit_event(&decision);
1697 assert!(
1698 matches!(kind, TraceEventKind::ApprovalDecision { ref tool, approved: false } if tool == "deploy")
1699 );
1700 }
1701
1702 #[test]
1704 fn test_audit_query_builder() {
1705 let session = Uuid::new_v4();
1706 let task = Uuid::new_v4();
1707 let since = Utc::now() - Duration::hours(1);
1708 let until = Utc::now();
1709
1710 let q = AuditQuery::new()
1711 .for_session(session)
1712 .for_task(task)
1713 .for_tool("file_read")
1714 .min_risk(RiskLevel::Execute)
1715 .successful()
1716 .since(since)
1717 .until(until);
1718
1719 assert_eq!(q.session_id, Some(session));
1720 assert_eq!(q.task_id, Some(task));
1721 assert_eq!(q.tool_name.as_deref(), Some("file_read"));
1722 assert_eq!(q.risk_level_min, Some(RiskLevel::Execute));
1723 assert_eq!(q.success_only, Some(true));
1724 assert_eq!(q.since, Some(since));
1725 assert_eq!(q.until, Some(until));
1726
1727 let q2 = AuditQuery::new().failed();
1729 assert_eq!(q2.success_only, Some(false));
1730 }
1731
1732 #[test]
1734 fn test_execution_trace_serde_roundtrip() {
1735 let mut trace = make_trace_with_tools();
1736 trace.total_usage = TokenUsage {
1737 input_tokens: 1234,
1738 output_tokens: 567,
1739 };
1740 trace.total_cost = CostEstimate {
1741 input_cost: 0.01,
1742 output_cost: 0.03,
1743 };
1744 trace.complete(true);
1745
1746 let json = serde_json::to_string(&trace).unwrap();
1747 let restored: ExecutionTrace = serde_json::from_str(&json).unwrap();
1748
1749 assert_eq!(restored.trace_id, trace.trace_id);
1750 assert_eq!(restored.goal, trace.goal);
1751 assert_eq!(restored.events.len(), trace.events.len());
1752 assert_eq!(restored.total_usage.input_tokens, 1234);
1753 assert_eq!(restored.total_usage.output_tokens, 567);
1754 assert!((restored.total_cost.total() - 0.04).abs() < f64::EPSILON);
1755 assert_eq!(restored.success, Some(true));
1756 }
1757
1758 #[test]
1762 fn test_audit_store_with_merkle_chain() {
1763 let store = AuditStore::with_merkle_chain();
1764 assert!(store.merkle_chain().is_some());
1765 assert!(store.is_empty());
1766 }
1767
1768 #[test]
1770 fn test_audit_store_merkle_appends_on_add() {
1771 let mut store = AuditStore::with_merkle_chain();
1772 store.add_trace(make_trace("trace 1"));
1773 store.add_trace(make_trace("trace 2"));
1774
1775 let chain = store.merkle_chain().unwrap();
1776 assert_eq!(chain.len(), 2);
1777 }
1778
1779 #[test]
1781 fn test_audit_store_verify_integrity_valid() {
1782 let mut store = AuditStore::with_merkle_chain();
1783 store.add_trace(make_trace("a"));
1784 store.add_trace(make_trace("b"));
1785 store.add_trace(make_trace("c"));
1786
1787 let result = store.verify_integrity().unwrap();
1788 assert!(result.is_valid);
1789 assert_eq!(result.checked_nodes, 3);
1790 assert!(result.first_invalid.is_none());
1791 }
1792
1793 #[test]
1795 fn test_audit_store_verify_integrity_without_merkle() {
1796 let store = AuditStore::new();
1797 assert!(store.verify_integrity().is_none());
1798 }
1799
1800 #[test]
1802 fn test_audit_store_merkle_root_hash_changes() {
1803 let mut store = AuditStore::with_merkle_chain();
1804 assert!(store.merkle_root_hash().is_none()); store.add_trace(make_trace("first"));
1807 let hash1 = store.merkle_root_hash().unwrap();
1808
1809 store.add_trace(make_trace("second"));
1810 let hash2 = store.merkle_root_hash().unwrap();
1811
1812 assert_ne!(hash1, hash2);
1813 }
1814
1815 #[test]
1817 fn test_audit_store_load_with_merkle_rebuilds() {
1818 let dir = tempfile::tempdir().unwrap();
1819 let path = dir.path().join("audit_merkle.json");
1820
1821 let mut store = AuditStore::new();
1823 store.add_trace(make_trace("alpha"));
1824 store.add_trace(make_trace("beta"));
1825 store.save(&path).unwrap();
1826
1827 let loaded = AuditStore::load_with_merkle(&path).unwrap();
1829 assert!(loaded.merkle_chain().is_some());
1830 assert_eq!(loaded.merkle_chain().unwrap().len(), 2);
1831
1832 let result = loaded.verify_integrity().unwrap();
1833 assert!(result.is_valid);
1834 }
1835
1836 #[test]
1838 fn test_audit_store_no_merkle_by_default() {
1839 let store = AuditStore::new();
1840 assert!(store.merkle_chain().is_none());
1841 assert!(store.merkle_root_hash().is_none());
1842 }
1843
1844 #[test]
1846 fn test_audit_error_display() {
1847 let err = AuditError::SerializationFailed("bad json".into());
1848 assert_eq!(err.to_string(), "serialization failed: bad json");
1849
1850 let err = AuditError::IoError("file not found".into());
1851 assert_eq!(err.to_string(), "io error: file not found");
1852
1853 let err = AuditError::EmptyStore;
1854 assert_eq!(err.to_string(), "store is empty");
1855
1856 let id = Uuid::new_v4();
1857 let err = AuditError::TraceNotFound(id);
1858 assert_eq!(err.to_string(), format!("trace not found: {}", id));
1859 }
1860}