mockforge_http/handlers/
xray.rs

1//! X-Ray API handlers for frontend debugging
2//!
3//! This module provides lightweight API endpoints for the browser extension
4//! to display current state (scenario, persona, reality level, chaos rules).
5
6use axum::{
7    extract::{Path, Query, State},
8    http::StatusCode,
9    response::Json,
10};
11use mockforge_core::consistency::ConsistencyEngine;
12use serde::Deserialize;
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::RwLock;
17use tracing::debug;
18
19/// Request context snapshot - stores the state that was active when a request was made
20#[derive(Debug, Clone)]
21pub(crate) struct RequestContextSnapshot {
22    /// Workspace ID
23    workspace_id: String,
24    /// Snapshot of unified state at request time
25    state_snapshot: serde_json::Value,
26    /// Timestamp when request was made
27    timestamp: i64,
28}
29
30/// State for X-Ray handlers
31#[derive(Clone)]
32pub struct XRayState {
33    /// Consistency engine
34    pub engine: Arc<ConsistencyEngine>,
35    /// Request context storage (request_id -> snapshot)
36    pub request_contexts: Arc<RwLock<HashMap<String, RequestContextSnapshot>>>,
37}
38
39/// Query parameters for X-Ray operations
40#[derive(Debug, Deserialize)]
41pub struct XRayQuery {
42    /// Workspace ID (defaults to "default" if not provided)
43    #[serde(default = "default_workspace")]
44    pub workspace: String,
45}
46
47fn default_workspace() -> String {
48    "default".to_string()
49}
50
51/// Get current state summary (optimized for extension overlay)
52///
53/// GET /api/v1/xray/state/summary?workspace={workspace_id}
54///
55/// Returns a lightweight summary suitable for the browser extension overlay.
56pub async fn get_state_summary(
57    State(state): State<XRayState>,
58    Query(params): Query<XRayQuery>,
59) -> Result<Json<Value>, StatusCode> {
60    let unified_state = state.engine.get_state(&params.workspace).await.ok_or_else(|| {
61        debug!("No state found for workspace: {}", params.workspace);
62        StatusCode::NOT_FOUND
63    })?;
64
65    // Build lightweight summary
66    let summary = serde_json::json!({
67        "workspace_id": unified_state.workspace_id,
68        "scenario": unified_state.active_scenario,
69        "persona": unified_state.active_persona.as_ref().map(|p| serde_json::json!({
70            "id": p.id,
71            "traits": p.traits,
72        })),
73        "reality_level": unified_state.reality_level.value(),
74        "reality_level_name": unified_state.reality_level.name(),
75        "reality_ratio": unified_state.reality_continuum_ratio,
76        // Note: ChaosScenario is now serde_json::Value, so we extract the name field
77        "chaos_rules": unified_state
78            .active_chaos_rules
79            .iter()
80            .filter_map(|r| r.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
81            .collect::<Vec<_>>(),
82        "timestamp": unified_state.last_updated,
83    });
84
85    Ok(Json(summary))
86}
87
88/// Get full state (for DevTools panel)
89///
90/// GET /api/v1/xray/state?workspace={workspace_id}
91pub async fn get_state(
92    State(state): State<XRayState>,
93    Query(params): Query<XRayQuery>,
94) -> Result<Json<Value>, StatusCode> {
95    let unified_state = state.engine.get_state(&params.workspace).await.ok_or_else(|| {
96        debug!("No state found for workspace: {}", params.workspace);
97        StatusCode::NOT_FOUND
98    })?;
99
100    Ok(Json(serde_json::to_value(&unified_state).unwrap()))
101}
102
103/// Get request context for a specific request ID
104///
105/// GET /api/v1/xray/request-context/{request_id}?workspace={workspace_id}
106///
107/// Returns the state that was active when a specific request was made.
108/// Request IDs are provided in X-MockForge-Request-ID headers.
109pub async fn get_request_context(
110    State(state): State<XRayState>,
111    Path(request_id): Path<String>,
112    Query(params): Query<XRayQuery>,
113) -> Result<Json<Value>, StatusCode> {
114    // Try to retrieve stored context snapshot
115    let contexts = state.request_contexts.read().await;
116    if let Some(snapshot) = contexts.get(&request_id) {
117        // Verify workspace matches (if provided)
118        if snapshot.workspace_id == params.workspace {
119            return Ok(Json(serde_json::json!({
120                "request_id": request_id,
121                "workspace": snapshot.workspace_id,
122                "state_snapshot": snapshot.state_snapshot,
123                "timestamp": snapshot.timestamp,
124                "cached": true,
125            })));
126        }
127    }
128    drop(contexts);
129
130    // Fallback: return current state if snapshot not found
131    debug!(
132        "Request context not found for request_id: {}, returning current state",
133        request_id
134    );
135    let unified_state = state.engine.get_state(&params.workspace).await.ok_or_else(|| {
136        debug!("No state found for workspace: {}", params.workspace);
137        StatusCode::NOT_FOUND
138    })?;
139
140    Ok(Json(serde_json::json!({
141        "request_id": request_id,
142        "workspace": params.workspace,
143        "state_snapshot": serde_json::to_value(&unified_state).unwrap(),
144        "timestamp": unified_state.last_updated,
145        "cached": false,
146        "note": "Snapshot not found, returning current state",
147    })))
148}
149
150/// Store request context snapshot
151///
152/// This is called by the middleware when a request is processed.
153/// It stores a snapshot of the unified state at the time of the request.
154pub async fn store_request_context(
155    state: &XRayState,
156    request_id: String,
157    workspace_id: String,
158    unified_state: &mockforge_core::consistency::types::UnifiedState,
159) {
160    let state_snapshot = serde_json::to_value(unified_state).unwrap_or_default();
161    let snapshot = RequestContextSnapshot {
162        workspace_id: workspace_id.clone(),
163        state_snapshot,
164        timestamp: unified_state.last_updated.timestamp(),
165    };
166
167    let mut contexts = state.request_contexts.write().await;
168
169    // Limit storage to last 1000 requests per workspace to prevent memory bloat
170    // Remove oldest entries if we exceed the limit
171    let workspace_entries: Vec<_> = contexts
172        .iter()
173        .filter(|(_, s)| s.workspace_id == workspace_id)
174        .map(|(k, _)| k.clone())
175        .collect();
176
177    if workspace_entries.len() >= 1000 {
178        // Remove oldest 100 entries for this workspace
179        let mut timestamps: Vec<_> = workspace_entries
180            .iter()
181            .filter_map(|id| contexts.get(id).map(|s| (id.clone(), s.timestamp)))
182            .collect();
183        timestamps.sort_by_key(|(_, ts)| *ts);
184
185        for (id, _) in timestamps.iter().take(100) {
186            contexts.remove(id);
187        }
188    }
189
190    contexts.insert(request_id, snapshot);
191}
192
193/// Get workspace summary
194///
195/// GET /api/v1/xray/workspace/{workspace_id}/summary
196pub async fn get_workspace_summary(
197    State(state): State<XRayState>,
198    Path(workspace_id): Path<String>,
199) -> Result<Json<Value>, StatusCode> {
200    let unified_state = state.engine.get_state(&workspace_id).await.ok_or_else(|| {
201        debug!("No state found for workspace: {}", workspace_id);
202        StatusCode::NOT_FOUND
203    })?;
204
205    let summary = serde_json::json!({
206        "workspace_id": unified_state.workspace_id,
207        "scenario": unified_state.active_scenario,
208        "persona_id": unified_state.active_persona.as_ref().map(|p| p.id.clone()),
209        "reality_level": unified_state.reality_level.value(),
210        "reality_ratio": unified_state.reality_continuum_ratio,
211        "active_chaos_rules_count": unified_state.active_chaos_rules.len(),
212        "entity_count": unified_state.entity_state.len(),
213        "protocol_count": unified_state.protocol_states.len(),
214        "last_updated": unified_state.last_updated,
215    });
216
217    Ok(Json(summary))
218}
219
220/// List all entities for a workspace
221///
222/// GET /api/v1/xray/entities?workspace={workspace_id}
223pub async fn list_entities(
224    State(state): State<XRayState>,
225    Query(params): Query<XRayQuery>,
226) -> Result<Json<Value>, StatusCode> {
227    let unified_state = state.engine.get_state(&params.workspace).await.ok_or_else(|| {
228        debug!("No state found for workspace: {}", params.workspace);
229        StatusCode::NOT_FOUND
230    })?;
231
232    let entities: Vec<&mockforge_core::consistency::EntityState> =
233        unified_state.entity_state.values().collect();
234
235    Ok(Json(serde_json::json!({
236        "workspace": params.workspace,
237        "entities": entities,
238        "count": entities.len(),
239    })))
240}
241
242/// Get specific entity
243///
244/// GET /api/v1/xray/entities/{entity_type}/{entity_id}?workspace={workspace_id}
245pub async fn get_entity(
246    State(state): State<XRayState>,
247    Path((entity_type, entity_id)): Path<(String, String)>,
248    Query(params): Query<XRayQuery>,
249) -> Result<Json<Value>, StatusCode> {
250    let entity = state
251        .engine
252        .get_entity(&params.workspace, &entity_type, &entity_id)
253        .await
254        .ok_or_else(|| {
255            debug!(
256                "Entity not found: {}:{} in workspace: {}",
257                entity_type, entity_id, params.workspace
258            );
259            StatusCode::NOT_FOUND
260        })?;
261
262    Ok(Json(serde_json::to_value(&entity).unwrap()))
263}
264
265/// Create X-Ray router
266pub fn xray_router(state: XRayState) -> axum::Router {
267    use axum::routing::get;
268
269    axum::Router::new()
270        .route("/api/v1/xray/state/summary", get(get_state_summary))
271        .route("/api/v1/xray/state", get(get_state))
272        .route("/api/v1/xray/request-context/{request_id}", get(get_request_context))
273        .route("/api/v1/xray/workspace/{workspace_id}/summary", get(get_workspace_summary))
274        .route("/api/v1/xray/entities", get(list_entities))
275        .route("/api/v1/xray/entities/{entity_type}/{entity_id}", get(get_entity))
276        .with_state(state)
277}