Skip to main content

nexus_memory_web/api/
observability.rs

1//! Observability API endpoints for jobs, digests, and runtime health.
2
3use axum::{
4    extract::{Query, State},
5    Json,
6};
7use serde::Deserialize;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use tracing::warn;
11
12use crate::error::{Result, WebError};
13use crate::models::{
14    AdaptiveDreamState, CognitionOverviewResponse, DashboardResponse, DigestEntry,
15    DigestFreshnessState, DigestListResponse, DreamState, JobEntry, JobListResponse,
16    JobSummaryResponse, QueryIntrospectionResponse, RecallComposition, ReflectionSampleEntry,
17    ReflectionStateResponse, RuntimeResponse,
18};
19use crate::state::AppState;
20use nexus_core::CognitiveLevel;
21
22// ---- Query parameter structs ----
23
24#[derive(Debug, Deserialize, Default)]
25pub struct JobQueryParams {
26    pub namespace: String,
27    pub status: Option<String>,
28    pub job_type: Option<String>,
29    #[serde(default = "default_limit")]
30    pub limit: i64,
31    #[serde(default)]
32    pub offset: i64,
33}
34
35#[derive(Debug, Deserialize, Default)]
36pub struct JobSummaryQueryParams {
37    pub namespace: String,
38    pub job_type: Option<String>,
39}
40
41#[derive(Debug, Deserialize, Default)]
42pub struct DigestQueryParams {
43    pub namespace: String,
44    pub session_key: Option<String>,
45    #[serde(default = "default_limit")]
46    pub limit: i64,
47    #[serde(default)]
48    pub offset: i64,
49}
50
51fn default_limit() -> i64 {
52    50
53}
54
55// ---- Handlers ----
56
57/// GET /api/cognition/jobs — list memory jobs for a namespace.
58pub async fn list_jobs(
59    State(state): State<Arc<RwLock<AppState>>>,
60    Query(params): Query<JobQueryParams>,
61) -> Result<Json<JobListResponse>> {
62    if params.namespace.trim().is_empty() {
63        return Err(WebError::InvalidRequest(
64            "namespace query parameter is required".to_string(),
65        ));
66    }
67
68    let state = state.read().await;
69
70    let namespace = state
71        .namespace_repo
72        .get_by_name(&params.namespace)
73        .await?
74        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
75
76    let limit = params.limit.clamp(1, 200);
77    let offset = params.offset.max(0);
78
79    let rows = state
80        .memory_repo
81        .list_jobs(
82            namespace.id,
83            params.job_type.as_deref(),
84            params.status.as_deref(),
85            limit,
86            offset,
87        )
88        .await?;
89
90    let total = state
91        .memory_repo
92        .count_jobs(
93            namespace.id,
94            params.job_type.as_deref(),
95            params.status.as_deref(),
96        )
97        .await?;
98
99    let jobs: Vec<JobEntry> = rows.into_iter().map(JobEntry::from).collect();
100
101    Ok(Json(JobListResponse {
102        success: true,
103        namespace: params.namespace,
104        jobs,
105        total,
106    }))
107}
108
109/// GET /api/cognition/jobs/summary — job counts grouped by status.
110pub async fn job_summary(
111    State(state): State<Arc<RwLock<AppState>>>,
112    Query(params): Query<JobSummaryQueryParams>,
113) -> Result<Json<JobSummaryResponse>> {
114    if params.namespace.trim().is_empty() {
115        return Err(WebError::InvalidRequest(
116            "namespace query parameter is required".to_string(),
117        ));
118    }
119
120    let state = state.read().await;
121
122    let namespace = state
123        .namespace_repo
124        .get_by_name(&params.namespace)
125        .await?
126        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
127
128    let rows = state
129        .memory_repo
130        .count_jobs_by_status(namespace.id, params.job_type.as_deref())
131        .await?;
132
133    let counts = rows.into_iter().collect();
134
135    Ok(Json(JobSummaryResponse {
136        success: true,
137        namespace: params.namespace,
138        counts,
139    }))
140}
141
142/// GET /api/cognition/digests — list session digests for a namespace.
143pub async fn list_digests(
144    State(state): State<Arc<RwLock<AppState>>>,
145    Query(params): Query<DigestQueryParams>,
146) -> Result<Json<DigestListResponse>> {
147    if params.namespace.trim().is_empty() {
148        return Err(WebError::InvalidRequest(
149            "namespace query parameter is required".to_string(),
150        ));
151    }
152
153    let state = state.read().await;
154
155    let namespace = state
156        .namespace_repo
157        .get_by_name(&params.namespace)
158        .await?
159        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
160
161    let limit = params.limit.clamp(1, 200);
162    let offset = params.offset.max(0);
163
164    let rows = state
165        .memory_repo
166        .list_digests(namespace.id, params.session_key.as_deref(), limit, offset)
167        .await?;
168
169    let total = state
170        .memory_repo
171        .count_digests(namespace.id, params.session_key.as_deref())
172        .await?;
173
174    let digests: Vec<DigestEntry> = rows.into_iter().map(DigestEntry::from).collect();
175
176    Ok(Json(DigestListResponse {
177        success: true,
178        namespace: params.namespace,
179        digests,
180        total,
181    }))
182}
183
184/// GET /api/cognition/runtime — runtime health info from AppState.
185pub async fn runtime_health(
186    State(state): State<Arc<RwLock<AppState>>>,
187) -> Result<Json<RuntimeResponse>> {
188    let state = state.read().await;
189
190    // Probe the DB connection.
191    let db_connected = sqlx::query_scalar::<_, i64>("SELECT 1")
192        .fetch_one(state.pool())
193        .await
194        .is_ok();
195
196    let agent_enabled = state.agent_supervisor.is_some();
197    let active_sessions = state.orchestrator.active_session_count().await;
198
199    Ok(Json(RuntimeResponse {
200        success: true,
201        version: env!("CARGO_PKG_VERSION").to_string(),
202        uptime_seconds: state.uptime_seconds(),
203        db_connected,
204        agent_enabled,
205        active_sessions,
206    }))
207}
208
209#[derive(Debug, Deserialize, Default)]
210pub struct OverviewQueryParams {
211    pub namespace: String,
212}
213
214#[derive(Debug, Deserialize, Default)]
215pub struct ReflectionQueryParams {
216    pub namespace: String,
217    #[serde(default = "default_limit")]
218    pub limit: i64,
219}
220
221/// GET /api/cognition/overview — aggregated cognition state for a namespace.
222pub async fn cognition_overview(
223    State(state): State<Arc<RwLock<AppState>>>,
224    Query(params): Query<OverviewQueryParams>,
225) -> Result<Json<CognitionOverviewResponse>> {
226    if params.namespace.trim().is_empty() {
227        return Err(WebError::InvalidRequest(
228            "namespace query parameter is required".to_string(),
229        ));
230    }
231
232    let state = state.read().await;
233
234    let namespace = state
235        .namespace_repo
236        .get_by_name(&params.namespace)
237        .await?
238        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
239
240    let status_rows = state
241        .memory_repo
242        .count_jobs_by_status(namespace.id, None)
243        .await?;
244    let jobs_by_status: std::collections::HashMap<String, i64> = status_rows.into_iter().collect();
245
246    let digest_count = state.memory_repo.count_digests(namespace.id, None).await?;
247
248    let evidence_count = state.memory_repo.count_evidence(namespace.id).await?;
249    let stage_metrics = state
250        .memory_repo
251        .latest_metrics_for_namespace(namespace.id, Some("cognition."), 64)
252        .await?
253        .into_iter()
254        .fold(
255            std::collections::HashMap::new(),
256            |mut acc: std::collections::HashMap<String, f64>, metric| {
257                acc.entry(metric.metric_name).or_insert(metric.metric_value);
258                acc
259            },
260        );
261
262    Ok(Json(CognitionOverviewResponse {
263        success: true,
264        namespace: params.namespace,
265        jobs_by_status,
266        digest_count,
267        evidence_count,
268        stage_metrics,
269    }))
270}
271
272/// GET /api/cognition/reflection — derived and contradiction observability for a namespace.
273pub async fn reflection_state(
274    State(state): State<Arc<RwLock<AppState>>>,
275    Query(params): Query<ReflectionQueryParams>,
276) -> Result<Json<ReflectionStateResponse>> {
277    if params.namespace.trim().is_empty() {
278        return Err(WebError::InvalidRequest(
279            "namespace query parameter is required".to_string(),
280        ));
281    }
282
283    let state = state.read().await;
284
285    let namespace = state
286        .namespace_repo
287        .get_by_name(&params.namespace)
288        .await?
289        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
290
291    let limit = params.limit.clamp(1, 50);
292    let contradiction_count = state
293        .memory_repo
294        .count_by_cognitive_level(namespace.id, CognitiveLevel::Contradiction)
295        .await?;
296    let derived_count = state
297        .memory_repo
298        .count_by_cognitive_level(namespace.id, CognitiveLevel::Derived)
299        .await?;
300    let recent_contradictions = state
301        .memory_repo
302        .get_by_cognitive_level(namespace.id, CognitiveLevel::Contradiction, limit)
303        .await?
304        .into_iter()
305        .map(ReflectionSampleEntry::from)
306        .collect();
307    let recent_derived = state
308        .memory_repo
309        .get_by_cognitive_level(namespace.id, CognitiveLevel::Derived, limit)
310        .await?
311        .into_iter()
312        .map(ReflectionSampleEntry::from)
313        .collect();
314
315    Ok(Json(ReflectionStateResponse {
316        success: true,
317        namespace: params.namespace,
318        contradiction_count,
319        derived_count,
320        recent_contradictions,
321        recent_derived,
322    }))
323}
324
325// ---- Query Introspection ----
326
327#[derive(Debug, Deserialize, Default)]
328pub struct QueryIntrospectionQueryParams {
329    pub namespace: String,
330    pub question: String,
331}
332
333/// GET /api/cognition/query-introspection — ranking decision introspection.
334///
335/// Purely structural analysis (no LLM calls). Returns included/excluded
336/// memories with per-memory signals, bucket stats, and relevant reflections.
337/// Works without agent supervisor enabled.
338pub async fn query_introspection(
339    State(state): State<Arc<RwLock<AppState>>>,
340    Query(params): Query<QueryIntrospectionQueryParams>,
341) -> Result<Json<QueryIntrospectionResponse>> {
342    if params.namespace.trim().is_empty() {
343        return Err(WebError::InvalidRequest(
344            "namespace query parameter is required".to_string(),
345        ));
346    }
347    if params.question.trim().is_empty() {
348        return Err(WebError::InvalidRequest(
349            "question query parameter is required".to_string(),
350        ));
351    }
352
353    let state = state.read().await;
354
355    let namespace = state
356        .namespace_repo
357        .get_by_name(&params.namespace)
358        .await?
359        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
360
361    let query_context_limit = nexus_core::Config::from_env()
362        .map(|config| config.agent.query_context_limit)
363        .unwrap_or_else(|_| nexus_core::config::AgentConfig::default().query_context_limit);
364
365    let request = nexus_core::WorkingRepresentationRequest {
366        namespace_id: namespace.id,
367        perspective: None,
368        query: Some(params.question.clone()),
369        max_items: query_context_limit,
370        include_raw: false,
371        ..nexus_core::WorkingRepresentationRequest::default()
372    };
373
374    let introspection =
375        nexus_agent::introspect_query(&request, &params.question, &state.memory_repo)
376            .await
377            .map_err(|e| WebError::Storage(format!("Introspection failed: {}", e)))?;
378
379    Ok(Json(QueryIntrospectionResponse {
380        success: true,
381        namespace: params.namespace,
382        question: params.question,
383        introspection,
384    }))
385}
386
387// ---- Operator Dashboard ----
388
389#[derive(Debug, Deserialize, Default)]
390pub struct DashboardQueryParams {
391    pub namespace: String,
392}
393
394/// GET /api/cognition/dashboard — at-a-glance operator view of dream, digest, recall, and adaptive state.
395pub async fn dashboard(
396    State(state): State<Arc<RwLock<AppState>>>,
397    Query(params): Query<DashboardQueryParams>,
398) -> Result<Json<DashboardResponse>> {
399    if params.namespace.trim().is_empty() {
400        return Err(WebError::InvalidRequest(
401            "namespace query parameter is required".to_string(),
402        ));
403    }
404
405    let state = state.read().await;
406
407    let namespace = state
408        .namespace_repo
409        .get_by_name(&params.namespace)
410        .await?
411        .ok_or_else(|| WebError::NotFound(format!("Namespace '{}' not found", params.namespace)))?;
412
413    // --- Dream throughput ---
414    let completed_reflections = state
415        .memory_repo
416        .count_jobs(namespace.id, Some("reflect_namespace"), Some("completed"))
417        .await?
418        + state
419            .memory_repo
420            .count_jobs(namespace.id, Some("reflect_perspective"), Some("completed"))
421            .await?;
422    let completed_digests = state
423        .memory_repo
424        .count_jobs(namespace.id, Some("digest_session"), Some("completed"))
425        .await?;
426    let failed_jobs = state
427        .memory_repo
428        .count_jobs(namespace.id, None, Some("failed"))
429        .await?;
430    let pending_jobs = state
431        .memory_repo
432        .count_jobs(namespace.id, None, Some("enqueued"))
433        .await?;
434
435    // Most recent completed dream job (reflection or digest).
436    let last_dream_at = {
437        let reflect_jobs = state
438            .memory_repo
439            .list_jobs(
440                namespace.id,
441                Some("reflect_namespace"),
442                Some("completed"),
443                1,
444                0,
445            )
446            .await
447            .unwrap_or_else(|e| {
448                warn!(error = %e, "Failed to list reflection jobs for dashboard");
449                Vec::new()
450            });
451        let digest_jobs = state
452            .memory_repo
453            .list_jobs(
454                namespace.id,
455                Some("digest_session"),
456                Some("completed"),
457                1,
458                0,
459            )
460            .await
461            .unwrap_or_else(|e| {
462                warn!(error = %e, "Failed to list digest jobs for dashboard");
463                Vec::new()
464            });
465        let most_recent = reflect_jobs
466            .iter()
467            .chain(digest_jobs.iter())
468            .max_by_key(|j| j.updated_at.as_str());
469        most_recent.map(|j| j.updated_at.clone())
470    };
471
472    // --- Digest freshness ---
473    let total_digests = state.memory_repo.count_digests(namespace.id, None).await?;
474    let sessions_with_cognition = state
475        .memory_repo
476        .count_distinct_session_keys_with_cognition(namespace.id)
477        .await?;
478
479    let (latest_digest_at, latest_digest_age_seconds) = {
480        let recent = state
481            .memory_repo
482            .list_digests(namespace.id, None, 1, 0)
483            .await
484            .unwrap_or_else(|e| {
485                warn!(error = %e, "Failed to list digests for dashboard");
486                Vec::new()
487            });
488        match recent.into_iter().next() {
489            Some(d) => match parse_timestamp(&d.created_at) {
490                Some(dt) => {
491                    let age = chrono::Utc::now()
492                        .signed_duration_since(dt)
493                        .num_seconds()
494                        .max(0);
495                    (Some(d.created_at), Some(age))
496                }
497                None => {
498                    warn!(
499                        created_at = %d.created_at,
500                        "Malformed digest timestamp; returning None for age"
501                    );
502                    (None, None)
503                }
504            },
505            None => (None, None),
506        }
507    };
508
509    // --- Recall composition ---
510    let raw = state
511        .memory_repo
512        .count_by_cognitive_level(namespace.id, CognitiveLevel::Raw)
513        .await?;
514    let explicit = state
515        .memory_repo
516        .count_by_cognitive_level(namespace.id, CognitiveLevel::Explicit)
517        .await?;
518    let derived = state
519        .memory_repo
520        .count_by_cognitive_level(namespace.id, CognitiveLevel::Derived)
521        .await?;
522    let summary_short = state
523        .memory_repo
524        .count_by_cognitive_level(namespace.id, CognitiveLevel::SummaryShort)
525        .await?;
526    let summary_long = state
527        .memory_repo
528        .count_by_cognitive_level(namespace.id, CognitiveLevel::SummaryLong)
529        .await?;
530    let contradiction = state
531        .memory_repo
532        .count_by_cognitive_level(namespace.id, CognitiveLevel::Contradiction)
533        .await?;
534    let total = raw + explicit + derived + summary_short + summary_long + contradiction;
535
536    // --- Adaptive dream state ---
537    let cognition_config = match nexus_core::Config::from_env() {
538        Ok(c) => c.cognition,
539        Err(e) => {
540            warn!(error = %e, "Failed to load cognition config for dashboard; using defaults");
541            nexus_core::config::CognitionConfig::default()
542        }
543    };
544    let contradiction_density = if total > 0 {
545        contradiction as f64 / total as f64
546    } else {
547        0.0
548    };
549
550    let base_interval = cognition_config.adaptive_dream_min_interval_secs;
551    let factor = 1.0 - ((contradiction as f32 * 0.10).min(0.9));
552    let adapted = (base_interval as f32 * factor) as u64;
553    let current_interval_secs = adapted.clamp(
554        cognition_config.adaptive_dream_min_interval_secs,
555        cognition_config.adaptive_dream_max_interval_secs,
556    );
557
558    Ok(Json(DashboardResponse {
559        success: true,
560        namespace: params.namespace,
561        dream: DreamState {
562            completed_reflections,
563            completed_digests,
564            failed_jobs,
565            pending_jobs,
566            last_dream_at,
567        },
568        digest: DigestFreshnessState {
569            total_digests,
570            sessions_with_cognition,
571            latest_digest_age_seconds,
572            latest_digest_at,
573        },
574        recall: RecallComposition {
575            raw,
576            explicit,
577            derived,
578            summary_short,
579            summary_long,
580            contradiction,
581            total,
582        },
583        adaptive: AdaptiveDreamState {
584            enabled: cognition_config.adaptive_dream_enabled,
585            current_interval_secs,
586            min_interval_secs: cognition_config.adaptive_dream_min_interval_secs,
587            max_interval_secs: cognition_config.adaptive_dream_max_interval_secs,
588            contradiction_count: contradiction,
589            contradiction_density,
590        },
591    }))
592}
593
594/// Parse a timestamp string that may be in RFC3339 or SQLite `datetime('now')` format.
595///
596/// SQLite `datetime('now')` produces `YYYY-MM-DD HH:MM:SS` (no timezone suffix),
597/// which is assumed to be UTC.  RFC3339 timestamps (if any exist) are tried first.
598fn parse_timestamp(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
599    // Try RFC3339 first (explicitly timezone-annotated timestamps).
600    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
601        return Some(dt.with_timezone(&chrono::Utc));
602    }
603    // Fall back to SQLite datetime('now') format: "YYYY-MM-DD HH:MM:SS"
604    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
605        return Some(naive.and_utc());
606    }
607    // Try with fractional seconds: "YYYY-MM-DD HH:MM:SS.fff"
608    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
609        return Some(naive.and_utc());
610    }
611    None
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use axum::body::Body;
618    use axum::http::{Request, StatusCode};
619    use axum::routing::get;
620    use axum::Router;
621    use nexus_orchestrator::Orchestrator;
622    use serde_json::Value;
623    use std::sync::Arc;
624    use tower::ServiceExt;
625
626    struct TestApp {
627        app: Router,
628        state: Arc<RwLock<crate::state::AppState>>,
629    }
630
631    async fn test_app() -> TestApp {
632        let pool = sqlx::SqlitePool::connect("sqlite::memory:")
633            .await
634            .expect("connect to in-memory db");
635        nexus_storage::migrations::run_migrations(&pool)
636            .await
637            .expect("run migrations");
638
639        let mut storage = nexus_storage::StorageManager::new(pool.clone());
640        storage.initialize().await.expect("initialize storage");
641
642        let orchestrator = Orchestrator::default();
643        let state = Arc::new(RwLock::new(
644            crate::state::AppState::new(storage, orchestrator)
645                .await
646                .expect("create app state"),
647        ));
648
649        let app = Router::new()
650            .route("/api/cognition/jobs", get(list_jobs))
651            .route("/api/cognition/jobs/summary", get(job_summary))
652            .route("/api/cognition/digests", get(list_digests))
653            .route("/api/cognition/overview", get(cognition_overview))
654            .route("/api/cognition/reflection", get(reflection_state))
655            .route("/api/cognition/runtime", get(runtime_health))
656            .route(
657                "/api/cognition/query-introspection",
658                get(query_introspection),
659            )
660            .route("/api/cognition/dashboard", get(dashboard))
661            .with_state(state.clone());
662
663        TestApp { app, state }
664    }
665
666    /// Helper: create a namespace via the shared state.
667    async fn create_namespace_in_test(state: &Arc<RwLock<crate::state::AppState>>, name: &str) {
668        let s = state.read().await;
669        s.namespace_repo
670            .get_or_create(name, "test-agent")
671            .await
672            .expect("create namespace");
673    }
674
675    /// Helper: parse response body into JSON Value.
676    fn body_to_json(body: axum::body::Bytes) -> Value {
677        serde_json::from_slice(&body).expect("valid JSON")
678    }
679
680    #[tokio::test]
681    async fn test_runtime_returns_honest_fields() {
682        let test = test_app().await;
683        let resp = test
684            .app
685            .oneshot(
686                Request::builder()
687                    .uri("/api/cognition/runtime")
688                    .body(Body::empty())
689                    .unwrap(),
690            )
691            .await
692            .unwrap();
693
694        assert_eq!(resp.status(), StatusCode::OK);
695        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
696            .await
697            .unwrap();
698        let json = body_to_json(body);
699
700        assert_eq!(json["success"], true);
701        assert!(!json["version"].as_str().unwrap().is_empty());
702        assert!(json["uptime_seconds"].as_u64().is_some());
703        assert!(json["db_connected"].is_boolean());
704        assert!(json["agent_enabled"].is_boolean());
705    }
706
707    #[tokio::test]
708    async fn test_query_introspection_missing_question_returns_400() {
709        let test = test_app().await;
710        create_namespace_in_test(&test.state, "intro-missing").await;
711
712        let resp = test
713            .app
714            .oneshot(
715                Request::builder()
716                    .uri("/api/cognition/query-introspection?namespace=intro-missing")
717                    .body(Body::empty())
718                    .unwrap(),
719            )
720            .await
721            .unwrap();
722
723        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
724    }
725
726    #[tokio::test]
727    async fn test_query_introspection_returns_structured_payload() {
728        let test = test_app().await;
729        create_namespace_in_test(&test.state, "intro-ns").await;
730
731        {
732            let state = test.state.read().await;
733            let namespace = state
734                .namespace_repo
735                .get_by_name("intro-ns")
736                .await
737                .unwrap()
738                .unwrap();
739
740            state
741                .memory_repo
742                .store(nexus_storage::StoreMemoryParams {
743                    namespace_id: namespace.id,
744                    content: "Authentication now uses session cookies with http-only flags.",
745                    category: &nexus_core::MemoryCategory::Facts,
746                    memory_lane_type: None,
747                    labels: &[],
748                    metadata: &serde_json::json!({
749                        "cognitive": {
750                            "level": "explicit",
751                            "observer": "claude-code",
752                            "subject": "claude-code",
753                            "generated_by": "test_fixture",
754                            "confidence": 0.92
755                        }
756                    }),
757                    embedding: None,
758                    embedding_model: None,
759                })
760                .await
761                .unwrap();
762        }
763
764        let resp = test
765            .app
766            .oneshot(
767                Request::builder()
768                    .uri("/api/cognition/query-introspection?namespace=intro-ns&question=session%20cookies")
769                    .body(Body::empty())
770                    .unwrap(),
771            )
772            .await
773            .unwrap();
774
775        assert_eq!(resp.status(), StatusCode::OK);
776        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
777            .await
778            .unwrap();
779        let json = body_to_json(body);
780
781        assert_eq!(json["success"], true);
782        assert_eq!(json["namespace"], "intro-ns");
783        assert_eq!(json["question"], "session cookies");
784        assert!(json["introspection"]["included"].is_array());
785        assert!(!json["introspection"]["included"]
786            .as_array()
787            .unwrap()
788            .is_empty());
789        assert!(json["introspection"]["bucket_stats"].is_array());
790    }
791
792    #[tokio::test]
793    async fn test_jobs_missing_namespace_returns_400() {
794        let test = test_app().await;
795        let resp = test
796            .app
797            .oneshot(
798                Request::builder()
799                    .uri("/api/cognition/jobs")
800                    .body(Body::empty())
801                    .unwrap(),
802            )
803            .await
804            .unwrap();
805        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
806    }
807
808    #[tokio::test]
809    async fn test_jobs_unknown_namespace_returns_404() {
810        let test = test_app().await;
811        let resp = test
812            .app
813            .oneshot(
814                Request::builder()
815                    .uri("/api/cognition/jobs?namespace=nonexistent")
816                    .body(Body::empty())
817                    .unwrap(),
818            )
819            .await
820            .unwrap();
821        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
822    }
823
824    #[tokio::test]
825    async fn test_reflection_missing_namespace_returns_400() {
826        let test = test_app().await;
827        let resp = test
828            .app
829            .oneshot(
830                Request::builder()
831                    .uri("/api/cognition/reflection")
832                    .body(Body::empty())
833                    .unwrap(),
834            )
835            .await
836            .unwrap();
837        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
838    }
839
840    #[tokio::test]
841    async fn test_reflection_returns_counts_and_samples() {
842        let test = test_app().await;
843        create_namespace_in_test(&test.state, "reflect-ns").await;
844
845        {
846            let state = test.state.read().await;
847            let namespace = state
848                .namespace_repo
849                .get_by_name("reflect-ns")
850                .await
851                .unwrap()
852                .unwrap();
853
854            for (content, level) in [
855                ("derived insight", CognitiveLevel::Derived),
856                ("contradiction note", CognitiveLevel::Contradiction),
857            ] {
858                state
859                    .memory_repo
860                    .store(nexus_storage::repository::StoreMemoryParams {
861                        namespace_id: namespace.id,
862                        content,
863                        category: &nexus_core::MemoryCategory::Facts,
864                        memory_lane_type: None,
865                        labels: &[],
866                        metadata: &serde_json::json!({
867                            "cognitive": {
868                                "level": level.as_str(),
869                                "observer": "claude-code",
870                                "subject": "claude-code",
871                                "confidence": 0.9,
872                                "generated_by": "test"
873                            }
874                        }),
875                        embedding: None,
876                        embedding_model: None,
877                    })
878                    .await
879                    .unwrap();
880            }
881        }
882
883        let resp = test
884            .app
885            .oneshot(
886                Request::builder()
887                    .uri("/api/cognition/reflection?namespace=reflect-ns")
888                    .body(Body::empty())
889                    .unwrap(),
890            )
891            .await
892            .unwrap();
893
894        assert_eq!(resp.status(), StatusCode::OK);
895        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
896            .await
897            .unwrap();
898        let json = body_to_json(body);
899
900        assert_eq!(json["success"], true);
901        assert_eq!(json["derived_count"], 1);
902        assert_eq!(json["contradiction_count"], 1);
903        assert_eq!(json["recent_derived"][0]["content"], "derived insight");
904        assert_eq!(
905            json["recent_contradictions"][0]["content"],
906            "contradiction note"
907        );
908    }
909
910    #[tokio::test]
911    async fn test_overview_returns_latest_stage_metrics() {
912        let test = test_app().await;
913        create_namespace_in_test(&test.state, "overview-ns").await;
914
915        {
916            let state = test.state.read().await;
917            let namespace = state
918                .namespace_repo
919                .get_by_name("overview-ns")
920                .await
921                .unwrap()
922                .unwrap();
923
924            state
925                .memory_repo
926                .record_metric(
927                    "cognition.query.total_ms",
928                    11.0,
929                    &serde_json::json!({"namespace_id": namespace.id, "stage": "total", "unit": "ms"}),
930                )
931                .await
932                .unwrap();
933            state
934                .memory_repo
935                .record_metric(
936                    "cognition.query.total_ms",
937                    15.5,
938                    &serde_json::json!({"namespace_id": namespace.id, "stage": "total", "unit": "ms"}),
939                )
940                .await
941                .unwrap();
942            state
943                .memory_repo
944                .record_metric(
945                    "cognition.dream.total_ms",
946                    44.0,
947                    &serde_json::json!({"namespace_id": namespace.id, "stage": "total", "unit": "ms"}),
948                )
949                .await
950                .unwrap();
951        }
952
953        let resp = test
954            .app
955            .oneshot(
956                Request::builder()
957                    .uri("/api/cognition/overview?namespace=overview-ns")
958                    .body(Body::empty())
959                    .unwrap(),
960            )
961            .await
962            .unwrap();
963
964        assert_eq!(resp.status(), StatusCode::OK);
965        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
966            .await
967            .unwrap();
968        let json = body_to_json(body);
969
970        assert_eq!(json["success"], true);
971        assert_eq!(json["stage_metrics"]["cognition.query.total_ms"], 15.5);
972        assert_eq!(json["stage_metrics"]["cognition.dream.total_ms"], 44.0);
973    }
974
975    #[tokio::test]
976    async fn test_digests_missing_namespace_returns_400() {
977        let test = test_app().await;
978        let resp = test
979            .app
980            .oneshot(
981                Request::builder()
982                    .uri("/api/cognition/digests")
983                    .body(Body::empty())
984                    .unwrap(),
985            )
986            .await
987            .unwrap();
988        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
989    }
990
991    #[tokio::test]
992    async fn test_overview_unknown_namespace_returns_404() {
993        let test = test_app().await;
994        let resp = test
995            .app
996            .oneshot(
997                Request::builder()
998                    .uri("/api/cognition/overview?namespace=nope")
999                    .body(Body::empty())
1000                    .unwrap(),
1001            )
1002            .await
1003            .unwrap();
1004        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1005    }
1006
1007    #[tokio::test]
1008    async fn test_jobs_with_existing_namespace_returns_empty_list() {
1009        let test = test_app().await;
1010        create_namespace_in_test(&test.state, "jobs-test-ns").await;
1011
1012        let resp = test
1013            .app
1014            .oneshot(
1015                Request::builder()
1016                    .uri("/api/cognition/jobs?namespace=jobs-test-ns")
1017                    .body(Body::empty())
1018                    .unwrap(),
1019            )
1020            .await
1021            .unwrap();
1022
1023        assert_eq!(resp.status(), StatusCode::OK);
1024        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1025            .await
1026            .unwrap();
1027        let json = body_to_json(body);
1028
1029        assert_eq!(json["success"], true);
1030        assert_eq!(json["namespace"], "jobs-test-ns");
1031        assert_eq!(json["jobs"], Value::Array(vec![]));
1032        assert_eq!(json["total"], 0);
1033    }
1034
1035    #[tokio::test]
1036    async fn test_jobs_reports_total_matching_rows_not_page_len() {
1037        let test = test_app().await;
1038        create_namespace_in_test(&test.state, "jobs-page-ns").await;
1039
1040        {
1041            let state = test.state.read().await;
1042            let namespace = state
1043                .namespace_repo
1044                .get_by_name("jobs-page-ns")
1045                .await
1046                .unwrap()
1047                .unwrap();
1048
1049            for idx in 0..3 {
1050                state
1051                    .memory_repo
1052                    .enqueue_job(nexus_storage::EnqueueJobParams {
1053                        namespace_id: namespace.id,
1054                        job_type: "derive",
1055                        priority: 10 - idx,
1056                        perspective: None,
1057                        payload: &serde_json::json!({ "idx": idx }),
1058                    })
1059                    .await
1060                    .unwrap();
1061            }
1062        }
1063
1064        let resp = test
1065            .app
1066            .oneshot(
1067                Request::builder()
1068                    .uri("/api/cognition/jobs?namespace=jobs-page-ns&limit=2")
1069                    .body(Body::empty())
1070                    .unwrap(),
1071            )
1072            .await
1073            .unwrap();
1074
1075        assert_eq!(resp.status(), StatusCode::OK);
1076        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1077            .await
1078            .unwrap();
1079        let json = body_to_json(body);
1080
1081        assert_eq!(json["success"], true);
1082        assert_eq!(json["jobs"].as_array().unwrap().len(), 2);
1083        assert_eq!(json["total"], 3);
1084    }
1085
1086    #[tokio::test]
1087    async fn test_overview_with_existing_namespace() {
1088        let test = test_app().await;
1089        create_namespace_in_test(&test.state, "overview-test-ns").await;
1090
1091        let resp = test
1092            .app
1093            .oneshot(
1094                Request::builder()
1095                    .uri("/api/cognition/overview?namespace=overview-test-ns")
1096                    .body(Body::empty())
1097                    .unwrap(),
1098            )
1099            .await
1100            .unwrap();
1101
1102        assert_eq!(resp.status(), StatusCode::OK);
1103        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1104            .await
1105            .unwrap();
1106        let json = body_to_json(body);
1107
1108        assert_eq!(json["success"], true);
1109        assert_eq!(json["namespace"], "overview-test-ns");
1110        assert_eq!(json["digest_count"], 0);
1111        assert_eq!(json["evidence_count"], 0);
1112    }
1113
1114    #[tokio::test]
1115    async fn test_job_summary_with_existing_namespace() {
1116        let test = test_app().await;
1117        create_namespace_in_test(&test.state, "summary-test-ns").await;
1118
1119        let resp = test
1120            .app
1121            .oneshot(
1122                Request::builder()
1123                    .uri("/api/cognition/jobs/summary?namespace=summary-test-ns")
1124                    .body(Body::empty())
1125                    .unwrap(),
1126            )
1127            .await
1128            .unwrap();
1129
1130        assert_eq!(resp.status(), StatusCode::OK);
1131        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1132            .await
1133            .unwrap();
1134        let json = body_to_json(body);
1135
1136        assert_eq!(json["success"], true);
1137        assert_eq!(json["namespace"], "summary-test-ns");
1138        assert!(json["counts"].is_object());
1139    }
1140
1141    #[tokio::test]
1142    async fn test_job_summary_returns_real_counts() {
1143        let test = test_app().await;
1144        create_namespace_in_test(&test.state, "summary-data-ns").await;
1145
1146        {
1147            let state = test.state.read().await;
1148            let namespace = state
1149                .namespace_repo
1150                .get_by_name("summary-data-ns")
1151                .await
1152                .unwrap()
1153                .unwrap();
1154
1155            state
1156                .memory_repo
1157                .enqueue_job(nexus_storage::EnqueueJobParams {
1158                    namespace_id: namespace.id,
1159                    job_type: "derive",
1160                    priority: 10,
1161                    perspective: None,
1162                    payload: &serde_json::json!({ "idx": 1 }),
1163                })
1164                .await
1165                .unwrap();
1166            state
1167                .memory_repo
1168                .enqueue_job(nexus_storage::EnqueueJobParams {
1169                    namespace_id: namespace.id,
1170                    job_type: "digest",
1171                    priority: 5,
1172                    perspective: None,
1173                    payload: &serde_json::json!({ "idx": 2 }),
1174                })
1175                .await
1176                .unwrap();
1177        }
1178
1179        let resp = test
1180            .app
1181            .oneshot(
1182                Request::builder()
1183                    .uri("/api/cognition/jobs/summary?namespace=summary-data-ns")
1184                    .body(Body::empty())
1185                    .unwrap(),
1186            )
1187            .await
1188            .unwrap();
1189
1190        assert_eq!(resp.status(), StatusCode::OK);
1191        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1192            .await
1193            .unwrap();
1194        let json = body_to_json(body);
1195
1196        assert_eq!(json["success"], true);
1197        assert_eq!(json["counts"]["pending"], 2);
1198    }
1199
1200    #[tokio::test]
1201    async fn test_digests_with_existing_namespace() {
1202        let test = test_app().await;
1203        create_namespace_in_test(&test.state, "digest-test-ns").await;
1204
1205        let resp = test
1206            .app
1207            .oneshot(
1208                Request::builder()
1209                    .uri("/api/cognition/digests?namespace=digest-test-ns")
1210                    .body(Body::empty())
1211                    .unwrap(),
1212            )
1213            .await
1214            .unwrap();
1215
1216        assert_eq!(resp.status(), StatusCode::OK);
1217        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1218            .await
1219            .unwrap();
1220        let json = body_to_json(body);
1221
1222        assert_eq!(json["success"], true);
1223        assert_eq!(json["namespace"], "digest-test-ns");
1224        assert_eq!(json["total"], 0);
1225    }
1226
1227    #[tokio::test]
1228    async fn test_digests_support_pagination_and_total() {
1229        let test = test_app().await;
1230        create_namespace_in_test(&test.state, "digest-page-ns").await;
1231
1232        {
1233            let state = test.state.read().await;
1234            let namespace = state
1235                .namespace_repo
1236                .get_by_name("digest-page-ns")
1237                .await
1238                .unwrap()
1239                .unwrap();
1240
1241            for idx in 0..3 {
1242                let content = format!("digest memory {idx}");
1243                let memory = state
1244                    .memory_repo
1245                    .store(nexus_storage::StoreMemoryParams {
1246                        namespace_id: namespace.id,
1247                        content: &content,
1248                        category: &nexus_core::MemoryCategory::Session,
1249                        memory_lane_type: None,
1250                        labels: &[],
1251                        metadata: &serde_json::json!({}),
1252                        embedding: None,
1253                        embedding_model: None,
1254                    })
1255                    .await
1256                    .unwrap();
1257
1258                state
1259                    .memory_repo
1260                    .store_digest(nexus_storage::StoreDigestParams {
1261                        namespace_id: namespace.id,
1262                        session_key: "digest-session",
1263                        digest_kind: if idx % 2 == 0 {
1264                            "summary_short"
1265                        } else {
1266                            "summary_long"
1267                        },
1268                        memory_id: memory.id,
1269                        start_memory_id: Some(memory.id),
1270                        end_memory_id: Some(memory.id),
1271                        token_count: 100 + idx,
1272                    })
1273                    .await
1274                    .unwrap();
1275            }
1276        }
1277
1278        let resp = test
1279            .app
1280            .oneshot(
1281                Request::builder()
1282                    .uri("/api/cognition/digests?namespace=digest-page-ns&limit=2")
1283                    .body(Body::empty())
1284                    .unwrap(),
1285            )
1286            .await
1287            .unwrap();
1288
1289        assert_eq!(resp.status(), StatusCode::OK);
1290        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1291            .await
1292            .unwrap();
1293        let json = body_to_json(body);
1294
1295        assert_eq!(json["success"], true);
1296        assert_eq!(json["digests"].as_array().unwrap().len(), 2);
1297        assert_eq!(json["total"], 3);
1298    }
1299
1300    #[test]
1301    fn test_job_entry_serialization() {
1302        let job = JobEntry {
1303            id: 1,
1304            job_type: "derive".to_string(),
1305            status: "pending".to_string(),
1306            priority: 10,
1307            attempts: 0,
1308            last_error: None,
1309            lease_owner: Some("worker-1".to_string()),
1310            lease_expires_at: None,
1311            created_at: "2026-01-01T00:00:00Z".to_string(),
1312            updated_at: "2026-01-01T00:00:00Z".to_string(),
1313        };
1314        let json = serde_json::to_value(&job).unwrap();
1315        assert_eq!(json["id"], 1);
1316        assert_eq!(json["job_type"], "derive");
1317        assert_eq!(json["lease_owner"], "worker-1");
1318        assert!(json["last_error"].is_null());
1319    }
1320
1321    #[test]
1322    fn test_digest_entry_serialization() {
1323        let digest = DigestEntry {
1324            id: 1,
1325            session_key: "sess-1".to_string(),
1326            digest_kind: "summary_short".to_string(),
1327            memory_id: 42,
1328            start_memory_id: Some(1),
1329            end_memory_id: Some(10),
1330            token_count: 200,
1331            created_at: "2026-01-01T00:00:00Z".to_string(),
1332        };
1333        let json = serde_json::to_value(&digest).unwrap();
1334        assert_eq!(json["session_key"], "sess-1");
1335        assert_eq!(json["memory_id"], 42);
1336        assert_eq!(json["token_count"], 200);
1337    }
1338
1339    #[tokio::test]
1340    async fn test_overview_with_enqueued_job_and_evidence() {
1341        let test = test_app().await;
1342        create_namespace_in_test(&test.state, "data-test-ns").await;
1343
1344        {
1345            let s = test.state.read().await;
1346            let ns = s
1347                .namespace_repo
1348                .get_by_name("data-test-ns")
1349                .await
1350                .unwrap()
1351                .expect("namespace exists");
1352
1353            let mem_id = s
1354                .memory_repo
1355                .store(nexus_storage::StoreMemoryParams {
1356                    namespace_id: ns.id,
1357                    content: "test memory for evidence",
1358                    category: &nexus_core::MemoryCategory::Session,
1359                    memory_lane_type: None,
1360                    labels: &[],
1361                    metadata: &serde_json::json!({}),
1362                    embedding: None,
1363                    embedding_model: None,
1364                })
1365                .await
1366                .unwrap();
1367
1368            s.memory_repo
1369                .enqueue_job(nexus_storage::EnqueueJobParams {
1370                    namespace_id: ns.id,
1371                    job_type: "derive",
1372                    priority: 5,
1373                    perspective: None,
1374                    payload: &serde_json::json!({"test": true}),
1375                })
1376                .await
1377                .unwrap();
1378
1379            s.memory_repo
1380                .store_with_lineage(nexus_storage::StoreMemoryWithLineageParams {
1381                    store: nexus_storage::StoreMemoryParams {
1382                        namespace_id: ns.id,
1383                        content: "derived from evidence",
1384                        category: &nexus_core::MemoryCategory::Facts,
1385                        memory_lane_type: None,
1386                        labels: &[],
1387                        metadata: &serde_json::json!({"cognitive": {"level": "derived"}}),
1388                        embedding: None,
1389                        embedding_model: None,
1390                    },
1391                    source_memory_ids: &[mem_id.id],
1392                    evidence_role: "source",
1393                })
1394                .await
1395                .unwrap();
1396
1397            let _ = mem_id;
1398        }
1399
1400        let resp = test
1401            .app
1402            .oneshot(
1403                Request::builder()
1404                    .uri("/api/cognition/overview?namespace=data-test-ns")
1405                    .body(Body::empty())
1406                    .unwrap(),
1407            )
1408            .await
1409            .unwrap();
1410
1411        assert_eq!(resp.status(), StatusCode::OK);
1412        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1413            .await
1414            .unwrap();
1415        let json = body_to_json(body);
1416
1417        assert_eq!(json["success"], true);
1418        assert_eq!(json["jobs_by_status"]["pending"], 1);
1419        assert!(json["evidence_count"].as_i64().unwrap() >= 1);
1420    }
1421
1422    // ---- Dashboard tests ----
1423
1424    #[tokio::test]
1425    async fn test_dashboard_missing_namespace_returns_400() {
1426        let test = test_app().await;
1427        let resp = test
1428            .app
1429            .oneshot(
1430                Request::builder()
1431                    .uri("/api/cognition/dashboard")
1432                    .body(Body::empty())
1433                    .unwrap(),
1434            )
1435            .await
1436            .unwrap();
1437        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1438    }
1439
1440    #[tokio::test]
1441    async fn test_dashboard_unknown_namespace_returns_404() {
1442        let test = test_app().await;
1443        let resp = test
1444            .app
1445            .oneshot(
1446                Request::builder()
1447                    .uri("/api/cognition/dashboard?namespace=nonexistent")
1448                    .body(Body::empty())
1449                    .unwrap(),
1450            )
1451            .await
1452            .unwrap();
1453        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1454    }
1455
1456    #[tokio::test]
1457    async fn test_dashboard_returns_all_sections() {
1458        let test = test_app().await;
1459        create_namespace_in_test(&test.state, "dash-empty-ns").await;
1460
1461        let resp = test
1462            .app
1463            .oneshot(
1464                Request::builder()
1465                    .uri("/api/cognition/dashboard?namespace=dash-empty-ns")
1466                    .body(Body::empty())
1467                    .unwrap(),
1468            )
1469            .await
1470            .unwrap();
1471
1472        assert_eq!(resp.status(), StatusCode::OK);
1473        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1474            .await
1475            .unwrap();
1476        let json = body_to_json(body);
1477
1478        assert_eq!(json["success"], true);
1479        assert_eq!(json["namespace"], "dash-empty-ns");
1480
1481        // Dream section
1482        assert_eq!(json["dream"]["completed_reflections"], 0);
1483        assert_eq!(json["dream"]["completed_digests"], 0);
1484        assert_eq!(json["dream"]["failed_jobs"], 0);
1485        assert_eq!(json["dream"]["pending_jobs"], 0);
1486        assert!(json["dream"]["last_dream_at"].is_null());
1487
1488        // Digest section
1489        assert_eq!(json["digest"]["total_digests"], 0);
1490        assert_eq!(json["digest"]["sessions_with_cognition"], 0);
1491        assert!(json["digest"]["latest_digest_at"].is_null());
1492        assert!(json["digest"]["latest_digest_age_seconds"].is_null());
1493
1494        // Recall section
1495        assert_eq!(json["recall"]["raw"], 0);
1496        assert_eq!(json["recall"]["explicit"], 0);
1497        assert_eq!(json["recall"]["contradiction"], 0);
1498        assert_eq!(json["recall"]["total"], 0);
1499
1500        // Adaptive section
1501        assert!(json["adaptive"]["enabled"].is_boolean());
1502        assert!(json["adaptive"]["current_interval_secs"].as_u64().is_some());
1503        assert!(json["adaptive"]["contradiction_density"].is_number());
1504    }
1505
1506    #[tokio::test]
1507    async fn test_dashboard_populates_recall_and_dream_from_data() {
1508        let test = test_app().await;
1509        create_namespace_in_test(&test.state, "dash-data-ns").await;
1510
1511        {
1512            let state = test.state.read().await;
1513            let namespace = state
1514                .namespace_repo
1515                .get_by_name("dash-data-ns")
1516                .await
1517                .unwrap()
1518                .unwrap();
1519
1520            // Store memories at different cognitive levels.
1521            for (content, level) in [
1522                ("raw event", CognitiveLevel::Raw),
1523                ("explicit fact", CognitiveLevel::Explicit),
1524                ("derived insight", CognitiveLevel::Derived),
1525                ("contradiction note", CognitiveLevel::Contradiction),
1526            ] {
1527                state
1528                    .memory_repo
1529                    .store(nexus_storage::repository::StoreMemoryParams {
1530                        namespace_id: namespace.id,
1531                        content,
1532                        category: &nexus_core::MemoryCategory::Facts,
1533                        memory_lane_type: None,
1534                        labels: &[],
1535                        metadata: &serde_json::json!({
1536                            "cognitive": {
1537                                "level": level.as_str(),
1538                                "observer": "claude-code",
1539                                "subject": "claude-code",
1540                                "confidence": 0.9,
1541                                "generated_by": "test"
1542                            }
1543                        }),
1544                        embedding: None,
1545                        embedding_model: None,
1546                    })
1547                    .await
1548                    .unwrap();
1549            }
1550        }
1551
1552        let resp = test
1553            .app
1554            .oneshot(
1555                Request::builder()
1556                    .uri("/api/cognition/dashboard?namespace=dash-data-ns")
1557                    .body(Body::empty())
1558                    .unwrap(),
1559            )
1560            .await
1561            .unwrap();
1562
1563        assert_eq!(resp.status(), StatusCode::OK);
1564        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1565            .await
1566            .unwrap();
1567        let json = body_to_json(body);
1568
1569        assert_eq!(json["recall"]["raw"], 1);
1570        assert_eq!(json["recall"]["explicit"], 1);
1571        assert_eq!(json["recall"]["derived"], 1);
1572        assert_eq!(json["recall"]["contradiction"], 1);
1573        assert_eq!(json["recall"]["total"], 4);
1574        // contradiction_density = 1/4 = 0.25
1575        assert!((json["adaptive"]["contradiction_density"].as_f64().unwrap() - 0.25).abs() < 0.01);
1576    }
1577
1578    #[test]
1579    fn test_dashboard_response_serialization_roundtrip() {
1580        let dash = DashboardResponse {
1581            success: true,
1582            namespace: "test".to_string(),
1583            dream: DreamState {
1584                completed_reflections: 5,
1585                completed_digests: 3,
1586                failed_jobs: 1,
1587                pending_jobs: 2,
1588                last_dream_at: Some("2026-03-27T12:00:00Z".to_string()),
1589            },
1590            digest: DigestFreshnessState {
1591                total_digests: 10,
1592                sessions_with_cognition: 4,
1593                latest_digest_age_seconds: Some(3600),
1594                latest_digest_at: Some("2026-03-27T11:00:00Z".to_string()),
1595            },
1596            recall: RecallComposition {
1597                raw: 50,
1598                explicit: 30,
1599                derived: 10,
1600                summary_short: 5,
1601                summary_long: 3,
1602                contradiction: 2,
1603                total: 100,
1604            },
1605            adaptive: AdaptiveDreamState {
1606                enabled: true,
1607                current_interval_secs: 120,
1608                min_interval_secs: 60,
1609                max_interval_secs: 600,
1610                contradiction_count: 2,
1611                contradiction_density: 0.02,
1612            },
1613        };
1614        let json = serde_json::to_value(&dash).unwrap();
1615        assert_eq!(json["success"], true);
1616        assert_eq!(json["dream"]["completed_reflections"], 5);
1617        assert_eq!(json["recall"]["total"], 100);
1618        assert_eq!(json["adaptive"]["enabled"], true);
1619        assert_eq!(json["adaptive"]["current_interval_secs"], 120);
1620
1621        // Round-trip
1622        let deserialized: DashboardResponse = serde_json::from_value(json).unwrap();
1623        assert_eq!(deserialized.dream.completed_reflections, 5);
1624        assert_eq!(deserialized.recall.total, 100);
1625    }
1626}