mockforge_core/incidents/
semantic_manager.rs

1//! Semantic drift incident manager
2//!
3//! This module provides management for semantic drift incidents,
4//! which are separate from structural drift incidents but can be
5//! cross-linked for unified contract health tracking.
6
7use crate::ai_contract_diff::semantic_analyzer::{SemanticChangeType, SemanticDriftResult};
8use crate::incidents::types::{IncidentSeverity, IncidentStatus};
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use uuid::Uuid;
13
14/// Semantic drift incident
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SemanticIncident {
17    /// Unique identifier
18    pub id: String,
19    /// Workspace ID
20    pub workspace_id: Option<String>,
21    /// Endpoint path
22    pub endpoint: String,
23    /// HTTP method
24    pub method: String,
25    /// Semantic change type
26    pub semantic_change_type: SemanticChangeType,
27    /// Severity
28    pub severity: IncidentSeverity,
29    /// Status
30    pub status: IncidentStatus,
31    /// Semantic confidence score (0.0-1.0)
32    pub semantic_confidence: f64,
33    /// Soft-breaking score (0.0-1.0)
34    pub soft_breaking_score: f64,
35    /// Full LLM analysis
36    pub llm_analysis: serde_json::Value,
37    /// Before semantic state
38    pub before_semantic_state: serde_json::Value,
39    /// After semantic state
40    pub after_semantic_state: serde_json::Value,
41    /// Additional details
42    pub details: serde_json::Value,
43    /// Link to related structural drift incident
44    pub related_drift_incident_id: Option<String>,
45    /// Contract diff ID that triggered this
46    pub contract_diff_id: Option<String>,
47    /// External ticket tracking
48    pub external_ticket_id: Option<String>,
49    /// External ticket URL (e.g., Jira, GitHub issue)
50    pub external_ticket_url: Option<String>,
51    /// Timestamps
52    pub detected_at: i64,
53    /// Creation timestamp
54    pub created_at: i64,
55    /// Acknowledgment timestamp
56    pub acknowledged_at: Option<i64>,
57    /// Resolution timestamp
58    pub resolved_at: Option<i64>,
59    /// Closure timestamp
60    pub closed_at: Option<i64>,
61    /// Last update timestamp
62    pub updated_at: i64,
63}
64
65impl SemanticIncident {
66    /// Create a new semantic incident from a semantic drift result
67    pub fn from_drift_result(
68        result: &SemanticDriftResult,
69        endpoint: String,
70        method: String,
71        workspace_id: Option<String>,
72        related_drift_incident_id: Option<String>,
73        contract_diff_id: Option<String>,
74    ) -> Self {
75        let id = Uuid::new_v4().to_string();
76        let now = Utc::now().timestamp();
77
78        // Determine severity based on soft-breaking score and confidence
79        let severity = if result.soft_breaking_score >= 0.8 && result.semantic_confidence >= 0.8 {
80            IncidentSeverity::Critical
81        } else if result.soft_breaking_score >= 0.65 || result.semantic_confidence >= 0.75 {
82            IncidentSeverity::High
83        } else if result.soft_breaking_score >= 0.5 || result.semantic_confidence >= 0.65 {
84            IncidentSeverity::Medium
85        } else {
86            IncidentSeverity::Low
87        };
88
89        let details = serde_json::json!({
90            "change_type": result.change_type,
91            "semantic_confidence": result.semantic_confidence,
92            "soft_breaking_score": result.soft_breaking_score,
93            "mismatch_count": result.semantic_mismatches.len(),
94        });
95
96        Self {
97            id,
98            workspace_id,
99            endpoint,
100            method,
101            semantic_change_type: result.change_type.clone(),
102            severity,
103            status: IncidentStatus::Open,
104            semantic_confidence: result.semantic_confidence,
105            soft_breaking_score: result.soft_breaking_score,
106            llm_analysis: result.llm_analysis.clone(),
107            before_semantic_state: result.before_semantic_state.clone(),
108            after_semantic_state: result.after_semantic_state.clone(),
109            details,
110            related_drift_incident_id,
111            contract_diff_id,
112            external_ticket_id: None,
113            external_ticket_url: None,
114            detected_at: now,
115            created_at: now,
116            acknowledged_at: None,
117            resolved_at: None,
118            closed_at: None,
119            updated_at: now,
120        }
121    }
122
123    /// Mark the incident as acknowledged
124    pub fn acknowledge(&mut self) {
125        if self.status == IncidentStatus::Open {
126            self.status = IncidentStatus::Acknowledged;
127            self.acknowledged_at = Some(Utc::now().timestamp());
128            self.updated_at = Utc::now().timestamp();
129        }
130    }
131
132    /// Mark the incident as resolved
133    pub fn resolve(&mut self) {
134        if self.status != IncidentStatus::Closed {
135            self.status = IncidentStatus::Resolved;
136            self.resolved_at = Some(Utc::now().timestamp());
137            self.updated_at = Utc::now().timestamp();
138        }
139    }
140
141    /// Mark the incident as closed
142    pub fn close(&mut self) {
143        self.status = IncidentStatus::Closed;
144        self.closed_at = Some(Utc::now().timestamp());
145        self.updated_at = Utc::now().timestamp();
146    }
147}
148
149/// Semantic incident manager
150pub struct SemanticIncidentManager {
151    /// In-memory store of incidents
152    incidents: std::sync::Arc<tokio::sync::RwLock<HashMap<String, SemanticIncident>>>,
153    /// Webhook configurations for notifications
154    webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
155}
156
157impl SemanticIncidentManager {
158    /// Create a new semantic incident manager
159    pub fn new() -> Self {
160        Self {
161            incidents: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
162            webhook_configs: Vec::new(),
163        }
164    }
165
166    /// Create a new semantic incident manager with webhooks
167    pub fn new_with_webhooks(
168        webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
169    ) -> Self {
170        Self {
171            incidents: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
172            webhook_configs,
173        }
174    }
175
176    /// Add webhook configuration
177    pub fn add_webhook(&mut self, config: crate::incidents::integrations::WebhookConfig) {
178        self.webhook_configs.push(config);
179    }
180
181    /// Create a semantic incident from a drift result
182    pub async fn create_incident(
183        &self,
184        result: &SemanticDriftResult,
185        endpoint: String,
186        method: String,
187        workspace_id: Option<String>,
188        related_drift_incident_id: Option<String>,
189        contract_diff_id: Option<String>,
190    ) -> SemanticIncident {
191        let incident = SemanticIncident::from_drift_result(
192            result,
193            endpoint,
194            method,
195            workspace_id,
196            related_drift_incident_id,
197            contract_diff_id,
198        );
199
200        let id = incident.id.clone();
201        let mut incidents = self.incidents.write().await;
202        incidents.insert(id, incident.clone());
203
204        // Trigger webhook notifications
205        self.trigger_webhooks("semantic.incident.created", &incident).await;
206
207        incident
208    }
209
210    /// Trigger webhook notifications for an event
211    async fn trigger_webhooks(&self, event_type: &str, incident: &SemanticIncident) {
212        use crate::incidents::integrations::send_webhook;
213        use serde_json::json;
214
215        for webhook in &self.webhook_configs {
216            if !webhook.enabled {
217                continue;
218            }
219
220            if !webhook.events.is_empty() && !webhook.events.contains(&event_type.to_string()) {
221                continue;
222            }
223
224            let payload = json!({
225                "event": event_type,
226                "incident": {
227                    "id": incident.id,
228                    "endpoint": incident.endpoint,
229                    "method": incident.method,
230                    "semantic_change_type": format!("{:?}", incident.semantic_change_type),
231                    "severity": format!("{:?}", incident.severity),
232                    "status": format!("{:?}", incident.status),
233                    "semantic_confidence": incident.semantic_confidence,
234                    "soft_breaking_score": incident.soft_breaking_score,
235                    "details": incident.details,
236                    "created_at": incident.created_at,
237                }
238            });
239
240            let webhook_clone = webhook.clone();
241            tokio::spawn(async move {
242                if let Err(e) = send_webhook(&webhook_clone, &payload).await {
243                    tracing::warn!("Failed to send webhook: {}", e);
244                }
245            });
246        }
247    }
248
249    /// Get an incident by ID
250    pub async fn get_incident(&self, id: &str) -> Option<SemanticIncident> {
251        let incidents = self.incidents.read().await;
252        incidents.get(id).cloned()
253    }
254
255    /// Update an incident
256    pub async fn update_incident(&self, incident: SemanticIncident) {
257        let mut incidents = self.incidents.write().await;
258        incidents.insert(incident.id.clone(), incident);
259    }
260
261    /// List incidents with optional filters
262    pub async fn list_incidents(
263        &self,
264        workspace_id: Option<&str>,
265        endpoint: Option<&str>,
266        method: Option<&str>,
267        status: Option<IncidentStatus>,
268        limit: Option<usize>,
269    ) -> Vec<SemanticIncident> {
270        let incidents = self.incidents.read().await;
271        let mut filtered: Vec<_> = incidents
272            .values()
273            .filter(|inc| {
274                if let Some(ws_id) = workspace_id {
275                    if inc.workspace_id.as_deref() != Some(ws_id) {
276                        return false;
277                    }
278                }
279                if let Some(ep) = endpoint {
280                    if inc.endpoint != ep {
281                        return false;
282                    }
283                }
284                if let Some(m) = method {
285                    if inc.method != m {
286                        return false;
287                    }
288                }
289                if let Some(s) = status {
290                    if inc.status != s {
291                        return false;
292                    }
293                }
294                true
295            })
296            .cloned()
297            .collect();
298
299        // Sort by detected_at descending
300        filtered.sort_by_key(|inc| std::cmp::Reverse(inc.detected_at));
301
302        if let Some(limit) = limit {
303            filtered.truncate(limit);
304        }
305
306        filtered
307    }
308
309    /// Acknowledge an incident
310    pub async fn acknowledge_incident(&self, id: &str) -> Option<SemanticIncident> {
311        let mut incidents = self.incidents.write().await;
312        if let Some(incident) = incidents.get_mut(id) {
313            incident.acknowledge();
314            Some(incident.clone())
315        } else {
316            None
317        }
318    }
319
320    /// Resolve an incident
321    pub async fn resolve_incident(&self, id: &str) -> Option<SemanticIncident> {
322        let mut incidents = self.incidents.write().await;
323        if let Some(incident) = incidents.get_mut(id) {
324            incident.resolve();
325            Some(incident.clone())
326        } else {
327            None
328        }
329    }
330}
331
332impl Default for SemanticIncidentManager {
333    fn default() -> Self {
334        Self::new()
335    }
336}