mockforge_intelligence/handlers/
contract_health.rs1use super::structural_incidents::StructuralIncidents;
10use crate::incidents::SemanticIncidentManager;
11use axum::{
12 extract::{Query, State},
13 http::StatusCode,
14 response::Json,
15};
16use serde::{Deserialize, Serialize};
17
18#[cfg(feature = "database")]
19mod persistence;
20
21pub trait ContractHealthStateProvider: Clone + Send + Sync + 'static {
23 type Incidents: StructuralIncidents;
25 fn incident_manager(&self) -> &Self::Incidents;
27 fn semantic_manager(&self) -> &SemanticIncidentManager;
29 #[cfg(feature = "database")]
32 fn database(&self) -> Option<&crate::database::Database> {
33 None
34 }
35}
36
37#[derive(Debug, Deserialize)]
39pub struct TimelineQuery {
40 pub workspace_id: Option<String>,
42 pub endpoint: Option<String>,
44 pub method: Option<String>,
46 pub start_date: Option<String>,
48 pub end_date: Option<String>,
50 pub event_type: Option<String>,
52 pub limit: Option<usize>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type")]
59pub enum TimelineEvent {
60 #[serde(rename = "structural_drift")]
62 StructuralDrift {
63 id: String,
65 endpoint: String,
67 method: String,
69 incident_type: String,
71 severity: String,
73 status: String,
75 detected_at: i64,
77 details: serde_json::Value,
79 },
80 #[serde(rename = "semantic_drift")]
82 SemanticDrift {
83 id: String,
85 endpoint: String,
87 method: String,
89 change_type: String,
91 severity: String,
93 status: String,
95 semantic_confidence: f64,
97 soft_breaking_score: f64,
99 detected_at: i64,
101 details: serde_json::Value,
103 },
104 #[serde(rename = "threat_assessment")]
106 ThreatAssessment {
107 id: String,
109 endpoint: Option<String>,
111 method: Option<String>,
113 threat_level: String,
115 threat_score: f64,
117 assessed_at: i64,
119 findings_count: usize,
121 },
122 #[serde(rename = "forecast")]
124 Forecast {
125 id: String,
127 endpoint: String,
129 method: String,
131 window_days: u32,
133 change_probability: f64,
135 break_probability: f64,
137 next_expected_change: Option<i64>,
139 confidence: f64,
141 predicted_at: i64,
143 },
144}
145
146#[derive(Debug, Serialize)]
148pub struct TimelineResponse {
149 pub events: Vec<TimelineEvent>,
151 pub total: usize,
153}
154
155pub async fn get_timeline<S: ContractHealthStateProvider>(
159 State(state): State<S>,
160 Query(params): Query<TimelineQuery>,
161) -> Result<Json<TimelineResponse>, StatusCode> {
162 let mut events = Vec::new();
163
164 let event_type_filter = params.event_type.as_deref().unwrap_or("all");
165
166 if event_type_filter == "all" || event_type_filter == "structural" {
168 let query = mockforge_foundation::incidents_types::IncidentQuery {
169 workspace_id: params.workspace_id.clone(),
170 endpoint: params.endpoint.clone(),
171 method: params.method.clone(),
172 ..mockforge_foundation::incidents_types::IncidentQuery::default()
173 };
174
175 let incidents = state.incident_manager().query_incidents(query).await;
176 for incident in incidents {
177 events.push(TimelineEvent::StructuralDrift {
178 id: incident.id,
179 endpoint: incident.endpoint,
180 method: incident.method,
181 incident_type: format!("{:?}", incident.incident_type),
182 severity: format!("{:?}", incident.severity),
183 status: format!("{:?}", incident.status),
184 detected_at: incident.detected_at,
185 details: incident.details,
186 });
187 }
188 }
189
190 if event_type_filter == "all" || event_type_filter == "semantic" {
192 let status = None; let semantic_incidents = state
194 .semantic_manager()
195 .list_incidents(
196 params.workspace_id.as_deref(),
197 params.endpoint.as_deref(),
198 params.method.as_deref(),
199 status,
200 params.limit,
201 )
202 .await;
203
204 for incident in semantic_incidents {
205 events.push(TimelineEvent::SemanticDrift {
206 id: incident.id,
207 endpoint: incident.endpoint,
208 method: incident.method,
209 change_type: format!("{:?}", incident.semantic_change_type),
210 severity: format!("{:?}", incident.severity),
211 status: format!("{:?}", incident.status),
212 semantic_confidence: incident.semantic_confidence,
213 soft_breaking_score: incident.soft_breaking_score,
214 detected_at: incident.detected_at,
215 details: incident.details,
216 });
217 }
218 }
219
220 #[cfg(feature = "database")]
222 if let Some(pool) = state.database().and_then(|db| db.pool()) {
223 let workspace = params
224 .workspace_id
225 .as_deref()
226 .map(uuid::Uuid::parse_str)
227 .transpose()
228 .map_err(|_| StatusCode::BAD_REQUEST)?;
229 events.extend(persistence::events(pool, workspace).await);
230 }
231
232 events.sort_by_key(|e| {
234 std::cmp::Reverse(match e {
235 TimelineEvent::StructuralDrift { detected_at, .. } => *detected_at,
236 TimelineEvent::SemanticDrift { detected_at, .. } => *detected_at,
237 TimelineEvent::ThreatAssessment { assessed_at, .. } => *assessed_at,
238 TimelineEvent::Forecast { predicted_at, .. } => *predicted_at,
239 })
240 });
241
242 let total = events.len();
244 if let Some(limit) = params.limit {
245 events.truncate(limit);
246 }
247
248 Ok(Json(TimelineResponse { events, total }))
249}
250
251pub fn contract_health_router<S: ContractHealthStateProvider>(state: S) -> axum::Router {
253 use axum::routing::get;
254 use axum::Router;
255
256 Router::new()
257 .route("/api/v1/contract-health/timeline", get(get_timeline::<S>))
258 .with_state(state)
259}