Skip to main content

mentedb_server/
grpc.rs

1//! gRPC service implementations for MenteDB.
2//!
3//! Provides a CognitionService with bidirectional streaming that wraps the
4//! CognitionStream engine, and a MemoryService with standard CRUD operations.
5
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use mentedb_cognitive::stream::{CognitionStream, StreamAlert};
11use mentedb_core::edge::EdgeType;
12use mentedb_core::memory::{AttributeValue, MemoryType};
13use mentedb_core::{MemoryEdge, MemoryNode};
14use tokio::sync::mpsc;
15use tokio_stream::{Stream, StreamExt, wrappers::ReceiverStream};
16use tonic::{Request, Response, Status, Streaming};
17use tracing::{error, info};
18use uuid::Uuid;
19
20use crate::auth;
21use crate::state::AppState;
22
23// Generated by tonic-build, not hand written: lints on it are tonic's to fix, and
24// each new clippy release adds some (large `tonic::Status` errors, doubled
25// `#[must_use]`), breaking CI on code nobody here writes.
26#[allow(clippy::all)]
27pub mod pb {
28    tonic::include_proto!("mentedb");
29}
30
31use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
32use pb::cognition_service_server::CognitionService;
33use pb::memory_service_server::MemoryService;
34
35// ---------------------------------------------------------------------------
36// CognitionService
37// ---------------------------------------------------------------------------
38
39/// gRPC service implementation for cognitive memory operations.
40pub struct CognitionServiceImpl {
41    #[allow(dead_code)]
42    pub state: Arc<AppState>,
43}
44
45type CognitionStream_ =
46    Pin<Box<dyn Stream<Item = Result<pb::CognitionAlert, Status>> + Send + 'static>>;
47
48#[tonic::async_trait]
49impl CognitionService for CognitionServiceImpl {
50    type StreamCognitionStream = CognitionStream_;
51
52    async fn stream_cognition(
53        &self,
54        request: Request<Streaming<pb::CognitionRequest>>,
55    ) -> Result<Response<Self::StreamCognitionStream>, Status> {
56        authenticate_grpc_streaming(&self.state, request.metadata())?;
57        info!("gRPC cognition stream opened");
58
59        let stream_engine = Arc::new(CognitionStream::new(1000));
60        let known_facts: Arc<std::sync::Mutex<Vec<(MemoryId, String)>>> =
61            Arc::new(std::sync::Mutex::new(Vec::new()));
62
63        let (tx, rx) = mpsc::channel(128);
64        let mut inbound = request.into_inner();
65
66        let engine = stream_engine.clone();
67        let facts = known_facts.clone();
68
69        tokio::spawn(async move {
70            while let Some(result) = inbound.next().await {
71                let req = match result {
72                    Ok(r) => r,
73                    Err(e) => {
74                        error!("cognition stream receive error: {e}");
75                        break;
76                    }
77                };
78
79                let payload = match req.payload {
80                    Some(p) => p,
81                    None => continue,
82                };
83
84                match payload {
85                    pb::cognition_request::Payload::Token(t) => {
86                        engine.feed_token(&t.text);
87
88                        let current_facts = facts.lock().unwrap().clone();
89                        let alerts = engine.check_alerts(&current_facts);
90                        for alert in alerts {
91                            let proto_alert = stream_alert_to_proto(alert);
92                            if tx.send(Ok(proto_alert)).await.is_err() {
93                                return;
94                            }
95                        }
96                    }
97                    pb::cognition_request::Payload::KnownFact(kf) => {
98                        let mid = kf
99                            .memory_id
100                            .parse::<MemoryId>()
101                            .unwrap_or_else(|_| MemoryId::nil());
102                        facts.lock().unwrap().push((mid, kf.content));
103                    }
104                    pb::cognition_request::Payload::EndOfTurn(_) => {
105                        let current_facts = facts.lock().unwrap().clone();
106                        let alerts = engine.check_alerts(&current_facts);
107                        for alert in alerts {
108                            let proto_alert = stream_alert_to_proto(alert);
109                            if tx.send(Ok(proto_alert)).await.is_err() {
110                                return;
111                            }
112                        }
113                    }
114                    pb::cognition_request::Payload::Flush(_) => {
115                        let text = engine.drain_buffer();
116                        let alert = pb::CognitionAlert {
117                            alert: Some(pb::cognition_alert::Alert::BufferFlushed(
118                                pb::BufferFlushedAlert {
119                                    accumulated_text: text,
120                                },
121                            )),
122                        };
123                        if tx.send(Ok(alert)).await.is_err() {
124                            return;
125                        }
126                    }
127                }
128            }
129            info!("gRPC cognition stream closed");
130        });
131
132        let output = ReceiverStream::new(rx);
133        Ok(Response::new(Box::pin(output)))
134    }
135}
136
137fn stream_alert_to_proto(alert: StreamAlert) -> pb::CognitionAlert {
138    let inner = match alert {
139        StreamAlert::Contradiction {
140            memory_id,
141            ai_said,
142            stored,
143        } => pb::cognition_alert::Alert::Contradiction(pb::ContradictionAlert {
144            memory_id: memory_id.to_string(),
145            ai_said,
146            stored,
147        }),
148        StreamAlert::Forgotten { memory_id, summary } => {
149            pb::cognition_alert::Alert::Forgotten(pb::ForgottenAlert {
150                memory_id: memory_id.to_string(),
151                summary,
152            })
153        }
154        StreamAlert::Correction {
155            memory_id,
156            old,
157            new,
158        } => pb::cognition_alert::Alert::Correction(pb::CorrectionAlert {
159            memory_id: memory_id.to_string(),
160            old_content: old,
161            new_content: new,
162        }),
163        StreamAlert::Reinforcement { memory_id } => {
164            pb::cognition_alert::Alert::Reinforcement(pb::ReinforcementAlert {
165                memory_id: memory_id.to_string(),
166            })
167        }
168    };
169    pb::CognitionAlert { alert: Some(inner) }
170}
171
172// ---------------------------------------------------------------------------
173// MemoryService
174// ---------------------------------------------------------------------------
175
176/// gRPC service implementation for basic memory CRUD operations.
177pub struct MemoryServiceImpl {
178    /// Shared application state containing the database handle.
179    pub state: Arc<AppState>,
180}
181
182#[tonic::async_trait]
183impl MemoryService for MemoryServiceImpl {
184    async fn store(
185        &self,
186        request: Request<pb::StoreRequest>,
187    ) -> Result<Response<pb::StoreResponse>, Status> {
188        let caller = authenticate_grpc_request(&self.state, &request)?;
189        let req = request.into_inner();
190
191        let agent_id = AgentId(parse_uuid_field(&req.agent_id, "agent_id")?);
192        if let Some(ref ta) = caller {
193            let tu: AgentId = ta
194                .parse()
195                .map_err(|_| Status::internal("bad token agent_id"))?;
196            if tu != agent_id {
197                return Err(Status::permission_denied("agent_id mismatch"));
198            }
199        }
200        let memory_type = parse_memory_type_str(&req.memory_type)?;
201        let space_id = if req.space_id.is_empty() {
202            SpaceId::nil()
203        } else {
204            SpaceId(parse_uuid_field(&req.space_id, "space_id")?)
205        };
206
207        let salience = if req.salience == 0.0 {
208            0.5
209        } else {
210            req.salience
211        };
212        let confidence = if req.confidence == 0.0 {
213            1.0
214        } else {
215            req.confidence
216        };
217
218        let now = std::time::SystemTime::now()
219            .duration_since(std::time::UNIX_EPOCH)
220            .unwrap_or_default()
221            .as_micros() as u64;
222
223        let id = MemoryId::new();
224
225        let attributes = req
226            .attributes
227            .into_iter()
228            .map(|(k, v)| (k, AttributeValue::String(v)))
229            .collect::<HashMap<String, AttributeValue>>();
230
231        let node = MemoryNode {
232            id,
233            agent_id,
234            user_id: UserId::nil(),
235            memory_type,
236            embedding: req.embedding,
237            content: req.content,
238            created_at: now,
239            accessed_at: now,
240            access_count: 0,
241            salience,
242            confidence,
243            space_id,
244            attributes,
245            tags: req.tags,
246            valid_from: req.valid_from,
247            valid_until: req.valid_until,
248            context: None,
249        };
250
251        let db = &*self.state.db;
252        db.store(node).map_err(|e| {
253            error!("gRPC store failed: {e}");
254            Status::internal(format!("store failed: {e}"))
255        })?;
256
257        Ok(Response::new(pb::StoreResponse {
258            id: id.to_string(),
259            status: "stored".into(),
260        }))
261    }
262
263    async fn recall(
264        &self,
265        request: Request<pb::RecallRequest>,
266    ) -> Result<Response<pb::RecallResponse>, Status> {
267        authenticate_grpc_request(&self.state, &request)?;
268        let req = request.into_inner();
269
270        let db = &*self.state.db;
271        let window = db.recall(&req.query).map_err(|e| {
272            error!("gRPC recall failed: {e}");
273            Status::internal(format!("recall failed: {e}"))
274        })?;
275
276        let memory_count: usize = window.blocks.iter().map(|b| b.memories.len()).sum();
277
278        Ok(Response::new(pb::RecallResponse {
279            context: window.format,
280            total_tokens: window.total_tokens as u64,
281            memory_count: memory_count as u64,
282        }))
283    }
284
285    async fn search(
286        &self,
287        request: Request<pb::SearchRequest>,
288    ) -> Result<Response<pb::SearchResponse>, Status> {
289        authenticate_grpc_request(&self.state, &request)?;
290        let req = request.into_inner();
291        let k = if req.k == 0 { 10 } else { req.k as usize };
292
293        if req.embedding.is_empty() {
294            return Err(Status::invalid_argument("missing embedding vector"));
295        }
296
297        let db = &*self.state.db;
298        let results = db.recall_similar(&req.embedding, k).map_err(|e| {
299            error!("gRPC search failed: {e}");
300            Status::internal(format!("search failed: {e}"))
301        })?;
302
303        let items: Vec<pb::SearchResult> = results
304            .iter()
305            .map(|(id, score)| pb::SearchResult {
306                id: id.to_string(),
307                score: *score,
308            })
309            .collect();
310
311        Ok(Response::new(pb::SearchResponse { results: items }))
312    }
313
314    async fn forget(
315        &self,
316        request: Request<pb::ForgetRequest>,
317    ) -> Result<Response<pb::ForgetResponse>, Status> {
318        authenticate_grpc_request(&self.state, &request)?;
319        let req = request.into_inner();
320        let id = MemoryId(parse_uuid_field(&req.id, "id")?);
321
322        let db = &*self.state.db;
323        db.forget(id).map_err(|e| {
324            error!("gRPC forget failed: {e}");
325            Status::internal(format!("forget failed: {e}"))
326        })?;
327
328        Ok(Response::new(pb::ForgetResponse {
329            status: "deleted".into(),
330        }))
331    }
332
333    async fn relate(
334        &self,
335        request: Request<pb::RelateRequest>,
336    ) -> Result<Response<pb::RelateResponse>, Status> {
337        authenticate_grpc_request(&self.state, &request)?;
338        let req = request.into_inner();
339        let source = MemoryId(parse_uuid_field(&req.source, "source")?);
340        let target = MemoryId(parse_uuid_field(&req.target, "target")?);
341        let edge_type = parse_edge_type_str(&req.edge_type)?;
342        let weight = if req.weight == 0.0 { 1.0 } else { req.weight };
343
344        let now = std::time::SystemTime::now()
345            .duration_since(std::time::UNIX_EPOCH)
346            .unwrap_or_default()
347            .as_micros() as u64;
348
349        let edge = MemoryEdge {
350            source,
351            target,
352            edge_type,
353            weight,
354            created_at: now,
355            valid_from: req.valid_from,
356            valid_until: req.valid_until,
357            label: None,
358        };
359
360        let db = &*self.state.db;
361        db.relate(edge).map_err(|e| {
362            error!("gRPC relate failed: {e}");
363            Status::internal(format!("relate failed: {e}"))
364        })?;
365
366        Ok(Response::new(pb::RelateResponse {
367            status: "created".into(),
368        }))
369    }
370}
371
372#[allow(clippy::result_large_err)]
373fn authenticate_grpc_request<T>(
374    state: &AppState,
375    request: &Request<T>,
376) -> Result<Option<String>, Status> {
377    let secret = match &state.jwt_secret {
378        Some(s) => s,
379        None => return Ok(None),
380    };
381    let token = request
382        .metadata()
383        .get("authorization")
384        .and_then(|v| v.to_str().ok())
385        .and_then(|v| v.strip_prefix("Bearer "))
386        .ok_or_else(|| Status::unauthenticated("missing or invalid authorization metadata"))?;
387    auth::validate_token(secret, token)
388        .map(|c| Some(c.agent_id))
389        .map_err(|e| Status::unauthenticated(format!("invalid token: {e}")))
390}
391#[allow(clippy::result_large_err)]
392fn authenticate_grpc_streaming(
393    state: &AppState,
394    metadata: &tonic::metadata::MetadataMap,
395) -> Result<Option<String>, Status> {
396    let secret = match &state.jwt_secret {
397        Some(s) => s,
398        None => return Ok(None),
399    };
400    let token = metadata
401        .get("authorization")
402        .and_then(|v| v.to_str().ok())
403        .and_then(|v| v.strip_prefix("Bearer "))
404        .ok_or_else(|| Status::unauthenticated("missing or invalid authorization metadata"))?;
405    auth::validate_token(secret, token)
406        .map(|c| Some(c.agent_id))
407        .map_err(|e| Status::unauthenticated(format!("invalid token: {e}")))
408}
409
410// ---------------------------------------------------------------------------
411// Helpers
412// ---------------------------------------------------------------------------
413
414#[allow(clippy::result_large_err)]
415fn parse_uuid_field(s: &str, field: &str) -> Result<Uuid, Status> {
416    Uuid::parse_str(s)
417        .map_err(|_| Status::invalid_argument(format!("invalid UUID for '{field}': {s}")))
418}
419
420#[allow(clippy::result_large_err)]
421fn parse_memory_type_str(s: &str) -> Result<MemoryType, Status> {
422    match s.to_lowercase().as_str() {
423        "episodic" => Ok(MemoryType::Episodic),
424        "semantic" => Ok(MemoryType::Semantic),
425        "procedural" => Ok(MemoryType::Procedural),
426        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
427        "reasoning" => Ok(MemoryType::Reasoning),
428        "correction" => Ok(MemoryType::Correction),
429        _ => Err(Status::invalid_argument(format!(
430            "unknown memory_type: {s}"
431        ))),
432    }
433}
434
435#[allow(clippy::result_large_err)]
436fn parse_edge_type_str(s: &str) -> Result<EdgeType, Status> {
437    match s.to_lowercase().as_str() {
438        "caused" => Ok(EdgeType::Caused),
439        "before" => Ok(EdgeType::Before),
440        "related" => Ok(EdgeType::Related),
441        "contradicts" => Ok(EdgeType::Contradicts),
442        "supports" => Ok(EdgeType::Supports),
443        "supersedes" => Ok(EdgeType::Supersedes),
444        "derived" => Ok(EdgeType::Derived),
445        "partof" | "part_of" => Ok(EdgeType::PartOf),
446        _ => Err(Status::invalid_argument(format!("unknown edge_type: {s}"))),
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Tests
452// ---------------------------------------------------------------------------
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use mentedb::MenteDb;
458    use std::time::Instant;
459    use tempfile::TempDir;
460
461    fn make_test_state() -> (Arc<AppState>, TempDir) {
462        let tmp = TempDir::new().unwrap();
463        let db = MenteDb::open(tmp.path()).unwrap();
464        let state = Arc::new(AppState {
465            db: Arc::new(db),
466            spaces: Arc::new(tokio::sync::RwLock::new(mentedb_core::SpaceManager::new())),
467            jwt_secret: None,
468            admin_key: None,
469            start_time: Instant::now(),
470            extraction_config: None,
471            auto_extract: false,
472            extraction_tx: None,
473            cluster: None,
474            turn_trackers: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
475        });
476        (state, tmp)
477    }
478
479    #[tokio::test]
480    async fn test_grpc_memory_store_and_recall() {
481        let (state, _tmp) = make_test_state();
482        let svc = MemoryServiceImpl {
483            state: state.clone(),
484        };
485
486        let agent_id = AgentId::new().to_string();
487        let store_req = Request::new(pb::StoreRequest {
488            agent_id: agent_id.clone(),
489            memory_type: "episodic".into(),
490            content: "The user prefers dark mode".into(),
491            embedding: vec![],
492            tags: vec!["preference".into()],
493            attributes: HashMap::new(),
494            space_id: String::new(),
495            salience: 0.8,
496            confidence: 1.0,
497            valid_from: None,
498            valid_until: None,
499        });
500
501        let resp = svc.store(store_req).await.unwrap();
502        let inner = resp.into_inner();
503        assert_eq!(inner.status, "stored");
504        assert!(!inner.id.is_empty());
505
506        let recall_req = Request::new(pb::RecallRequest {
507            query: "RECALL memories LIMIT 100".into(),
508        });
509        let resp = svc.recall(recall_req).await.unwrap();
510        let inner = resp.into_inner();
511        // Recall succeeded without error; count may be 0 if the engine has not
512        // indexed the memory yet, so we just verify the response is valid.
513        let _ = inner.memory_count;
514    }
515
516    #[tokio::test]
517    async fn test_grpc_memory_forget() {
518        let (state, _tmp) = make_test_state();
519        let svc = MemoryServiceImpl {
520            state: state.clone(),
521        };
522
523        let agent_id = AgentId::new().to_string();
524        let store_resp = svc
525            .store(Request::new(pb::StoreRequest {
526                agent_id,
527                memory_type: "semantic".into(),
528                content: "Temporary memory".into(),
529                embedding: vec![],
530                tags: vec![],
531                attributes: HashMap::new(),
532                space_id: String::new(),
533                salience: 0.5,
534                confidence: 1.0,
535                valid_from: None,
536                valid_until: None,
537            }))
538            .await
539            .unwrap();
540        let stored_id = store_resp.into_inner().id;
541
542        let forget_resp = svc
543            .forget(Request::new(pb::ForgetRequest {
544                id: stored_id.clone(),
545            }))
546            .await
547            .unwrap();
548        assert_eq!(forget_resp.into_inner().status, "deleted");
549    }
550
551    #[tokio::test]
552    async fn test_grpc_memory_relate() {
553        let (state, _tmp) = make_test_state();
554        let svc = MemoryServiceImpl {
555            state: state.clone(),
556        };
557
558        let agent_id = AgentId::new().to_string();
559        let id1 = svc
560            .store(Request::new(pb::StoreRequest {
561                agent_id: agent_id.clone(),
562                memory_type: "episodic".into(),
563                content: "Event A".into(),
564                embedding: vec![],
565                tags: vec![],
566                attributes: HashMap::new(),
567                space_id: String::new(),
568                salience: 0.5,
569                confidence: 1.0,
570                valid_from: None,
571                valid_until: None,
572            }))
573            .await
574            .unwrap()
575            .into_inner()
576            .id;
577
578        let id2 = svc
579            .store(Request::new(pb::StoreRequest {
580                agent_id,
581                memory_type: "episodic".into(),
582                content: "Event B".into(),
583                embedding: vec![],
584                tags: vec![],
585                attributes: HashMap::new(),
586                space_id: String::new(),
587                salience: 0.5,
588                confidence: 1.0,
589                valid_from: None,
590                valid_until: None,
591            }))
592            .await
593            .unwrap()
594            .into_inner()
595            .id;
596
597        let relate_resp = svc
598            .relate(Request::new(pb::RelateRequest {
599                source: id1,
600                target: id2,
601                edge_type: "caused".into(),
602                weight: 0.9,
603                valid_from: None,
604                valid_until: None,
605            }))
606            .await
607            .unwrap();
608        assert_eq!(relate_resp.into_inner().status, "created");
609    }
610
611    #[tokio::test]
612    async fn test_grpc_cognition_stream() {
613        use pb::cognition_service_server::CognitionServiceServer;
614        use tokio::net::TcpListener;
615        use tonic::transport::{Channel, Server};
616
617        let (state, _tmp) = make_test_state();
618
619        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
620        let addr = listener.local_addr().unwrap();
621
622        let svc = CognitionServiceServer::new(CognitionServiceImpl {
623            state: state.clone(),
624        });
625
626        tokio::spawn(async move {
627            let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
628            Server::builder()
629                .add_service(svc)
630                .serve_with_incoming(incoming)
631                .await
632                .unwrap();
633        });
634
635        // Small delay to let the server start
636        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
637
638        let channel = Channel::from_shared(format!("http://{addr}"))
639            .unwrap()
640            .connect()
641            .await
642            .unwrap();
643
644        let mut client = pb::cognition_service_client::CognitionServiceClient::new(channel);
645
646        let (tx, rx) = mpsc::channel(32);
647        let outbound = ReceiverStream::new(rx);
648
649        let response = client.stream_cognition(outbound).await.unwrap();
650        let mut inbound = response.into_inner();
651
652        // Register a known fact
653        let mid = MemoryId::new();
654        tx.send(pb::CognitionRequest {
655            payload: Some(pb::cognition_request::Payload::KnownFact(pb::KnownFact {
656                memory_id: mid.to_string(),
657                content: "The system uses PostgreSQL for storage".into(),
658            })),
659        })
660        .await
661        .unwrap();
662
663        // Feed tokens that contradict the known fact
664        tx.send(pb::CognitionRequest {
665            payload: Some(pb::cognition_request::Payload::Token(pb::TokenPayload {
666                text: "The system does not use PostgreSQL, actually it uses MySQL".into(),
667            })),
668        })
669        .await
670        .unwrap();
671
672        // We should get a contradiction alert
673        if let Some(Ok(alert)) = inbound.next().await {
674            match alert.alert {
675                Some(pb::cognition_alert::Alert::Contradiction(c)) => {
676                    assert_eq!(c.memory_id, mid.to_string());
677                    assert!(!c.ai_said.is_empty());
678                    assert!(!c.stored.is_empty());
679                }
680                other => panic!("expected contradiction alert, got: {:?}", other),
681            }
682        } else {
683            panic!("expected an alert from the cognition stream");
684        }
685
686        // Test flush
687        tx.send(pb::CognitionRequest {
688            payload: Some(pb::cognition_request::Payload::Flush(pb::FlushBuffer {})),
689        })
690        .await
691        .unwrap();
692
693        if let Some(Ok(alert)) = inbound.next().await {
694            match alert.alert {
695                Some(pb::cognition_alert::Alert::BufferFlushed(f)) => {
696                    assert!(!f.accumulated_text.is_empty());
697                }
698                other => panic!("expected buffer_flushed alert, got: {:?}", other),
699            }
700        }
701
702        drop(tx);
703    }
704
705    #[tokio::test]
706    async fn test_grpc_invalid_uuid() {
707        let (state, _tmp) = make_test_state();
708        let svc = MemoryServiceImpl { state };
709
710        let result = svc
711            .store(Request::new(pb::StoreRequest {
712                agent_id: "not-a-uuid".into(),
713                memory_type: "episodic".into(),
714                content: "test".into(),
715                embedding: vec![],
716                tags: vec![],
717                attributes: HashMap::new(),
718                space_id: String::new(),
719                salience: 0.5,
720                confidence: 1.0,
721                valid_from: None,
722                valid_until: None,
723            }))
724            .await;
725
726        assert!(result.is_err());
727        let status = result.unwrap_err();
728        assert_eq!(status.code(), tonic::Code::InvalidArgument);
729    }
730
731    #[tokio::test]
732    async fn test_grpc_invalid_memory_type() {
733        let (state, _tmp) = make_test_state();
734        let svc = MemoryServiceImpl { state };
735
736        let result = svc
737            .store(Request::new(pb::StoreRequest {
738                agent_id: AgentId::new().to_string(),
739                memory_type: "nonexistent".into(),
740                content: "test".into(),
741                embedding: vec![],
742                tags: vec![],
743                attributes: HashMap::new(),
744                space_id: String::new(),
745                salience: 0.5,
746                confidence: 1.0,
747                valid_from: None,
748                valid_until: None,
749            }))
750            .await;
751
752        assert!(result.is_err());
753        let status = result.unwrap_err();
754        assert_eq!(status.code(), tonic::Code::InvalidArgument);
755    }
756
757    #[tokio::test]
758    async fn test_grpc_search_missing_embedding() {
759        let (state, _tmp) = make_test_state();
760        let svc = MemoryServiceImpl { state };
761
762        let result = svc
763            .search(Request::new(pb::SearchRequest {
764                embedding: vec![],
765                k: 10,
766            }))
767            .await;
768
769        assert!(result.is_err());
770        assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
771    }
772
773    fn make_auth_test_state(secret: &str) -> (Arc<AppState>, TempDir) {
774        let tmp = TempDir::new().unwrap();
775        let db = MenteDb::open(tmp.path()).unwrap();
776        (
777            Arc::new(AppState {
778                db: Arc::new(db),
779                spaces: Arc::new(tokio::sync::RwLock::new(mentedb_core::SpaceManager::new())),
780                jwt_secret: Some(secret.into()),
781                admin_key: Some("ak".into()),
782                start_time: Instant::now(),
783                extraction_config: None,
784                auto_extract: false,
785                extraction_tx: None,
786                cluster: None,
787                turn_trackers: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
788            }),
789            tmp,
790        )
791    }
792    #[tokio::test]
793    async fn test_grpc_auth_required_when_secret_set() {
794        let (s, _t) = make_auth_test_state("s");
795        let svc = MemoryServiceImpl { state: s };
796        let r = svc
797            .recall(Request::new(pb::RecallRequest {
798                query: "RECALL memories LIMIT 10".into(),
799            }))
800            .await;
801        assert!(r.is_err());
802        assert_eq!(r.unwrap_err().code(), tonic::Code::Unauthenticated);
803    }
804    #[tokio::test]
805    async fn test_grpc_auth_succeeds_with_valid_token() {
806        let (s, _t) = make_auth_test_state("s");
807        let svc = MemoryServiceImpl { state: s };
808        let a = AgentId::new();
809        let tok = crate::auth::create_token("s", &a.to_string(), false, 1);
810        let mut r = Request::new(pb::StoreRequest {
811            agent_id: a.to_string(),
812            memory_type: "episodic".into(),
813            content: "t".into(),
814            embedding: vec![],
815            tags: vec![],
816            attributes: HashMap::new(),
817            space_id: String::new(),
818            salience: 0.5,
819            confidence: 1.0,
820            valid_from: None,
821            valid_until: None,
822        });
823        r.metadata_mut()
824            .insert("authorization", format!("Bearer {tok}").parse().unwrap());
825        assert_eq!(svc.store(r).await.unwrap().into_inner().status, "stored");
826    }
827    #[tokio::test]
828    async fn test_grpc_auth_rejects_wrong_agent_id() {
829        let (s, _t) = make_auth_test_state("s");
830        let svc = MemoryServiceImpl { state: s };
831        let ta = AgentId::new();
832        let ra = AgentId::new();
833        let tok = crate::auth::create_token("s", &ta.to_string(), false, 1);
834        let mut r = Request::new(pb::StoreRequest {
835            agent_id: ra.to_string(),
836            memory_type: "episodic".into(),
837            content: "t".into(),
838            embedding: vec![],
839            tags: vec![],
840            attributes: HashMap::new(),
841            space_id: String::new(),
842            salience: 0.5,
843            confidence: 1.0,
844            valid_from: None,
845            valid_until: None,
846        });
847        r.metadata_mut()
848            .insert("authorization", format!("Bearer {tok}").parse().unwrap());
849        assert_eq!(
850            svc.store(r).await.unwrap_err().code(),
851            tonic::Code::PermissionDenied
852        );
853    }
854    #[tokio::test]
855    async fn test_grpc_auth_invalid_token() {
856        let (s, _t) = make_auth_test_state("s");
857        let svc = MemoryServiceImpl { state: s };
858        let mut r = Request::new(pb::RecallRequest {
859            query: "RECALL memories LIMIT 10".into(),
860        });
861        r.metadata_mut()
862            .insert("authorization", "Bearer bad.tok".parse().unwrap());
863        assert_eq!(
864            svc.recall(r).await.unwrap_err().code(),
865            tonic::Code::Unauthenticated
866        );
867    }
868}