1use std::collections::VecDeque;
5
6use tokio::sync::watch;
7
8pub use zeph_llm::{ClassifierMetricsSnapshot, TaskMetricsSnapshot};
9pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SecurityEventCategory {
14 InjectionFlag,
15 InjectionBlocked,
17 ExfiltrationBlock,
18 Quarantine,
19 Truncation,
20 RateLimit,
21 MemoryValidation,
22 PreExecutionBlock,
23 PreExecutionWarn,
24 ResponseVerification,
25 CausalIpiFlag,
27 CrossBoundaryMcpToAcp,
29 VigilFlag,
31}
32
33impl SecurityEventCategory {
34 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::InjectionFlag => "injection",
38 Self::InjectionBlocked => "injection_blocked",
39 Self::ExfiltrationBlock => "exfil",
40 Self::Quarantine => "quarantine",
41 Self::Truncation => "truncation",
42 Self::RateLimit => "rate_limit",
43 Self::MemoryValidation => "memory_validation",
44 Self::PreExecutionBlock => "pre_exec_block",
45 Self::PreExecutionWarn => "pre_exec_warn",
46 Self::ResponseVerification => "response_verify",
47 Self::CausalIpiFlag => "causal_ipi",
48 Self::CrossBoundaryMcpToAcp => "cross_boundary_mcp_to_acp",
49 Self::VigilFlag => "vigil",
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct SecurityEvent {
57 pub timestamp: u64,
59 pub category: SecurityEventCategory,
60 pub source: String,
62 pub detail: String,
64}
65
66impl SecurityEvent {
67 #[must_use]
68 pub fn new(
69 category: SecurityEventCategory,
70 source: impl Into<String>,
71 detail: impl Into<String>,
72 ) -> Self {
73 let source: String = source
75 .into()
76 .chars()
77 .filter(|c| !c.is_ascii_control())
78 .take(64)
79 .collect();
80 let detail = detail.into();
82 let detail = if detail.len() > 128 {
83 let end = detail.floor_char_boundary(127);
84 format!("{}…", &detail[..end])
85 } else {
86 detail
87 };
88 Self {
89 timestamp: std::time::SystemTime::now()
90 .duration_since(std::time::UNIX_EPOCH)
91 .unwrap_or_default()
92 .as_secs(),
93 category,
94 source,
95 detail,
96 }
97 }
98}
99
100pub const SECURITY_EVENT_CAP: usize = 100;
102
103#[derive(Debug, Clone)]
107pub struct TaskSnapshotRow {
108 pub id: u32,
109 pub title: String,
110 pub status: String,
112 pub agent: Option<String>,
113 pub duration_ms: u64,
114 pub error: Option<String>,
116}
117
118#[derive(Debug, Clone, Default)]
120pub struct TaskGraphSnapshot {
121 pub graph_id: String,
122 pub goal: String,
123 pub status: String,
125 pub tasks: Vec<TaskSnapshotRow>,
126 pub completed_at: Option<std::time::Instant>,
127}
128
129impl TaskGraphSnapshot {
130 #[must_use]
133 pub fn is_stale(&self) -> bool {
134 self.completed_at
135 .is_some_and(|t| t.elapsed().as_secs() > 30)
136 }
137}
138
139#[derive(Debug, Clone, Default)]
143pub struct OrchestrationMetrics {
144 pub plans_total: u64,
145 pub tasks_total: u64,
146 pub tasks_completed: u64,
147 pub tasks_failed: u64,
148 pub tasks_skipped: u64,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum McpServerConnectionStatus {
154 Connected,
155 Failed,
156}
157
158#[derive(Debug, Clone)]
160pub struct McpServerStatus {
161 pub id: String,
162 pub status: McpServerConnectionStatus,
163 pub tool_count: usize,
165 pub error: String,
167}
168
169#[derive(Debug, Clone, Default)]
171pub struct SkillConfidence {
172 pub name: String,
173 pub posterior: f64,
174 pub total_uses: u32,
175}
176
177#[derive(Debug, Clone, Default)]
179pub struct SubAgentMetrics {
180 pub id: String,
181 pub name: String,
182 pub state: String,
184 pub turns_used: u32,
185 pub max_turns: u32,
186 pub background: bool,
187 pub elapsed_secs: u64,
188 pub permission_mode: String,
191 pub transcript_dir: Option<String>,
194}
195
196#[derive(Debug, Clone, Default)]
201pub struct TurnTimings {
202 pub prepare_context_ms: u64,
203 pub llm_chat_ms: u64,
204 pub tool_exec_ms: u64,
205 pub persist_message_ms: u64,
206}
207
208#[derive(Debug, Clone, Default)]
215#[allow(clippy::struct_excessive_bools)]
216pub struct MetricsSnapshot {
217 pub prompt_tokens: u64,
218 pub completion_tokens: u64,
219 pub total_tokens: u64,
220 pub context_tokens: u64,
221 pub api_calls: u64,
222 pub active_skills: Vec<String>,
223 pub total_skills: usize,
224 pub mcp_server_count: usize,
226 pub mcp_tool_count: usize,
227 pub mcp_connected_count: usize,
229 pub mcp_servers: Vec<McpServerStatus>,
231 pub active_mcp_tools: Vec<String>,
232 pub sqlite_message_count: u64,
233 pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
234 pub qdrant_available: bool,
235 pub vector_backend: String,
236 pub embeddings_generated: u64,
237 pub last_llm_latency_ms: u64,
238 pub uptime_seconds: u64,
239 pub provider_name: String,
240 pub model_name: String,
241 pub summaries_count: u64,
242 pub context_compactions: u64,
243 pub compaction_hard_count: u64,
246 pub compaction_turns_after_hard: Vec<u64>,
250 pub compression_events: u64,
251 pub compression_tokens_saved: u64,
252 pub tool_output_prunes: u64,
253 pub compaction_probe_passes: u64,
255 pub compaction_probe_soft_failures: u64,
257 pub compaction_probe_failures: u64,
259 pub compaction_probe_errors: u64,
261 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
263 pub last_probe_score: Option<f32>,
266 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
268 pub compaction_probe_threshold: f32,
270 pub compaction_probe_hard_fail_threshold: f32,
272 pub cache_read_tokens: u64,
273 pub cache_creation_tokens: u64,
274 pub cost_spent_cents: f64,
275 pub cost_cps_cents: Option<f64>,
277 pub cost_successful_tasks: u64,
279 pub provider_cost_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
281 pub filter_raw_tokens: u64,
282 pub filter_saved_tokens: u64,
283 pub filter_applications: u64,
284 pub filter_total_commands: u64,
285 pub filter_filtered_commands: u64,
286 pub filter_confidence_full: u64,
287 pub filter_confidence_partial: u64,
288 pub filter_confidence_fallback: u64,
289 pub cancellations: u64,
290 pub server_compaction_events: u64,
291 pub sanitizer_runs: u64,
292 pub sanitizer_injection_flags: u64,
293 pub sanitizer_injection_fp_local: u64,
299 pub sanitizer_truncations: u64,
300 pub quarantine_invocations: u64,
301 pub quarantine_failures: u64,
302 pub classifier_tool_blocks: u64,
304 pub classifier_tool_suspicious: u64,
306 pub causal_ipi_flags: u64,
308 pub vigil_flags_total: u64,
310 pub vigil_blocks_total: u64,
312 pub exfiltration_images_blocked: u64,
313 pub exfiltration_tool_urls_flagged: u64,
314 pub exfiltration_memory_guards: u64,
315 pub pii_scrub_count: u64,
316 pub pii_ner_timeouts: u64,
318 pub pii_ner_circuit_breaker_trips: u64,
320 pub memory_validation_failures: u64,
321 pub rate_limit_trips: u64,
322 pub pre_execution_blocks: u64,
323 pub pre_execution_warnings: u64,
324 pub guardrail_enabled: bool,
326 pub guardrail_warn_mode: bool,
328 pub sub_agents: Vec<SubAgentMetrics>,
329 pub skill_confidence: Vec<SkillConfidence>,
330 pub scheduled_tasks: Vec<[String; 4]>,
332 pub router_thompson_stats: Vec<(String, f64, f64)>,
334 pub security_events: VecDeque<SecurityEvent>,
336 pub orchestration: OrchestrationMetrics,
337 pub orchestration_graph: Option<TaskGraphSnapshot>,
339 pub graph_community_detection_failures: u64,
340 pub graph_entities_total: u64,
341 pub graph_edges_total: u64,
342 pub graph_communities_total: u64,
343 pub graph_extraction_count: u64,
344 pub graph_extraction_failures: u64,
345 pub extended_context: bool,
348 pub guidelines_version: u32,
350 pub guidelines_updated_at: String,
352 pub tool_cache_hits: u64,
353 pub tool_cache_misses: u64,
354 pub tool_cache_entries: usize,
355 pub semantic_fact_count: u64,
357 pub stt_model: Option<String>,
359 pub compaction_model: Option<String>,
361 pub provider_temperature: Option<f32>,
363 pub provider_top_p: Option<f32>,
365 pub embedding_model: String,
367 pub token_budget: Option<u64>,
369 pub compaction_threshold: Option<u32>,
371 pub vault_backend: String,
373 pub active_channel: String,
375 pub bg_inflight: u64,
377 pub bg_dropped: u64,
379 pub bg_completed: u64,
381 pub bg_enrichment_inflight: u64,
383 pub bg_telemetry_inflight: u64,
385 pub self_learning_enabled: bool,
387 pub semantic_cache_enabled: bool,
389 pub cache_enabled: bool,
391 pub autosave_enabled: bool,
393 pub classifier: ClassifierMetricsSnapshot,
395 pub last_turn_timings: TurnTimings,
397 pub avg_turn_timings: TurnTimings,
399 pub max_turn_timings: TurnTimings,
403 pub timing_sample_count: u64,
405 pub egress_requests_total: u64,
407 pub egress_dropped_total: u64,
409 pub egress_blocked_total: u64,
411 pub context_max_tokens: u64,
417 pub compaction_last_before: u64,
419 pub compaction_last_after: u64,
421 pub compaction_last_at_ms: u64,
423}
424
425#[derive(Debug, Default)]
443pub struct StaticMetricsInit {
444 pub stt_model: Option<String>,
446 pub compaction_model: Option<String>,
448 pub semantic_cache_enabled: bool,
453 pub embedding_model: String,
455 pub self_learning_enabled: bool,
457 pub active_channel: String,
459 pub token_budget: Option<u64>,
461 pub compaction_threshold: Option<u32>,
463 pub vault_backend: String,
465 pub autosave_enabled: bool,
467 pub model_name_override: Option<String>,
471}
472
473fn strip_ctrl(s: &str) -> String {
479 let mut out = String::with_capacity(s.len());
480 let mut chars = s.chars().peekable();
481 while let Some(c) = chars.next() {
482 if c == '\x1b' {
483 if chars.peek() == Some(&'[') {
485 chars.next(); for inner in chars.by_ref() {
487 if ('\x40'..='\x7e').contains(&inner) {
488 break;
489 }
490 }
491 }
492 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
494 } else {
496 out.push(c);
497 }
498 }
499 out
500}
501
502impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
504 fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
505 let tasks = graph
506 .tasks
507 .iter()
508 .map(|t| {
509 let error = t
510 .result
511 .as_ref()
512 .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
513 .and_then(|r| {
514 if r.output.is_empty() {
515 None
516 } else {
517 let s = strip_ctrl(&r.output);
519 if s.len() > 80 {
520 let end = s.floor_char_boundary(79);
521 Some(format!("{}…", &s[..end]))
522 } else {
523 Some(s)
524 }
525 }
526 });
527 let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
528 TaskSnapshotRow {
529 id: t.id.as_u32(),
530 title: strip_ctrl(&t.title),
531 status: t.status.to_string(),
532 agent: t.assigned_agent.as_deref().map(strip_ctrl),
533 duration_ms,
534 error,
535 }
536 })
537 .collect();
538 Self {
539 graph_id: graph.id.to_string(),
540 goal: strip_ctrl(&graph.goal),
541 status: graph.status.to_string(),
542 tasks,
543 completed_at: None,
544 }
545 }
546}
547
548pub struct MetricsCollector {
549 tx: watch::Sender<MetricsSnapshot>,
550}
551
552impl MetricsCollector {
553 #[must_use]
554 pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
555 let (tx, rx) = watch::channel(MetricsSnapshot::default());
556 (Self { tx }, rx)
557 }
558
559 pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
560 self.tx.send_modify(f);
561 }
562
563 pub fn set_context_max_tokens(&self, max_tokens: u64) {
578 self.tx.send_modify(|m| m.context_max_tokens = max_tokens);
579 }
580
581 pub fn record_compaction(&self, before: u64, after: u64, at_ms: u64) {
599 self.tx.send_modify(|m| {
600 m.compaction_last_before = before;
601 m.compaction_last_after = after;
602 m.compaction_last_at_ms = at_ms;
603 });
604 }
605
606 #[must_use]
612 pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
613 self.tx.clone()
614 }
615}
616
617pub trait HistogramRecorder: Send + Sync {
655 fn observe_llm_latency(&self, duration: std::time::Duration);
657
658 fn observe_turn_duration(&self, duration: std::time::Duration);
660
661 fn observe_tool_execution(&self, duration: std::time::Duration);
663
664 fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
668}
669
670#[cfg(test)]
671mod tests {
672 #![allow(clippy::field_reassign_with_default)]
673
674 use super::*;
675
676 #[test]
677 fn default_metrics_snapshot() {
678 let m = MetricsSnapshot::default();
679 assert_eq!(m.total_tokens, 0);
680 assert_eq!(m.api_calls, 0);
681 assert!(m.active_skills.is_empty());
682 assert!(m.active_mcp_tools.is_empty());
683 assert_eq!(m.mcp_tool_count, 0);
684 assert_eq!(m.mcp_server_count, 0);
685 assert!(m.provider_name.is_empty());
686 assert_eq!(m.summaries_count, 0);
687 assert!(m.stt_model.is_none());
689 assert!(m.compaction_model.is_none());
690 assert!(m.provider_temperature.is_none());
691 assert!(m.provider_top_p.is_none());
692 assert!(m.active_channel.is_empty());
693 assert!(m.embedding_model.is_empty());
694 assert!(m.token_budget.is_none());
695 assert!(!m.self_learning_enabled);
696 assert!(!m.semantic_cache_enabled);
697 }
698
699 #[test]
700 fn metrics_collector_update_phase2_fields() {
701 let (collector, rx) = MetricsCollector::new();
702 collector.update(|m| {
703 m.stt_model = Some("whisper-1".into());
704 m.compaction_model = Some("haiku".into());
705 m.provider_temperature = Some(0.7);
706 m.provider_top_p = Some(0.95);
707 m.active_channel = "tui".into();
708 m.embedding_model = "nomic-embed-text".into();
709 m.token_budget = Some(200_000);
710 m.self_learning_enabled = true;
711 m.semantic_cache_enabled = true;
712 });
713 let s = rx.borrow();
714 assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
715 assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
716 assert_eq!(s.provider_temperature, Some(0.7));
717 assert_eq!(s.provider_top_p, Some(0.95));
718 assert_eq!(s.active_channel, "tui");
719 assert_eq!(s.embedding_model, "nomic-embed-text");
720 assert_eq!(s.token_budget, Some(200_000));
721 assert!(s.self_learning_enabled);
722 assert!(s.semantic_cache_enabled);
723 }
724
725 #[test]
726 fn metrics_collector_update() {
727 let (collector, rx) = MetricsCollector::new();
728 collector.update(|m| {
729 m.api_calls = 5;
730 m.total_tokens = 1000;
731 });
732 let snapshot = rx.borrow().clone();
733 assert_eq!(snapshot.api_calls, 5);
734 assert_eq!(snapshot.total_tokens, 1000);
735 }
736
737 #[test]
738 fn metrics_collector_multiple_updates() {
739 let (collector, rx) = MetricsCollector::new();
740 collector.update(|m| m.api_calls = 1);
741 collector.update(|m| m.api_calls += 1);
742 assert_eq!(rx.borrow().api_calls, 2);
743 }
744
745 #[test]
746 fn metrics_snapshot_clone() {
747 let mut m = MetricsSnapshot::default();
748 m.provider_name = "ollama".into();
749 let cloned = m.clone();
750 assert_eq!(cloned.provider_name, "ollama");
751 }
752
753 #[test]
754 fn filter_metrics_tracking() {
755 let (collector, rx) = MetricsCollector::new();
756 collector.update(|m| {
757 m.filter_raw_tokens += 250;
758 m.filter_saved_tokens += 200;
759 m.filter_applications += 1;
760 });
761 collector.update(|m| {
762 m.filter_raw_tokens += 100;
763 m.filter_saved_tokens += 80;
764 m.filter_applications += 1;
765 });
766 let s = rx.borrow();
767 assert_eq!(s.filter_raw_tokens, 350);
768 assert_eq!(s.filter_saved_tokens, 280);
769 assert_eq!(s.filter_applications, 2);
770 }
771
772 #[test]
773 fn filter_confidence_and_command_metrics() {
774 let (collector, rx) = MetricsCollector::new();
775 collector.update(|m| {
776 m.filter_total_commands += 1;
777 m.filter_filtered_commands += 1;
778 m.filter_confidence_full += 1;
779 });
780 collector.update(|m| {
781 m.filter_total_commands += 1;
782 m.filter_confidence_partial += 1;
783 });
784 let s = rx.borrow();
785 assert_eq!(s.filter_total_commands, 2);
786 assert_eq!(s.filter_filtered_commands, 1);
787 assert_eq!(s.filter_confidence_full, 1);
788 assert_eq!(s.filter_confidence_partial, 1);
789 assert_eq!(s.filter_confidence_fallback, 0);
790 }
791
792 #[test]
793 fn summaries_count_tracks_summarizations() {
794 let (collector, rx) = MetricsCollector::new();
795 collector.update(|m| m.summaries_count += 1);
796 collector.update(|m| m.summaries_count += 1);
797 assert_eq!(rx.borrow().summaries_count, 2);
798 }
799
800 #[test]
801 fn cancellations_counter_increments() {
802 let (collector, rx) = MetricsCollector::new();
803 assert_eq!(rx.borrow().cancellations, 0);
804 collector.update(|m| m.cancellations += 1);
805 collector.update(|m| m.cancellations += 1);
806 assert_eq!(rx.borrow().cancellations, 2);
807 }
808
809 #[test]
810 fn security_event_detail_exact_128_not_truncated() {
811 let s = "a".repeat(128);
812 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
813 assert_eq!(ev.detail, s, "128-char string must not be truncated");
814 }
815
816 #[test]
817 fn security_event_detail_129_is_truncated() {
818 let s = "a".repeat(129);
819 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
820 assert!(
821 ev.detail.ends_with('…'),
822 "129-char string must end with ellipsis"
823 );
824 assert!(
825 ev.detail.len() <= 130,
826 "truncated detail must be at most 130 bytes"
827 );
828 }
829
830 #[test]
831 fn security_event_detail_multibyte_utf8_no_panic() {
832 let s = "中".repeat(43);
834 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
835 assert!(ev.detail.ends_with('…'));
836 }
837
838 #[test]
839 fn security_event_source_capped_at_64_chars() {
840 let long_source = "x".repeat(200);
841 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
842 assert_eq!(ev.source.len(), 64);
843 }
844
845 #[test]
846 fn security_event_source_strips_control_chars() {
847 let source = "tool\x00name\x1b[31m";
848 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
849 assert!(!ev.source.contains('\x00'));
850 assert!(!ev.source.contains('\x1b'));
851 }
852
853 #[test]
854 fn security_event_category_as_str() {
855 assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
856 assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
857 assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
858 assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
859 assert_eq!(
860 SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
861 "cross_boundary_mcp_to_acp"
862 );
863 }
864
865 #[test]
866 fn ring_buffer_respects_cap_via_update() {
867 let (collector, rx) = MetricsCollector::new();
868 for i in 0..110u64 {
869 let event = SecurityEvent::new(
870 SecurityEventCategory::InjectionFlag,
871 "src",
872 format!("event {i}"),
873 );
874 collector.update(|m| {
875 if m.security_events.len() >= SECURITY_EVENT_CAP {
876 m.security_events.pop_front();
877 }
878 m.security_events.push_back(event);
879 });
880 }
881 let snap = rx.borrow();
882 assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
883 assert!(snap.security_events.back().unwrap().detail.contains("109"));
885 }
886
887 #[test]
888 fn security_events_empty_by_default() {
889 let m = MetricsSnapshot::default();
890 assert!(m.security_events.is_empty());
891 }
892
893 #[test]
894 fn orchestration_metrics_default_zero() {
895 let m = OrchestrationMetrics::default();
896 assert_eq!(m.plans_total, 0);
897 assert_eq!(m.tasks_total, 0);
898 assert_eq!(m.tasks_completed, 0);
899 assert_eq!(m.tasks_failed, 0);
900 assert_eq!(m.tasks_skipped, 0);
901 }
902
903 #[test]
904 fn metrics_snapshot_includes_orchestration_default_zero() {
905 let m = MetricsSnapshot::default();
906 assert_eq!(m.orchestration.plans_total, 0);
907 assert_eq!(m.orchestration.tasks_total, 0);
908 assert_eq!(m.orchestration.tasks_completed, 0);
909 }
910
911 #[test]
912 fn orchestration_metrics_update_via_collector() {
913 let (collector, rx) = MetricsCollector::new();
914 collector.update(|m| {
915 m.orchestration.plans_total += 1;
916 m.orchestration.tasks_total += 5;
917 m.orchestration.tasks_completed += 3;
918 m.orchestration.tasks_failed += 1;
919 m.orchestration.tasks_skipped += 1;
920 });
921 let s = rx.borrow();
922 assert_eq!(s.orchestration.plans_total, 1);
923 assert_eq!(s.orchestration.tasks_total, 5);
924 assert_eq!(s.orchestration.tasks_completed, 3);
925 assert_eq!(s.orchestration.tasks_failed, 1);
926 assert_eq!(s.orchestration.tasks_skipped, 1);
927 }
928
929 #[test]
930 fn strip_ctrl_removes_escape_sequences() {
931 let input = "hello\x1b[31mworld\x00end";
932 let result = strip_ctrl(input);
933 assert_eq!(result, "helloworldend");
934 }
935
936 #[test]
937 fn strip_ctrl_allows_tab_lf_cr() {
938 let input = "a\tb\nc\rd";
939 let result = strip_ctrl(input);
940 assert_eq!(result, "a\tb\nc\rd");
941 }
942
943 #[test]
944 fn task_graph_snapshot_is_stale_after_30s() {
945 let mut snap = TaskGraphSnapshot::default();
946 assert!(!snap.is_stale());
948 snap.completed_at = Some(std::time::Instant::now());
950 assert!(!snap.is_stale());
951 snap.completed_at = Some(
953 std::time::Instant::now()
954 .checked_sub(std::time::Duration::from_secs(31))
955 .unwrap(),
956 );
957 assert!(snap.is_stale());
958 }
959
960 #[test]
962 fn task_graph_snapshot_from_task_graph_maps_fields() {
963 use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
964
965 let mut graph = TaskGraph::new("My goal");
966 let mut task = TaskNode::new(0, "Do work", "description");
967 task.status = TaskStatus::Failed;
968 task.assigned_agent = Some("agent-1".into());
969 task.result = Some(TaskResult {
970 output: "error occurred here".into(),
971 artifacts: vec![],
972 duration_ms: 1234,
973 agent_id: None,
974 agent_def: None,
975 });
976 graph.tasks.push(task);
977 graph.status = GraphStatus::Failed;
978
979 let snap = TaskGraphSnapshot::from(&graph);
980 assert_eq!(snap.goal, "My goal");
981 assert_eq!(snap.status, "failed");
982 assert_eq!(snap.tasks.len(), 1);
983 let row = &snap.tasks[0];
984 assert_eq!(row.title, "Do work");
985 assert_eq!(row.status, "failed");
986 assert_eq!(row.agent.as_deref(), Some("agent-1"));
987 assert_eq!(row.duration_ms, 1234);
988 assert!(row.error.as_deref().unwrap().contains("error occurred"));
989 }
990
991 #[test]
993 fn task_graph_snapshot_from_compiles_with_feature() {
994 use zeph_orchestration::TaskGraph;
995 let graph = TaskGraph::new("feature flag test");
996 let snap = TaskGraphSnapshot::from(&graph);
997 assert_eq!(snap.goal, "feature flag test");
998 assert!(snap.tasks.is_empty());
999 assert!(!snap.is_stale());
1000 }
1001
1002 #[test]
1004 fn task_graph_snapshot_error_truncated_at_80_chars() {
1005 use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
1006
1007 let mut graph = TaskGraph::new("goal");
1008 let mut task = TaskNode::new(0, "t", "d");
1009 task.status = TaskStatus::Failed;
1010 task.result = Some(TaskResult {
1011 output: "e".repeat(100),
1012 artifacts: vec![],
1013 duration_ms: 0,
1014 agent_id: None,
1015 agent_def: None,
1016 });
1017 graph.tasks.push(task);
1018
1019 let snap = TaskGraphSnapshot::from(&graph);
1020 let err = snap.tasks[0].error.as_ref().unwrap();
1021 assert!(err.ends_with('…'), "truncated error must end with ellipsis");
1022 assert!(
1023 err.len() <= 83,
1024 "truncated error must not exceed 80 chars + ellipsis"
1025 );
1026 }
1027
1028 #[test]
1030 fn task_graph_snapshot_strips_control_chars_from_title() {
1031 use zeph_orchestration::{TaskGraph, TaskNode};
1032
1033 let mut graph = TaskGraph::new("goal\x1b[31m");
1034 let task = TaskNode::new(0, "title\x00injected", "d");
1035 graph.tasks.push(task);
1036
1037 let snap = TaskGraphSnapshot::from(&graph);
1038 assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
1039 assert!(
1040 !snap.tasks[0].title.contains('\x00'),
1041 "title must not contain null byte"
1042 );
1043 }
1044
1045 #[test]
1046 fn graph_metrics_default_zero() {
1047 let m = MetricsSnapshot::default();
1048 assert_eq!(m.graph_entities_total, 0);
1049 assert_eq!(m.graph_edges_total, 0);
1050 assert_eq!(m.graph_communities_total, 0);
1051 assert_eq!(m.graph_extraction_count, 0);
1052 assert_eq!(m.graph_extraction_failures, 0);
1053 }
1054
1055 #[test]
1056 fn graph_metrics_update_via_collector() {
1057 let (collector, rx) = MetricsCollector::new();
1058 collector.update(|m| {
1059 m.graph_entities_total = 5;
1060 m.graph_edges_total = 10;
1061 m.graph_communities_total = 2;
1062 m.graph_extraction_count = 7;
1063 m.graph_extraction_failures = 1;
1064 });
1065 let snapshot = rx.borrow().clone();
1066 assert_eq!(snapshot.graph_entities_total, 5);
1067 assert_eq!(snapshot.graph_edges_total, 10);
1068 assert_eq!(snapshot.graph_communities_total, 2);
1069 assert_eq!(snapshot.graph_extraction_count, 7);
1070 assert_eq!(snapshot.graph_extraction_failures, 1);
1071 }
1072
1073 #[test]
1074 fn histogram_recorder_trait_is_object_safe() {
1075 use std::sync::Arc;
1076 use std::time::Duration;
1077
1078 struct NoOpRecorder;
1079 impl HistogramRecorder for NoOpRecorder {
1080 fn observe_llm_latency(&self, _: Duration) {}
1081 fn observe_turn_duration(&self, _: Duration) {}
1082 fn observe_tool_execution(&self, _: Duration) {}
1083 fn observe_bg_task(&self, _: &str, _: Duration) {}
1084 }
1085
1086 let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
1088 recorder.observe_llm_latency(Duration::from_millis(500));
1089 recorder.observe_turn_duration(Duration::from_secs(3));
1090 recorder.observe_tool_execution(Duration::from_millis(100));
1091 }
1092}