Skip to main content

khive_runtime/
runtime.rs

1//! KhiveRuntime — composable handle to all storage capabilities.
2
3use std::sync::{Arc, RwLock};
4
5use khive_db::StorageBackend;
6use khive_gate::{AllowAllGate, GateRef};
7use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
8use khive_types::EdgeEndpointRule;
9use lattice_embed::{
10    CachedEmbeddingService, EmbeddingModel, EmbeddingService, NativeEmbeddingService,
11};
12use tokio::sync::OnceCell;
13
14use crate::error::RuntimeResult;
15
16/// Runtime configuration.
17#[derive(Clone, Debug)]
18pub struct RuntimeConfig {
19    /// Path to the SQLite database file. `None` = in-memory (tests).
20    pub db_path: Option<std::path::PathBuf>,
21    /// Namespace used when no explicit namespace is provided.
22    pub default_namespace: String,
23    /// Local embedding model. `None` disables embedding and hybrid vector search;
24    /// `hybrid_search` then falls back to text-only.
25    pub embedding_model: Option<EmbeddingModel>,
26    /// Authorization gate consulted before each verb dispatch (ADR-029).
27    /// Default: `AllowAllGate` (permissive). For production policy enforcement,
28    /// plug in a Rego- or capability-witness-backed impl.
29    pub gate: GateRef,
30    /// Names of packs the transport layer should register into the VerbRegistry.
31    /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
32    /// the matching concrete pack types. Unknown names are reported as errors
33    /// by the transport, not silently ignored.
34    /// Default: `["kg"]`.
35    pub packs: Vec<String>,
36}
37
38/// Parse a comma- or whitespace-separated pack list from a single string.
39///
40/// Empty entries are dropped, surrounding whitespace is trimmed.
41pub fn parse_pack_list(s: &str) -> Vec<String> {
42    s.split(|c: char| c == ',' || c.is_whitespace())
43        .map(str::trim)
44        .filter(|s| !s.is_empty())
45        .map(str::to_owned)
46        .collect()
47}
48
49impl Default for RuntimeConfig {
50    fn default() -> Self {
51        let db_path = std::env::var("HOME")
52            .ok()
53            .map(|h| std::path::PathBuf::from(h).join(".khive/khive-graph.db"));
54        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
55            .ok()
56            .and_then(|s| s.parse().ok())
57            .or(Some(EmbeddingModel::AllMiniLmL6V2));
58        let packs = std::env::var("KHIVE_PACKS")
59            .ok()
60            .map(|s| parse_pack_list(&s))
61            .filter(|v| !v.is_empty())
62            .unwrap_or_else(|| vec!["kg".to_string()]);
63        Self {
64            db_path,
65            default_namespace: "local".to_string(),
66            embedding_model,
67            gate: Arc::new(AllowAllGate),
68            packs,
69        }
70    }
71}
72
73/// Composable runtime handle used by the MCP server.
74///
75/// Wraps a `StorageBackend` and provides namespace-scoped accessor methods
76/// for each storage capability, plus a lazily-loaded embedder.
77#[derive(Clone)]
78pub struct KhiveRuntime {
79    backend: Arc<StorageBackend>,
80    config: RuntimeConfig,
81    embedder: Arc<OnceCell<Arc<dyn EmbeddingService>>>,
82    /// Pack-extensible edge endpoint rules (ADR-031). Shared across clones
83    /// via `Arc<RwLock<_>>`; installed once by the transport after the
84    /// `VerbRegistry` is built. Empty until installed — base rules
85    /// (ADR-002) still apply on their own.
86    edge_rules: Arc<RwLock<Vec<EdgeEndpointRule>>>,
87}
88
89impl KhiveRuntime {
90    /// Create a new runtime with the given config.
91    pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
92        let backend = match &config.db_path {
93            Some(path) => {
94                if let Some(parent) = path.parent() {
95                    std::fs::create_dir_all(parent).ok();
96                }
97                StorageBackend::sqlite(path)?
98            }
99            None => StorageBackend::memory()?,
100        };
101        Ok(Self {
102            backend: Arc::new(backend),
103            config,
104            embedder: Arc::new(OnceCell::new()),
105            edge_rules: Arc::new(RwLock::new(Vec::new())),
106        })
107    }
108
109    /// Create an in-memory runtime (for tests and ephemeral use).
110    pub fn memory() -> RuntimeResult<Self> {
111        Self::new(RuntimeConfig {
112            db_path: None,
113            default_namespace: "local".to_string(),
114            embedding_model: None,
115            gate: Arc::new(AllowAllGate),
116            packs: vec!["kg".to_string()],
117        })
118    }
119
120    /// Return a reference to the runtime config.
121    pub fn config(&self) -> &RuntimeConfig {
122        &self.config
123    }
124
125    /// Return a reference to the underlying storage backend.
126    pub fn backend(&self) -> &StorageBackend {
127        &self.backend
128    }
129
130    /// Resolve namespace: use provided value or fall back to `default_namespace`.
131    pub fn ns<'a>(&'a self, namespace: Option<&'a str>) -> &'a str {
132        namespace.unwrap_or(&self.config.default_namespace)
133    }
134
135    // ---- Store accessors ----
136
137    /// Get an EntityStore scoped to the given namespace (or default).
138    pub fn entities(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn EntityStore>> {
139        Ok(self.backend.entities_for_namespace(self.ns(namespace))?)
140    }
141
142    /// Get a GraphStore scoped to the given namespace (or default).
143    pub fn graph(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn GraphStore>> {
144        Ok(self.backend.graph_for_namespace(self.ns(namespace))?)
145    }
146
147    /// Get a NoteStore scoped to the given namespace (or default).
148    pub fn notes(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn NoteStore>> {
149        Ok(self.backend.notes_for_namespace(self.ns(namespace))?)
150    }
151
152    /// Get an EventStore scoped to the given namespace (or default).
153    pub fn events(&self, namespace: Option<&str>) -> RuntimeResult<Arc<dyn EventStore>> {
154        Ok(self.backend.events_for_namespace(self.ns(namespace))?)
155    }
156
157    /// Get the raw SQL access capability (for ad-hoc queries).
158    pub fn sql(&self) -> Arc<dyn SqlAccess> {
159        self.backend.sql()
160    }
161
162    /// Get a VectorStore for the configured embedding model, scoped to the namespace.
163    ///
164    /// Returns `Unconfigured("embedding_model")` if no model is set.
165    pub fn vectors(
166        &self,
167        namespace: Option<&str>,
168    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
169        let model = self
170            .config
171            .embedding_model
172            .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?;
173        Ok(self.backend.vectors_for_namespace(
174            &vec_model_key(model),
175            model.dimensions(),
176            self.ns(namespace),
177        )?)
178    }
179
180    /// Get a TextSearch index for the namespace's entity corpus.
181    pub fn text(
182        &self,
183        namespace: Option<&str>,
184    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
185        let key = format!("entities_{}", sanitize_key(self.ns(namespace)));
186        Ok(self.backend.text(&key)?)
187    }
188
189    /// Get a TextSearch index for the namespace's notes corpus.
190    pub fn text_for_notes(
191        &self,
192        namespace: Option<&str>,
193    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
194        let key = format!("notes_{}", sanitize_key(self.ns(namespace)));
195        Ok(self.backend.text(&key)?)
196    }
197
198    /// Install the pack-aggregated edge endpoint rules (ADR-031).
199    ///
200    /// Called by the transport layer after the `VerbRegistry` is built so
201    /// that runtime-layer edge validation (in `validate_edge_relation_endpoints`)
202    /// can consult pack rules in addition to the ADR-002 base contract. Idempotent:
203    /// later calls overwrite the previous rule set.
204    pub fn install_edge_rules(&self, rules: Vec<EdgeEndpointRule>) {
205        if let Ok(mut guard) = self.edge_rules.write() {
206            *guard = rules;
207        }
208    }
209
210    /// Snapshot of currently-installed pack edge rules.
211    pub(crate) fn pack_edge_rules(&self) -> Vec<EdgeEndpointRule> {
212        self.edge_rules
213            .read()
214            .map(|g| g.clone())
215            .unwrap_or_default()
216    }
217
218    /// Get the lazily-initialized embedding service.
219    ///
220    /// Returns a `CachedEmbeddingService` wrapping a `NativeEmbeddingService`.
221    /// First call loads the model (cold start cost); subsequent calls are cheap and
222    /// benefit from LRU caching of repeated inputs.
223    ///
224    /// Returns `Unconfigured("embedding_model")` if no model is set.
225    pub async fn embedder(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
226        let model = self
227            .config
228            .embedding_model
229            .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?;
230        let service = self
231            .embedder
232            .get_or_init(|| async move {
233                let native = Arc::new(NativeEmbeddingService::with_model(model));
234                let cached = CachedEmbeddingService::with_default_cache(native);
235                Arc::new(cached) as Arc<dyn EmbeddingService>
236            })
237            .await
238            .clone();
239        Ok(service)
240    }
241}
242
243/// Sanitize an embedding model into a valid SQL table suffix.
244/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
245pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
246    sanitize_key(&model.to_string())
247}
248
249fn sanitize_key(s: &str) -> String {
250    s.chars()
251        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
252        .collect()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn memory_runtime_creates_successfully() {
261        let rt = KhiveRuntime::memory().expect("memory runtime should create");
262        assert!(rt.config().db_path.is_none());
263    }
264
265    #[test]
266    fn file_runtime_creates_successfully() {
267        let dir = tempfile::tempdir().unwrap();
268        let path = dir.path().join("test.db");
269        let config = RuntimeConfig {
270            db_path: Some(path.clone()),
271            default_namespace: "test".to_string(),
272            embedding_model: None,
273            gate: Arc::new(AllowAllGate),
274            packs: vec!["kg".to_string()],
275        };
276        let rt = KhiveRuntime::new(config).expect("file runtime should create");
277        assert!(path.exists());
278        assert_eq!(rt.config().default_namespace, "test");
279    }
280
281    #[test]
282    fn ns_defaults_to_config_namespace() {
283        let rt = KhiveRuntime::memory().unwrap();
284        assert_eq!(rt.ns(None), "local");
285        assert_eq!(rt.ns(Some("custom")), "custom");
286    }
287
288    #[test]
289    fn store_accessors_return_ok() {
290        let rt = KhiveRuntime::memory().unwrap();
291        assert!(rt.entities(None).is_ok());
292        assert!(rt.graph(None).is_ok());
293        assert!(rt.notes(None).is_ok());
294        assert!(rt.events(None).is_ok());
295    }
296
297    #[test]
298    fn vectors_returns_unconfigured_without_model() {
299        let rt = KhiveRuntime::memory().unwrap();
300        match rt.vectors(None) {
301            Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
302            Err(other) => panic!("expected Unconfigured, got {:?}", other),
303            Ok(_) => panic!("expected Err, got Ok"),
304        }
305    }
306
307    #[test]
308    fn vec_model_key_sanitizes_dots_and_dashes() {
309        assert_eq!(
310            vec_model_key(EmbeddingModel::BgeSmallEnV15),
311            "bge_small_en_v1_5"
312        );
313        assert_eq!(
314            vec_model_key(EmbeddingModel::BgeBaseEnV15),
315            "bge_base_en_v1_5"
316        );
317        assert_eq!(
318            vec_model_key(EmbeddingModel::AllMiniLmL6V2),
319            "all_minilm_l6_v2"
320        );
321    }
322
323    #[test]
324    fn default_config_uses_allow_all_gate() {
325        let cfg = RuntimeConfig::default();
326        // Default gate is permissive — checked via type identity (no leak of
327        // concrete gate kind otherwise).
328        assert_eq!(cfg.default_namespace, "local");
329        // `gate` is non-`Debug`-comparable; smoke-check by running a request
330        // through it via the registry layer would belong in pack.rs tests.
331        let _: GateRef = cfg.gate.clone();
332    }
333
334    #[test]
335    fn parse_pack_list_handles_comma_and_whitespace() {
336        assert_eq!(parse_pack_list("kg"), vec!["kg".to_string()]);
337        assert_eq!(
338            parse_pack_list("kg,gtd"),
339            vec!["kg".to_string(), "gtd".to_string()]
340        );
341        assert_eq!(
342            parse_pack_list("  kg ,  gtd  "),
343            vec!["kg".to_string(), "gtd".to_string()]
344        );
345        assert_eq!(
346            parse_pack_list("kg gtd"),
347            vec!["kg".to_string(), "gtd".to_string()]
348        );
349        assert_eq!(parse_pack_list(",,"), Vec::<String>::new());
350        assert_eq!(parse_pack_list(""), Vec::<String>::new());
351    }
352
353    #[test]
354    fn default_config_packs_falls_back_to_kg() {
355        let prior = std::env::var("KHIVE_PACKS").ok();
356        // SAFETY: test function runs single-threaded; no other threads read or write KHIVE_PACKS.
357        unsafe {
358            std::env::remove_var("KHIVE_PACKS");
359        }
360        let cfg = RuntimeConfig::default();
361        assert_eq!(cfg.packs, vec!["kg".to_string()]);
362        if let Some(v) = prior {
363            // SAFETY: single-threaded test cleanup; restores KHIVE_PACKS to its prior value.
364            unsafe {
365                std::env::set_var("KHIVE_PACKS", v);
366            }
367        }
368    }
369
370    #[test]
371    fn default_config_uses_minilm_when_env_unset() {
372        // Snapshot + clear the env var so this test is deterministic.
373        let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
374        // SAFETY: tests are serial by default for env mutation here; if other tests
375        // mutate this var, mark them with the same scope.
376        unsafe {
377            std::env::remove_var("KHIVE_EMBEDDING_MODEL");
378        }
379        let cfg = RuntimeConfig::default();
380        assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
381        if let Some(v) = prior {
382            // SAFETY: single-threaded test cleanup; restores KHIVE_EMBEDDING_MODEL to its prior value.
383            unsafe {
384                std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
385            }
386        }
387    }
388}