Skip to main content

victauri_plugin/
introspection.rs

1//! Backend introspection and chaos engineering types.
2//!
3//! These types support Victauri's intervention capabilities — features that exploit
4//! the plugin's position inside the Rust process to provide insights and control
5//! that browser-external tools like CDP cannot access.
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::RwLock;
9use std::sync::atomic::AtomicBool;
10use std::time::{Duration, Instant};
11
12use serde::Serialize;
13
14/// Per-command timing statistics aggregated from IPC invocations.
15#[derive(Debug, Clone, Serialize)]
16pub struct CommandTimingStats {
17    /// Command name.
18    pub command: String,
19    /// Number of invocations recorded.
20    pub count: u64,
21    /// Minimum execution time in milliseconds.
22    pub min_ms: f64,
23    /// Maximum execution time in milliseconds.
24    pub max_ms: f64,
25    /// Mean execution time in milliseconds.
26    pub avg_ms: f64,
27    /// 95th percentile execution time in milliseconds.
28    pub p95_ms: f64,
29    /// Total execution time across all invocations.
30    pub total_ms: f64,
31}
32
33/// Accumulated raw timing samples for a single command.
34#[derive(Debug, Default)]
35pub struct TimingSamples {
36    /// Duration of each invocation, in order.
37    pub samples: Vec<Duration>,
38}
39
40impl TimingSamples {
41    /// Add a timing sample.
42    pub fn record(&mut self, duration: Duration) {
43        self.samples.push(duration);
44    }
45
46    /// Compute aggregate statistics.
47    #[must_use]
48    pub fn stats(&self, command: &str) -> CommandTimingStats {
49        if self.samples.is_empty() {
50            return CommandTimingStats {
51                command: command.to_string(),
52                count: 0,
53                min_ms: 0.0,
54                max_ms: 0.0,
55                avg_ms: 0.0,
56                p95_ms: 0.0,
57                total_ms: 0.0,
58            };
59        }
60        let mut sorted: Vec<f64> = self
61            .samples
62            .iter()
63            .map(|d| d.as_secs_f64() * 1000.0)
64            .collect();
65        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
66
67        let count = sorted.len() as u64;
68        let total: f64 = sorted.iter().sum();
69        let min = sorted[0];
70        let max = sorted[sorted.len() - 1];
71        let avg = total / sorted.len() as f64;
72        let p95_idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
73        let p95 = sorted[p95_idx.min(sorted.len() - 1)];
74
75        CommandTimingStats {
76            command: command.to_string(),
77            count,
78            min_ms: (min * 100.0).round() / 100.0,
79            max_ms: (max * 100.0).round() / 100.0,
80            avg_ms: (avg * 100.0).round() / 100.0,
81            p95_ms: (p95 * 100.0).round() / 100.0,
82            total_ms: (total * 100.0).round() / 100.0,
83        }
84    }
85}
86
87/// Thread-safe store for per-command timing data.
88pub struct CommandTimings {
89    inner: RwLock<HashMap<String, TimingSamples>>,
90}
91
92impl CommandTimings {
93    /// Create a new empty store.
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            inner: RwLock::new(HashMap::new()),
98        }
99    }
100
101    /// Record a timing sample for a command.
102    pub fn record(&self, command: &str, duration: Duration) {
103        let mut map = self
104            .inner
105            .write()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        map.entry(command.to_string()).or_default().record(duration);
108    }
109
110    /// Get stats for all commands, sorted by total time descending.
111    #[must_use]
112    pub fn all_stats(&self) -> Vec<CommandTimingStats> {
113        let map = self
114            .inner
115            .read()
116            .unwrap_or_else(std::sync::PoisonError::into_inner);
117        let mut stats: Vec<CommandTimingStats> =
118            map.iter().map(|(name, s)| s.stats(name)).collect();
119        stats.sort_by(|a, b| {
120            b.total_ms
121                .partial_cmp(&a.total_ms)
122                .unwrap_or(std::cmp::Ordering::Equal)
123        });
124        stats
125    }
126
127    /// Get stats for a single command.
128    #[must_use]
129    pub fn stats_for(&self, command: &str) -> Option<CommandTimingStats> {
130        let map = self
131            .inner
132            .read()
133            .unwrap_or_else(std::sync::PoisonError::into_inner);
134        map.get(command).map(|s| s.stats(command))
135    }
136
137    /// Clear all timing data.
138    pub fn clear(&self) {
139        let mut map = self
140            .inner
141            .write()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        map.clear();
144    }
145}
146
147impl Default for CommandTimings {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153// ── Fault Injection ─────────────────────────────────────────────────────────
154
155/// The type of fault to inject into a command.
156#[derive(Debug, Clone, Serialize)]
157pub enum FaultType {
158    /// Add artificial latency before command execution.
159    Delay {
160        /// Delay in milliseconds.
161        delay_ms: u64,
162    },
163    /// Return an error without executing the command.
164    Error {
165        /// Error message to return.
166        message: String,
167    },
168    /// Drop the response entirely (return empty/timeout-like response).
169    Drop,
170    /// Execute normally but corrupt the response (randomize field values).
171    Corrupt,
172}
173
174/// Configuration for a single fault injection rule.
175#[derive(Debug, Clone, Serialize)]
176pub struct FaultConfig {
177    /// Target command name.
178    pub command: String,
179    /// Type of fault to inject.
180    pub fault_type: FaultType,
181    /// Number of times this fault has been triggered.
182    pub trigger_count: u64,
183    /// Maximum number of times to trigger (0 = unlimited).
184    pub max_triggers: u64,
185    /// When this fault was created.
186    #[serde(skip)]
187    pub created_at: Instant,
188}
189
190/// Faults auto-expire this long after creation, so a forgotten fault cannot
191/// silently sabotage or mask a later test run (audit #34). Re-inject to refresh.
192pub const FAULT_TTL: Duration = Duration::from_secs(900); // 15 minutes
193
194impl FaultConfig {
195    /// Whether this fault should still trigger, evaluated at `now`. A fault is
196    /// inert once it is older than [`FAULT_TTL`] or has hit `max_triggers`.
197    #[must_use]
198    pub fn should_trigger_at(&self, now: Instant) -> bool {
199        if now.saturating_duration_since(self.created_at) >= FAULT_TTL {
200            return false;
201        }
202        self.max_triggers == 0 || self.trigger_count < self.max_triggers
203    }
204
205    /// Check if this fault should still trigger right now.
206    #[must_use]
207    pub fn should_trigger(&self) -> bool {
208        self.should_trigger_at(Instant::now())
209    }
210}
211
212/// Thread-safe registry of active fault injection rules.
213pub struct FaultRegistry {
214    inner: RwLock<HashMap<String, FaultConfig>>,
215}
216
217impl FaultRegistry {
218    /// Create an empty registry.
219    #[must_use]
220    pub fn new() -> Self {
221        Self {
222            inner: RwLock::new(HashMap::new()),
223        }
224    }
225
226    /// Register a fault for a command.
227    pub fn inject(&self, config: FaultConfig) {
228        let mut map = self
229            .inner
230            .write()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        map.insert(config.command.clone(), config);
233    }
234
235    /// Look up and optionally trigger a fault for a command.
236    /// Returns the fault type if one is active and should trigger.
237    pub fn check_and_trigger(&self, command: &str) -> Option<FaultType> {
238        let mut map = self
239            .inner
240            .write()
241            .unwrap_or_else(std::sync::PoisonError::into_inner);
242        if let Some(config) = map.get_mut(command)
243            && config.should_trigger()
244        {
245            config.trigger_count += 1;
246            return Some(config.fault_type.clone());
247        }
248        None
249    }
250
251    /// List all active fault rules.
252    #[must_use]
253    pub fn list(&self) -> Vec<FaultConfig> {
254        let map = self
255            .inner
256            .read()
257            .unwrap_or_else(std::sync::PoisonError::into_inner);
258        map.values().cloned().collect()
259    }
260
261    /// Remove a fault rule for a command.
262    pub fn clear(&self, command: &str) -> bool {
263        let mut map = self
264            .inner
265            .write()
266            .unwrap_or_else(std::sync::PoisonError::into_inner);
267        map.remove(command).is_some()
268    }
269
270    /// Remove all fault rules.
271    pub fn clear_all(&self) -> usize {
272        let mut map = self
273            .inner
274            .write()
275            .unwrap_or_else(std::sync::PoisonError::into_inner);
276        let count = map.len();
277        map.clear();
278        count
279    }
280}
281
282impl Default for FaultRegistry {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288// ── IPC Contract Testing ────────────────────────────────────────────────────
289
290/// Describes the shape of a JSON value for contract comparison.
291#[derive(Debug, Clone, Serialize, PartialEq)]
292pub enum JsonShape {
293    /// null
294    Null,
295    /// boolean
296    Bool,
297    /// number (integer or float)
298    Number,
299    /// string
300    String,
301    /// array with element shape (from first element, or Null if empty)
302    Array(Box<Self>),
303    /// object with field names and their shapes
304    Object(HashMap<String, Self>),
305}
306
307impl JsonShape {
308    /// Extract the shape of a JSON value.
309    #[must_use]
310    pub fn from_value(value: &serde_json::Value) -> Self {
311        match value {
312            serde_json::Value::Null => Self::Null,
313            serde_json::Value::Bool(_) => Self::Bool,
314            serde_json::Value::Number(_) => Self::Number,
315            serde_json::Value::String(_) => Self::String,
316            serde_json::Value::Array(arr) => {
317                let elem = arr.first().map_or(Self::Null, Self::from_value);
318                Self::Array(Box::new(elem))
319            }
320            serde_json::Value::Object(obj) => {
321                let fields: HashMap<String, Self> = obj
322                    .iter()
323                    .map(|(k, v)| (k.clone(), Self::from_value(v)))
324                    .collect();
325                Self::Object(fields)
326            }
327        }
328    }
329
330    /// Human-readable type name.
331    #[must_use]
332    pub fn type_name(&self) -> &'static str {
333        match self {
334            Self::Null => "null",
335            Self::Bool => "bool",
336            Self::Number => "number",
337            Self::String => "string",
338            Self::Array(_) => "array",
339            Self::Object(_) => "object",
340        }
341    }
342}
343
344/// A recorded contract baseline for a command's response.
345#[derive(Debug, Clone, Serialize)]
346pub struct ContractBaseline {
347    /// Command name.
348    pub command: String,
349    /// Arguments used when recording.
350    pub args: serde_json::Value,
351    /// Shape of the response.
352    pub shape: JsonShape,
353    /// Raw sample response (first 4KB).
354    pub sample: String,
355    /// When this baseline was recorded.
356    pub recorded_at: String,
357}
358
359/// Differences found when checking a contract against baseline.
360#[derive(Debug, Clone, Serialize)]
361pub struct ContractDrift {
362    /// Command name.
363    pub command: String,
364    /// Fields present in current but not in baseline.
365    pub new_fields: Vec<String>,
366    /// Fields present in baseline but not in current.
367    pub removed_fields: Vec<String>,
368    /// Fields whose type changed.
369    pub type_changes: Vec<TypeChange>,
370    /// Whether the overall shape matches.
371    pub shape_matches: bool,
372}
373
374/// A single field type change between baseline and current.
375#[derive(Debug, Clone, Serialize)]
376pub struct TypeChange {
377    /// Dot-separated field path.
378    pub path: String,
379    /// Type in the baseline.
380    pub baseline_type: String,
381    /// Type in the current response.
382    pub current_type: String,
383}
384
385/// Compare two JSON shapes and report differences.
386#[must_use]
387pub fn diff_shapes(baseline: &JsonShape, current: &JsonShape, prefix: &str) -> ContractDrift {
388    let mut new_fields = Vec::new();
389    let mut removed_fields = Vec::new();
390    let mut type_changes = Vec::new();
391
392    diff_shapes_inner(
393        baseline,
394        current,
395        prefix,
396        &mut new_fields,
397        &mut removed_fields,
398        &mut type_changes,
399    );
400
401    let shape_matches =
402        new_fields.is_empty() && removed_fields.is_empty() && type_changes.is_empty();
403    ContractDrift {
404        command: prefix.to_string(),
405        new_fields,
406        removed_fields,
407        type_changes,
408        shape_matches,
409    }
410}
411
412fn diff_shapes_inner(
413    baseline: &JsonShape,
414    current: &JsonShape,
415    prefix: &str,
416    new_fields: &mut Vec<String>,
417    removed_fields: &mut Vec<String>,
418    type_changes: &mut Vec<TypeChange>,
419) {
420    match (baseline, current) {
421        (JsonShape::Object(b_fields), JsonShape::Object(c_fields)) => {
422            for (key, b_shape) in b_fields {
423                let path = if prefix.is_empty() {
424                    key.clone()
425                } else {
426                    format!("{prefix}.{key}")
427                };
428                if let Some(c_shape) = c_fields.get(key) {
429                    diff_shapes_inner(
430                        b_shape,
431                        c_shape,
432                        &path,
433                        new_fields,
434                        removed_fields,
435                        type_changes,
436                    );
437                } else {
438                    removed_fields.push(path);
439                }
440            }
441            for key in c_fields.keys() {
442                if !b_fields.contains_key(key) {
443                    let path = if prefix.is_empty() {
444                        key.clone()
445                    } else {
446                        format!("{prefix}.{key}")
447                    };
448                    new_fields.push(path);
449                }
450            }
451        }
452        (JsonShape::Array(b_elem), JsonShape::Array(c_elem)) => {
453            let path = format!("{prefix}[]");
454            diff_shapes_inner(
455                b_elem,
456                c_elem,
457                &path,
458                new_fields,
459                removed_fields,
460                type_changes,
461            );
462        }
463        (b, c) if b.type_name() != c.type_name() => {
464            type_changes.push(TypeChange {
465                path: prefix.to_string(),
466                baseline_type: b.type_name().to_string(),
467                current_type: c.type_name().to_string(),
468            });
469        }
470        _ => {}
471    }
472}
473
474/// Thread-safe store for IPC contract baselines.
475pub struct ContractStore {
476    inner: RwLock<HashMap<String, ContractBaseline>>,
477}
478
479impl ContractStore {
480    /// Create an empty store.
481    #[must_use]
482    pub fn new() -> Self {
483        Self {
484            inner: RwLock::new(HashMap::new()),
485        }
486    }
487
488    /// Record a baseline for a command.
489    pub fn record(&self, baseline: ContractBaseline) {
490        let mut map = self
491            .inner
492            .write()
493            .unwrap_or_else(std::sync::PoisonError::into_inner);
494        map.insert(baseline.command.clone(), baseline);
495    }
496
497    /// Get the baseline for a command.
498    #[must_use]
499    pub fn get(&self, command: &str) -> Option<ContractBaseline> {
500        let map = self
501            .inner
502            .read()
503            .unwrap_or_else(std::sync::PoisonError::into_inner);
504        map.get(command).cloned()
505    }
506
507    /// Get all baselines.
508    #[must_use]
509    pub fn all(&self) -> Vec<ContractBaseline> {
510        let map = self
511            .inner
512            .read()
513            .unwrap_or_else(std::sync::PoisonError::into_inner);
514        map.values().cloned().collect()
515    }
516
517    /// Clear all baselines.
518    pub fn clear(&self) -> usize {
519        let mut map = self
520            .inner
521            .write()
522            .unwrap_or_else(std::sync::PoisonError::into_inner);
523        let count = map.len();
524        map.clear();
525        count
526    }
527}
528
529impl Default for ContractStore {
530    fn default() -> Self {
531        Self::new()
532    }
533}
534
535// ── Startup Profiling ───────────────────────────────────────────────────────
536
537/// A single phase in the startup timeline.
538#[derive(Debug, Clone, Serialize)]
539pub struct StartupPhase {
540    /// Phase name.
541    pub name: String,
542    /// Duration of this phase in milliseconds.
543    pub duration_ms: f64,
544    /// Cumulative time from plugin init start.
545    pub cumulative_ms: f64,
546}
547
548/// Records timestamps at key phases during plugin initialization.
549pub struct StartupTimeline {
550    start: Instant,
551    phases: RwLock<Vec<(String, Instant)>>,
552}
553
554impl StartupTimeline {
555    /// Begin recording from now.
556    #[must_use]
557    pub fn new() -> Self {
558        Self {
559            start: Instant::now(),
560            phases: RwLock::new(Vec::new()),
561        }
562    }
563
564    /// Mark a phase as completed.
565    pub fn mark(&self, name: &str) {
566        let mut phases = self
567            .phases
568            .write()
569            .unwrap_or_else(std::sync::PoisonError::into_inner);
570        phases.push((name.to_string(), Instant::now()));
571    }
572
573    /// Get the timeline as a list of phases with durations.
574    #[must_use]
575    pub fn report(&self) -> Vec<StartupPhase> {
576        let phases = self
577            .phases
578            .read()
579            .unwrap_or_else(std::sync::PoisonError::into_inner);
580        let mut result = Vec::new();
581        let mut prev = self.start;
582
583        for (name, instant) in phases.iter() {
584            let duration = instant.duration_since(prev);
585            let cumulative = instant.duration_since(self.start);
586            result.push(StartupPhase {
587                name: name.clone(),
588                duration_ms: (duration.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
589                cumulative_ms: (cumulative.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
590            });
591            prev = *instant;
592        }
593        result
594    }
595
596    /// Total time from start to last recorded phase.
597    #[must_use]
598    pub fn total_ms(&self) -> f64 {
599        let phases = self
600            .phases
601            .read()
602            .unwrap_or_else(std::sync::PoisonError::into_inner);
603        if let Some((_, last)) = phases.last() {
604            (last.duration_since(self.start).as_secs_f64() * 1000.0 * 100.0).round() / 100.0
605        } else {
606            0.0
607        }
608    }
609}
610
611impl Default for StartupTimeline {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617// ── Tauri Event Bus Monitor ─────────────────────────────────────────────
618
619/// A Tauri event captured from the application's native event bus.
620#[derive(Debug, Clone, Serialize)]
621pub struct CapturedTauriEvent {
622    /// Event name (e.g. "notification-added", `tauri://focus`).
623    pub name: String,
624    /// Serialized event payload.
625    pub payload: String,
626    /// ISO 8601 timestamp.
627    pub timestamp: String,
628}
629
630const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000;
631
632/// Thread-safe ring buffer for captured Tauri events.
633#[derive(Clone)]
634pub struct EventBusMonitor {
635    inner: std::sync::Arc<RwLock<VecDeque<CapturedTauriEvent>>>,
636    capacity: usize,
637}
638
639impl EventBusMonitor {
640    /// Create a new monitor with the given capacity.
641    #[must_use]
642    pub fn new(capacity: usize) -> Self {
643        Self {
644            inner: std::sync::Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
645            capacity,
646        }
647    }
648
649    /// Record a captured event.
650    pub fn push(&self, event: CapturedTauriEvent) {
651        let mut buf = self
652            .inner
653            .write()
654            .unwrap_or_else(std::sync::PoisonError::into_inner);
655        if buf.len() >= self.capacity {
656            buf.pop_front();
657        }
658        buf.push_back(event);
659    }
660
661    /// Get all captured events.
662    #[must_use]
663    pub fn events(&self) -> Vec<CapturedTauriEvent> {
664        self.inner
665            .read()
666            .unwrap_or_else(std::sync::PoisonError::into_inner)
667            .iter()
668            .cloned()
669            .collect()
670    }
671
672    /// Get the number of captured events.
673    #[must_use]
674    pub fn len(&self) -> usize {
675        self.inner
676            .read()
677            .unwrap_or_else(std::sync::PoisonError::into_inner)
678            .len()
679    }
680
681    /// Returns true if no events have been captured.
682    #[must_use]
683    pub fn is_empty(&self) -> bool {
684        self.len() == 0
685    }
686
687    /// Clear all captured events, returning how many were removed.
688    pub fn clear(&self) -> usize {
689        let mut buf = self
690            .inner
691            .write()
692            .unwrap_or_else(std::sync::PoisonError::into_inner);
693        let count = buf.len();
694        buf.clear();
695        count
696    }
697}
698
699impl Default for EventBusMonitor {
700    fn default() -> Self {
701        Self::new(DEFAULT_EVENT_BUS_CAPACITY)
702    }
703}
704
705// ── Application State Probes ─────────────────────────────────────────────
706
707/// A named closure that returns a snapshot of application-specific backend state
708/// as JSON. Registered via [`VictauriBuilder::probe`](crate::VictauriBuilder::probe).
709pub type ProbeFn = dyn Fn() -> serde_json::Value + Send + Sync + 'static;
710
711/// Registry of application-defined state probes surfaced through the `app_state`
712/// MCP tool.
713///
714/// Probes give an agent first-class, discoverable access to domain state that
715/// would otherwise require `query_db` + log-grepping (e.g. a scoring pipeline's
716/// version, queue depth, or cache stats). Because a probe runs in the Rust
717/// process with direct access to whatever state the app captured into it, it
718/// reads backend state with **no IPC round-trip and no frontend involvement** —
719/// the kind of introspection a browser-external tool like CDP cannot do.
720#[derive(Clone, Default)]
721pub struct AppStateProbes {
722    inner: std::sync::Arc<RwLock<std::collections::BTreeMap<String, std::sync::Arc<ProbeFn>>>>,
723}
724
725impl AppStateProbes {
726    /// Register (or replace) a probe under `name`.
727    pub fn register(&self, name: impl Into<String>, probe: std::sync::Arc<ProbeFn>) {
728        self.inner
729            .write()
730            .unwrap_or_else(std::sync::PoisonError::into_inner)
731            .insert(name.into(), probe);
732    }
733
734    /// Sorted list of registered probe names.
735    #[must_use]
736    pub fn names(&self) -> Vec<String> {
737        self.inner
738            .read()
739            .unwrap_or_else(std::sync::PoisonError::into_inner)
740            .keys()
741            .cloned()
742            .collect()
743    }
744
745    /// Run the named probe and return its JSON snapshot, or `None` if no probe is
746    /// registered under that name.
747    #[must_use]
748    pub fn run(&self, name: &str) -> Option<serde_json::Value> {
749        let probe = self
750            .inner
751            .read()
752            .unwrap_or_else(std::sync::PoisonError::into_inner)
753            .get(name)
754            .cloned();
755        probe.map(|p| p())
756    }
757
758    /// Number of registered probes.
759    #[must_use]
760    pub fn len(&self) -> usize {
761        self.inner
762            .read()
763            .unwrap_or_else(std::sync::PoisonError::into_inner)
764            .len()
765    }
766
767    /// Returns true if no probes are registered.
768    #[must_use]
769    pub fn is_empty(&self) -> bool {
770        self.len() == 0
771    }
772}
773
774// ── Internal Task Tracker ──────────────────────────────────────────────
775
776/// Info about a tracked async task spawned by Victauri.
777#[derive(Debug, Clone, Serialize)]
778pub struct TrackedTaskInfo {
779    /// Human-readable task name.
780    pub name: String,
781    /// ISO 8601 timestamp when the task was spawned.
782    pub spawned_at: String,
783    /// Whether the task has finished (completed or errored).
784    pub is_finished: bool,
785    /// How long the task has been running in seconds.
786    pub uptime_secs: u64,
787}
788
789struct TrackedTaskEntry {
790    name: String,
791    spawned_at: Instant,
792    spawned_at_wall: String,
793    finished: std::sync::Arc<AtomicBool>,
794}
795
796/// Tracks Victauri's own spawned async tasks for observability.
797pub struct TaskTracker {
798    tasks: RwLock<Vec<TrackedTaskEntry>>,
799}
800
801impl TaskTracker {
802    /// Create a new empty tracker.
803    #[must_use]
804    pub fn new() -> Self {
805        Self {
806            tasks: RwLock::new(Vec::new()),
807        }
808    }
809
810    /// Register a new task. Returns a flag that the task should set to `true` when it finishes.
811    pub fn track(&self, name: &str) -> std::sync::Arc<AtomicBool> {
812        let finished = std::sync::Arc::new(AtomicBool::new(false));
813        let entry = TrackedTaskEntry {
814            name: name.to_string(),
815            spawned_at: Instant::now(),
816            spawned_at_wall: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
817            finished: finished.clone(),
818        };
819        self.tasks
820            .write()
821            .unwrap_or_else(std::sync::PoisonError::into_inner)
822            .push(entry);
823        finished
824    }
825
826    /// List all tracked tasks with their current status.
827    #[must_use]
828    pub fn list(&self) -> Vec<TrackedTaskInfo> {
829        let tasks = self
830            .tasks
831            .read()
832            .unwrap_or_else(std::sync::PoisonError::into_inner);
833        tasks
834            .iter()
835            .map(|t| TrackedTaskInfo {
836                name: t.name.clone(),
837                spawned_at: t.spawned_at_wall.clone(),
838                is_finished: t.finished.load(std::sync::atomic::Ordering::Relaxed),
839                uptime_secs: t.spawned_at.elapsed().as_secs(),
840            })
841            .collect()
842    }
843
844    /// Count of active (non-finished) tasks.
845    #[must_use]
846    pub fn active_count(&self) -> usize {
847        let tasks = self
848            .tasks
849            .read()
850            .unwrap_or_else(std::sync::PoisonError::into_inner);
851        tasks
852            .iter()
853            .filter(|t| !t.finished.load(std::sync::atomic::Ordering::Relaxed))
854            .count()
855    }
856}
857
858impl Default for TaskTracker {
859    fn default() -> Self {
860        Self::new()
861    }
862}
863
864// ── Child Process Enumeration ──────────────────────────────────────────
865
866/// Information about a child process of the Tauri application.
867#[derive(Debug, Clone, Serialize)]
868pub struct ChildProcessInfo {
869    /// Process ID.
870    pub pid: u32,
871    /// Parent process ID.
872    pub ppid: u32,
873    /// Executable name (not full path).
874    pub name: String,
875    /// Memory usage in bytes (working set / RSS), if available.
876    pub memory_bytes: Option<u64>,
877}
878
879/// Enumerate child processes of the current process.
880///
881/// Uses platform-native APIs:
882/// - Windows: `CreateToolhelp32Snapshot` + `Process32First/Next`
883/// - Linux: `/proc/` filesystem
884/// - macOS: `proc_listpids` + `proc_pidinfo`
885#[must_use]
886pub fn enumerate_child_processes() -> Vec<ChildProcessInfo> {
887    let my_pid = std::process::id();
888
889    #[cfg(windows)]
890    {
891        enumerate_children_windows(my_pid)
892    }
893
894    #[cfg(target_os = "linux")]
895    {
896        enumerate_children_linux(my_pid)
897    }
898
899    #[cfg(target_os = "macos")]
900    {
901        enumerate_children_macos(my_pid)
902    }
903
904    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
905    {
906        let _ = my_pid;
907        Vec::new()
908    }
909}
910
911#[cfg(windows)]
912#[allow(unsafe_code)]
913fn enumerate_children_windows(parent_pid: u32) -> Vec<ChildProcessInfo> {
914    use windows::Win32::Foundation::CloseHandle;
915    use windows::Win32::System::Diagnostics::ToolHelp::{
916        CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
917    };
918
919    let mut children = Vec::new();
920
921    // SAFETY: `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` creates a
922    // read-only snapshot of all running processes. The returned handle is
923    // closed via `CloseHandle` when we're done. `Process32First/Next` iterate
924    // the snapshot entries.
925    unsafe {
926        let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
927            return children;
928        };
929
930        let mut entry: PROCESSENTRY32 = std::mem::zeroed();
931        entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
932
933        if Process32First(snapshot, &mut entry).is_ok() {
934            loop {
935                if entry.th32ParentProcessID == parent_pid && entry.th32ProcessID != parent_pid {
936                    let name_bytes: Vec<u8> = entry
937                        .szExeFile
938                        .iter()
939                        .take_while(|&&b| b != 0)
940                        .map(|&b| b as u8)
941                        .collect();
942                    let name = String::from_utf8_lossy(&name_bytes).to_string();
943
944                    let memory_bytes = get_process_memory_windows(entry.th32ProcessID);
945
946                    children.push(ChildProcessInfo {
947                        pid: entry.th32ProcessID,
948                        ppid: entry.th32ParentProcessID,
949                        name,
950                        memory_bytes,
951                    });
952                }
953
954                if Process32Next(snapshot, &mut entry).is_err() {
955                    break;
956                }
957            }
958        }
959
960        let _ = CloseHandle(snapshot);
961    }
962
963    children
964}
965
966#[cfg(windows)]
967#[allow(unsafe_code)]
968fn get_process_memory_windows(pid: u32) -> Option<u64> {
969    use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
970    use windows::Win32::System::Threading::{
971        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
972    };
973
974    // SAFETY: `OpenProcess` with `PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ`
975    // opens a limited handle for reading memory stats. The process handle is closed
976    // automatically when dropped (windows crate handles this).
977    unsafe {
978        let process = OpenProcess(
979            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
980            false,
981            pid,
982        )
983        .ok()?;
984
985        let mut counters: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
986        counters.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
987
988        if GetProcessMemoryInfo(process, &mut counters, counters.cb).is_ok() {
989            Some(counters.WorkingSetSize as u64)
990        } else {
991            None
992        }
993    }
994}
995
996#[cfg(target_os = "linux")]
997fn enumerate_children_linux(parent_pid: u32) -> Vec<ChildProcessInfo> {
998    let mut children = Vec::new();
999    let Ok(entries) = std::fs::read_dir("/proc") else {
1000        return children;
1001    };
1002
1003    for entry in entries.flatten() {
1004        let file_name = entry.file_name();
1005        let Some(pid_str) = file_name.to_str() else {
1006            continue;
1007        };
1008        let Ok(pid) = pid_str.parse::<u32>() else {
1009            continue;
1010        };
1011
1012        let status_path = format!("/proc/{pid}/status");
1013        let Ok(status) = std::fs::read_to_string(&status_path) else {
1014            continue;
1015        };
1016
1017        let mut ppid: Option<u32> = None;
1018        let mut name = String::new();
1019        let mut vm_rss_kb: u64 = 0;
1020
1021        for line in status.lines() {
1022            if let Some(v) = line.strip_prefix("PPid:\t") {
1023                ppid = v.trim().parse().ok();
1024            } else if let Some(v) = line.strip_prefix("Name:\t") {
1025                name = v.trim().to_string();
1026            } else if let Some(v) = line.strip_prefix("VmRSS:") {
1027                vm_rss_kb = v
1028                    .split_whitespace()
1029                    .next()
1030                    .and_then(|n| n.parse().ok())
1031                    .unwrap_or(0);
1032            }
1033        }
1034
1035        if ppid == Some(parent_pid) {
1036            children.push(ChildProcessInfo {
1037                pid,
1038                ppid: parent_pid,
1039                name,
1040                memory_bytes: if vm_rss_kb > 0 {
1041                    Some(vm_rss_kb * 1024)
1042                } else {
1043                    None
1044                },
1045            });
1046        }
1047    }
1048
1049    children
1050}
1051
1052#[cfg(target_os = "macos")]
1053#[allow(unsafe_code)]
1054fn enumerate_children_macos(parent_pid: u32) -> Vec<ChildProcessInfo> {
1055    use std::mem;
1056
1057    unsafe extern "C" {
1058        fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32;
1059        fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut u8, buffersize: i32) -> i32;
1060        fn proc_name(pid: i32, buffer: *mut u8, buffersize: u32) -> i32;
1061    }
1062
1063    const PROC_PIDTASKINFO: i32 = 4;
1064
1065    #[repr(C)]
1066    struct ProcTaskInfo {
1067        pti_virtual_size: u64,
1068        pti_resident_size: u64,
1069        pti_total_user: u64,
1070        pti_total_system: u64,
1071        pti_threads_user: u64,
1072        pti_threads_system: u64,
1073        pti_policy: i32,
1074        pti_faults: i32,
1075        pti_pageins: i32,
1076        pti_cow_faults: i32,
1077        pti_messages_sent: i32,
1078        pti_messages_received: i32,
1079        pti_syscalls_mach: i32,
1080        pti_syscalls_unix: i32,
1081        pti_csw: i32,
1082        pti_threadnum: i32,
1083        pti_numrunning: i32,
1084        pti_priority: i32,
1085    }
1086
1087    let mut children = Vec::new();
1088
1089    // SAFETY: `proc_listchildpids` populates a buffer of child PIDs for the given
1090    // parent PID. We first call with a zero buffer to get the count, then allocate
1091    // and call again. `proc_name` and `proc_pidinfo` read metadata for a given PID.
1092    unsafe {
1093        let ppid = parent_pid as i32;
1094        // `proc_listchildpids` returns the COUNT of child PIDs written, not bytes —
1095        // verified empirically on macOS 26 / arm64 (1 child → returns 1, pids[0] is
1096        // the child). The old code divided the return by `size_of::<i32>()`, so a
1097        // single child (1/4 = 0) always enumerated as zero. Allocate a generous
1098        // buffer and call directly; if the returned count meets our capacity the list
1099        // may be truncated, so grow and retry.
1100        let mut cap = 256usize;
1101        let (pids, n) = loop {
1102            let mut pids = vec![0i32; cap];
1103            let buf_size = (cap * mem::size_of::<i32>()) as i32;
1104            let actual = proc_listchildpids(ppid, pids.as_mut_ptr(), buf_size);
1105            if actual <= 0 {
1106                return children;
1107            }
1108            let count = actual as usize;
1109            // `count` is clamped to `cap` for the slice below (stays in bounds even if
1110            // the syscall ever reports a total larger than the buffer it filled).
1111            if count < cap || cap >= 65536 {
1112                break (pids, count.min(cap));
1113            }
1114            cap = (count + 16).max(cap * 2);
1115        };
1116        for &pid in &pids[..n] {
1117            if pid <= 0 {
1118                continue;
1119            }
1120
1121            let mut name_buf = [0u8; 256];
1122            let name_len = proc_name(pid, name_buf.as_mut_ptr(), 256);
1123            let name = if name_len > 0 {
1124                String::from_utf8_lossy(&name_buf[..name_len as usize]).to_string()
1125            } else {
1126                String::from("<unknown>")
1127            };
1128
1129            let mut task_info: ProcTaskInfo = mem::zeroed();
1130            let info_size = mem::size_of::<ProcTaskInfo>() as i32;
1131            let ret = proc_pidinfo(
1132                pid,
1133                PROC_PIDTASKINFO,
1134                0,
1135                &mut task_info as *mut _ as *mut u8,
1136                info_size,
1137            );
1138
1139            let memory_bytes = if ret == info_size {
1140                Some(task_info.pti_resident_size)
1141            } else {
1142                None
1143            };
1144
1145            children.push(ChildProcessInfo {
1146                pid: pid as u32,
1147                ppid: parent_pid,
1148                name,
1149                memory_bytes,
1150            });
1151        }
1152    }
1153
1154    children
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    #[test]
1162    fn app_state_probes_register_run_list() {
1163        let probes = AppStateProbes::default();
1164        assert!(probes.is_empty());
1165
1166        probes.register(
1167            "scoring",
1168            std::sync::Arc::new(|| serde_json::json!({ "pipeline_version": 5 })),
1169        );
1170        probes.register("queue", std::sync::Arc::new(|| serde_json::json!(0)));
1171
1172        // names() is sorted (BTreeMap).
1173        assert_eq!(
1174            probes.names(),
1175            vec!["queue".to_string(), "scoring".to_string()]
1176        );
1177        assert_eq!(probes.len(), 2);
1178
1179        let snapshot = probes.run("scoring").expect("probe runs");
1180        assert_eq!(snapshot["pipeline_version"], 5);
1181        assert!(probes.run("missing").is_none());
1182    }
1183
1184    #[test]
1185    fn app_state_probe_reflects_live_state() {
1186        // A probe closes over shared state and reflects mutations at call time —
1187        // proving it reads live backend state, not a registration-time snapshot.
1188        let counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
1189        let probe_counter = std::sync::Arc::clone(&counter);
1190        let probes = AppStateProbes::default();
1191        probes.register(
1192            "counter",
1193            std::sync::Arc::new(move || {
1194                serde_json::json!(probe_counter.load(std::sync::atomic::Ordering::SeqCst))
1195            }),
1196        );
1197
1198        assert_eq!(probes.run("counter").unwrap(), serde_json::json!(0));
1199        counter.store(42, std::sync::atomic::Ordering::SeqCst);
1200        assert_eq!(probes.run("counter").unwrap(), serde_json::json!(42));
1201    }
1202
1203    #[test]
1204    fn event_bus_push_and_read() {
1205        let bus = EventBusMonitor::new(3);
1206        assert!(bus.is_empty());
1207        bus.push(CapturedTauriEvent {
1208            name: "test".to_string(),
1209            payload: "{}".to_string(),
1210            timestamp: "2026-01-01T00:00:00Z".to_string(),
1211        });
1212        assert_eq!(bus.len(), 1);
1213        assert_eq!(bus.events()[0].name, "test");
1214    }
1215
1216    #[test]
1217    fn event_bus_ring_buffer_eviction() {
1218        let bus = EventBusMonitor::new(2);
1219        for i in 0..5 {
1220            bus.push(CapturedTauriEvent {
1221                name: format!("event_{i}"),
1222                payload: String::new(),
1223                timestamp: String::new(),
1224            });
1225        }
1226        assert_eq!(bus.len(), 2);
1227        assert_eq!(bus.events()[0].name, "event_3");
1228        assert_eq!(bus.events()[1].name, "event_4");
1229    }
1230
1231    #[test]
1232    fn event_bus_clear() {
1233        let bus = EventBusMonitor::new(10);
1234        bus.push(CapturedTauriEvent {
1235            name: "a".to_string(),
1236            payload: String::new(),
1237            timestamp: String::new(),
1238        });
1239        assert_eq!(bus.clear(), 1);
1240        assert!(bus.is_empty());
1241    }
1242
1243    #[test]
1244    fn task_tracker_lifecycle() {
1245        let tracker = TaskTracker::new();
1246        let flag = tracker.track("mcp_server");
1247        let tasks = tracker.list();
1248        assert_eq!(tasks.len(), 1);
1249        assert_eq!(tasks[0].name, "mcp_server");
1250        assert!(!tasks[0].is_finished);
1251        assert_eq!(tracker.active_count(), 1);
1252
1253        flag.store(true, std::sync::atomic::Ordering::Relaxed);
1254        let tasks = tracker.list();
1255        assert!(tasks[0].is_finished);
1256        assert_eq!(tracker.active_count(), 0);
1257    }
1258
1259    #[test]
1260    fn timing_samples_basic() {
1261        let mut samples = TimingSamples::default();
1262        samples.record(Duration::from_millis(10));
1263        samples.record(Duration::from_millis(20));
1264        samples.record(Duration::from_millis(30));
1265        let stats = samples.stats("test_cmd");
1266        assert_eq!(stats.count, 3);
1267        assert!((stats.min_ms - 10.0).abs() < 1.0);
1268        assert!((stats.max_ms - 30.0).abs() < 1.0);
1269        assert!((stats.avg_ms - 20.0).abs() < 1.0);
1270    }
1271
1272    #[test]
1273    fn timing_samples_empty() {
1274        let samples = TimingSamples::default();
1275        let stats = samples.stats("empty");
1276        assert_eq!(stats.count, 0);
1277        assert_eq!(stats.min_ms, 0.0);
1278    }
1279
1280    #[test]
1281    fn command_timings_thread_safe() {
1282        let timings = CommandTimings::new();
1283        timings.record("cmd_a", Duration::from_millis(5));
1284        timings.record("cmd_a", Duration::from_millis(15));
1285        timings.record("cmd_b", Duration::from_millis(100));
1286
1287        let all = timings.all_stats();
1288        assert_eq!(all.len(), 2);
1289        assert_eq!(all[0].command, "cmd_b");
1290
1291        let a = timings.stats_for("cmd_a").unwrap();
1292        assert_eq!(a.count, 2);
1293    }
1294
1295    #[test]
1296    fn fault_registry_lifecycle() {
1297        let registry = FaultRegistry::new();
1298        registry.inject(FaultConfig {
1299            command: "slow_cmd".to_string(),
1300            fault_type: FaultType::Delay { delay_ms: 500 },
1301            trigger_count: 0,
1302            max_triggers: 2,
1303            created_at: Instant::now(),
1304        });
1305
1306        assert!(registry.check_and_trigger("slow_cmd").is_some());
1307        assert!(registry.check_and_trigger("slow_cmd").is_some());
1308        assert!(registry.check_and_trigger("slow_cmd").is_none());
1309
1310        assert_eq!(registry.list().len(), 1);
1311        assert!(registry.clear("slow_cmd"));
1312        assert_eq!(registry.list().len(), 0);
1313    }
1314
1315    #[test]
1316    fn fault_registry_unlimited() {
1317        let registry = FaultRegistry::new();
1318        registry.inject(FaultConfig {
1319            command: "always_fail".to_string(),
1320            fault_type: FaultType::Error {
1321                message: "injected".to_string(),
1322            },
1323            trigger_count: 0,
1324            max_triggers: 0,
1325            created_at: Instant::now(),
1326        });
1327
1328        for _ in 0..100 {
1329            assert!(registry.check_and_trigger("always_fail").is_some());
1330        }
1331    }
1332
1333    #[test]
1334    fn fault_expires_after_ttl() {
1335        let cfg = FaultConfig {
1336            command: "x".to_string(),
1337            fault_type: FaultType::Error {
1338                message: "e".to_string(),
1339            },
1340            trigger_count: 0,
1341            max_triggers: 0, // unlimited by count...
1342            created_at: Instant::now(),
1343        };
1344        // ...but still inert once older than the TTL (audit #34).
1345        assert!(cfg.should_trigger_at(cfg.created_at));
1346        assert!(!cfg.should_trigger_at(cfg.created_at + FAULT_TTL + Duration::from_secs(1)));
1347    }
1348
1349    #[test]
1350    fn json_shape_extraction() {
1351        let value = serde_json::json!({
1352            "name": "test",
1353            "count": 42,
1354            "active": true,
1355            "items": [{"id": 1}],
1356            "meta": null
1357        });
1358        let shape = JsonShape::from_value(&value);
1359        match &shape {
1360            JsonShape::Object(fields) => {
1361                assert_eq!(fields.len(), 5);
1362                assert_eq!(*fields.get("name").unwrap(), JsonShape::String);
1363                assert_eq!(*fields.get("count").unwrap(), JsonShape::Number);
1364                assert_eq!(*fields.get("active").unwrap(), JsonShape::Bool);
1365                assert_eq!(*fields.get("meta").unwrap(), JsonShape::Null);
1366            }
1367            _ => panic!("expected object"),
1368        }
1369    }
1370
1371    #[test]
1372    fn contract_diff_detects_changes() {
1373        let baseline = serde_json::json!({"name": "old", "count": 1});
1374        let current = serde_json::json!({"name": "new", "count": "not_a_number", "extra": true});
1375
1376        let b_shape = JsonShape::from_value(&baseline);
1377        let c_shape = JsonShape::from_value(&current);
1378        let drift = diff_shapes(&b_shape, &c_shape, "test_cmd");
1379
1380        assert!(!drift.shape_matches);
1381        assert_eq!(drift.new_fields, vec!["test_cmd.extra"]);
1382        assert_eq!(drift.type_changes.len(), 1);
1383        assert_eq!(drift.type_changes[0].path, "test_cmd.count");
1384    }
1385
1386    #[test]
1387    fn contract_store_crud() {
1388        let store = ContractStore::new();
1389        let baseline = ContractBaseline {
1390            command: "get_user".to_string(),
1391            args: serde_json::json!({}),
1392            shape: JsonShape::Object(HashMap::new()),
1393            sample: "{}".to_string(),
1394            recorded_at: "2026-05-26".to_string(),
1395        };
1396        store.record(baseline);
1397        assert!(store.get("get_user").is_some());
1398        assert_eq!(store.all().len(), 1);
1399        assert_eq!(store.clear(), 1);
1400        assert!(store.get("get_user").is_none());
1401    }
1402
1403    #[test]
1404    fn startup_timeline_records_phases() {
1405        let timeline = StartupTimeline::new();
1406        std::thread::sleep(Duration::from_millis(5));
1407        timeline.mark("phase_1");
1408        std::thread::sleep(Duration::from_millis(5));
1409        timeline.mark("phase_2");
1410
1411        let report = timeline.report();
1412        assert_eq!(report.len(), 2);
1413        assert_eq!(report[0].name, "phase_1");
1414        assert!(report[1].cumulative_ms >= report[0].cumulative_ms);
1415        assert!(timeline.total_ms() > 0.0);
1416    }
1417
1418    #[test]
1419    fn enumerate_child_processes_returns_vec() {
1420        let children = enumerate_child_processes();
1421        // The test process itself may or may not have children, but the
1422        // function must not panic and must return a well-formed Vec.
1423        for child in &children {
1424            assert_ne!(child.pid, 0, "child PID should be non-zero");
1425            assert_eq!(
1426                child.ppid,
1427                std::process::id(),
1428                "parent PID should match current process"
1429            );
1430            assert!(!child.name.is_empty(), "child name should not be empty");
1431        }
1432    }
1433
1434    #[test]
1435    fn enumerate_child_processes_with_spawned_child() {
1436        // Spawn a short-lived child process and verify we can enumerate it.
1437        let child = std::process::Command::new(if cfg!(windows) { "cmd.exe" } else { "sleep" })
1438            .args(if cfg!(windows) {
1439                &["/c", "timeout /t 10 /nobreak >nul"][..]
1440            } else {
1441                &["10"][..]
1442            })
1443            .spawn();
1444
1445        if let Ok(mut child_proc) = child {
1446            let children = enumerate_child_processes();
1447            assert!(
1448                !children.is_empty(),
1449                "should find at least one child process"
1450            );
1451
1452            let found = children.iter().any(|c| c.pid == child_proc.id());
1453            assert!(
1454                found,
1455                "spawned child (PID {}) should appear in enumeration",
1456                child_proc.id()
1457            );
1458
1459            let _ = child_proc.kill();
1460            let _ = child_proc.wait();
1461        }
1462    }
1463
1464    #[test]
1465    fn child_process_info_serializes() {
1466        let info = ChildProcessInfo {
1467            pid: 1234,
1468            ppid: 5678,
1469            name: "test-sidecar".to_string(),
1470            memory_bytes: Some(1_048_576),
1471        };
1472        let json = serde_json::to_value(&info).unwrap();
1473        assert_eq!(json["pid"], 1234);
1474        assert_eq!(json["ppid"], 5678);
1475        assert_eq!(json["name"], "test-sidecar");
1476        assert_eq!(json["memory_bytes"], 1_048_576);
1477    }
1478
1479    #[test]
1480    fn child_process_info_serializes_no_memory() {
1481        let info = ChildProcessInfo {
1482            pid: 42,
1483            ppid: 1,
1484            name: "zombie".to_string(),
1485            memory_bytes: None,
1486        };
1487        let json = serde_json::to_value(&info).unwrap();
1488        assert!(json["memory_bytes"].is_null());
1489    }
1490}