1use std::{collections::HashMap, sync::atomic::Ordering, sync::Arc, time::Instant};
4
5use axum::{
6 body::Bytes,
7 extract::{Path, Query, State},
8 http::{HeaderMap, Response, StatusCode},
9 response::{
10 sse::{Event, KeepAlive, Sse},
11 IntoResponse, Json,
12 },
13};
14use uuid::Uuid;
15
16use crate::auth;
17use crate::state::AppState;
18use crate::types::*;
19use crate::validate;
20
21const UI_HTML: &str = include_str!("../assets/ui.html");
24static OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
25const SWAGGER_UI_HTML: &str = include_str!("../assets/swagger.html");
26
27pub async fn health_handler() -> Json<HealthResponse> {
31 Json(HealthResponse {
32 status: "ok",
33 version: env!("CARGO_PKG_VERSION"),
34 })
35}
36
37pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
39 state.metrics.refresh_gauges(&state.processor);
40 let body = state.metrics.render(state.processor.metrics());
41 (
42 [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
43 body,
44 )
45}
46
47pub async fn ui_handler() -> impl IntoResponse {
49 ([("content-type", "text/html; charset=utf-8")], UI_HTML)
50}
51
52pub async fn root_handler() -> axum::response::Redirect {
54 axum::response::Redirect::temporary("/ui")
55}
56
57pub async fn openapi_handler() -> impl IntoResponse {
59 let spec = OPENAPI_JSON.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
60 ([("content-type", "application/json")], spec)
61}
62
63pub async fn swagger_ui_handler() -> impl IntoResponse {
65 (
66 [("content-type", "text/html; charset=utf-8")],
67 SWAGGER_UI_HTML,
68 )
69}
70
71pub async fn post_signal_handler(
75 State(state): State<Arc<AppState>>,
76 headers: HeaderMap,
77 Json(body): Json<SignalRequest>,
78) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
79 auth::check_auth(&state, &headers, "write")?;
80
81 validate::check_content(&body.content)?;
82 if let Some(ns) = body.namespace.as_deref() {
83 validate::check_short_ident("namespace", ns)?;
84 }
85 if let Some(agent) = body.agent.as_deref() {
86 validate::check_short_ident("agent", agent)?;
87 }
88 if let Some(sid) = body.session_id.as_deref() {
89 validate::check_short_ident("session_id", sid)?;
90 }
91 if let Some(sender) = body.sender.as_deref() {
92 validate::check_short_ident("sender", sender)?;
93 }
94 if let Some(channel) = body.channel.as_deref() {
95 validate::check_short_ident("channel", channel)?;
96 }
97 if let Some(source) = body.source.as_deref() {
98 validate::check_short_ident("source", source)?;
99 }
100
101 let t0 = Instant::now();
102 state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
103
104 let principal = auth::resolve_principal(&state, &headers).await;
108
109 let source = signal::SignalSource::parse(body.source.as_deref(), signal::SignalSource::Http);
110 let sig = signal::Signal::from_adapter_request(signal::AdapterRequest {
111 source,
112 content: body.content,
113 channel: body.channel,
114 sender: body.sender,
115 metadata: body.metadata,
116 namespace: body.namespace,
117 agent: body.agent,
118 session_id: body.session_id,
119 default_channel: "http".to_string(),
120 default_sender: "apiclient".to_string(),
121 })
122 .with_principal_opt(principal);
123
124 let signal_id = sig.id;
125 let result = state.processor.process(sig).await;
126
127 let elapsed_ms = t0.elapsed().as_millis() as u64;
128 state
129 .metrics
130 .signals_latency_ms_total
131 .fetch_add(elapsed_ms, Ordering::Relaxed);
132
133 let response = match result {
134 Ok(r) => {
135 state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
136 tracing::info!(
137 signal_id = %signal_id,
138 latency_ms = elapsed_ms,
139 "signal processed"
140 );
141 r
142 }
143 Err(e) => {
144 state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
145 tracing::error!(
146 signal_id = %signal_id,
147 latency_ms = elapsed_ms,
148 error = %e,
149 "signal processing failed"
150 );
151 let public = e.to_public();
152 return Err((
153 StatusCode::INTERNAL_SERVER_ERROR,
154 serde_json::json!({
155 "error": public.code,
156 "message": public.message,
157 })
158 .to_string(),
159 ));
160 }
161 };
162
163 state.cache.lock().await.put(signal_id, response.clone());
164 Ok(Json(response))
165}
166
167pub async fn get_signal_handler(
169 State(state): State<Arc<AppState>>,
170 headers: HeaderMap,
171 Path(id): Path<String>,
172) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
173 auth::check_auth(&state, &headers, "read")?;
174
175 let uuid = Uuid::parse_str(&id)
176 .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
177
178 let mut cache = state.cache.lock().await;
179 match cache.get(&uuid) {
180 Some(resp) => Ok(Json(resp.clone())),
181 None => Err((
182 StatusCode::NOT_FOUND,
183 format!("Signal {uuid} not found in cache"),
184 )),
185 }
186}
187
188pub async fn search_memory_handler(
190 State(state): State<Arc<AppState>>,
191 headers: HeaderMap,
192 Json(body): Json<SearchRequest>,
193) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
194 auth::check_auth(&state, &headers, "read")?;
195
196 validate::check_query(&body.query)?;
197 validate::check_top_k(body.top_k)?;
198 if let Some(ns) = body.namespace.as_deref() {
199 validate::check_short_ident("namespace", ns)?;
200 }
201
202 state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
203 let t0 = Instant::now();
204 let top_k = body.top_k.unwrap_or(10);
205 let namespace = body.namespace.as_deref();
206 let results = state
207 .processor
208 .search_facts(&body.query, top_k, namespace)
209 .await;
210 tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
211
212 let facts = results
213 .into_iter()
214 .map(|r| FactJson {
215 id: r.fact.id,
216 namespace: r.fact.namespace,
217 category: r.fact.category,
218 subject: r.fact.subject,
219 predicate: r.fact.predicate,
220 object: r.fact.object,
221 confidence: r.fact.confidence,
222 distance: Some(r.distance),
223 })
224 .collect();
225
226 Ok(Json(facts))
227}
228
229pub async fn get_facts_handler(
231 State(state): State<Arc<AppState>>,
232 headers: HeaderMap,
233 Query(params): Query<HashMap<String, String>>,
234) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
235 auth::check_auth(&state, &headers, "read")?;
236
237 state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
238 let namespace = params.get("namespace").map(|s| s.as_str());
239 const DEFAULT_LIMIT: usize = 100;
244 const MAX_LIMIT: usize = 1000;
245 let limit_param = params.get("limit").and_then(|s| s.parse::<usize>().ok());
246 let offset = params
247 .get("offset")
248 .and_then(|s| s.parse::<usize>().ok())
249 .unwrap_or(0);
250 let limit = match limit_param {
251 Some(0) => None,
252 Some(n) => Some(n.min(MAX_LIMIT)),
253 None => Some(DEFAULT_LIMIT),
254 };
255 let facts = state
256 .processor
257 .list_facts_paginated(namespace, limit, offset)
258 .into_iter()
259 .map(|f| FactJson {
260 id: f.id,
261 namespace: f.namespace,
262 category: f.category,
263 subject: f.subject,
264 predicate: f.predicate,
265 object: f.object,
266 confidence: f.confidence,
267 distance: None,
268 })
269 .collect();
270
271 Ok(Json(facts))
272}
273
274pub async fn get_namespaces_handler(
276 State(state): State<Arc<AppState>>,
277 headers: HeaderMap,
278) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
279 auth::check_auth(&state, &headers, "read")?;
280
281 let namespaces = state
282 .processor
283 .list_namespaces()
284 .into_iter()
285 .map(|n| NamespaceJson {
286 namespace: n.namespace,
287 fact_count: n.fact_count,
288 episode_count: n.episode_count,
289 })
290 .collect();
291
292 Ok(Json(namespaces))
293}
294
295pub async fn export_memory_handler(
301 State(state): State<Arc<AppState>>,
302 headers: HeaderMap,
303) -> Result<Json<ExportJson>, (StatusCode, String)> {
304 auth::check_auth(&state, &headers, "export")?;
305
306 let facts = state.processor.export_facts().map_err(|e| {
307 (
308 StatusCode::INTERNAL_SERVER_ERROR,
309 format!("Export failed: {e}"),
310 )
311 })?;
312 let episodes = state.processor.export_episodes().map_err(|e| {
313 (
314 StatusCode::INTERNAL_SERVER_ERROR,
315 format!("Export failed: {e}"),
316 )
317 })?;
318
319 Ok(Json(ExportJson {
320 version: env!("CARGO_PKG_VERSION").to_string(),
321 exported_at: chrono::Utc::now().to_rfc3339(),
322 facts,
323 episodes,
324 }))
325}
326
327pub async fn import_memory_handler(
329 State(state): State<Arc<AppState>>,
330 headers: HeaderMap,
331 Json(body): Json<ImportRequest>,
332) -> Result<Json<ImportResponse>, (StatusCode, String)> {
333 auth::check_auth(&state, &headers, "write")?;
334
335 if body.dry_run {
336 return Ok(Json(ImportResponse {
337 facts_imported: body.facts.len(),
338 episodes_imported: body.episodes.len(),
339 facts_already_existed: 0,
340 episodes_already_existed: 0,
341 embedded: 0,
342 embed_failed: 0,
343 }));
344 }
345
346 let (facts_imported, new_fact_indices) =
347 state.processor.import_facts(&body.facts).map_err(|e| {
348 (
349 StatusCode::INTERNAL_SERVER_ERROR,
350 format!("Import failed: {e}"),
351 )
352 })?;
353 let episodes_imported = state
354 .processor
355 .import_episodes(&body.episodes)
356 .map_err(|e| {
357 (
358 StatusCode::INTERNAL_SERVER_ERROR,
359 format!("Import failed: {e}"),
360 )
361 })?;
362
363 let mut embedded = 0;
364 let mut embed_failed = 0;
365 if !new_fact_indices.is_empty() {
366 let new_facts: Vec<signal::ExportedFact> = new_fact_indices
367 .iter()
368 .map(|&idx| body.facts[idx].clone())
369 .collect();
370 let (e, f) = state.processor.reembed_facts(&new_facts).await;
371 embedded = e;
372 embed_failed = f;
373 }
374
375 Ok(Json(ImportResponse {
376 facts_imported,
377 episodes_imported,
378 facts_already_existed: body.facts.len() - facts_imported,
379 episodes_already_existed: body.episodes.len() - episodes_imported,
380 embedded,
381 embed_failed,
382 }))
383}
384
385pub async fn list_schedules_handler(
387 State(state): State<Arc<AppState>>,
388 headers: HeaderMap,
389 Query(params): Query<HashMap<String, String>>,
390) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
391 auth::check_auth(&state, &headers, "read")?;
392
393 let namespace = params.get("namespace").map(|s| s.as_str());
394 let intents = state
395 .processor
396 .list_scheduled_intents(namespace)
397 .map_err(|e| {
398 (
399 StatusCode::INTERNAL_SERVER_ERROR,
400 format!("Failed to list schedules: {e}"),
401 )
402 })?;
403
404 let json: Vec<serde_json::Value> = intents
405 .iter()
406 .map(|i| {
407 serde_json::json!({
408 "id": i.id,
409 "description": i.description,
410 "cron": i.cron,
411 "namespace": i.namespace,
412 "created_at": i.created_at,
413 "status": i.status,
414 })
415 })
416 .collect();
417
418 Ok(Json(json))
419}
420
421pub async fn cancel_schedule_handler(
423 State(state): State<Arc<AppState>>,
424 headers: HeaderMap,
425 Path(id): Path<String>,
426) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
427 auth::check_auth(&state, &headers, "write")?;
428
429 let cancelled = state.processor.cancel_scheduled_intent(&id).map_err(|e| {
430 (
431 StatusCode::INTERNAL_SERVER_ERROR,
432 format!("Failed to cancel schedule: {e}"),
433 )
434 })?;
435
436 if cancelled {
437 Ok(Json(serde_json::json!({"cancelled": true, "id": id})))
438 } else {
439 Err((
440 StatusCode::NOT_FOUND,
441 format!("No scheduled intent found with ID: {id}"),
442 ))
443 }
444}
445
446#[derive(Debug, Default, serde::Deserialize)]
448pub struct EventQuery {
449 pub kind: Option<String>,
451 pub tool_id: Option<String>,
453 pub correlation: Option<String>,
458 pub principal: Option<String>,
462 pub since: Option<chrono::DateTime<chrono::Utc>>,
464}
465
466impl EventQuery {
467 pub fn matches(&self, ev: &observe::BrainEvent) -> bool {
469 if let Some(k) = &self.kind {
470 if ev.kind() != k.as_str() {
471 return false;
472 }
473 }
474 if let Some(t) = &self.tool_id {
475 if ev.tool_id() != Some(t.as_str()) {
476 return false;
477 }
478 }
479 if let Some(c) = &self.correlation {
480 if ev.id().to_string() != *c {
481 return false;
482 }
483 }
484 if let Some(since) = self.since {
485 let ts = brain_event_ts(ev);
486 if ts < since {
487 return false;
488 }
489 }
490 if self.principal.is_some() {
492 return false;
493 }
494 true
495 }
496}
497
498fn brain_event_ts(ev: &observe::BrainEvent) -> chrono::DateTime<chrono::Utc> {
499 use observe::BrainEvent::*;
500 match ev {
501 SignalReceived { ts, .. }
502 | IntentClassified { ts, .. }
503 | ToolRouteResolved { ts, .. }
504 | ConfirmationRequested { ts, .. }
505 | ConfirmationResolved { ts, .. }
506 | ToolCallStarted { ts, .. }
507 | ToolCallFinished { ts, .. }
508 | ReflexFired { ts, .. }
509 | AuditAppended { ts, .. }
510 | BudgetCrossed { ts, .. }
511 | ResourcePressure { ts, .. }
512 | BreakerStateChange { ts, .. }
513 | Error { ts, .. }
514 | TerminalSessionOpened { ts, .. }
515 | TerminalSessionClosed { ts, .. }
516 | TaskStateChange { ts, .. }
517 | ServiceHealthChanged { ts, .. } => *ts,
518 }
519}
520
521pub async fn sse_events_handler(
529 State(state): State<Arc<AppState>>,
530 Query(filter): Query<EventQuery>,
531 headers: HeaderMap,
532) -> Result<
533 Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
534 (StatusCode, String),
535> {
536 auth::check_auth(&state, &headers, "read")?;
537
538 let mut signal_rx = state.processor.subscribe_events();
539 let mut notif_rx = state.processor.notification_router().map(|r| r.subscribe());
540 let mut brain_rx = state.processor.subscribe_brain_events();
541 let redact = state.processor.config().adapters.http.sse_redact_previews;
544
545 let stream = async_stream::stream! {
546 loop {
547 tokio::select! {
548 result = async {
549 match brain_rx.as_mut() {
550 Some(rx) => rx.recv().await,
551 None => std::future::pending().await,
552 }
553 } => {
554 match result {
555 Ok(ev) => {
556 if !filter.matches(&ev) { continue; }
557 yield Ok(Event::default()
558 .event("brain_event")
559 .json_data(&ev)
560 .unwrap_or_else(|_| Event::default().data("{}")));
561 }
562 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
563 tracing::warn!(skipped = n, "SSE brain_event stream lagged");
564 yield Ok(Event::default()
565 .event("error")
566 .data(format!("{{\"lagged\":{n}}}")));
567 }
568 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
569 brain_rx = None;
570 }
571 }
572 }
573 result = signal_rx.recv() => {
574 match result {
575 Ok(event) => {
576 let response_field = if redact {
577 serde_json::json!({ "redacted": true, "len": event.response.len() })
578 } else {
579 serde_json::Value::String(event.response.clone())
580 };
581 let payload = serde_json::json!({
582 "type": "signal",
583 "signal_id": event.signal_id.to_string(),
584 "source": format!("{:?}", event.source),
585 "status": format!("{:?}", event.status),
586 "response": response_field,
587 "facts_used": event.facts_used,
588 "episodes_used": event.episodes_used,
589 });
590 yield Ok(Event::default()
591 .event("signal")
592 .json_data(payload)
593 .unwrap_or_else(|_| Event::default().data("{}")));
594 }
595 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
596 tracing::warn!(skipped = n, "SSE signal stream lagged");
597 yield Ok(Event::default()
598 .event("error")
599 .data(format!("{{\"lagged\":{n}}}")));
600 }
601 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
602 }
603 }
604 result = async {
605 match notif_rx.as_mut() {
606 Some(rx) => rx.recv().await,
607 None => std::future::pending().await,
608 }
609 } => {
610 match result {
611 Ok(notification) => {
612 let content_field = if redact {
613 serde_json::json!({ "redacted": true, "len": notification.content.len() })
614 } else {
615 serde_json::Value::String(notification.content.clone())
616 };
617 let payload = serde_json::json!({
618 "type": "proactive",
619 "content": content_field,
620 "triggered_by": notification.triggered_by,
621 "priority": notification.priority,
622 "agent": notification.agent,
623 });
624 yield Ok(Event::default()
625 .event("notification")
626 .json_data(payload)
627 .unwrap_or_else(|_| Event::default().data("{}")));
628 }
629 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
630 tracing::warn!(skipped = n, "SSE notification stream lagged");
631 yield Ok(Event::default()
632 .event("error")
633 .data(format!("{{\"lagged\":{n}}}")));
634 }
635 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
636 notif_rx = None;
637 }
638 }
639 }
640 }
641 }
642 };
643
644 Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
645}
646
647pub async fn post_webhook_handler(
661 State(state): State<Arc<AppState>>,
662 Path(id): Path<String>,
663 headers: HeaderMap,
664 body: Bytes,
665) -> impl IntoResponse {
666 let transport = match state.webhook_handlers.get(&id) {
667 Some(t) => t,
668 None => {
669 return build_webhook_response(
670 StatusCode::NOT_FOUND,
671 None,
672 format!("Unknown transport ID: {id}"),
673 )
674 .into_response();
675 }
676 };
677
678 if !transport.has_verifier() {
679 if let Err((status, msg)) = auth::check_auth(&state, &headers, "write") {
680 tracing::warn!(
681 transport = %id,
682 status = %status,
683 "webhook rejected — verifier-less transport requires Bearer auth"
684 );
685 return build_webhook_response(status, None, msg).into_response();
686 }
687 }
688
689 let resp = transport.handle_request(&headers, &body).await;
690 let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK);
691 build_webhook_response(status, Some(resp.content_type), resp.body).into_response()
692}
693
694fn build_webhook_response(
697 status: StatusCode,
698 content_type: Option<String>,
699 body: String,
700) -> Response<String> {
701 let mut builder = Response::builder().status(status);
702 if let Some(ct) = content_type {
703 builder = builder.header("content-type", ct);
704 }
705 builder.body(body).unwrap_or_else(|err| {
706 tracing::error!(error = %err, "failed to build webhook response");
707 let mut fallback = Response::new("Internal Server Error".to_string());
708 *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
709 fallback
710 })
711}
712
713#[cfg(test)]
714mod event_query_tests {
715 use super::EventQuery;
716 use chrono::Utc;
717 use uuid::Uuid;
718
719 fn signal_received(id: Uuid) -> observe::BrainEvent {
720 observe::BrainEvent::SignalReceived {
721 id,
722 signal: observe::SignalSummary {
723 source: "cli".into(),
724 channel: "c".into(),
725 sender: "s".into(),
726 namespace: "personal".into(),
727 content_preview: "hi".into(),
728 },
729 ts: Utc::now(),
730 }
731 }
732
733 #[test]
734 fn correlation_filter_matches_only_its_flow() {
735 let mine = Uuid::new_v4();
736 let other = Uuid::new_v4();
737 let filter = EventQuery {
738 correlation: Some(mine.to_string()),
739 ..Default::default()
740 };
741 assert!(filter.matches(&signal_received(mine)));
742 assert!(!filter.matches(&signal_received(other)));
743 }
744
745 #[test]
746 fn malformed_correlation_matches_nothing_rather_than_erroring() {
747 let filter = EventQuery {
748 correlation: Some("not-a-uuid".into()),
749 ..Default::default()
750 };
751 assert!(!filter.matches(&signal_received(Uuid::new_v4())));
752 }
753
754 #[test]
755 fn absent_correlation_does_not_constrain() {
756 let filter = EventQuery::default();
757 assert!(filter.matches(&signal_received(Uuid::new_v4())));
758 }
759}