Skip to main content

zeph_core/
metrics.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::VecDeque;
5
6use tokio::sync::watch;
7
8pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
9
10/// Category of a security event for TUI display.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SecurityEventCategory {
13    InjectionFlag,
14    ExfiltrationBlock,
15    Quarantine,
16    Truncation,
17    RateLimit,
18    MemoryValidation,
19    PreExecutionBlock,
20    PreExecutionWarn,
21    ResponseVerification,
22}
23
24impl SecurityEventCategory {
25    #[must_use]
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::InjectionFlag => "injection",
29            Self::ExfiltrationBlock => "exfil",
30            Self::Quarantine => "quarantine",
31            Self::Truncation => "truncation",
32            Self::RateLimit => "rate_limit",
33            Self::MemoryValidation => "memory_validation",
34            Self::PreExecutionBlock => "pre_exec_block",
35            Self::PreExecutionWarn => "pre_exec_warn",
36            Self::ResponseVerification => "response_verify",
37        }
38    }
39}
40
41/// A single security event record for TUI display.
42#[derive(Debug, Clone)]
43pub struct SecurityEvent {
44    /// Unix timestamp (seconds since epoch).
45    pub timestamp: u64,
46    pub category: SecurityEventCategory,
47    /// Source that triggered the event (e.g., `web_scrape`, `mcp_response`).
48    pub source: String,
49    /// Short description, capped at 128 chars.
50    pub detail: String,
51}
52
53impl SecurityEvent {
54    #[must_use]
55    pub fn new(
56        category: SecurityEventCategory,
57        source: impl Into<String>,
58        detail: impl Into<String>,
59    ) -> Self {
60        // IMP-1: cap source at 64 chars and strip ASCII control chars.
61        let source: String = source
62            .into()
63            .chars()
64            .filter(|c| !c.is_ascii_control())
65            .take(64)
66            .collect();
67        // CR-1: UTF-8 safe truncation using floor_char_boundary (stable since Rust 1.82).
68        let detail = detail.into();
69        let detail = if detail.len() > 128 {
70            let end = detail.floor_char_boundary(127);
71            format!("{}…", &detail[..end])
72        } else {
73            detail
74        };
75        Self {
76            timestamp: std::time::SystemTime::now()
77                .duration_since(std::time::UNIX_EPOCH)
78                .unwrap_or_default()
79                .as_secs(),
80            category,
81            source,
82            detail,
83        }
84    }
85}
86
87/// Ring buffer capacity for security events.
88pub const SECURITY_EVENT_CAP: usize = 100;
89
90/// Lightweight snapshot of a single task row for TUI display.
91///
92/// Cloned from [`TaskGraph`] on each metrics tick; kept minimal on purpose.
93#[derive(Debug, Clone)]
94pub struct TaskSnapshotRow {
95    pub id: u32,
96    pub title: String,
97    /// Stringified `TaskStatus` (e.g. `"pending"`, `"running"`, `"completed"`).
98    pub status: String,
99    pub agent: Option<String>,
100    pub duration_ms: u64,
101    /// Truncated error message (first 80 chars) when the task failed.
102    pub error: Option<String>,
103}
104
105/// Lightweight snapshot of a `TaskGraph` for TUI display.
106#[derive(Debug, Clone, Default)]
107pub struct TaskGraphSnapshot {
108    pub graph_id: String,
109    pub goal: String,
110    /// Stringified `GraphStatus` (e.g. `"created"`, `"running"`, `"completed"`).
111    pub status: String,
112    pub tasks: Vec<TaskSnapshotRow>,
113    pub completed_at: Option<std::time::Instant>,
114}
115
116impl TaskGraphSnapshot {
117    /// Returns `true` if this snapshot represents a terminal plan that finished
118    /// more than 30 seconds ago and should no longer be shown in the TUI.
119    #[must_use]
120    pub fn is_stale(&self) -> bool {
121        self.completed_at
122            .is_some_and(|t| t.elapsed().as_secs() > 30)
123    }
124}
125
126/// Counters for the task orchestration subsystem.
127///
128/// Always present in [`MetricsSnapshot`]; zero-valued when orchestration is inactive.
129#[derive(Debug, Clone, Default)]
130pub struct OrchestrationMetrics {
131    pub plans_total: u64,
132    pub tasks_total: u64,
133    pub tasks_completed: u64,
134    pub tasks_failed: u64,
135    pub tasks_skipped: u64,
136}
137
138/// Bayesian confidence data for a single skill, used by TUI confidence bar.
139#[derive(Debug, Clone, Default)]
140pub struct SkillConfidence {
141    pub name: String,
142    pub posterior: f64,
143    pub total_uses: u32,
144}
145
146/// Snapshot of a single sub-agent's runtime status.
147#[derive(Debug, Clone, Default)]
148pub struct SubAgentMetrics {
149    pub id: String,
150    pub name: String,
151    /// Stringified `TaskState`: "working", "completed", "failed", "canceled", etc.
152    pub state: String,
153    pub turns_used: u32,
154    pub max_turns: u32,
155    pub background: bool,
156    pub elapsed_secs: u64,
157    /// Stringified `PermissionMode`: `"default"`, `"accept_edits"`, `"dont_ask"`,
158    /// `"bypass_permissions"`, `"plan"`. Empty string when mode is `Default`.
159    pub permission_mode: String,
160}
161
162#[derive(Debug, Clone, Default)]
163#[allow(clippy::struct_excessive_bools)]
164pub struct MetricsSnapshot {
165    pub prompt_tokens: u64,
166    pub completion_tokens: u64,
167    pub total_tokens: u64,
168    pub context_tokens: u64,
169    pub api_calls: u64,
170    pub active_skills: Vec<String>,
171    pub total_skills: usize,
172    pub mcp_server_count: usize,
173    pub mcp_tool_count: usize,
174    pub active_mcp_tools: Vec<String>,
175    pub sqlite_message_count: u64,
176    pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
177    pub qdrant_available: bool,
178    pub vector_backend: String,
179    pub embeddings_generated: u64,
180    pub last_llm_latency_ms: u64,
181    pub uptime_seconds: u64,
182    pub provider_name: String,
183    pub model_name: String,
184    pub summaries_count: u64,
185    pub context_compactions: u64,
186    /// Number of times the agent entered the Hard compaction tier, including cooldown-skipped
187    /// turns. Not equal to the actual LLM summarization count — reflects pressure, not action.
188    pub compaction_hard_count: u64,
189    /// User-message turns elapsed after each hard compaction event.
190    /// Entry i = turns between hard compaction i and hard compaction i+1 (or session end).
191    /// Empty when no hard compaction occurred during the session.
192    pub compaction_turns_after_hard: Vec<u64>,
193    pub compression_events: u64,
194    pub compression_tokens_saved: u64,
195    pub tool_output_prunes: u64,
196    /// Compaction probe outcomes (#1609).
197    pub compaction_probe_passes: u64,
198    /// Compaction probe soft failures (summary borderline — compaction proceeded with warning).
199    pub compaction_probe_soft_failures: u64,
200    /// Compaction probe hard failures (compaction blocked due to lossy summary).
201    pub compaction_probe_failures: u64,
202    /// Compaction probe errors (LLM/timeout — non-blocking, compaction proceeded).
203    pub compaction_probe_errors: u64,
204    /// Last compaction probe verdict. `None` before the first probe completes.
205    pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
206    /// Last compaction probe score in [0.0, 1.0]. `None` before the first probe
207    /// completes or after an Error verdict (errors produce no score).
208    pub last_probe_score: Option<f32>,
209    /// Per-category scores from the last completed probe.
210    pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
211    /// Configured pass threshold for the compaction probe. Used by TUI for category color-coding.
212    pub compaction_probe_threshold: f32,
213    /// Configured hard-fail threshold for the compaction probe.
214    pub compaction_probe_hard_fail_threshold: f32,
215    pub cache_read_tokens: u64,
216    pub cache_creation_tokens: u64,
217    pub cost_spent_cents: f64,
218    pub filter_raw_tokens: u64,
219    pub filter_saved_tokens: u64,
220    pub filter_applications: u64,
221    pub filter_total_commands: u64,
222    pub filter_filtered_commands: u64,
223    pub filter_confidence_full: u64,
224    pub filter_confidence_partial: u64,
225    pub filter_confidence_fallback: u64,
226    pub cancellations: u64,
227    pub server_compaction_events: u64,
228    pub sanitizer_runs: u64,
229    pub sanitizer_injection_flags: u64,
230    pub sanitizer_truncations: u64,
231    pub quarantine_invocations: u64,
232    pub quarantine_failures: u64,
233    pub exfiltration_images_blocked: u64,
234    pub exfiltration_tool_urls_flagged: u64,
235    pub exfiltration_memory_guards: u64,
236    pub pii_scrub_count: u64,
237    pub memory_validation_failures: u64,
238    pub rate_limit_trips: u64,
239    pub pre_execution_blocks: u64,
240    pub pre_execution_warnings: u64,
241    /// `true` when a guardrail filter is active for this session.
242    #[cfg(feature = "guardrail")]
243    pub guardrail_enabled: bool,
244    /// `true` when guardrail is in warn-only mode (action = warn).
245    #[cfg(feature = "guardrail")]
246    pub guardrail_warn_mode: bool,
247    pub sub_agents: Vec<SubAgentMetrics>,
248    pub skill_confidence: Vec<SkillConfidence>,
249    /// Scheduled task summaries: `[name, kind, mode, next_run]`.
250    pub scheduled_tasks: Vec<[String; 4]>,
251    /// Thompson Sampling distribution snapshots: `(provider, alpha, beta)`.
252    pub router_thompson_stats: Vec<(String, f64, f64)>,
253    /// Ring buffer of recent security events (cap 100, FIFO eviction).
254    pub security_events: VecDeque<SecurityEvent>,
255    pub orchestration: OrchestrationMetrics,
256    /// Live snapshot of the currently active task graph. `None` when no plan is active.
257    pub orchestration_graph: Option<TaskGraphSnapshot>,
258    pub graph_community_detection_failures: u64,
259    pub graph_entities_total: u64,
260    pub graph_edges_total: u64,
261    pub graph_communities_total: u64,
262    pub graph_extraction_count: u64,
263    pub graph_extraction_failures: u64,
264    /// `true` when `config.llm.cloud.enable_extended_context = true`.
265    /// Never set for other providers to avoid false positives.
266    pub extended_context: bool,
267    /// Latest compression-guidelines version (0 = no guidelines yet).
268    pub guidelines_version: u32,
269    /// ISO 8601 timestamp of the latest guidelines update (empty if none).
270    pub guidelines_updated_at: String,
271    pub tool_cache_hits: u64,
272    pub tool_cache_misses: u64,
273    pub tool_cache_entries: usize,
274    /// Number of semantic-tier facts in memory (0 when tier promotion disabled).
275    pub semantic_fact_count: u64,
276    /// STT model name (e.g. "whisper-1"). `None` when STT is not configured.
277    pub stt_model: Option<String>,
278    /// Model used for context compaction/summarization. `None` when no summary provider is set.
279    pub compaction_model: Option<String>,
280    /// Temperature of the active provider when using Candle. `None` for API providers.
281    pub provider_temperature: Option<f32>,
282    /// Top-p of the active provider when using Candle. `None` for API providers.
283    pub provider_top_p: Option<f32>,
284    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
285    pub embedding_model: String,
286    /// Token budget for context window. `None` when not configured.
287    pub token_budget: Option<u64>,
288    /// Token threshold that triggers soft compaction. `None` when not configured.
289    pub compaction_threshold: Option<u32>,
290    /// Vault backend identifier: "age", "env", or "none".
291    pub vault_backend: String,
292    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
293    pub active_channel: String,
294    /// Whether self-learning (skill evolution) is enabled.
295    pub self_learning_enabled: bool,
296    /// Whether the semantic response cache is enabled.
297    pub semantic_cache_enabled: bool,
298    /// Whether semantic response caching is enabled (alias for `semantic_cache_enabled`).
299    pub cache_enabled: bool,
300    /// Whether assistant messages are auto-saved to memory.
301    pub autosave_enabled: bool,
302}
303
304/// Strip ASCII control characters and ANSI escape sequences from a string for safe TUI display.
305///
306/// Allows tab, LF, and CR; removes everything else in the `0x00–0x1F` range including full
307/// ANSI CSI sequences (`ESC[...`). This prevents escape-sequence injection from LLM planner
308/// output into the TUI.
309fn strip_ctrl(s: &str) -> String {
310    let mut out = String::with_capacity(s.len());
311    let mut chars = s.chars().peekable();
312    while let Some(c) = chars.next() {
313        if c == '\x1b' {
314            // Consume an ANSI CSI sequence: ESC [ <params> <final-byte in 0x40–0x7E>
315            if chars.peek() == Some(&'[') {
316                chars.next(); // consume '['
317                for inner in chars.by_ref() {
318                    if ('\x40'..='\x7e').contains(&inner) {
319                        break;
320                    }
321                }
322            }
323            // Drop ESC and any consumed sequence — write nothing.
324        } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
325            // drop other control chars
326        } else {
327            out.push(c);
328        }
329    }
330    out
331}
332
333/// Convert a live `TaskGraph` into a lightweight snapshot for TUI display.
334impl From<&crate::orchestration::TaskGraph> for TaskGraphSnapshot {
335    fn from(graph: &crate::orchestration::TaskGraph) -> Self {
336        let tasks = graph
337            .tasks
338            .iter()
339            .map(|t| {
340                let error = t
341                    .result
342                    .as_ref()
343                    .filter(|_| t.status == crate::orchestration::TaskStatus::Failed)
344                    .and_then(|r| {
345                        if r.output.is_empty() {
346                            None
347                        } else {
348                            // Strip control chars, then truncate at 80 chars (SEC-P6-01).
349                            let s = strip_ctrl(&r.output);
350                            if s.len() > 80 {
351                                let end = s.floor_char_boundary(79);
352                                Some(format!("{}…", &s[..end]))
353                            } else {
354                                Some(s)
355                            }
356                        }
357                    });
358                let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
359                TaskSnapshotRow {
360                    id: t.id.as_u32(),
361                    title: strip_ctrl(&t.title),
362                    status: t.status.to_string(),
363                    agent: t.assigned_agent.as_deref().map(strip_ctrl),
364                    duration_ms,
365                    error,
366                }
367            })
368            .collect();
369        Self {
370            graph_id: graph.id.to_string(),
371            goal: strip_ctrl(&graph.goal),
372            status: graph.status.to_string(),
373            tasks,
374            completed_at: None,
375        }
376    }
377}
378
379pub struct MetricsCollector {
380    tx: watch::Sender<MetricsSnapshot>,
381}
382
383impl MetricsCollector {
384    #[must_use]
385    pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
386        let (tx, rx) = watch::channel(MetricsSnapshot::default());
387        (Self { tx }, rx)
388    }
389
390    pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
391        self.tx.send_modify(f);
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    #![allow(clippy::field_reassign_with_default)]
398
399    use super::*;
400
401    #[test]
402    fn default_metrics_snapshot() {
403        let m = MetricsSnapshot::default();
404        assert_eq!(m.total_tokens, 0);
405        assert_eq!(m.api_calls, 0);
406        assert!(m.active_skills.is_empty());
407        assert!(m.active_mcp_tools.is_empty());
408        assert_eq!(m.mcp_tool_count, 0);
409        assert_eq!(m.mcp_server_count, 0);
410        assert!(m.provider_name.is_empty());
411        assert_eq!(m.summaries_count, 0);
412        // Phase 2 fields
413        assert!(m.stt_model.is_none());
414        assert!(m.compaction_model.is_none());
415        assert!(m.provider_temperature.is_none());
416        assert!(m.provider_top_p.is_none());
417        assert!(m.active_channel.is_empty());
418        assert!(m.embedding_model.is_empty());
419        assert!(m.token_budget.is_none());
420        assert!(!m.self_learning_enabled);
421        assert!(!m.semantic_cache_enabled);
422    }
423
424    #[test]
425    fn metrics_collector_update_phase2_fields() {
426        let (collector, rx) = MetricsCollector::new();
427        collector.update(|m| {
428            m.stt_model = Some("whisper-1".into());
429            m.compaction_model = Some("haiku".into());
430            m.provider_temperature = Some(0.7);
431            m.provider_top_p = Some(0.95);
432            m.active_channel = "tui".into();
433            m.embedding_model = "nomic-embed-text".into();
434            m.token_budget = Some(200_000);
435            m.self_learning_enabled = true;
436            m.semantic_cache_enabled = true;
437        });
438        let s = rx.borrow();
439        assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
440        assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
441        assert_eq!(s.provider_temperature, Some(0.7));
442        assert_eq!(s.provider_top_p, Some(0.95));
443        assert_eq!(s.active_channel, "tui");
444        assert_eq!(s.embedding_model, "nomic-embed-text");
445        assert_eq!(s.token_budget, Some(200_000));
446        assert!(s.self_learning_enabled);
447        assert!(s.semantic_cache_enabled);
448    }
449
450    #[test]
451    fn metrics_collector_update() {
452        let (collector, rx) = MetricsCollector::new();
453        collector.update(|m| {
454            m.api_calls = 5;
455            m.total_tokens = 1000;
456        });
457        let snapshot = rx.borrow().clone();
458        assert_eq!(snapshot.api_calls, 5);
459        assert_eq!(snapshot.total_tokens, 1000);
460    }
461
462    #[test]
463    fn metrics_collector_multiple_updates() {
464        let (collector, rx) = MetricsCollector::new();
465        collector.update(|m| m.api_calls = 1);
466        collector.update(|m| m.api_calls += 1);
467        assert_eq!(rx.borrow().api_calls, 2);
468    }
469
470    #[test]
471    fn metrics_snapshot_clone() {
472        let mut m = MetricsSnapshot::default();
473        m.provider_name = "ollama".into();
474        let cloned = m.clone();
475        assert_eq!(cloned.provider_name, "ollama");
476    }
477
478    #[test]
479    fn filter_metrics_tracking() {
480        let (collector, rx) = MetricsCollector::new();
481        collector.update(|m| {
482            m.filter_raw_tokens += 250;
483            m.filter_saved_tokens += 200;
484            m.filter_applications += 1;
485        });
486        collector.update(|m| {
487            m.filter_raw_tokens += 100;
488            m.filter_saved_tokens += 80;
489            m.filter_applications += 1;
490        });
491        let s = rx.borrow();
492        assert_eq!(s.filter_raw_tokens, 350);
493        assert_eq!(s.filter_saved_tokens, 280);
494        assert_eq!(s.filter_applications, 2);
495    }
496
497    #[test]
498    fn filter_confidence_and_command_metrics() {
499        let (collector, rx) = MetricsCollector::new();
500        collector.update(|m| {
501            m.filter_total_commands += 1;
502            m.filter_filtered_commands += 1;
503            m.filter_confidence_full += 1;
504        });
505        collector.update(|m| {
506            m.filter_total_commands += 1;
507            m.filter_confidence_partial += 1;
508        });
509        let s = rx.borrow();
510        assert_eq!(s.filter_total_commands, 2);
511        assert_eq!(s.filter_filtered_commands, 1);
512        assert_eq!(s.filter_confidence_full, 1);
513        assert_eq!(s.filter_confidence_partial, 1);
514        assert_eq!(s.filter_confidence_fallback, 0);
515    }
516
517    #[test]
518    fn summaries_count_tracks_summarizations() {
519        let (collector, rx) = MetricsCollector::new();
520        collector.update(|m| m.summaries_count += 1);
521        collector.update(|m| m.summaries_count += 1);
522        assert_eq!(rx.borrow().summaries_count, 2);
523    }
524
525    #[test]
526    fn cancellations_counter_increments() {
527        let (collector, rx) = MetricsCollector::new();
528        assert_eq!(rx.borrow().cancellations, 0);
529        collector.update(|m| m.cancellations += 1);
530        collector.update(|m| m.cancellations += 1);
531        assert_eq!(rx.borrow().cancellations, 2);
532    }
533
534    #[test]
535    fn security_event_detail_exact_128_not_truncated() {
536        let s = "a".repeat(128);
537        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
538        assert_eq!(ev.detail, s, "128-char string must not be truncated");
539    }
540
541    #[test]
542    fn security_event_detail_129_is_truncated() {
543        let s = "a".repeat(129);
544        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
545        assert!(
546            ev.detail.ends_with('…'),
547            "129-char string must end with ellipsis"
548        );
549        assert!(
550            ev.detail.len() <= 130,
551            "truncated detail must be at most 130 bytes"
552        );
553    }
554
555    #[test]
556    fn security_event_detail_multibyte_utf8_no_panic() {
557        // Each '中' is 3 bytes. 43 chars = 129 bytes — triggers truncation at a multi-byte boundary.
558        let s = "中".repeat(43);
559        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
560        assert!(ev.detail.ends_with('…'));
561    }
562
563    #[test]
564    fn security_event_source_capped_at_64_chars() {
565        let long_source = "x".repeat(200);
566        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
567        assert_eq!(ev.source.len(), 64);
568    }
569
570    #[test]
571    fn security_event_source_strips_control_chars() {
572        let source = "tool\x00name\x1b[31m";
573        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
574        assert!(!ev.source.contains('\x00'));
575        assert!(!ev.source.contains('\x1b'));
576    }
577
578    #[test]
579    fn security_event_category_as_str() {
580        assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
581        assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
582        assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
583        assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
584    }
585
586    #[test]
587    fn ring_buffer_respects_cap_via_update() {
588        let (collector, rx) = MetricsCollector::new();
589        for i in 0..110u64 {
590            let event = SecurityEvent::new(
591                SecurityEventCategory::InjectionFlag,
592                "src",
593                format!("event {i}"),
594            );
595            collector.update(|m| {
596                if m.security_events.len() >= SECURITY_EVENT_CAP {
597                    m.security_events.pop_front();
598                }
599                m.security_events.push_back(event);
600            });
601        }
602        let snap = rx.borrow();
603        assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
604        // FIFO: earliest events evicted, last one present
605        assert!(snap.security_events.back().unwrap().detail.contains("109"));
606    }
607
608    #[test]
609    fn security_events_empty_by_default() {
610        let m = MetricsSnapshot::default();
611        assert!(m.security_events.is_empty());
612    }
613
614    #[test]
615    fn orchestration_metrics_default_zero() {
616        let m = OrchestrationMetrics::default();
617        assert_eq!(m.plans_total, 0);
618        assert_eq!(m.tasks_total, 0);
619        assert_eq!(m.tasks_completed, 0);
620        assert_eq!(m.tasks_failed, 0);
621        assert_eq!(m.tasks_skipped, 0);
622    }
623
624    #[test]
625    fn metrics_snapshot_includes_orchestration_default_zero() {
626        let m = MetricsSnapshot::default();
627        assert_eq!(m.orchestration.plans_total, 0);
628        assert_eq!(m.orchestration.tasks_total, 0);
629        assert_eq!(m.orchestration.tasks_completed, 0);
630    }
631
632    #[test]
633    fn orchestration_metrics_update_via_collector() {
634        let (collector, rx) = MetricsCollector::new();
635        collector.update(|m| {
636            m.orchestration.plans_total += 1;
637            m.orchestration.tasks_total += 5;
638            m.orchestration.tasks_completed += 3;
639            m.orchestration.tasks_failed += 1;
640            m.orchestration.tasks_skipped += 1;
641        });
642        let s = rx.borrow();
643        assert_eq!(s.orchestration.plans_total, 1);
644        assert_eq!(s.orchestration.tasks_total, 5);
645        assert_eq!(s.orchestration.tasks_completed, 3);
646        assert_eq!(s.orchestration.tasks_failed, 1);
647        assert_eq!(s.orchestration.tasks_skipped, 1);
648    }
649
650    #[test]
651    fn strip_ctrl_removes_escape_sequences() {
652        let input = "hello\x1b[31mworld\x00end";
653        let result = strip_ctrl(input);
654        assert_eq!(result, "helloworldend");
655    }
656
657    #[test]
658    fn strip_ctrl_allows_tab_lf_cr() {
659        let input = "a\tb\nc\rd";
660        let result = strip_ctrl(input);
661        assert_eq!(result, "a\tb\nc\rd");
662    }
663
664    #[test]
665    fn task_graph_snapshot_is_stale_after_30s() {
666        let mut snap = TaskGraphSnapshot::default();
667        // Not stale if no completed_at.
668        assert!(!snap.is_stale());
669        // Not stale if just completed.
670        snap.completed_at = Some(std::time::Instant::now());
671        assert!(!snap.is_stale());
672        // Stale if completed more than 30s ago.
673        snap.completed_at = Some(
674            std::time::Instant::now()
675                .checked_sub(std::time::Duration::from_secs(31))
676                .unwrap(),
677        );
678        assert!(snap.is_stale());
679    }
680
681    // T1: From<&TaskGraph> correctly maps fields including duration_ms and error truncation.
682    #[test]
683    fn task_graph_snapshot_from_task_graph_maps_fields() {
684        use crate::orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
685
686        let mut graph = TaskGraph::new("My goal");
687        let mut task = TaskNode::new(0, "Do work", "description");
688        task.status = TaskStatus::Failed;
689        task.assigned_agent = Some("agent-1".into());
690        task.result = Some(TaskResult {
691            output: "error occurred here".into(),
692            artifacts: vec![],
693            duration_ms: 1234,
694            agent_id: None,
695            agent_def: None,
696        });
697        graph.tasks.push(task);
698        graph.status = GraphStatus::Failed;
699
700        let snap = TaskGraphSnapshot::from(&graph);
701        assert_eq!(snap.goal, "My goal");
702        assert_eq!(snap.status, "failed");
703        assert_eq!(snap.tasks.len(), 1);
704        let row = &snap.tasks[0];
705        assert_eq!(row.title, "Do work");
706        assert_eq!(row.status, "failed");
707        assert_eq!(row.agent.as_deref(), Some("agent-1"));
708        assert_eq!(row.duration_ms, 1234);
709        assert!(row.error.as_deref().unwrap().contains("error occurred"));
710    }
711
712    // T2: From impl compiles with orchestration feature active.
713    #[test]
714    fn task_graph_snapshot_from_compiles_with_feature() {
715        use crate::orchestration::TaskGraph;
716        let graph = TaskGraph::new("feature flag test");
717        let snap = TaskGraphSnapshot::from(&graph);
718        assert_eq!(snap.goal, "feature flag test");
719        assert!(snap.tasks.is_empty());
720        assert!(!snap.is_stale());
721    }
722
723    // T1-extra: long error is truncated with ellipsis.
724    #[test]
725    fn task_graph_snapshot_error_truncated_at_80_chars() {
726        use crate::orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
727
728        let mut graph = TaskGraph::new("goal");
729        let mut task = TaskNode::new(0, "t", "d");
730        task.status = TaskStatus::Failed;
731        task.result = Some(TaskResult {
732            output: "e".repeat(100),
733            artifacts: vec![],
734            duration_ms: 0,
735            agent_id: None,
736            agent_def: None,
737        });
738        graph.tasks.push(task);
739
740        let snap = TaskGraphSnapshot::from(&graph);
741        let err = snap.tasks[0].error.as_ref().unwrap();
742        assert!(err.ends_with('…'), "truncated error must end with ellipsis");
743        assert!(
744            err.len() <= 83,
745            "truncated error must not exceed 80 chars + ellipsis"
746        );
747    }
748
749    // SEC-P6-01: control chars in task title are stripped.
750    #[test]
751    fn task_graph_snapshot_strips_control_chars_from_title() {
752        use crate::orchestration::{TaskGraph, TaskNode};
753
754        let mut graph = TaskGraph::new("goal\x1b[31m");
755        let task = TaskNode::new(0, "title\x00injected", "d");
756        graph.tasks.push(task);
757
758        let snap = TaskGraphSnapshot::from(&graph);
759        assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
760        assert!(
761            !snap.tasks[0].title.contains('\x00'),
762            "title must not contain null byte"
763        );
764    }
765
766    #[test]
767    fn graph_metrics_default_zero() {
768        let m = MetricsSnapshot::default();
769        assert_eq!(m.graph_entities_total, 0);
770        assert_eq!(m.graph_edges_total, 0);
771        assert_eq!(m.graph_communities_total, 0);
772        assert_eq!(m.graph_extraction_count, 0);
773        assert_eq!(m.graph_extraction_failures, 0);
774    }
775
776    #[test]
777    fn graph_metrics_update_via_collector() {
778        let (collector, rx) = MetricsCollector::new();
779        collector.update(|m| {
780            m.graph_entities_total = 5;
781            m.graph_edges_total = 10;
782            m.graph_communities_total = 2;
783            m.graph_extraction_count = 7;
784            m.graph_extraction_failures = 1;
785        });
786        let snapshot = rx.borrow().clone();
787        assert_eq!(snapshot.graph_entities_total, 5);
788        assert_eq!(snapshot.graph_edges_total, 10);
789        assert_eq!(snapshot.graph_communities_total, 2);
790        assert_eq!(snapshot.graph_extraction_count, 7);
791        assert_eq!(snapshot.graph_extraction_failures, 1);
792    }
793}