Skip to main content

khive_runtime/
runtime.rs

1//! KhiveRuntime — composable handle to all storage capabilities.
2
3use std::sync::Arc;
4
5use khive_db::StorageBackend;
6use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
7use lattice_embed::{
8    CachedEmbeddingService, EmbeddingModel, EmbeddingService, NativeEmbeddingService,
9};
10use tokio::sync::OnceCell;
11
12use crate::error::RuntimeResult;
13
14/// Runtime configuration.
15#[derive(Clone, Debug)]
16pub struct RuntimeConfig {
17    /// Path to the SQLite database file. `None` = in-memory (tests).
18    pub db_path: Option<std::path::PathBuf>,
19    /// Namespace used when no explicit namespace is provided.
20    pub default_namespace: String,
21    /// Local embedding model. `None` disables embedding and hybrid vector search;
22    /// `hybrid_search` then falls back to text-only.
23    pub embedding_model: Option<EmbeddingModel>,
24}
25
26impl Default for RuntimeConfig {
27    fn default() -> Self {
28        let db_path = std::env::var("HOME")
29            .ok()
30            .map(|h| std::path::PathBuf::from(h).join(".khive/khive-graph.db"));
31        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
32            .ok()
33            .and_then(|s| s.parse().ok())
34            .or(Some(EmbeddingModel::AllMiniLmL6V2));
35        Self {
36            db_path,
37            default_namespace: "local".to_string(),
38            embedding_model,
39        }
40    }
41}
42
43/// Composable runtime handle used by the MCP server.
44///
45/// Wraps a `StorageBackend` and provides namespace-scoped accessor methods
46/// for each storage capability, plus a lazily-loaded embedder.
47#[derive(Clone)]
48pub struct KhiveRuntime {
49    backend: Arc<StorageBackend>,
50    config: RuntimeConfig,
51    embedder: Arc<OnceCell<Arc<dyn EmbeddingService>>>,
52}
53
54impl KhiveRuntime {
55    /// Create a new runtime with the given config.
56    pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
57        let backend = match &config.db_path {
58            Some(path) => {
59                if let Some(parent) = path.parent() {
60                    std::fs::create_dir_all(parent).ok();
61                }
62                StorageBackend::sqlite(path)?
63            }
64            None => StorageBackend::memory()?,
65        };
66        Ok(Self {
67            backend: Arc::new(backend),
68            config,
69            embedder: Arc::new(OnceCell::new()),
70        })
71    }
72
73    /// Create an in-memory runtime (for tests and ephemeral use).
74    pub fn memory() -> RuntimeResult<Self> {
75        Self::new(RuntimeConfig {
76            db_path: None,
77            default_namespace: "local".to_string(),
78            embedding_model: None,
79        })
80    }
81
82    /// Return a reference to the runtime config.
83    pub fn config(&self) -> &RuntimeConfig {
84        &self.config
85    }
86
87    /// Return a reference to the underlying storage backend.
88    pub fn backend(&self) -> &StorageBackend {
89        &self.backend
90    }
91
92    /// Resolve namespace: use provided value or fall back to `default_namespace`.
93    pub fn ns<'a>(&'a self, namespace: Option<&'a str>) -> &'a str {
94        namespace.unwrap_or(&self.config.default_namespace)
95    }
96
97    // ---- Store accessors ----
98
99    /// Get an EntityStore scoped to the given namespace (or default).
100    pub fn entities(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn EntityStore>> {
101        Ok(self.backend.entities_for_namespace(self.ns(namespace))?)
102    }
103
104    /// Get a GraphStore scoped to the given namespace (or default).
105    pub fn graph(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn GraphStore>> {
106        Ok(self.backend.graph_for_namespace(self.ns(namespace))?)
107    }
108
109    /// Get a NoteStore scoped to the given namespace (or default).
110    pub fn notes(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn NoteStore>> {
111        Ok(self.backend.notes_for_namespace(self.ns(namespace))?)
112    }
113
114    /// Get an EventStore scoped to the given namespace (or default).
115    pub fn events(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn EventStore>> {
116        Ok(self.backend.events_for_namespace(self.ns(namespace))?)
117    }
118
119    /// Get the raw SQL access capability (for ad-hoc queries).
120    pub fn sql(&self) -> Arc<dyn SqlAccess> {
121        self.backend.sql()
122    }
123
124    /// Get a VectorStore for the configured embedding model, scoped to the namespace.
125    ///
126    /// Returns `Unconfigured("embedding_model")` if no model is set.
127    pub fn vectors(
128        &self,
129        namespace: Option<&str>,
130    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
131        let model = self
132            .config
133            .embedding_model
134            .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?;
135        Ok(self.backend.vectors_for_namespace(
136            &vec_model_key(model),
137            model.dimensions(),
138            self.ns(namespace),
139        )?)
140    }
141
142    /// Get a TextSearch index for the namespace's entity corpus.
143    pub fn text(
144        &self,
145        namespace: Option<&str>,
146    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
147        let key = format!("entities_{}", sanitize_key(self.ns(namespace)));
148        Ok(self.backend.text(&key)?)
149    }
150
151    /// Get a TextSearch index for the namespace's notes corpus.
152    pub fn text_for_notes(
153        &self,
154        namespace: Option<&str>,
155    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
156        let key = format!("notes_{}", sanitize_key(self.ns(namespace)));
157        Ok(self.backend.text(&key)?)
158    }
159
160    /// Get the lazily-initialized embedding service.
161    ///
162    /// Returns a `CachedEmbeddingService` wrapping a `NativeEmbeddingService`.
163    /// First call loads the model (cold start cost); subsequent calls are cheap and
164    /// benefit from LRU caching of repeated inputs.
165    ///
166    /// Returns `Unconfigured("embedding_model")` if no model is set.
167    pub async fn embedder(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
168        let model = self
169            .config
170            .embedding_model
171            .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?;
172        let service = self
173            .embedder
174            .get_or_init(|| async move {
175                let native = Arc::new(NativeEmbeddingService::with_model(model));
176                let cached = CachedEmbeddingService::with_default_cache(native);
177                Arc::new(cached) as Arc<dyn EmbeddingService>
178            })
179            .await
180            .clone();
181        Ok(service)
182    }
183}
184
185/// Sanitize an embedding model into a valid SQL table suffix.
186/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
187pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
188    sanitize_key(&model.to_string())
189}
190
191fn sanitize_key(s: &str) -> String {
192    s.chars()
193        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
194        .collect()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn memory_runtime_creates_successfully() {
203        let rt = KhiveRuntime::memory().expect("memory runtime should create");
204        assert!(rt.config().db_path.is_none());
205    }
206
207    #[test]
208    fn file_runtime_creates_successfully() {
209        let dir = tempfile::tempdir().unwrap();
210        let path = dir.path().join("test.db");
211        let config = RuntimeConfig {
212            db_path: Some(path.clone()),
213            default_namespace: "test".to_string(),
214            embedding_model: None,
215        };
216        let rt = KhiveRuntime::new(config).expect("file runtime should create");
217        assert!(path.exists());
218        assert_eq!(rt.config().default_namespace, "test");
219    }
220
221    #[test]
222    fn ns_defaults_to_config_namespace() {
223        let rt = KhiveRuntime::memory().unwrap();
224        assert_eq!(rt.ns(None), "local");
225        assert_eq!(rt.ns(Some("custom")), "custom");
226    }
227
228    #[test]
229    fn store_accessors_return_ok() {
230        let rt = KhiveRuntime::memory().unwrap();
231        assert!(rt.entities(None).is_ok());
232        assert!(rt.graph(None).is_ok());
233        assert!(rt.notes(None).is_ok());
234        assert!(rt.events(None).is_ok());
235    }
236
237    #[test]
238    fn vectors_returns_unconfigured_without_model() {
239        let rt = KhiveRuntime::memory().unwrap();
240        match rt.vectors(None) {
241            Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
242            Err(other) => panic!("expected Unconfigured, got {:?}", other),
243            Ok(_) => panic!("expected Err, got Ok"),
244        }
245    }
246
247    #[test]
248    fn vec_model_key_sanitizes_dots_and_dashes() {
249        assert_eq!(
250            vec_model_key(EmbeddingModel::BgeSmallEnV15),
251            "bge_small_en_v1_5"
252        );
253        assert_eq!(
254            vec_model_key(EmbeddingModel::BgeBaseEnV15),
255            "bge_base_en_v1_5"
256        );
257        assert_eq!(
258            vec_model_key(EmbeddingModel::AllMiniLmL6V2),
259            "all_minilm_l6_v2"
260        );
261    }
262
263    #[test]
264    fn default_config_uses_minilm_when_env_unset() {
265        // Snapshot + clear the env var so this test is deterministic.
266        let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
267        // SAFETY: tests are serial by default for env mutation here; if other tests
268        // mutate this var, mark them with the same scope.
269        unsafe {
270            std::env::remove_var("KHIVE_EMBEDDING_MODEL");
271        }
272        let cfg = RuntimeConfig::default();
273        assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
274        if let Some(v) = prior {
275            unsafe {
276                std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
277            }
278        }
279    }
280}