1use std::collections::VecDeque;
5
6use tokio::sync::watch;
7use zeph_common::SecurityEventCategory;
8
9pub use zeph_llm::{ClassifierMetricsSnapshot, TaskMetricsSnapshot};
10pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
11
12#[derive(Debug, Clone)]
14pub struct SecurityEvent {
15 pub timestamp: u64,
17 pub category: SecurityEventCategory,
18 pub source: String,
20 pub detail: String,
22}
23
24impl SecurityEvent {
25 #[must_use]
26 pub fn new(
27 category: SecurityEventCategory,
28 source: impl Into<String>,
29 detail: impl Into<String>,
30 ) -> Self {
31 let source: String = source
33 .into()
34 .chars()
35 .filter(|c| !c.is_ascii_control())
36 .take(64)
37 .collect();
38 let detail = detail.into();
40 let detail = if detail.len() > 128 {
41 let end = detail.floor_char_boundary(127);
42 format!("{}…", &detail[..end])
43 } else {
44 detail
45 };
46 Self {
47 timestamp: std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .unwrap_or_default()
50 .as_secs(),
51 category,
52 source,
53 detail,
54 }
55 }
56}
57
58pub const SECURITY_EVENT_CAP: usize = 100;
60
61#[derive(Debug, Clone)]
65pub struct TaskSnapshotRow {
66 pub id: u32,
67 pub title: String,
68 pub status: String,
70 pub agent: Option<String>,
71 pub duration_ms: u64,
72 pub error: Option<String>,
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct TaskGraphSnapshot {
79 pub graph_id: String,
80 pub goal: String,
81 pub status: String,
83 pub tasks: Vec<TaskSnapshotRow>,
84 pub completed_at: Option<std::time::Instant>,
85}
86
87impl TaskGraphSnapshot {
88 #[must_use]
91 pub fn is_stale(&self) -> bool {
92 self.completed_at
93 .is_some_and(|t| t.elapsed().as_secs() > 30)
94 }
95}
96
97#[derive(Debug, Clone, Default)]
101pub struct OrchestrationMetrics {
102 pub plans_total: u64,
103 pub tasks_total: u64,
104 pub tasks_completed: u64,
105 pub tasks_failed: u64,
106 pub tasks_skipped: u64,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum McpServerConnectionStatus {
112 Connected,
113 Failed,
114}
115
116#[derive(Debug, Clone)]
118pub struct McpServerStatus {
119 pub id: String,
120 pub status: McpServerConnectionStatus,
121 pub tool_count: usize,
123 pub error: String,
125}
126
127#[derive(Debug, Clone, Default)]
129pub struct SkillConfidence {
130 pub name: String,
131 pub posterior: f64,
132 pub total_uses: u32,
133}
134
135#[derive(Debug, Clone, Default)]
137pub struct SubAgentMetrics {
138 pub id: String,
139 pub name: String,
140 pub state: String,
142 pub turns_used: u32,
143 pub max_turns: u32,
144 pub background: bool,
145 pub elapsed_secs: u64,
146 pub permission_mode: String,
149 pub transcript_dir: Option<String>,
152}
153
154#[derive(Debug, Clone, Default)]
159pub struct TurnTimings {
160 pub prepare_context_ms: u64,
161 pub llm_chat_ms: u64,
162 pub tool_exec_ms: u64,
163 pub persist_message_ms: u64,
164}
165
166#[derive(Debug, Clone, Default)]
173#[allow(clippy::struct_excessive_bools)] pub struct MetricsSnapshot {
175 pub prompt_tokens: u64,
176 pub completion_tokens: u64,
177 pub total_tokens: u64,
178 pub reasoning_tokens: u64,
182 pub context_tokens: u64,
183 pub api_calls: u64,
184 pub active_skills: Vec<String>,
185 pub total_skills: usize,
186 pub mcp_server_count: usize,
188 pub mcp_tool_count: usize,
189 pub mcp_connected_count: usize,
191 pub mcp_servers: Vec<McpServerStatus>,
193 pub active_mcp_tools: Vec<String>,
194 pub sqlite_message_count: u64,
195 pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
196 pub qdrant_available: bool,
197 pub vector_backend: String,
198 pub embeddings_generated: u64,
199 pub last_llm_latency_ms: u64,
200 pub uptime_seconds: u64,
201 pub provider_name: String,
202 pub model_name: String,
203 pub summaries_count: u64,
204 pub context_compactions: u64,
205 pub compaction_hard_count: u64,
208 pub compaction_turns_after_hard: Vec<u64>,
212 pub compression_events: u64,
213 pub compression_tokens_saved: u64,
214 pub tool_output_prunes: u64,
215 pub compaction_probe_passes: u64,
217 pub compaction_probe_soft_failures: u64,
219 pub compaction_probe_failures: u64,
221 pub compaction_probe_errors: u64,
223 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
225 pub last_probe_score: Option<f32>,
228 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
230 pub compaction_probe_threshold: f32,
232 pub compaction_probe_hard_fail_threshold: f32,
234 pub cache_read_tokens: u64,
235 pub cache_creation_tokens: u64,
236 pub cost_spent_cents: f64,
237 pub cost_cps_cents: Option<f64>,
239 pub cost_successful_tasks: u64,
241 pub provider_cost_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
243 pub filter_raw_tokens: u64,
244 pub filter_saved_tokens: u64,
245 pub filter_applications: u64,
246 pub filter_total_commands: u64,
247 pub filter_filtered_commands: u64,
248 pub filter_confidence_full: u64,
249 pub filter_confidence_partial: u64,
250 pub filter_confidence_fallback: u64,
251 pub cancellations: u64,
252 pub server_compaction_events: u64,
253 pub sanitizer_runs: u64,
254 pub sanitizer_injection_flags: u64,
255 pub sanitizer_injection_fp_local: u64,
261 pub sanitizer_truncations: u64,
262 pub quarantine_invocations: u64,
263 pub quarantine_failures: u64,
264 pub classifier_tool_blocks: u64,
266 pub classifier_tool_suspicious: u64,
268 pub causal_ipi_flags: u64,
270 pub vigil_flags_total: u64,
272 pub vigil_blocks_total: u64,
274 pub exfiltration_images_blocked: u64,
275 pub exfiltration_tool_urls_flagged: u64,
276 pub exfiltration_memory_guards: u64,
277 pub pii_scrub_count: u64,
278 pub pii_ner_timeouts: u64,
280 pub pii_ner_circuit_breaker_trips: u64,
282 pub memory_validation_failures: u64,
283 pub rate_limit_trips: u64,
284 pub pre_execution_blocks: u64,
285 pub pre_execution_warnings: u64,
286 pub guardrail_enabled: bool,
288 pub guardrail_warn_mode: bool,
290 pub sub_agents: Vec<SubAgentMetrics>,
291 pub skill_confidence: Vec<SkillConfidence>,
292 pub scheduled_tasks: Vec<[String; 4]>,
294 pub router_thompson_stats: Vec<(String, f64, f64)>,
296 pub security_events: VecDeque<SecurityEvent>,
298 pub orchestration: OrchestrationMetrics,
299 pub orchestration_graph: Option<TaskGraphSnapshot>,
301 pub graph_community_detection_failures: u64,
302 pub graph_entities_total: u64,
303 pub graph_edges_total: u64,
304 pub graph_communities_total: u64,
305 pub graph_extraction_count: u64,
306 pub graph_extraction_failures: u64,
307 pub extended_context: bool,
310 pub guidelines_version: u32,
312 pub guidelines_updated_at: String,
314 pub tool_cache_hits: u64,
315 pub tool_cache_misses: u64,
316 pub tool_cache_entries: usize,
317 pub semantic_fact_count: u64,
319 pub stt_model: Option<String>,
321 pub compaction_model: Option<String>,
323 pub provider_temperature: Option<f32>,
325 pub provider_top_p: Option<f32>,
327 pub embedding_model: String,
329 pub token_budget: Option<u64>,
331 pub compaction_threshold: Option<u32>,
333 pub vault_backend: String,
335 pub active_channel: String,
337 pub bg_inflight: u64,
339 pub bg_dropped: u64,
341 pub bg_completed: u64,
343 pub bg_enrichment_inflight: u64,
345 pub bg_telemetry_inflight: u64,
347 pub shell_background_runs: Vec<ShellBackgroundRunRow>,
349 pub self_learning_enabled: bool,
351 pub semantic_cache_enabled: bool,
353 pub cache_enabled: bool,
355 pub autosave_enabled: bool,
357 pub classifier: ClassifierMetricsSnapshot,
359 pub last_turn_timings: TurnTimings,
361 pub avg_turn_timings: TurnTimings,
363 pub max_turn_timings: TurnTimings,
367 pub timing_sample_count: u64,
369 pub egress_requests_total: u64,
371 pub egress_dropped_total: u64,
373 pub egress_blocked_total: u64,
375 pub context_max_tokens: u64,
381 pub compaction_last_before: u64,
383 pub compaction_last_after: u64,
385 pub compaction_last_at_ms: u64,
387 pub active_goal: Option<crate::goal::GoalSnapshot>,
389 pub cocoon_connected: Option<bool>,
392 pub cocoon_worker_count: u32,
394 pub cocoon_model_count: usize,
396 pub cocoon_ton_balance: Option<f64>,
398}
399
400#[derive(Debug, Clone, Default, serde::Serialize)]
406pub struct ShellBackgroundRunRow {
407 pub run_id: String,
409 pub command: String,
411 pub elapsed_secs: u64,
413}
414
415#[derive(Debug, Default)]
433pub struct StaticMetricsInit {
434 pub stt_model: Option<String>,
436 pub compaction_model: Option<String>,
438 pub semantic_cache_enabled: bool,
443 pub embedding_model: String,
445 pub self_learning_enabled: bool,
447 pub active_channel: String,
449 pub token_budget: Option<u64>,
451 pub compaction_threshold: Option<u32>,
453 pub vault_backend: String,
455 pub autosave_enabled: bool,
457 pub model_name_override: Option<String>,
461}
462
463fn strip_ctrl(s: &str) -> String {
469 let mut out = String::with_capacity(s.len());
470 let mut chars = s.chars().peekable();
471 while let Some(c) = chars.next() {
472 if c == '\x1b' {
473 if chars.peek() == Some(&'[') {
475 chars.next(); for inner in chars.by_ref() {
477 if ('\x40'..='\x7e').contains(&inner) {
478 break;
479 }
480 }
481 }
482 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
484 } else {
486 out.push(c);
487 }
488 }
489 out
490}
491
492impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
494 fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
495 let tasks = graph
496 .tasks
497 .iter()
498 .map(|t| {
499 let error = t
500 .result
501 .as_ref()
502 .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
503 .and_then(|r| {
504 if r.output.is_empty() {
505 None
506 } else {
507 let s = strip_ctrl(&r.output);
509 if s.len() > 80 {
510 let end = s.floor_char_boundary(79);
511 Some(format!("{}…", &s[..end]))
512 } else {
513 Some(s)
514 }
515 }
516 });
517 let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
518 TaskSnapshotRow {
519 id: t.id.as_u32(),
520 title: strip_ctrl(&t.title),
521 status: t.status.to_string(),
522 agent: t.assigned_agent.as_deref().map(strip_ctrl),
523 duration_ms,
524 error,
525 }
526 })
527 .collect();
528 Self {
529 graph_id: graph.id.to_string(),
530 goal: strip_ctrl(&graph.goal),
531 status: graph.status.to_string(),
532 tasks,
533 completed_at: None,
534 }
535 }
536}
537
538pub struct MetricsCollector {
539 tx: watch::Sender<MetricsSnapshot>,
540}
541
542impl MetricsCollector {
543 #[must_use]
544 pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
545 let (tx, rx) = watch::channel(MetricsSnapshot::default());
546 (Self { tx }, rx)
547 }
548
549 pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
550 self.tx.send_modify(f);
551 }
552
553 pub fn set_context_max_tokens(&self, max_tokens: u64) {
568 self.tx.send_modify(|m| m.context_max_tokens = max_tokens);
569 }
570
571 pub fn record_compaction(&self, before: u64, after: u64, at_ms: u64) {
589 self.tx.send_modify(|m| {
590 m.compaction_last_before = before;
591 m.compaction_last_after = after;
592 m.compaction_last_at_ms = at_ms;
593 });
594 }
595
596 #[must_use]
602 pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
603 self.tx.clone()
604 }
605}
606
607pub trait HistogramRecorder: Send + Sync {
645 fn observe_llm_latency(&self, duration: std::time::Duration);
647
648 fn observe_turn_duration(&self, duration: std::time::Duration);
650
651 fn observe_tool_execution(&self, duration: std::time::Duration);
653
654 fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
658}
659
660#[cfg(test)]
661mod tests {
662 #![allow(clippy::field_reassign_with_default)]
663
664 use super::*;
665
666 #[test]
667 fn default_metrics_snapshot() {
668 let m = MetricsSnapshot::default();
669 assert_eq!(m.total_tokens, 0);
670 assert_eq!(m.api_calls, 0);
671 assert!(m.active_skills.is_empty());
672 assert!(m.active_mcp_tools.is_empty());
673 assert_eq!(m.mcp_tool_count, 0);
674 assert_eq!(m.mcp_server_count, 0);
675 assert!(m.provider_name.is_empty());
676 assert_eq!(m.summaries_count, 0);
677 assert!(m.stt_model.is_none());
679 assert!(m.compaction_model.is_none());
680 assert!(m.provider_temperature.is_none());
681 assert!(m.provider_top_p.is_none());
682 assert!(m.active_channel.is_empty());
683 assert!(m.embedding_model.is_empty());
684 assert!(m.token_budget.is_none());
685 assert!(!m.self_learning_enabled);
686 assert!(!m.semantic_cache_enabled);
687 }
688
689 #[test]
690 fn metrics_collector_update_phase2_fields() {
691 let (collector, rx) = MetricsCollector::new();
692 collector.update(|m| {
693 m.stt_model = Some("whisper-1".into());
694 m.compaction_model = Some("haiku".into());
695 m.provider_temperature = Some(0.7);
696 m.provider_top_p = Some(0.95);
697 m.active_channel = "tui".into();
698 m.embedding_model = "nomic-embed-text".into();
699 m.token_budget = Some(200_000);
700 m.self_learning_enabled = true;
701 m.semantic_cache_enabled = true;
702 });
703 let s = rx.borrow();
704 assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
705 assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
706 assert_eq!(s.provider_temperature, Some(0.7));
707 assert_eq!(s.provider_top_p, Some(0.95));
708 assert_eq!(s.active_channel, "tui");
709 assert_eq!(s.embedding_model, "nomic-embed-text");
710 assert_eq!(s.token_budget, Some(200_000));
711 assert!(s.self_learning_enabled);
712 assert!(s.semantic_cache_enabled);
713 }
714
715 #[test]
716 fn metrics_collector_update() {
717 let (collector, rx) = MetricsCollector::new();
718 collector.update(|m| {
719 m.api_calls = 5;
720 m.total_tokens = 1000;
721 });
722 let snapshot = rx.borrow().clone();
723 assert_eq!(snapshot.api_calls, 5);
724 assert_eq!(snapshot.total_tokens, 1000);
725 }
726
727 #[test]
728 fn metrics_collector_multiple_updates() {
729 let (collector, rx) = MetricsCollector::new();
730 collector.update(|m| m.api_calls = 1);
731 collector.update(|m| m.api_calls += 1);
732 assert_eq!(rx.borrow().api_calls, 2);
733 }
734
735 #[test]
736 fn metrics_snapshot_clone() {
737 let mut m = MetricsSnapshot::default();
738 m.provider_name = "ollama".into();
739 let cloned = m.clone();
740 assert_eq!(cloned.provider_name, "ollama");
741 }
742
743 #[test]
744 fn filter_metrics_tracking() {
745 let (collector, rx) = MetricsCollector::new();
746 collector.update(|m| {
747 m.filter_raw_tokens += 250;
748 m.filter_saved_tokens += 200;
749 m.filter_applications += 1;
750 });
751 collector.update(|m| {
752 m.filter_raw_tokens += 100;
753 m.filter_saved_tokens += 80;
754 m.filter_applications += 1;
755 });
756 let s = rx.borrow();
757 assert_eq!(s.filter_raw_tokens, 350);
758 assert_eq!(s.filter_saved_tokens, 280);
759 assert_eq!(s.filter_applications, 2);
760 }
761
762 #[test]
763 fn filter_confidence_and_command_metrics() {
764 let (collector, rx) = MetricsCollector::new();
765 collector.update(|m| {
766 m.filter_total_commands += 1;
767 m.filter_filtered_commands += 1;
768 m.filter_confidence_full += 1;
769 });
770 collector.update(|m| {
771 m.filter_total_commands += 1;
772 m.filter_confidence_partial += 1;
773 });
774 let s = rx.borrow();
775 assert_eq!(s.filter_total_commands, 2);
776 assert_eq!(s.filter_filtered_commands, 1);
777 assert_eq!(s.filter_confidence_full, 1);
778 assert_eq!(s.filter_confidence_partial, 1);
779 assert_eq!(s.filter_confidence_fallback, 0);
780 }
781
782 #[test]
783 fn summaries_count_tracks_summarizations() {
784 let (collector, rx) = MetricsCollector::new();
785 collector.update(|m| m.summaries_count += 1);
786 collector.update(|m| m.summaries_count += 1);
787 assert_eq!(rx.borrow().summaries_count, 2);
788 }
789
790 #[test]
791 fn cancellations_counter_increments() {
792 let (collector, rx) = MetricsCollector::new();
793 assert_eq!(rx.borrow().cancellations, 0);
794 collector.update(|m| m.cancellations += 1);
795 collector.update(|m| m.cancellations += 1);
796 assert_eq!(rx.borrow().cancellations, 2);
797 }
798
799 #[test]
800 fn security_event_detail_exact_128_not_truncated() {
801 let s = "a".repeat(128);
802 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
803 assert_eq!(ev.detail, s, "128-char string must not be truncated");
804 }
805
806 #[test]
807 fn security_event_detail_129_is_truncated() {
808 let s = "a".repeat(129);
809 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
810 assert!(
811 ev.detail.ends_with('…'),
812 "129-char string must end with ellipsis"
813 );
814 assert!(
815 ev.detail.len() <= 130,
816 "truncated detail must be at most 130 bytes"
817 );
818 }
819
820 #[test]
821 fn security_event_detail_multibyte_utf8_no_panic() {
822 let s = "中".repeat(43);
824 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
825 assert!(ev.detail.ends_with('…'));
826 }
827
828 #[test]
829 fn security_event_source_capped_at_64_chars() {
830 let long_source = "x".repeat(200);
831 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
832 assert_eq!(ev.source.len(), 64);
833 }
834
835 #[test]
836 fn security_event_source_strips_control_chars() {
837 let source = "tool\x00name\x1b[31m";
838 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
839 assert!(!ev.source.contains('\x00'));
840 assert!(!ev.source.contains('\x1b'));
841 }
842
843 #[test]
844 fn security_event_category_as_str() {
845 assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
846 assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
847 assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
848 assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
849 assert_eq!(
850 SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
851 "cross_boundary_mcp_to_acp"
852 );
853 }
854
855 #[test]
856 fn ring_buffer_respects_cap_via_update() {
857 let (collector, rx) = MetricsCollector::new();
858 for i in 0..110u64 {
859 let event = SecurityEvent::new(
860 SecurityEventCategory::InjectionFlag,
861 "src",
862 format!("event {i}"),
863 );
864 collector.update(|m| {
865 if m.security_events.len() >= SECURITY_EVENT_CAP {
866 m.security_events.pop_front();
867 }
868 m.security_events.push_back(event);
869 });
870 }
871 let snap = rx.borrow();
872 assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
873 assert!(snap.security_events.back().unwrap().detail.contains("109"));
875 }
876
877 #[test]
878 fn security_events_empty_by_default() {
879 let m = MetricsSnapshot::default();
880 assert!(m.security_events.is_empty());
881 }
882
883 #[test]
884 fn orchestration_metrics_default_zero() {
885 let m = OrchestrationMetrics::default();
886 assert_eq!(m.plans_total, 0);
887 assert_eq!(m.tasks_total, 0);
888 assert_eq!(m.tasks_completed, 0);
889 assert_eq!(m.tasks_failed, 0);
890 assert_eq!(m.tasks_skipped, 0);
891 }
892
893 #[test]
894 fn metrics_snapshot_includes_orchestration_default_zero() {
895 let m = MetricsSnapshot::default();
896 assert_eq!(m.orchestration.plans_total, 0);
897 assert_eq!(m.orchestration.tasks_total, 0);
898 assert_eq!(m.orchestration.tasks_completed, 0);
899 }
900
901 #[test]
902 fn orchestration_metrics_update_via_collector() {
903 let (collector, rx) = MetricsCollector::new();
904 collector.update(|m| {
905 m.orchestration.plans_total += 1;
906 m.orchestration.tasks_total += 5;
907 m.orchestration.tasks_completed += 3;
908 m.orchestration.tasks_failed += 1;
909 m.orchestration.tasks_skipped += 1;
910 });
911 let s = rx.borrow();
912 assert_eq!(s.orchestration.plans_total, 1);
913 assert_eq!(s.orchestration.tasks_total, 5);
914 assert_eq!(s.orchestration.tasks_completed, 3);
915 assert_eq!(s.orchestration.tasks_failed, 1);
916 assert_eq!(s.orchestration.tasks_skipped, 1);
917 }
918
919 #[test]
920 fn strip_ctrl_removes_escape_sequences() {
921 let input = "hello\x1b[31mworld\x00end";
922 let result = strip_ctrl(input);
923 assert_eq!(result, "helloworldend");
924 }
925
926 #[test]
927 fn strip_ctrl_allows_tab_lf_cr() {
928 let input = "a\tb\nc\rd";
929 let result = strip_ctrl(input);
930 assert_eq!(result, "a\tb\nc\rd");
931 }
932
933 #[test]
934 fn task_graph_snapshot_is_stale_after_30s() {
935 let mut snap = TaskGraphSnapshot::default();
936 assert!(!snap.is_stale());
938 snap.completed_at = Some(std::time::Instant::now());
940 assert!(!snap.is_stale());
941 snap.completed_at = Some(
943 std::time::Instant::now()
944 .checked_sub(std::time::Duration::from_secs(31))
945 .unwrap(),
946 );
947 assert!(snap.is_stale());
948 }
949
950 #[test]
952 fn task_graph_snapshot_from_task_graph_maps_fields() {
953 use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
954
955 let mut graph = TaskGraph::new("My goal");
956 let mut task = TaskNode::new(0, "Do work", "description");
957 task.status = TaskStatus::Failed;
958 task.assigned_agent = Some("agent-1".into());
959 task.result = Some(TaskResult {
960 output: "error occurred here".into(),
961 artifacts: vec![],
962 duration_ms: 1234,
963 agent_id: None,
964 agent_def: None,
965 });
966 graph.tasks.push(task);
967 graph.status = GraphStatus::Failed;
968
969 let snap = TaskGraphSnapshot::from(&graph);
970 assert_eq!(snap.goal, "My goal");
971 assert_eq!(snap.status, "failed");
972 assert_eq!(snap.tasks.len(), 1);
973 let row = &snap.tasks[0];
974 assert_eq!(row.title, "Do work");
975 assert_eq!(row.status, "failed");
976 assert_eq!(row.agent.as_deref(), Some("agent-1"));
977 assert_eq!(row.duration_ms, 1234);
978 assert!(row.error.as_deref().unwrap().contains("error occurred"));
979 }
980
981 #[test]
983 fn task_graph_snapshot_from_compiles_with_feature() {
984 use zeph_orchestration::TaskGraph;
985 let graph = TaskGraph::new("feature flag test");
986 let snap = TaskGraphSnapshot::from(&graph);
987 assert_eq!(snap.goal, "feature flag test");
988 assert!(snap.tasks.is_empty());
989 assert!(!snap.is_stale());
990 }
991
992 #[test]
994 fn task_graph_snapshot_error_truncated_at_80_chars() {
995 use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
996
997 let mut graph = TaskGraph::new("goal");
998 let mut task = TaskNode::new(0, "t", "d");
999 task.status = TaskStatus::Failed;
1000 task.result = Some(TaskResult {
1001 output: "e".repeat(100),
1002 artifacts: vec![],
1003 duration_ms: 0,
1004 agent_id: None,
1005 agent_def: None,
1006 });
1007 graph.tasks.push(task);
1008
1009 let snap = TaskGraphSnapshot::from(&graph);
1010 let err = snap.tasks[0].error.as_ref().unwrap();
1011 assert!(err.ends_with('…'), "truncated error must end with ellipsis");
1012 assert!(
1013 err.len() <= 83,
1014 "truncated error must not exceed 80 chars + ellipsis"
1015 );
1016 }
1017
1018 #[test]
1020 fn task_graph_snapshot_strips_control_chars_from_title() {
1021 use zeph_orchestration::{TaskGraph, TaskNode};
1022
1023 let mut graph = TaskGraph::new("goal\x1b[31m");
1024 let task = TaskNode::new(0, "title\x00injected", "d");
1025 graph.tasks.push(task);
1026
1027 let snap = TaskGraphSnapshot::from(&graph);
1028 assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
1029 assert!(
1030 !snap.tasks[0].title.contains('\x00'),
1031 "title must not contain null byte"
1032 );
1033 }
1034
1035 #[test]
1036 fn graph_metrics_default_zero() {
1037 let m = MetricsSnapshot::default();
1038 assert_eq!(m.graph_entities_total, 0);
1039 assert_eq!(m.graph_edges_total, 0);
1040 assert_eq!(m.graph_communities_total, 0);
1041 assert_eq!(m.graph_extraction_count, 0);
1042 assert_eq!(m.graph_extraction_failures, 0);
1043 }
1044
1045 #[test]
1046 fn graph_metrics_update_via_collector() {
1047 let (collector, rx) = MetricsCollector::new();
1048 collector.update(|m| {
1049 m.graph_entities_total = 5;
1050 m.graph_edges_total = 10;
1051 m.graph_communities_total = 2;
1052 m.graph_extraction_count = 7;
1053 m.graph_extraction_failures = 1;
1054 });
1055 let snapshot = rx.borrow().clone();
1056 assert_eq!(snapshot.graph_entities_total, 5);
1057 assert_eq!(snapshot.graph_edges_total, 10);
1058 assert_eq!(snapshot.graph_communities_total, 2);
1059 assert_eq!(snapshot.graph_extraction_count, 7);
1060 assert_eq!(snapshot.graph_extraction_failures, 1);
1061 }
1062
1063 #[test]
1064 fn histogram_recorder_trait_is_object_safe() {
1065 use std::sync::Arc;
1066 use std::time::Duration;
1067
1068 struct NoOpRecorder;
1069 impl HistogramRecorder for NoOpRecorder {
1070 fn observe_llm_latency(&self, _: Duration) {}
1071 fn observe_turn_duration(&self, _: Duration) {}
1072 fn observe_tool_execution(&self, _: Duration) {}
1073 fn observe_bg_task(&self, _: &str, _: Duration) {}
1074 }
1075
1076 let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
1078 recorder.observe_llm_latency(Duration::from_millis(500));
1079 recorder.observe_turn_duration(Duration::from_secs(3));
1080 recorder.observe_tool_execution(Duration::from_millis(100));
1081 }
1082}