1use std::{
22 collections::HashMap,
23 net::SocketAddr,
24 num::NonZeroUsize,
25 sync::{
26 atomic::{AtomicU64, Ordering},
27 Arc,
28 },
29 time::Instant,
30};
31
32use axum::{
33 extract::{Path, Query, State},
34 http::{HeaderMap, StatusCode},
35 response::{
36 sse::{Event, KeepAlive, Sse},
37 IntoResponse, Json,
38 },
39 routing::{delete, get, post},
40 Router,
41};
42use brain_core::ApiKeyConfig;
43use serde::{Deserialize, Serialize};
44use tokio::sync::Mutex;
45use uuid::Uuid;
46
47use signal::{Signal, SignalResponse, SignalSource};
48
49#[derive(Debug, thiserror::Error)]
52pub enum HttpAdapterError {
53 #[error("Server error: {0}")]
54 Server(String),
55}
56
57#[derive(Debug, Deserialize)]
61pub struct SignalRequest {
62 pub source: Option<String>,
63 pub channel: Option<String>,
64 pub sender: Option<String>,
65 pub content: String,
66 pub metadata: Option<HashMap<String, String>>,
67 pub namespace: Option<String>,
69 pub agent: Option<String>,
71 pub session_id: Option<String>,
73}
74
75#[derive(Debug, Deserialize)]
77pub struct SearchRequest {
78 pub query: String,
79 pub top_k: Option<usize>,
80 pub namespace: Option<String>,
82}
83
84#[derive(Debug, Serialize)]
86pub struct NamespaceJson {
87 pub namespace: String,
88 pub fact_count: i64,
89 pub episode_count: i64,
90}
91
92#[derive(Debug, Serialize)]
94pub struct ExportJson {
95 pub version: String,
96 pub exported_at: String,
97 pub facts: Vec<signal::ExportedFact>,
98 pub episodes: Vec<signal::ExportedEpisode>,
99}
100
101#[derive(Debug, Deserialize)]
103pub struct ImportRequest {
104 pub facts: Vec<signal::ExportedFact>,
105 pub episodes: Vec<signal::ExportedEpisode>,
106 #[serde(default)]
108 pub dry_run: bool,
109}
110
111#[derive(Debug, Serialize)]
113pub struct ImportResponse {
114 pub facts_imported: usize,
115 pub episodes_imported: usize,
116 pub facts_already_existed: usize,
117 pub episodes_already_existed: usize,
118 pub embedded: usize,
119 pub embed_failed: usize,
120}
121
122#[derive(Debug, Serialize)]
124pub struct FactJson {
125 pub id: String,
126 pub namespace: String,
127 pub category: String,
128 pub subject: String,
129 pub predicate: String,
130 pub object: String,
131 pub confidence: f64,
132 pub distance: Option<f32>,
133}
134
135#[derive(Debug, Serialize)]
137pub struct HealthResponse {
138 pub status: &'static str,
139 pub version: &'static str,
140}
141
142#[derive(Default)]
146struct GaugeCache {
147 fact_count: i64,
148 episode_count: i64,
149 last_updated_ms: u64,
150}
151
152const GAUGE_TTL_MS: u64 = 10_000;
153
154#[derive(Default)]
156pub struct Metrics {
157 pub signals_total: AtomicU64,
159 pub signals_ok: AtomicU64,
161 pub signals_error: AtomicU64,
163 pub search_total: AtomicU64,
165 pub facts_total: AtomicU64,
167 pub signals_latency_ms_total: AtomicU64,
169 gauge_cache: std::sync::Mutex<GaugeCache>,
171}
172
173impl Metrics {
174 pub fn refresh_gauges(&self, processor: &signal::SignalProcessor) {
176 let now_ms = std::time::SystemTime::now()
177 .duration_since(std::time::UNIX_EPOCH)
178 .unwrap_or_default()
179 .as_millis() as u64;
180 let mut guard = self.gauge_cache.lock().unwrap_or_else(|e| e.into_inner());
181 if now_ms.saturating_sub(guard.last_updated_ms) < GAUGE_TTL_MS {
182 return;
183 }
184 let pool = processor.episodic().pool();
185 let fact_count = pool
186 .with_conn(|conn| {
187 let mut stmt = conn.prepare("SELECT COUNT(*) FROM semantic_facts")?;
188 let row: i64 = stmt.query_row([], |r| r.get(0))?;
189 Ok(row)
190 })
191 .unwrap_or(0);
192 let episode_count = pool
193 .with_conn(|conn| {
194 let mut stmt = conn.prepare("SELECT COUNT(*) FROM episodes")?;
195 let row: i64 = stmt.query_row([], |r| r.get(0))?;
196 Ok(row)
197 })
198 .unwrap_or(0);
199 guard.fact_count = fact_count;
200 guard.episode_count = episode_count;
201 guard.last_updated_ms = now_ms;
202 }
203
204 pub fn render(&self, subsystems: &brain_core::metrics::SubsystemMetrics) -> String {
206 let signals_total = self.signals_total.load(Ordering::Relaxed);
207 let signals_ok = self.signals_ok.load(Ordering::Relaxed);
208 let signals_error = self.signals_error.load(Ordering::Relaxed);
209 let search_total = self.search_total.load(Ordering::Relaxed);
210 let facts_total = self.facts_total.load(Ordering::Relaxed);
211 let latency_ms = self.signals_latency_ms_total.load(Ordering::Relaxed);
212 let sub = subsystems.snapshot();
213 let (fact_count, episode_count) = {
214 let g = self.gauge_cache.lock().unwrap_or_else(|e| e.into_inner());
215 (g.fact_count, g.episode_count)
216 };
217
218 format!(
219 "# HELP brain_signals_total Total signal requests received.\n\
220 # TYPE brain_signals_total counter\n\
221 brain_signals_total {signals_total}\n\
222 # HELP brain_signals_ok_total Successful signal requests.\n\
223 # TYPE brain_signals_ok_total counter\n\
224 brain_signals_ok_total {signals_ok}\n\
225 # HELP brain_signals_error_total Failed signal requests (5xx).\n\
226 # TYPE brain_signals_error_total counter\n\
227 brain_signals_error_total {signals_error}\n\
228 # HELP brain_search_total Total memory search requests.\n\
229 # TYPE brain_search_total counter\n\
230 brain_search_total {search_total}\n\
231 # HELP brain_facts_total Total memory facts requests.\n\
232 # TYPE brain_facts_total counter\n\
233 brain_facts_total {facts_total}\n\
234 # HELP brain_signals_latency_ms_total Cumulative signal processing latency in ms.\n\
235 # TYPE brain_signals_latency_ms_total counter\n\
236 brain_signals_latency_ms_total {latency_ms}\n\
237 # HELP brain_facts_count Total semantic facts currently stored.\n\
238 # TYPE brain_facts_count gauge\n\
239 brain_facts_count {fact_count}\n\
240 # HELP brain_episodes_count Total episodes currently stored.\n\
241 # TYPE brain_episodes_count gauge\n\
242 brain_episodes_count {episode_count}\n\
243 # HELP brain_embedding_requests_total Total embedding requests.\n\
244 # TYPE brain_embedding_requests_total counter\n\
245 brain_embedding_requests_total {embed_req}\n\
246 # HELP brain_embedding_fallbacks_total Times deterministic fallback vectors were used.\n\
247 # TYPE brain_embedding_fallbacks_total counter\n\
248 brain_embedding_fallbacks_total {embed_fb}\n\
249 # HELP brain_consolidation_runs_total Number of consolidation cycles completed.\n\
250 # TYPE brain_consolidation_runs_total counter\n\
251 brain_consolidation_runs_total {cons_runs}\n\
252 # HELP brain_consolidation_pruned_total Episodes pruned during consolidation.\n\
253 # TYPE brain_consolidation_pruned_total counter\n\
254 brain_consolidation_pruned_total {cons_pruned}\n\
255 # HELP brain_consolidation_promoted_total Episodes promoted to semantic facts.\n\
256 # TYPE brain_consolidation_promoted_total counter\n\
257 brain_consolidation_promoted_total {cons_promoted}\n\
258 # HELP brain_circuit_open_total Times a circuit breaker opened.\n\
259 # TYPE brain_circuit_open_total counter\n\
260 brain_circuit_open_total {cb_open}\n\
261 # HELP brain_circuit_resets_total Times a circuit breaker reset on success.\n\
262 # TYPE brain_circuit_resets_total counter\n\
263 brain_circuit_resets_total {cb_reset}\n\
264 # HELP brain_intent_classifications_total Total intent classifications.\n\
265 # TYPE brain_intent_classifications_total counter\n\
266 brain_intent_classifications_total {intents}\n\
267 # HELP brain_intent_llm_fallbacks_total Intents resolved via LLM fallback.\n\
268 # TYPE brain_intent_llm_fallbacks_total counter\n\
269 brain_intent_llm_fallbacks_total {intent_llm}\n",
270 embed_req = sub.embedding_requests_total,
271 embed_fb = sub.embedding_fallbacks_total,
272 cons_runs = sub.consolidation_runs_total,
273 cons_pruned = sub.consolidation_pruned_total,
274 cons_promoted = sub.consolidation_promoted_total,
275 cb_open = sub.circuit_open_total,
276 cb_reset = sub.circuit_resets_total,
277 intents = sub.intent_classifications_total,
278 intent_llm = sub.intent_llm_fallbacks_total,
279 )
280 }
281}
282
283const CACHE_CAPACITY: usize = 1000;
287
288pub struct AppState {
290 processor: Arc<signal::SignalProcessor>,
291 cache: Mutex<lru::LruCache<Uuid, SignalResponse>>,
293 api_keys: Vec<ApiKeyConfig>,
295 metrics: Arc<Metrics>,
297}
298
299fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
303 headers
304 .get("authorization")
305 .and_then(|v| v.to_str().ok())
306 .and_then(brain_core::auth::extract_bearer_from_value)
307}
308
309fn check_auth(
312 state: &AppState,
313 headers: &HeaderMap,
314 permission: &str,
315) -> Result<(), (StatusCode, String)> {
316 let provided_key = extract_bearer(headers);
317 let result = brain_core::check_auth(&state.api_keys, provided_key, permission);
318 if result.is_allowed() {
319 Ok(())
320 } else {
321 Err((
322 StatusCode::UNAUTHORIZED,
323 result
324 .error_message(permission)
325 .unwrap_or_else(|| "Unauthorized".to_string()),
326 ))
327 }
328}
329
330pub fn create_router(
338 processor: Arc<signal::SignalProcessor>,
339 api_keys: Vec<ApiKeyConfig>,
340 cors_enabled: bool,
341) -> Router {
342 let state = Arc::new(AppState {
343 processor,
344 cache: Mutex::new(lru::LruCache::new(
345 NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
346 )),
347 api_keys,
348 metrics: Arc::new(Metrics::default()),
349 });
350
351 let v1_routes = Router::new()
354 .route("/v1/signals", post(post_signal_handler))
355 .route("/v1/signals/:id", get(get_signal_handler))
356 .route("/v1/memory/search", post(search_memory_handler))
357 .route("/v1/memory/facts", get(get_facts_handler))
358 .route("/v1/memory/namespaces", get(get_namespaces_handler))
359 .route("/v1/memory/export", get(export_memory_handler))
360 .route("/v1/memory/import", post(import_memory_handler))
361 .route("/v1/schedules", get(list_schedules_handler))
362 .route("/v1/schedules/:id", delete(cancel_schedule_handler))
363 .route("/v1/events", get(sse_events_handler))
364 .layer(axum::extract::DefaultBodyLimit::max(1_048_576))
365 .layer(tower::limit::ConcurrencyLimitLayer::new(100));
366
367 let router = Router::new()
368 .route("/health", get(health_handler))
369 .route("/metrics", get(metrics_handler))
370 .route("/ui", get(ui_handler))
371 .route("/openapi.json", get(openapi_handler))
372 .route("/api", get(swagger_ui_handler))
373 .merge(v1_routes)
374 .with_state(state);
375
376 if cors_enabled {
377 router.layer(brain_core::cors::localhost_cors())
378 } else {
379 router
380 }
381}
382
383pub async fn serve(
387 processor: Arc<signal::SignalProcessor>,
388 host: &str,
389 port: u16,
390) -> anyhow::Result<()> {
391 let cors_enabled = processor.config().adapters.http.cors;
392 let api_keys = processor.config().access.api_keys.clone();
393 let router = create_router(processor, api_keys, cors_enabled);
394 let addr: SocketAddr = format!("{host}:{port}").parse()?;
395 tracing::info!("Synapse HTTP online at http://{addr}");
396 let listener = tokio::net::TcpListener::bind(addr).await?;
397 axum::serve(listener, router).await?;
398 Ok(())
399}
400
401async fn health_handler() -> Json<HealthResponse> {
405 Json(HealthResponse {
406 status: "ok",
407 version: env!("CARGO_PKG_VERSION"),
408 })
409}
410
411async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
413 state.metrics.refresh_gauges(&state.processor);
414 let body = state.metrics.render(state.processor.metrics());
415 (
416 [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
417 body,
418 )
419}
420
421const UI_HTML: &str = include_str!("../assets/ui.html");
423
424async fn ui_handler() -> impl IntoResponse {
426 ([("content-type", "text/html; charset=utf-8")], UI_HTML)
427}
428
429static OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
434
435async fn openapi_handler() -> impl IntoResponse {
437 let spec = OPENAPI_JSON.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
438 ([("content-type", "application/json")], spec)
439}
440
441const SWAGGER_UI_HTML: &str = include_str!("../assets/swagger.html");
443
444async fn swagger_ui_handler() -> impl IntoResponse {
446 (
447 [("content-type", "text/html; charset=utf-8")],
448 SWAGGER_UI_HTML,
449 )
450}
451
452async fn post_signal_handler(
454 State(state): State<Arc<AppState>>,
455 headers: HeaderMap,
456 Json(body): Json<SignalRequest>,
457) -> Result<Json<SignalResponse>, (StatusCode, String)> {
458 check_auth(&state, &headers, "write")?;
459
460 let t0 = Instant::now();
461 state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
462
463 let source = SignalSource::parse(body.source.as_deref(), SignalSource::Http);
464 let signal = Signal::from_adapter_request(signal::AdapterRequest {
465 source,
466 content: body.content,
467 channel: body.channel,
468 sender: body.sender,
469 metadata: body.metadata,
470 namespace: body.namespace,
471 agent: body.agent,
472 session_id: body.session_id,
473 default_channel: "http".to_string(),
474 default_sender: "apiclient".to_string(),
475 });
476
477 let signal_id = signal.id;
478 let result = state.processor.process(signal).await;
479
480 let elapsed_ms = t0.elapsed().as_millis() as u64;
481 state
482 .metrics
483 .signals_latency_ms_total
484 .fetch_add(elapsed_ms, Ordering::Relaxed);
485
486 let response = match result {
487 Ok(r) => {
488 state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
489 tracing::info!(
490 signal_id = %signal_id,
491 latency_ms = elapsed_ms,
492 "signal processed"
493 );
494 r
495 }
496 Err(e) => {
497 state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
498 tracing::error!(
499 signal_id = %signal_id,
500 latency_ms = elapsed_ms,
501 error = %e,
502 "signal processing failed"
503 );
504 return Err((
506 StatusCode::INTERNAL_SERVER_ERROR,
507 serde_json::json!({
508 "error": "Signal processing failed",
509 "details": e.to_string()
510 })
511 .to_string(),
512 ));
513 }
514 };
515
516 state.cache.lock().await.put(signal_id, response.clone());
518
519 Ok(Json(response))
520}
521
522async fn get_signal_handler(
524 State(state): State<Arc<AppState>>,
525 headers: HeaderMap,
526 Path(id): Path<String>,
527) -> Result<Json<SignalResponse>, (StatusCode, String)> {
528 check_auth(&state, &headers, "read")?;
529
530 let uuid = Uuid::parse_str(&id)
531 .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
532
533 let mut cache = state.cache.lock().await;
534 match cache.get(&uuid) {
535 Some(resp) => Ok(Json(resp.clone())),
536 None => Err((
537 StatusCode::NOT_FOUND,
538 format!("Signal {uuid} not found in cache"),
539 )),
540 }
541}
542
543async fn search_memory_handler(
545 State(state): State<Arc<AppState>>,
546 headers: HeaderMap,
547 Json(body): Json<SearchRequest>,
548) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
549 check_auth(&state, &headers, "read")?;
550
551 state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
552 let t0 = Instant::now();
553 let top_k = body.top_k.unwrap_or(10);
554 let namespace = body.namespace.as_deref();
555 let results = state
556 .processor
557 .search_facts(&body.query, top_k, namespace)
558 .await;
559 tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
560
561 let facts = results
562 .into_iter()
563 .map(|r| FactJson {
564 id: r.fact.id,
565 namespace: r.fact.namespace,
566 category: r.fact.category,
567 subject: r.fact.subject,
568 predicate: r.fact.predicate,
569 object: r.fact.object,
570 confidence: r.fact.confidence,
571 distance: Some(r.distance),
572 })
573 .collect();
574
575 Ok(Json(facts))
576}
577
578async fn get_facts_handler(
582 State(state): State<Arc<AppState>>,
583 headers: HeaderMap,
584 Query(params): Query<HashMap<String, String>>,
585) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
586 check_auth(&state, &headers, "read")?;
587
588 state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
589 let namespace = params.get("namespace").map(|s| s.as_str());
590 let facts = state
591 .processor
592 .list_facts(namespace)
593 .into_iter()
594 .map(|f| FactJson {
595 id: f.id,
596 namespace: f.namespace,
597 category: f.category,
598 subject: f.subject,
599 predicate: f.predicate,
600 object: f.object,
601 confidence: f.confidence,
602 distance: None,
603 })
604 .collect();
605
606 Ok(Json(facts))
607}
608
609async fn get_namespaces_handler(
611 State(state): State<Arc<AppState>>,
612 headers: HeaderMap,
613) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
614 check_auth(&state, &headers, "read")?;
615
616 let namespaces = state
617 .processor
618 .list_namespaces()
619 .into_iter()
620 .map(|n| NamespaceJson {
621 namespace: n.namespace,
622 fact_count: n.fact_count,
623 episode_count: n.episode_count,
624 })
625 .collect();
626
627 Ok(Json(namespaces))
628}
629
630async fn export_memory_handler(
632 State(state): State<Arc<AppState>>,
633 headers: HeaderMap,
634) -> Result<Json<ExportJson>, (StatusCode, String)> {
635 check_auth(&state, &headers, "read")?;
636
637 let facts = state.processor.export_facts().map_err(|e| {
638 (
639 StatusCode::INTERNAL_SERVER_ERROR,
640 format!("Export failed: {e}"),
641 )
642 })?;
643 let episodes = state.processor.export_episodes().map_err(|e| {
644 (
645 StatusCode::INTERNAL_SERVER_ERROR,
646 format!("Export failed: {e}"),
647 )
648 })?;
649
650 Ok(Json(ExportJson {
651 version: env!("CARGO_PKG_VERSION").to_string(),
652 exported_at: chrono::Utc::now().to_rfc3339(),
653 facts,
654 episodes,
655 }))
656}
657
658async fn import_memory_handler(
660 State(state): State<Arc<AppState>>,
661 headers: HeaderMap,
662 Json(body): Json<ImportRequest>,
663) -> Result<Json<ImportResponse>, (StatusCode, String)> {
664 check_auth(&state, &headers, "write")?;
665
666 if body.dry_run {
667 return Ok(Json(ImportResponse {
669 facts_imported: body.facts.len(),
670 episodes_imported: body.episodes.len(),
671 facts_already_existed: 0,
672 episodes_already_existed: 0,
673 embedded: 0,
674 embed_failed: 0,
675 }));
676 }
677
678 let (facts_imported, new_fact_indices) =
679 state.processor.import_facts(&body.facts).map_err(|e| {
680 (
681 StatusCode::INTERNAL_SERVER_ERROR,
682 format!("Import failed: {e}"),
683 )
684 })?;
685 let episodes_imported = state
686 .processor
687 .import_episodes(&body.episodes)
688 .map_err(|e| {
689 (
690 StatusCode::INTERNAL_SERVER_ERROR,
691 format!("Import failed: {e}"),
692 )
693 })?;
694
695 let mut embedded = 0;
696 let mut embed_failed = 0;
697 if !new_fact_indices.is_empty() {
698 let new_facts: Vec<signal::ExportedFact> = new_fact_indices
699 .iter()
700 .map(|&idx| body.facts[idx].clone())
701 .collect();
702 let (e, f) = state.processor.reembed_facts(&new_facts).await;
703 embedded = e;
704 embed_failed = f;
705 }
706
707 Ok(Json(ImportResponse {
708 facts_imported,
709 episodes_imported,
710 facts_already_existed: body.facts.len() - facts_imported,
711 episodes_already_existed: body.episodes.len() - episodes_imported,
712 embedded,
713 embed_failed,
714 }))
715}
716
717async fn list_schedules_handler(
721 State(state): State<Arc<AppState>>,
722 headers: HeaderMap,
723 Query(params): Query<HashMap<String, String>>,
724) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
725 check_auth(&state, &headers, "read")?;
726
727 let namespace = params.get("namespace").map(|s| s.as_str());
728 let intents = state
729 .processor
730 .list_scheduled_intents(namespace)
731 .map_err(|e| {
732 (
733 StatusCode::INTERNAL_SERVER_ERROR,
734 format!("Failed to list schedules: {e}"),
735 )
736 })?;
737
738 let json: Vec<serde_json::Value> = intents
739 .iter()
740 .map(|i| {
741 serde_json::json!({
742 "id": i.id,
743 "description": i.description,
744 "cron": i.cron,
745 "namespace": i.namespace,
746 "created_at": i.created_at,
747 "status": i.status,
748 })
749 })
750 .collect();
751
752 Ok(Json(json))
753}
754
755async fn cancel_schedule_handler(
757 State(state): State<Arc<AppState>>,
758 headers: HeaderMap,
759 Path(id): Path<String>,
760) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
761 check_auth(&state, &headers, "write")?;
762
763 let cancelled = state.processor.cancel_scheduled_intent(&id).map_err(|e| {
764 (
765 StatusCode::INTERNAL_SERVER_ERROR,
766 format!("Failed to cancel schedule: {e}"),
767 )
768 })?;
769
770 if cancelled {
771 Ok(Json(serde_json::json!({"cancelled": true, "id": id})))
772 } else {
773 Err((
774 StatusCode::NOT_FOUND,
775 format!("No scheduled intent found with ID: {id}"),
776 ))
777 }
778}
779
780async fn sse_events_handler(
790 State(state): State<Arc<AppState>>,
791 headers: HeaderMap,
792) -> Result<
793 Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
794 (StatusCode, String),
795> {
796 check_auth(&state, &headers, "read")?;
797
798 let mut signal_rx = state.processor.subscribe_events();
799 let mut notif_rx = state.processor.notification_router().map(|r| r.subscribe());
800
801 let stream = async_stream::stream! {
802 loop {
803 tokio::select! {
804 result = signal_rx.recv() => {
805 match result {
806 Ok(event) => {
807 let payload = serde_json::json!({
808 "type": "signal",
809 "signal_id": event.signal_id.to_string(),
810 "source": format!("{:?}", event.source),
811 "status": format!("{:?}", event.status),
812 "response": event.response,
813 "facts_used": event.facts_used,
814 "episodes_used": event.episodes_used,
815 });
816 yield Ok(Event::default()
817 .event("signal")
818 .json_data(payload)
819 .unwrap_or_else(|_| Event::default().data("{}")));
820 }
821 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
822 tracing::warn!(skipped = n, "SSE signal stream lagged");
823 yield Ok(Event::default()
824 .event("error")
825 .data(format!("{{\"lagged\":{n}}}")));
826 }
827 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
828 }
829 }
830 result = async {
831 match notif_rx.as_mut() {
832 Some(rx) => rx.recv().await,
833 None => std::future::pending().await,
834 }
835 } => {
836 match result {
837 Ok(notification) => {
838 let payload = serde_json::json!({
839 "type": "proactive",
840 "content": notification.content,
841 "triggered_by": notification.triggered_by,
842 "priority": notification.priority,
843 "agent": notification.agent,
844 });
845 yield Ok(Event::default()
846 .event("notification")
847 .json_data(payload)
848 .unwrap_or_else(|_| Event::default().data("{}")));
849 }
850 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
851 tracing::warn!(skipped = n, "SSE notification stream lagged");
852 yield Ok(Event::default()
853 .event("error")
854 .data(format!("{{\"lagged\":{n}}}")));
855 }
856 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
857 notif_rx = None;
859 }
860 }
861 }
862 }
863 }
864 };
865
866 Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
867}
868
869#[cfg(test)]
874mod tests {
875 use super::*;
876
877 async fn make_router() -> (Router, tempfile::TempDir, String) {
879 let temp = tempfile::tempdir().unwrap();
880 let mut config = brain_core::BrainConfig::default();
881 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
882 let api_key = config.access.api_keys.first().unwrap().key.clone();
883 let api_keys = config.access.api_keys.clone();
884 let processor = signal::SignalProcessor::new(config).await.unwrap();
885 let router = create_router(Arc::new(processor), api_keys, true);
886 (router, temp, api_key)
887 }
888
889 #[test]
890 fn test_parse_source_defaults_to_http() {
891 assert_eq!(
892 SignalSource::parse(None, SignalSource::Http),
893 SignalSource::Http
894 );
895 assert_eq!(
896 SignalSource::parse(Some("http"), SignalSource::Http),
897 SignalSource::Http
898 );
899 }
900
901 #[test]
902 fn test_parse_source_all_variants() {
903 assert_eq!(
904 SignalSource::parse(Some("cli"), SignalSource::Http),
905 SignalSource::Cli
906 );
907 assert_eq!(
908 SignalSource::parse(Some("ws"), SignalSource::Http),
909 SignalSource::WebSocket
910 );
911 assert_eq!(
912 SignalSource::parse(Some("mcp"), SignalSource::Http),
913 SignalSource::Mcp
914 );
915 assert_eq!(
916 SignalSource::parse(Some("grpc"), SignalSource::Http),
917 SignalSource::Grpc
918 );
919 }
920
921 #[test]
922 fn test_health_response_serializes() {
923 let h = HealthResponse {
924 status: "ok",
925 version: "1.0.0",
926 };
927 let json = serde_json::to_string(&h).unwrap();
928 assert!(json.contains("\"status\":\"ok\""));
929 assert!(json.contains("\"version\""));
930 }
931
932 #[test]
933 fn test_fact_json_serializes() {
934 let f = FactJson {
935 id: "abc".into(),
936 namespace: "personal".into(),
937 category: "personal".into(),
938 subject: "user".into(),
939 predicate: "likes".into(),
940 object: "Rust".into(),
941 confidence: 0.9,
942 distance: Some(0.05),
943 };
944 let json = serde_json::to_string(&f).unwrap();
945 assert!(json.contains("\"subject\":\"user\""));
946 assert!(json.contains("\"namespace\":\"personal\""));
947 assert!(json.contains("\"distance\":0.05"));
948 }
949
950 #[tokio::test]
952 async fn test_openapi_endpoint() {
953 use axum::body::Body;
954 use axum::http::{self, Request};
955 use tower::util::ServiceExt;
956
957 let (router, _tmp, _api_key) = make_router().await;
958
959 let request = Request::builder()
960 .method(http::Method::GET)
961 .uri("/openapi.json")
962 .body(Body::empty())
963 .unwrap();
964
965 let response = router.oneshot(request).await.unwrap();
966 assert_eq!(response.status(), StatusCode::OK);
967
968 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
969 .await
970 .unwrap();
971 let spec: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
972 assert_eq!(spec["openapi"], "3.0.3");
973 assert!(
974 spec["paths"]["/v1/signals"].is_object(),
975 "missing /v1/signals path"
976 );
977 assert!(
978 spec["components"]["schemas"]["FactJson"].is_object(),
979 "missing FactJson schema"
980 );
981 }
982
983 #[tokio::test]
985 async fn test_swagger_ui_endpoint() {
986 use axum::body::Body;
987 use axum::http::{self, Request};
988 use tower::util::ServiceExt;
989
990 let (router, _tmp, _api_key) = make_router().await;
991
992 let request = Request::builder()
993 .method(http::Method::GET)
994 .uri("/api")
995 .body(Body::empty())
996 .unwrap();
997
998 let response = router.oneshot(request).await.unwrap();
999 assert_eq!(response.status(), StatusCode::OK);
1000
1001 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1002 .await
1003 .unwrap();
1004 let body = std::str::from_utf8(&bytes).unwrap();
1005 assert!(body.contains("swagger-ui"), "missing Swagger UI element");
1006 assert!(body.contains("/openapi.json"), "missing spec URL reference");
1007 }
1008
1009 #[tokio::test]
1011 async fn test_ui_endpoint() {
1012 use axum::body::Body;
1013 use axum::http::{self, Request};
1014 use tower::util::ServiceExt;
1015
1016 let (router, _tmp, _api_key) = make_router().await;
1017
1018 let request = Request::builder()
1019 .method(http::Method::GET)
1020 .uri("/ui")
1021 .body(Body::empty())
1022 .unwrap();
1023
1024 let response = router.oneshot(request).await.unwrap();
1025 assert_eq!(response.status(), StatusCode::OK);
1026
1027 let ct = response
1028 .headers()
1029 .get("content-type")
1030 .unwrap()
1031 .to_str()
1032 .unwrap();
1033 assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
1034
1035 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1036 .await
1037 .unwrap();
1038 let body = std::str::from_utf8(&bytes).unwrap();
1039 assert!(body.contains("Brain Memory Explorer"), "missing page title");
1040 assert!(
1041 body.contains("/v1/memory/search"),
1042 "missing API endpoint reference"
1043 );
1044 }
1045
1046 #[tokio::test]
1048 async fn test_metrics_endpoint() {
1049 use axum::body::Body;
1050 use axum::http::{self, Request};
1051 use tower::util::ServiceExt;
1052
1053 let (router, _tmp, _api_key) = make_router().await;
1054
1055 let request = Request::builder()
1056 .method(http::Method::GET)
1057 .uri("/metrics")
1058 .body(Body::empty())
1059 .unwrap();
1060
1061 let response = router.oneshot(request).await.unwrap();
1062 assert_eq!(response.status(), StatusCode::OK);
1063
1064 let ct = response
1065 .headers()
1066 .get("content-type")
1067 .unwrap()
1068 .to_str()
1069 .unwrap();
1070 assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}");
1071
1072 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1073 .await
1074 .unwrap();
1075 let body = std::str::from_utf8(&bytes).unwrap();
1076 assert!(
1077 body.contains("brain_signals_total"),
1078 "missing counter in metrics output"
1079 );
1080 assert!(
1081 body.contains("brain_search_total"),
1082 "missing search counter"
1083 );
1084 }
1085
1086 #[tokio::test]
1088 async fn test_health_endpoint() {
1089 use axum::body::Body;
1090 use axum::http::{self, Request};
1091 use tower::util::ServiceExt;
1092
1093 let (router, _tmp, _api_key) = make_router().await;
1094
1095 let request = Request::builder()
1096 .method(http::Method::GET)
1097 .uri("/health")
1098 .body(Body::empty())
1099 .unwrap();
1100
1101 let response = router.oneshot(request).await.unwrap();
1102 assert_eq!(response.status(), StatusCode::OK);
1103
1104 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1105 .await
1106 .unwrap();
1107 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1108 assert_eq!(body["status"], "ok");
1109 }
1110
1111 #[tokio::test]
1113 async fn test_post_signal_no_auth_returns_401() {
1114 use axum::body::Body;
1115 use axum::http::{self, Request};
1116 use tower::util::ServiceExt;
1117
1118 let (router, _tmp, _api_key) = make_router().await;
1119
1120 let payload = serde_json::json!({"content": "Remember Rust is fast"});
1121 let request = Request::builder()
1122 .method(http::Method::POST)
1123 .uri("/v1/signals")
1124 .header("content-type", "application/json")
1125 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1126 .unwrap();
1127
1128 let response = router.oneshot(request).await.unwrap();
1129 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1130 }
1131
1132 #[tokio::test]
1134 async fn test_post_signal_invalid_key_returns_401() {
1135 use axum::body::Body;
1136 use axum::http::{self, Request};
1137 use tower::util::ServiceExt;
1138
1139 let (router, _tmp, _api_key) = make_router().await;
1140
1141 let payload = serde_json::json!({"content": "Remember Rust is fast"});
1142 let request = Request::builder()
1143 .method(http::Method::POST)
1144 .uri("/v1/signals")
1145 .header("content-type", "application/json")
1146 .header("authorization", "Bearer wrong-key")
1147 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1148 .unwrap();
1149
1150 let response = router.oneshot(request).await.unwrap();
1151 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1152 }
1153
1154 #[tokio::test]
1156 async fn test_post_signal_store_fact_with_auth() {
1157 use axum::body::Body;
1158 use axum::http::{self, Request};
1159 use tower::util::ServiceExt;
1160
1161 let (router, _tmp, api_key) = make_router().await;
1162
1163 let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1164 let request = Request::builder()
1165 .method(http::Method::POST)
1166 .uri("/v1/signals")
1167 .header("content-type", "application/json")
1168 .header("authorization", format!("Bearer {api_key}"))
1169 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1170 .unwrap();
1171
1172 let response = router.oneshot(request).await.unwrap();
1173 assert_eq!(response.status(), StatusCode::OK);
1174
1175 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1176 .await
1177 .unwrap();
1178 let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1179 assert_eq!(resp["status"], "Ok");
1180 }
1181
1182 #[tokio::test]
1184 async fn test_get_facts_no_auth_returns_401() {
1185 use axum::body::Body;
1186 use axum::http::{self, Request};
1187 use tower::util::ServiceExt;
1188
1189 let (router, _tmp, _api_key) = make_router().await;
1190
1191 let request = Request::builder()
1192 .method(http::Method::GET)
1193 .uri("/v1/memory/facts")
1194 .body(Body::empty())
1195 .unwrap();
1196
1197 let response = router.oneshot(request).await.unwrap();
1198 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1199 }
1200
1201 #[tokio::test]
1203 async fn test_get_facts_endpoint_with_auth() {
1204 use axum::body::Body;
1205 use axum::http::{self, Request};
1206 use tower::util::ServiceExt;
1207
1208 let (router, _tmp, api_key) = make_router().await;
1209
1210 let request = Request::builder()
1211 .method(http::Method::GET)
1212 .uri("/v1/memory/facts")
1213 .header("authorization", format!("Bearer {api_key}"))
1214 .body(Body::empty())
1215 .unwrap();
1216
1217 let response = router.oneshot(request).await.unwrap();
1218 assert_eq!(response.status(), StatusCode::OK);
1219
1220 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1221 .await
1222 .unwrap();
1223 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1224 assert!(body.is_array());
1225 }
1226
1227 #[tokio::test]
1229 async fn test_search_with_read_only_key() {
1230 use axum::body::Body;
1231 use axum::http::{self, Request};
1232 use tower::util::ServiceExt;
1233
1234 let temp = tempfile::tempdir().unwrap();
1235 let mut config = brain_core::BrainConfig::default();
1236 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1237 config.access.api_keys.push(ApiKeyConfig {
1239 key: "read-only-key".to_string(),
1240 name: "Read Only".to_string(),
1241 permissions: vec!["read".to_string()],
1242 });
1243 let api_keys = config.access.api_keys.clone();
1244 let processor = signal::SignalProcessor::new(config).await.unwrap();
1245 let router = create_router(Arc::new(processor), api_keys, true);
1246
1247 let payload = serde_json::json!({"query": "Rust", "top_k": 5});
1248 let request = Request::builder()
1249 .method(http::Method::POST)
1250 .uri("/v1/memory/search")
1251 .header("content-type", "application/json")
1252 .header("authorization", "Bearer read-only-key")
1253 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1254 .unwrap();
1255
1256 let response = router.oneshot(request).await.unwrap();
1257 assert_eq!(response.status(), StatusCode::OK);
1258 }
1259
1260 #[tokio::test]
1262 async fn test_post_signal_read_only_key_returns_401() {
1263 use axum::body::Body;
1264 use axum::http::{self, Request};
1265 use tower::util::ServiceExt;
1266
1267 let temp = tempfile::tempdir().unwrap();
1268 let mut config = brain_core::BrainConfig::default();
1269 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1270 config.access.api_keys.push(ApiKeyConfig {
1271 key: "read-only-key".to_string(),
1272 name: "Read Only".to_string(),
1273 permissions: vec!["read".to_string()],
1274 });
1275 let api_keys = config.access.api_keys.clone();
1276 let processor = signal::SignalProcessor::new(config).await.unwrap();
1277 let router = create_router(Arc::new(processor), api_keys, true);
1278
1279 let payload = serde_json::json!({"content": "Remember something"});
1280 let request = Request::builder()
1281 .method(http::Method::POST)
1282 .uri("/v1/signals")
1283 .header("content-type", "application/json")
1284 .header("authorization", "Bearer read-only-key")
1285 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1286 .unwrap();
1287
1288 let response = router.oneshot(request).await.unwrap();
1289 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1290 }
1291
1292 #[tokio::test]
1298 async fn test_http_store_signal_fact_persisted_in_db() {
1299 use axum::body::Body;
1300 use axum::http::{self, Request};
1301 use tower::util::ServiceExt;
1302
1303 let temp = tempfile::tempdir().unwrap();
1304 let mut config = brain_core::BrainConfig::default();
1305 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1306 let api_key = config.access.api_keys.first().unwrap().key.clone();
1307 let api_keys = config.access.api_keys.clone();
1308 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1309 let state = Arc::new(AppState {
1310 processor,
1311 cache: Mutex::new(lru::LruCache::new(
1312 NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1313 )),
1314 api_keys,
1315 metrics: Arc::new(Metrics::default()),
1316 });
1317
1318 let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1320 let post_req = Request::builder()
1321 .method(http::Method::POST)
1322 .uri("/v1/signals")
1323 .header("content-type", "application/json")
1324 .header("authorization", format!("Bearer {api_key}"))
1325 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1326 .unwrap();
1327
1328 let router = Router::new()
1329 .route("/v1/signals", post(post_signal_handler))
1330 .route("/v1/memory/facts", get(get_facts_handler))
1331 .with_state(state.clone());
1332
1333 let post_resp = router.clone().oneshot(post_req).await.unwrap();
1334 assert_eq!(post_resp.status(), StatusCode::OK);
1335
1336 let bytes = axum::body::to_bytes(post_resp.into_body(), usize::MAX)
1337 .await
1338 .unwrap();
1339 let resp_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1340 assert_eq!(resp_json["status"], "Ok");
1341 assert!(resp_json["memory_context"].is_object());
1343
1344 let get_req = Request::builder()
1346 .method(http::Method::GET)
1347 .uri("/v1/memory/facts")
1348 .header("authorization", format!("Bearer {api_key}"))
1349 .body(Body::empty())
1350 .unwrap();
1351
1352 let get_resp = router.oneshot(get_req).await.unwrap();
1353 assert_eq!(get_resp.status(), StatusCode::OK);
1354
1355 let bytes = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
1356 .await
1357 .unwrap();
1358 let facts: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1359 assert!(facts.is_array(), "Expected array of facts");
1360 assert!(
1361 !facts.as_array().unwrap().is_empty(),
1362 "Stored fact should appear in GET /v1/memory/facts"
1363 );
1364 }
1365
1366 #[tokio::test]
1371 async fn test_http_memory_search_returns_stored_fact() {
1372 use axum::body::Body;
1373 use axum::http::{self, Request};
1374 use tower::util::ServiceExt;
1375
1376 let temp = tempfile::tempdir().unwrap();
1377 let mut config = brain_core::BrainConfig::default();
1378 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1379 let api_key = config.access.api_keys.first().unwrap().key.clone();
1380 let api_keys = config.access.api_keys.clone();
1381 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1382
1383 let _ = processor
1385 .store_fact_direct("personal", "test", "Ferris", "is", "the Rust mascot", None)
1386 .await
1387 .unwrap();
1388
1389 let state = Arc::new(AppState {
1390 processor,
1391 cache: Mutex::new(lru::LruCache::new(
1392 NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1393 )),
1394 api_keys,
1395 metrics: Arc::new(Metrics::default()),
1396 });
1397
1398 let router = Router::new()
1399 .route("/v1/memory/search", post(search_memory_handler))
1400 .with_state(state);
1401
1402 let payload = serde_json::json!({"query": "Ferris Rust mascot", "top_k": 5});
1404 let request = Request::builder()
1405 .method(http::Method::POST)
1406 .uri("/v1/memory/search")
1407 .header("content-type", "application/json")
1408 .header("authorization", format!("Bearer {api_key}"))
1409 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1410 .unwrap();
1411
1412 let response = router.oneshot(request).await.unwrap();
1413 assert_eq!(response.status(), StatusCode::OK);
1414
1415 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1416 .await
1417 .unwrap();
1418 let results: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1419 assert!(results.is_array(), "Expected array of search results");
1422 }
1423
1424 #[tokio::test]
1426 async fn test_get_cached_signal_with_auth() {
1427 use axum::body::Body;
1428 use axum::http::{self, Request};
1429 use tower::util::ServiceExt;
1430
1431 let temp = tempfile::tempdir().unwrap();
1432 let mut config = brain_core::BrainConfig::default();
1433 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1434 let api_key = config.access.api_keys.first().unwrap().key.clone();
1435 let api_keys = config.access.api_keys.clone();
1436 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1437 let state = Arc::new(AppState {
1438 processor,
1439 cache: Mutex::new(lru::LruCache::new(
1440 NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1441 )),
1442 api_keys,
1443 metrics: Arc::new(Metrics::default()),
1444 });
1445
1446 let id = Uuid::new_v4();
1448 let fake_resp = SignalResponse::ok(id, "test response");
1449 state.cache.lock().await.put(id, fake_resp);
1450
1451 let router = Router::new()
1452 .route("/v1/signals/:id", get(get_signal_handler))
1453 .with_state(state);
1454
1455 let request = Request::builder()
1456 .method(http::Method::GET)
1457 .uri(format!("/v1/signals/{id}"))
1458 .header("authorization", format!("Bearer {api_key}"))
1459 .body(Body::empty())
1460 .unwrap();
1461
1462 let response = router.oneshot(request).await.unwrap();
1463 assert_eq!(response.status(), StatusCode::OK);
1464 }
1465}