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}
30
31impl SecurityEventCategory {
32 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::InjectionFlag => "injection",
36 Self::InjectionBlocked => "injection_blocked",
37 Self::ExfiltrationBlock => "exfil",
38 Self::Quarantine => "quarantine",
39 Self::Truncation => "truncation",
40 Self::RateLimit => "rate_limit",
41 Self::MemoryValidation => "memory_validation",
42 Self::PreExecutionBlock => "pre_exec_block",
43 Self::PreExecutionWarn => "pre_exec_warn",
44 Self::ResponseVerification => "response_verify",
45 Self::CausalIpiFlag => "causal_ipi",
46 Self::CrossBoundaryMcpToAcp => "cross_boundary_mcp_to_acp",
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct SecurityEvent {
54 pub timestamp: u64,
56 pub category: SecurityEventCategory,
57 pub source: String,
59 pub detail: String,
61}
62
63impl SecurityEvent {
64 #[must_use]
65 pub fn new(
66 category: SecurityEventCategory,
67 source: impl Into<String>,
68 detail: impl Into<String>,
69 ) -> Self {
70 let source: String = source
72 .into()
73 .chars()
74 .filter(|c| !c.is_ascii_control())
75 .take(64)
76 .collect();
77 let detail = detail.into();
79 let detail = if detail.len() > 128 {
80 let end = detail.floor_char_boundary(127);
81 format!("{}…", &detail[..end])
82 } else {
83 detail
84 };
85 Self {
86 timestamp: std::time::SystemTime::now()
87 .duration_since(std::time::UNIX_EPOCH)
88 .unwrap_or_default()
89 .as_secs(),
90 category,
91 source,
92 detail,
93 }
94 }
95}
96
97pub const SECURITY_EVENT_CAP: usize = 100;
99
100#[derive(Debug, Clone)]
104pub struct TaskSnapshotRow {
105 pub id: u32,
106 pub title: String,
107 pub status: String,
109 pub agent: Option<String>,
110 pub duration_ms: u64,
111 pub error: Option<String>,
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct TaskGraphSnapshot {
118 pub graph_id: String,
119 pub goal: String,
120 pub status: String,
122 pub tasks: Vec<TaskSnapshotRow>,
123 pub completed_at: Option<std::time::Instant>,
124}
125
126impl TaskGraphSnapshot {
127 #[must_use]
130 pub fn is_stale(&self) -> bool {
131 self.completed_at
132 .is_some_and(|t| t.elapsed().as_secs() > 30)
133 }
134}
135
136#[derive(Debug, Clone, Default)]
140pub struct OrchestrationMetrics {
141 pub plans_total: u64,
142 pub tasks_total: u64,
143 pub tasks_completed: u64,
144 pub tasks_failed: u64,
145 pub tasks_skipped: u64,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum McpServerConnectionStatus {
151 Connected,
152 Failed,
153}
154
155#[derive(Debug, Clone)]
157pub struct McpServerStatus {
158 pub id: String,
159 pub status: McpServerConnectionStatus,
160 pub tool_count: usize,
162 pub error: String,
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct SkillConfidence {
169 pub name: String,
170 pub posterior: f64,
171 pub total_uses: u32,
172}
173
174#[derive(Debug, Clone, Default)]
176pub struct SubAgentMetrics {
177 pub id: String,
178 pub name: String,
179 pub state: String,
181 pub turns_used: u32,
182 pub max_turns: u32,
183 pub background: bool,
184 pub elapsed_secs: u64,
185 pub permission_mode: String,
188 pub transcript_dir: Option<String>,
191}
192
193#[derive(Debug, Clone, Default)]
194#[allow(clippy::struct_excessive_bools)]
195pub struct MetricsSnapshot {
196 pub prompt_tokens: u64,
197 pub completion_tokens: u64,
198 pub total_tokens: u64,
199 pub context_tokens: u64,
200 pub api_calls: u64,
201 pub active_skills: Vec<String>,
202 pub total_skills: usize,
203 pub mcp_server_count: usize,
205 pub mcp_tool_count: usize,
206 pub mcp_connected_count: usize,
208 pub mcp_servers: Vec<McpServerStatus>,
210 pub active_mcp_tools: Vec<String>,
211 pub sqlite_message_count: u64,
212 pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
213 pub qdrant_available: bool,
214 pub vector_backend: String,
215 pub embeddings_generated: u64,
216 pub last_llm_latency_ms: u64,
217 pub uptime_seconds: u64,
218 pub provider_name: String,
219 pub model_name: String,
220 pub summaries_count: u64,
221 pub context_compactions: u64,
222 pub compaction_hard_count: u64,
225 pub compaction_turns_after_hard: Vec<u64>,
229 pub compression_events: u64,
230 pub compression_tokens_saved: u64,
231 pub tool_output_prunes: u64,
232 pub compaction_probe_passes: u64,
234 pub compaction_probe_soft_failures: u64,
236 pub compaction_probe_failures: u64,
238 pub compaction_probe_errors: u64,
240 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
242 pub last_probe_score: Option<f32>,
245 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
247 pub compaction_probe_threshold: f32,
249 pub compaction_probe_hard_fail_threshold: f32,
251 pub cache_read_tokens: u64,
252 pub cache_creation_tokens: u64,
253 pub cost_spent_cents: f64,
254 pub filter_raw_tokens: u64,
255 pub filter_saved_tokens: u64,
256 pub filter_applications: u64,
257 pub filter_total_commands: u64,
258 pub filter_filtered_commands: u64,
259 pub filter_confidence_full: u64,
260 pub filter_confidence_partial: u64,
261 pub filter_confidence_fallback: u64,
262 pub cancellations: u64,
263 pub server_compaction_events: u64,
264 pub sanitizer_runs: u64,
265 pub sanitizer_injection_flags: u64,
266 pub sanitizer_injection_fp_local: u64,
272 pub sanitizer_truncations: u64,
273 pub quarantine_invocations: u64,
274 pub quarantine_failures: u64,
275 pub classifier_tool_blocks: u64,
277 pub classifier_tool_suspicious: u64,
279 pub causal_ipi_flags: u64,
281 pub exfiltration_images_blocked: u64,
282 pub exfiltration_tool_urls_flagged: u64,
283 pub exfiltration_memory_guards: u64,
284 pub pii_scrub_count: u64,
285 pub pii_ner_timeouts: u64,
287 pub memory_validation_failures: u64,
288 pub rate_limit_trips: u64,
289 pub pre_execution_blocks: u64,
290 pub pre_execution_warnings: u64,
291 #[cfg(feature = "guardrail")]
293 pub guardrail_enabled: bool,
294 #[cfg(feature = "guardrail")]
296 pub guardrail_warn_mode: bool,
297 pub sub_agents: Vec<SubAgentMetrics>,
298 pub skill_confidence: Vec<SkillConfidence>,
299 pub scheduled_tasks: Vec<[String; 4]>,
301 pub router_thompson_stats: Vec<(String, f64, f64)>,
303 pub security_events: VecDeque<SecurityEvent>,
305 pub orchestration: OrchestrationMetrics,
306 pub orchestration_graph: Option<TaskGraphSnapshot>,
308 pub graph_community_detection_failures: u64,
309 pub graph_entities_total: u64,
310 pub graph_edges_total: u64,
311 pub graph_communities_total: u64,
312 pub graph_extraction_count: u64,
313 pub graph_extraction_failures: u64,
314 pub extended_context: bool,
317 pub guidelines_version: u32,
319 pub guidelines_updated_at: String,
321 pub tool_cache_hits: u64,
322 pub tool_cache_misses: u64,
323 pub tool_cache_entries: usize,
324 pub semantic_fact_count: u64,
326 pub stt_model: Option<String>,
328 pub compaction_model: Option<String>,
330 pub provider_temperature: Option<f32>,
332 pub provider_top_p: Option<f32>,
334 pub embedding_model: String,
336 pub token_budget: Option<u64>,
338 pub compaction_threshold: Option<u32>,
340 pub vault_backend: String,
342 pub active_channel: String,
344 pub self_learning_enabled: bool,
346 pub semantic_cache_enabled: bool,
348 pub cache_enabled: bool,
350 pub autosave_enabled: bool,
352 pub classifier: ClassifierMetricsSnapshot,
354}
355
356fn strip_ctrl(s: &str) -> String {
362 let mut out = String::with_capacity(s.len());
363 let mut chars = s.chars().peekable();
364 while let Some(c) = chars.next() {
365 if c == '\x1b' {
366 if chars.peek() == Some(&'[') {
368 chars.next(); for inner in chars.by_ref() {
370 if ('\x40'..='\x7e').contains(&inner) {
371 break;
372 }
373 }
374 }
375 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
377 } else {
379 out.push(c);
380 }
381 }
382 out
383}
384
385impl From<&crate::orchestration::TaskGraph> for TaskGraphSnapshot {
387 fn from(graph: &crate::orchestration::TaskGraph) -> Self {
388 let tasks = graph
389 .tasks
390 .iter()
391 .map(|t| {
392 let error = t
393 .result
394 .as_ref()
395 .filter(|_| t.status == crate::orchestration::TaskStatus::Failed)
396 .and_then(|r| {
397 if r.output.is_empty() {
398 None
399 } else {
400 let s = strip_ctrl(&r.output);
402 if s.len() > 80 {
403 let end = s.floor_char_boundary(79);
404 Some(format!("{}…", &s[..end]))
405 } else {
406 Some(s)
407 }
408 }
409 });
410 let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
411 TaskSnapshotRow {
412 id: t.id.as_u32(),
413 title: strip_ctrl(&t.title),
414 status: t.status.to_string(),
415 agent: t.assigned_agent.as_deref().map(strip_ctrl),
416 duration_ms,
417 error,
418 }
419 })
420 .collect();
421 Self {
422 graph_id: graph.id.to_string(),
423 goal: strip_ctrl(&graph.goal),
424 status: graph.status.to_string(),
425 tasks,
426 completed_at: None,
427 }
428 }
429}
430
431pub struct MetricsCollector {
432 tx: watch::Sender<MetricsSnapshot>,
433}
434
435impl MetricsCollector {
436 #[must_use]
437 pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
438 let (tx, rx) = watch::channel(MetricsSnapshot::default());
439 (Self { tx }, rx)
440 }
441
442 pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
443 self.tx.send_modify(f);
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 #![allow(clippy::field_reassign_with_default)]
450
451 use super::*;
452
453 #[test]
454 fn default_metrics_snapshot() {
455 let m = MetricsSnapshot::default();
456 assert_eq!(m.total_tokens, 0);
457 assert_eq!(m.api_calls, 0);
458 assert!(m.active_skills.is_empty());
459 assert!(m.active_mcp_tools.is_empty());
460 assert_eq!(m.mcp_tool_count, 0);
461 assert_eq!(m.mcp_server_count, 0);
462 assert!(m.provider_name.is_empty());
463 assert_eq!(m.summaries_count, 0);
464 assert!(m.stt_model.is_none());
466 assert!(m.compaction_model.is_none());
467 assert!(m.provider_temperature.is_none());
468 assert!(m.provider_top_p.is_none());
469 assert!(m.active_channel.is_empty());
470 assert!(m.embedding_model.is_empty());
471 assert!(m.token_budget.is_none());
472 assert!(!m.self_learning_enabled);
473 assert!(!m.semantic_cache_enabled);
474 }
475
476 #[test]
477 fn metrics_collector_update_phase2_fields() {
478 let (collector, rx) = MetricsCollector::new();
479 collector.update(|m| {
480 m.stt_model = Some("whisper-1".into());
481 m.compaction_model = Some("haiku".into());
482 m.provider_temperature = Some(0.7);
483 m.provider_top_p = Some(0.95);
484 m.active_channel = "tui".into();
485 m.embedding_model = "nomic-embed-text".into();
486 m.token_budget = Some(200_000);
487 m.self_learning_enabled = true;
488 m.semantic_cache_enabled = true;
489 });
490 let s = rx.borrow();
491 assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
492 assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
493 assert_eq!(s.provider_temperature, Some(0.7));
494 assert_eq!(s.provider_top_p, Some(0.95));
495 assert_eq!(s.active_channel, "tui");
496 assert_eq!(s.embedding_model, "nomic-embed-text");
497 assert_eq!(s.token_budget, Some(200_000));
498 assert!(s.self_learning_enabled);
499 assert!(s.semantic_cache_enabled);
500 }
501
502 #[test]
503 fn metrics_collector_update() {
504 let (collector, rx) = MetricsCollector::new();
505 collector.update(|m| {
506 m.api_calls = 5;
507 m.total_tokens = 1000;
508 });
509 let snapshot = rx.borrow().clone();
510 assert_eq!(snapshot.api_calls, 5);
511 assert_eq!(snapshot.total_tokens, 1000);
512 }
513
514 #[test]
515 fn metrics_collector_multiple_updates() {
516 let (collector, rx) = MetricsCollector::new();
517 collector.update(|m| m.api_calls = 1);
518 collector.update(|m| m.api_calls += 1);
519 assert_eq!(rx.borrow().api_calls, 2);
520 }
521
522 #[test]
523 fn metrics_snapshot_clone() {
524 let mut m = MetricsSnapshot::default();
525 m.provider_name = "ollama".into();
526 let cloned = m.clone();
527 assert_eq!(cloned.provider_name, "ollama");
528 }
529
530 #[test]
531 fn filter_metrics_tracking() {
532 let (collector, rx) = MetricsCollector::new();
533 collector.update(|m| {
534 m.filter_raw_tokens += 250;
535 m.filter_saved_tokens += 200;
536 m.filter_applications += 1;
537 });
538 collector.update(|m| {
539 m.filter_raw_tokens += 100;
540 m.filter_saved_tokens += 80;
541 m.filter_applications += 1;
542 });
543 let s = rx.borrow();
544 assert_eq!(s.filter_raw_tokens, 350);
545 assert_eq!(s.filter_saved_tokens, 280);
546 assert_eq!(s.filter_applications, 2);
547 }
548
549 #[test]
550 fn filter_confidence_and_command_metrics() {
551 let (collector, rx) = MetricsCollector::new();
552 collector.update(|m| {
553 m.filter_total_commands += 1;
554 m.filter_filtered_commands += 1;
555 m.filter_confidence_full += 1;
556 });
557 collector.update(|m| {
558 m.filter_total_commands += 1;
559 m.filter_confidence_partial += 1;
560 });
561 let s = rx.borrow();
562 assert_eq!(s.filter_total_commands, 2);
563 assert_eq!(s.filter_filtered_commands, 1);
564 assert_eq!(s.filter_confidence_full, 1);
565 assert_eq!(s.filter_confidence_partial, 1);
566 assert_eq!(s.filter_confidence_fallback, 0);
567 }
568
569 #[test]
570 fn summaries_count_tracks_summarizations() {
571 let (collector, rx) = MetricsCollector::new();
572 collector.update(|m| m.summaries_count += 1);
573 collector.update(|m| m.summaries_count += 1);
574 assert_eq!(rx.borrow().summaries_count, 2);
575 }
576
577 #[test]
578 fn cancellations_counter_increments() {
579 let (collector, rx) = MetricsCollector::new();
580 assert_eq!(rx.borrow().cancellations, 0);
581 collector.update(|m| m.cancellations += 1);
582 collector.update(|m| m.cancellations += 1);
583 assert_eq!(rx.borrow().cancellations, 2);
584 }
585
586 #[test]
587 fn security_event_detail_exact_128_not_truncated() {
588 let s = "a".repeat(128);
589 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
590 assert_eq!(ev.detail, s, "128-char string must not be truncated");
591 }
592
593 #[test]
594 fn security_event_detail_129_is_truncated() {
595 let s = "a".repeat(129);
596 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
597 assert!(
598 ev.detail.ends_with('…'),
599 "129-char string must end with ellipsis"
600 );
601 assert!(
602 ev.detail.len() <= 130,
603 "truncated detail must be at most 130 bytes"
604 );
605 }
606
607 #[test]
608 fn security_event_detail_multibyte_utf8_no_panic() {
609 let s = "中".repeat(43);
611 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
612 assert!(ev.detail.ends_with('…'));
613 }
614
615 #[test]
616 fn security_event_source_capped_at_64_chars() {
617 let long_source = "x".repeat(200);
618 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
619 assert_eq!(ev.source.len(), 64);
620 }
621
622 #[test]
623 fn security_event_source_strips_control_chars() {
624 let source = "tool\x00name\x1b[31m";
625 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
626 assert!(!ev.source.contains('\x00'));
627 assert!(!ev.source.contains('\x1b'));
628 }
629
630 #[test]
631 fn security_event_category_as_str() {
632 assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
633 assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
634 assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
635 assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
636 assert_eq!(
637 SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
638 "cross_boundary_mcp_to_acp"
639 );
640 }
641
642 #[test]
643 fn ring_buffer_respects_cap_via_update() {
644 let (collector, rx) = MetricsCollector::new();
645 for i in 0..110u64 {
646 let event = SecurityEvent::new(
647 SecurityEventCategory::InjectionFlag,
648 "src",
649 format!("event {i}"),
650 );
651 collector.update(|m| {
652 if m.security_events.len() >= SECURITY_EVENT_CAP {
653 m.security_events.pop_front();
654 }
655 m.security_events.push_back(event);
656 });
657 }
658 let snap = rx.borrow();
659 assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
660 assert!(snap.security_events.back().unwrap().detail.contains("109"));
662 }
663
664 #[test]
665 fn security_events_empty_by_default() {
666 let m = MetricsSnapshot::default();
667 assert!(m.security_events.is_empty());
668 }
669
670 #[test]
671 fn orchestration_metrics_default_zero() {
672 let m = OrchestrationMetrics::default();
673 assert_eq!(m.plans_total, 0);
674 assert_eq!(m.tasks_total, 0);
675 assert_eq!(m.tasks_completed, 0);
676 assert_eq!(m.tasks_failed, 0);
677 assert_eq!(m.tasks_skipped, 0);
678 }
679
680 #[test]
681 fn metrics_snapshot_includes_orchestration_default_zero() {
682 let m = MetricsSnapshot::default();
683 assert_eq!(m.orchestration.plans_total, 0);
684 assert_eq!(m.orchestration.tasks_total, 0);
685 assert_eq!(m.orchestration.tasks_completed, 0);
686 }
687
688 #[test]
689 fn orchestration_metrics_update_via_collector() {
690 let (collector, rx) = MetricsCollector::new();
691 collector.update(|m| {
692 m.orchestration.plans_total += 1;
693 m.orchestration.tasks_total += 5;
694 m.orchestration.tasks_completed += 3;
695 m.orchestration.tasks_failed += 1;
696 m.orchestration.tasks_skipped += 1;
697 });
698 let s = rx.borrow();
699 assert_eq!(s.orchestration.plans_total, 1);
700 assert_eq!(s.orchestration.tasks_total, 5);
701 assert_eq!(s.orchestration.tasks_completed, 3);
702 assert_eq!(s.orchestration.tasks_failed, 1);
703 assert_eq!(s.orchestration.tasks_skipped, 1);
704 }
705
706 #[test]
707 fn strip_ctrl_removes_escape_sequences() {
708 let input = "hello\x1b[31mworld\x00end";
709 let result = strip_ctrl(input);
710 assert_eq!(result, "helloworldend");
711 }
712
713 #[test]
714 fn strip_ctrl_allows_tab_lf_cr() {
715 let input = "a\tb\nc\rd";
716 let result = strip_ctrl(input);
717 assert_eq!(result, "a\tb\nc\rd");
718 }
719
720 #[test]
721 fn task_graph_snapshot_is_stale_after_30s() {
722 let mut snap = TaskGraphSnapshot::default();
723 assert!(!snap.is_stale());
725 snap.completed_at = Some(std::time::Instant::now());
727 assert!(!snap.is_stale());
728 snap.completed_at = Some(
730 std::time::Instant::now()
731 .checked_sub(std::time::Duration::from_secs(31))
732 .unwrap(),
733 );
734 assert!(snap.is_stale());
735 }
736
737 #[test]
739 fn task_graph_snapshot_from_task_graph_maps_fields() {
740 use crate::orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
741
742 let mut graph = TaskGraph::new("My goal");
743 let mut task = TaskNode::new(0, "Do work", "description");
744 task.status = TaskStatus::Failed;
745 task.assigned_agent = Some("agent-1".into());
746 task.result = Some(TaskResult {
747 output: "error occurred here".into(),
748 artifacts: vec![],
749 duration_ms: 1234,
750 agent_id: None,
751 agent_def: None,
752 });
753 graph.tasks.push(task);
754 graph.status = GraphStatus::Failed;
755
756 let snap = TaskGraphSnapshot::from(&graph);
757 assert_eq!(snap.goal, "My goal");
758 assert_eq!(snap.status, "failed");
759 assert_eq!(snap.tasks.len(), 1);
760 let row = &snap.tasks[0];
761 assert_eq!(row.title, "Do work");
762 assert_eq!(row.status, "failed");
763 assert_eq!(row.agent.as_deref(), Some("agent-1"));
764 assert_eq!(row.duration_ms, 1234);
765 assert!(row.error.as_deref().unwrap().contains("error occurred"));
766 }
767
768 #[test]
770 fn task_graph_snapshot_from_compiles_with_feature() {
771 use crate::orchestration::TaskGraph;
772 let graph = TaskGraph::new("feature flag test");
773 let snap = TaskGraphSnapshot::from(&graph);
774 assert_eq!(snap.goal, "feature flag test");
775 assert!(snap.tasks.is_empty());
776 assert!(!snap.is_stale());
777 }
778
779 #[test]
781 fn task_graph_snapshot_error_truncated_at_80_chars() {
782 use crate::orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
783
784 let mut graph = TaskGraph::new("goal");
785 let mut task = TaskNode::new(0, "t", "d");
786 task.status = TaskStatus::Failed;
787 task.result = Some(TaskResult {
788 output: "e".repeat(100),
789 artifacts: vec![],
790 duration_ms: 0,
791 agent_id: None,
792 agent_def: None,
793 });
794 graph.tasks.push(task);
795
796 let snap = TaskGraphSnapshot::from(&graph);
797 let err = snap.tasks[0].error.as_ref().unwrap();
798 assert!(err.ends_with('…'), "truncated error must end with ellipsis");
799 assert!(
800 err.len() <= 83,
801 "truncated error must not exceed 80 chars + ellipsis"
802 );
803 }
804
805 #[test]
807 fn task_graph_snapshot_strips_control_chars_from_title() {
808 use crate::orchestration::{TaskGraph, TaskNode};
809
810 let mut graph = TaskGraph::new("goal\x1b[31m");
811 let task = TaskNode::new(0, "title\x00injected", "d");
812 graph.tasks.push(task);
813
814 let snap = TaskGraphSnapshot::from(&graph);
815 assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
816 assert!(
817 !snap.tasks[0].title.contains('\x00'),
818 "title must not contain null byte"
819 );
820 }
821
822 #[test]
823 fn graph_metrics_default_zero() {
824 let m = MetricsSnapshot::default();
825 assert_eq!(m.graph_entities_total, 0);
826 assert_eq!(m.graph_edges_total, 0);
827 assert_eq!(m.graph_communities_total, 0);
828 assert_eq!(m.graph_extraction_count, 0);
829 assert_eq!(m.graph_extraction_failures, 0);
830 }
831
832 #[test]
833 fn graph_metrics_update_via_collector() {
834 let (collector, rx) = MetricsCollector::new();
835 collector.update(|m| {
836 m.graph_entities_total = 5;
837 m.graph_edges_total = 10;
838 m.graph_communities_total = 2;
839 m.graph_extraction_count = 7;
840 m.graph_extraction_failures = 1;
841 });
842 let snapshot = rx.borrow().clone();
843 assert_eq!(snapshot.graph_entities_total, 5);
844 assert_eq!(snapshot.graph_edges_total, 10);
845 assert_eq!(snapshot.graph_communities_total, 2);
846 assert_eq!(snapshot.graph_extraction_count, 7);
847 assert_eq!(snapshot.graph_extraction_failures, 1);
848 }
849}