Skip to main content

khive_runtime/
runtime.rs

1//! KhiveRuntime — composable handle to all storage capabilities.
2//!
3//! `RuntimeConfig`, `BackendId`, `NamespaceToken`, and embedding model helpers
4//! live in `super::config` and are re-exported from here.
5
6use std::sync::{Arc, RwLock};
7
8use khive_db::StorageBackend;
9use khive_gate::{ActorRef, AllowAllGate, GateRequest};
10use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
11use khive_types::{EdgeEndpointRule, Namespace};
12use lattice_embed::{EmbeddingModel, EmbeddingService};
13
14use crate::config::{
15    build_embedder_registry, parse_embedding_model_alias, register_configured_embedding_models,
16    sanitize_key, vec_model_key,
17};
18use crate::error::RuntimeResult;
19
20pub use crate::config::{
21    parse_pack_list, runtime_config_from_khive_config, BackendId, NamespaceToken, RuntimeConfig,
22};
23
24// ---- KhiveRuntime ----
25
26/// Composable runtime handle used by the MCP server.
27///
28/// Wraps a `StorageBackend` and provides namespace-scoped accessor methods
29/// for each storage capability, plus a lazily-loaded embedder.
30#[derive(Clone)]
31pub struct KhiveRuntime {
32    backend: Arc<StorageBackend>,
33    config: RuntimeConfig,
34    /// Pack-extensible embedder registry.
35    ///
36    /// Shared across clones via `Arc<RwLock<_>>` so that
37    /// [`register_embedder`](Self::register_embedder) after clone is visible
38    /// to all handles. Built-in lattice models are pre-registered during
39    /// construction; packs may add more via [`PackRuntime::register_embedders`].
40    embedder_registry: Arc<std::sync::RwLock<crate::embedder_registry::EmbedderRegistry>>,
41    default_embedder_name: Arc<str>,
42    /// Pack-extensible edge endpoint rules. Shared across clones
43    /// via `Arc<RwLock<_>>`; installed once by the transport after the
44    /// `VerbRegistry` is built. Empty until installed
45    edge_rules: Arc<RwLock<Vec<EdgeEndpointRule>>>,
46    /// Pack-aggregated valid entity and note kind strings.
47    ///
48    /// Installed by the transport layer after building the `VerbRegistry`.
49    /// When non-empty, `create_entity`, `create_note_inner`, and `import_kg`
50    /// reject kinds not in these sets. When empty (no packs loaded, e.g.
51    /// bare runtime in unit tests), kind validation is skipped — the pack
52    /// handler layer is the primary enforcement point.
53    valid_entity_kinds: Arc<RwLock<Vec<String>>>,
54    valid_note_kinds: Arc<RwLock<Vec<String>>>,
55}
56
57impl KhiveRuntime {
58    /// Create a new runtime with the given config.
59    ///
60    /// The config's `db_path` is used to open or create the SQLite backend.
61    /// For the preferred boot path in multi-backend deployments, use
62    /// [`from_backend`](Self::from_backend) instead.
63    pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
64        let backend = match &config.db_path {
65            Some(path) => {
66                if let Some(parent) = path.parent() {
67                    std::fs::create_dir_all(parent).ok();
68                }
69                StorageBackend::sqlite(path)?
70            }
71            None => StorageBackend::memory()?,
72        };
73        // Run versioned migrations (V1..V17) at startup so file-backed and
74        // in-memory DBs both have proposals_open (V15) and the embedding_model
75        // columns (V16/V17) before any pack handler runs.  Migration is
76        // idempotent — already-applied versions are skipped.  A failure here
77        // aborts construction so the caller sees a clear error rather than a
78        // cryptic "no such table" on the first verb dispatch.
79        {
80            let mut writer = backend.pool().try_writer()?;
81            khive_db::run_migrations(writer.conn_mut())?;
82        }
83        register_configured_embedding_models(&backend, &config)?;
84        let (registry, default_embedder_name) = build_embedder_registry(&config);
85        Ok(Self {
86            backend: Arc::new(backend),
87            config,
88            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
89            default_embedder_name,
90            edge_rules: Arc::new(RwLock::new(Vec::new())),
91            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
92            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
93        })
94    }
95
96    /// Open a runtime for read-only inspection (no model registration, no DB creation).
97    ///
98    /// Runs migrations (idempotent) but skips `register_configured_embedding_models`,
99    /// so `engine list` / `engine status` cannot mutate the registry as a side effect.
100    /// Returns `None` when `db_path` is `None` and the default DB does not exist.
101    pub fn new_readonly(config: RuntimeConfig) -> RuntimeResult<Self> {
102        let backend = match &config.db_path {
103            Some(path) => StorageBackend::sqlite(path)?,
104            None => StorageBackend::memory()?,
105        };
106        {
107            let mut writer = backend.pool().try_writer()?;
108            khive_db::run_migrations(writer.conn_mut())?;
109        }
110        let (registry, default_embedder_name) = build_embedder_registry(&config);
111        Ok(Self {
112            backend: Arc::new(backend),
113            config,
114            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
115            default_embedder_name,
116            edge_rules: Arc::new(RwLock::new(Vec::new())),
117            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
118            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
119        })
120    }
121
122    /// Construct a runtime from an already-opened backend.
123    ///
124    /// This is the preferred constructor for multi-backend deployments. The caller
125    /// (boot path in `kkernel` or `khive-mcp`) opens each backend from `khive.toml`,
126    /// then constructs a `KhiveRuntime` per pack using this method.
127    ///
128    /// The returned runtime has `db_path = None` and `embedding_model = None`; all
129    /// storage access is through the provided `backend`. Set `backend_id` and
130    /// `default_namespace` via the config builder pattern if non-defaults are needed.
131    pub fn from_backend(backend: Arc<StorageBackend>, config: RuntimeConfig) -> Self {
132        if let Err(err) = register_configured_embedding_models(&backend, &config) {
133            tracing::warn!(error = %err, "failed to register configured embedding models");
134        }
135        let (registry, default_embedder_name) = build_embedder_registry(&config);
136        Self {
137            backend,
138            config,
139            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
140            default_embedder_name,
141            edge_rules: Arc::new(RwLock::new(Vec::new())),
142            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
143            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
144        }
145    }
146
147    /// Create an in-memory runtime (for tests and ephemeral use).
148    pub fn memory() -> RuntimeResult<Self> {
149        Self::new(RuntimeConfig {
150            db_path: None,
151            default_namespace: Namespace::local(),
152            embedding_model: None,
153            additional_embedding_models: vec![],
154            gate: Arc::new(AllowAllGate),
155            packs: vec!["kg".to_string()],
156            backend_id: BackendId::main(),
157            brain_profile: None,
158        })
159    }
160
161    /// Return the [`BackendId`] for this runtime's backend.
162    ///
163    /// Used by `SubstrateCoordinator` in `kkernel`
164    /// to identify which backend owns a given node, and to detect cross-backend merges.
165    pub fn backend_id(&self) -> &BackendId {
166        &self.config.backend_id
167    }
168
169    /// Return a reference to the runtime config.
170    pub fn config(&self) -> &RuntimeConfig {
171        &self.config
172    }
173
174    /// Return a reference to the underlying storage backend.
175    pub fn backend(&self) -> &StorageBackend {
176        &self.backend
177    }
178
179    // ---- Store accessors (token-scoped) ----
180
181    /// Get an EntityStore scoped to the token's namespace.
182    pub fn entities(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EntityStore>> {
183        Ok(self
184            .backend
185            .entities_for_namespace(token.namespace().as_str())?)
186    }
187
188    /// Get a GraphStore scoped to the token's namespace.
189    pub fn graph(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn GraphStore>> {
190        Ok(self
191            .backend
192            .graph_for_namespace(token.namespace().as_str())?)
193    }
194
195    /// Get a NoteStore scoped to the token's namespace.
196    pub fn notes(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn NoteStore>> {
197        Ok(self
198            .backend
199            .notes_for_namespace(token.namespace().as_str())?)
200    }
201
202    /// Get an EventStore scoped to the token's namespace.
203    pub fn events(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EventStore>> {
204        Ok(self
205            .backend
206            .events_for_namespace(token.namespace().as_str())?)
207    }
208
209    /// Get the raw SQL access capability (for ad-hoc queries).
210    pub fn sql(&self) -> Arc<dyn SqlAccess> {
211        self.backend.sql()
212    }
213
214    /// Get a VectorStore for the configured embedding model, scoped to the token's namespace.
215    ///
216    /// Returns `Unconfigured("embedding_model")` if no model is set.
217    pub fn vectors(
218        &self,
219        token: &NamespaceToken,
220    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
221        let model = self.resolve_embedding_model(None)?;
222        self.vectors_for_embedding_model(token, model)
223    }
224
225    /// Get a VectorStore for a specific named embedding model, scoped to the token's namespace.
226    ///
227    /// Accepts both built-in lattice model names/aliases and custom provider names
228    /// registered via [`register_embedder`](Self::register_embedder). Lattice names
229    /// are routed through the enum-backed path; custom provider names use the
230    /// provider's declared `dimensions()` directly so that the vector store key
231    /// is consistent with how vectors were written during `remember`/`recall`.
232    pub fn vectors_for_model(
233        &self,
234        token: &NamespaceToken,
235        model_name: &str,
236    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
237        // Try the lattice enum path first (handles aliases like "paraphrase").
238        if let Some(model) = parse_embedding_model_alias(model_name) {
239            // Only proceed via the lattice path if this model is actually in the
240            // registry; otherwise fall through to the custom-provider path.
241            let key = model.to_string();
242            let in_registry = self
243                .embedder_registry
244                .read()
245                .map(|reg| reg.contains(&key))
246                .unwrap_or(false);
247            if in_registry {
248                return self.vectors_for_embedding_model(token, model);
249            }
250        }
251        // Custom provider path: look up dimensions from the registry and build
252        // the vector store using the sanitized provider name as the table key.
253        let dims = {
254            let registry = self.embedder_registry.read().map_err(|_| {
255                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
256            })?;
257            registry
258                .get_provider(model_name)
259                .map(|p| p.dimensions())
260                .ok_or_else(|| crate::RuntimeError::UnknownModel(model_name.to_string()))?
261        };
262        let model_key = sanitize_key(model_name);
263        Ok(self.backend.vectors_for_namespace(
264            &model_key,
265            model_name,
266            dims,
267            token.namespace().as_str(),
268        )?)
269    }
270
271    fn vectors_for_embedding_model(
272        &self,
273        token: &NamespaceToken,
274        model: EmbeddingModel,
275    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
276        Ok(self.backend.vectors_for_namespace(
277            &vec_model_key(model),
278            &model.to_string(),
279            model.dimensions(),
280            token.namespace().as_str(),
281        )?)
282    }
283
284    /// Get a TextSearch index for the token's namespace entity corpus.
285    pub fn text(
286        &self,
287        token: &NamespaceToken,
288    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
289        let key = format!("entities_{}", sanitize_key(token.namespace().as_str()));
290        Ok(self.backend.text(&key)?)
291    }
292
293    /// Get a TextSearch index for the token's namespace notes corpus.
294    pub fn text_for_notes(
295        &self,
296        token: &NamespaceToken,
297    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
298        let key = format!("notes_{}", sanitize_key(token.namespace().as_str()));
299        Ok(self.backend.text(&key)?)
300    }
301
302    /// Mint an authorization token for the given namespace.
303    ///
304    /// Consults the configured [`crate::Gate`] before minting. With the default
305    /// `AllowAllGate` this always succeeds. When a real policy-backed gate is
306    /// installed, this method enforces it and returns `PermissionDenied` on
307    /// denial.
308    pub fn authorize(&self, ns: Namespace) -> RuntimeResult<NamespaceToken> {
309        let actor = ActorRef::anonymous();
310        let req = GateRequest::new(
311            actor.clone(),
312            ns.clone(),
313            "authorize",
314            serde_json::Value::Null,
315        );
316        match self.config.gate.check(&req) {
317            Ok(ref decision) if decision.is_allow() => {
318                if let khive_gate::GateDecision::Allow { ref obligations } = decision {
319                    if !obligations.is_empty() {
320                        tracing::debug!(
321                            namespace = %ns.as_str(),
322                            "authorize: obligations={:?}",
323                            obligations
324                        );
325                    }
326                }
327                Ok(NamespaceToken::mint_authorized(ns, actor))
328            }
329            Ok(khive_gate::GateDecision::Deny { reason }) => {
330                Err(crate::RuntimeError::PermissionDenied {
331                    verb: "authorize".to_string(),
332                    reason,
333                })
334            }
335            Ok(_) => Err(crate::RuntimeError::PermissionDenied {
336                verb: "authorize".to_string(),
337                reason: "gate denied".to_string(),
338            }),
339            Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
340        }
341    }
342
343    /// Install the pack-aggregated edge endpoint rules.
344    ///
345    /// Called by the transport layer after the `VerbRegistry` is built so
346    /// that runtime-layer edge validation can consult pack rules. Idempotent:
347    /// later calls overwrite the previous rule set.
348    pub fn install_edge_rules(&self, rules: Vec<EdgeEndpointRule>) {
349        if let Ok(mut guard) = self.edge_rules.write() {
350            *guard = rules;
351        }
352    }
353
354    /// Install the pack-aggregated valid entity and note kinds.
355    ///
356    /// Called by the transport layer after the `VerbRegistry` is built so that
357    /// runtime-layer entity/note creation and import validate kind strings against
358    /// the merged pack vocabulary. Idempotent: later calls overwrite previous sets.
359    ///
360    /// When no kinds are installed (empty lists), kind validation is skipped at
361    /// the runtime layer. The pack handler layer remains the primary enforcement
362    /// point; this provides defense-in-depth for direct Rust callers and import.
363    pub fn install_kind_registry(&self, entity_kinds: Vec<String>, note_kinds: Vec<String>) {
364        if let Ok(mut guard) = self.valid_entity_kinds.write() {
365            *guard = entity_kinds;
366        }
367        if let Ok(mut guard) = self.valid_note_kinds.write() {
368            *guard = note_kinds;
369        }
370    }
371
372    /// Validate that `kind` is a pack-registered entity kind.
373    ///
374    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
375    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
376    pub(crate) fn validate_entity_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
377        let guard = self.valid_entity_kinds.read().map_err(|_| {
378            crate::RuntimeError::Internal("entity kind registry lock poisoned".into())
379        })?;
380        if guard.is_empty() {
381            return Ok(());
382        }
383        if guard.iter().any(|k| k == kind) {
384            Ok(())
385        } else {
386            Err(crate::RuntimeError::InvalidInput(format!(
387                "unknown entity kind {kind:?}; valid: {}",
388                guard.join(", ")
389            )))
390        }
391    }
392
393    /// Validate that `kind` is a pack-registered note kind.
394    ///
395    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
396    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
397    pub(crate) fn validate_note_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
398        let guard = self.valid_note_kinds.read().map_err(|_| {
399            crate::RuntimeError::Internal("note kind registry lock poisoned".into())
400        })?;
401        if guard.is_empty() {
402            return Ok(());
403        }
404        if guard.iter().any(|k| k == kind) {
405            Ok(())
406        } else {
407            Err(crate::RuntimeError::InvalidInput(format!(
408                "unknown note kind {kind:?}; valid: {}",
409                guard.join(", ")
410            )))
411        }
412    }
413
414    /// Snapshot of currently-installed pack edge rules.
415    pub(crate) fn pack_edge_rules(&self) -> Vec<EdgeEndpointRule> {
416        self.edge_rules
417            .read()
418            .map(|g| g.clone())
419            .unwrap_or_default()
420    }
421
422    /// Return the name of the default embedding model (empty string if none configured).
423    pub fn default_embedder_name(&self) -> &str {
424        self.default_embedder_name.as_ref()
425    }
426
427    /// Resolve a model name (or `None` for the default) to an `EmbeddingModel`.
428    ///
429    /// Returns `UnknownModel` if the name is not in the registry, or
430    /// `Unconfigured` if `None` is passed and no default model is set.
431    pub fn resolve_embedding_model(&self, name: Option<&str>) -> RuntimeResult<EmbeddingModel> {
432        let model = match name {
433            Some(raw) => parse_embedding_model_alias(raw)
434                .ok_or_else(|| crate::RuntimeError::UnknownModel(raw.to_string()))?,
435            None => self
436                .config
437                .embedding_model
438                .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?,
439        };
440        let key = model.to_string();
441        let contains = self
442            .embedder_registry
443            .read()
444            .map(|reg| reg.contains(&key))
445            .unwrap_or(false);
446        if contains {
447            Ok(model)
448        } else {
449            Err(crate::RuntimeError::UnknownModel(
450                name.unwrap_or_else(|| self.default_embedder_name())
451                    .to_string(),
452            ))
453        }
454    }
455
456    /// Names of all registered embedding models in this runtime.
457    ///
458    /// Includes both built-in lattice models and any custom embedders
459    /// registered by packs via [`register_embedder`](Self::register_embedder).
460    /// Useful for operations that must touch every model's storage (e.g.,
461    /// scoped vector deletion on note delete — codex High 2 (PR #407)).
462    /// The default model is included.
463    pub fn registered_embedding_model_names(&self) -> Vec<String> {
464        self.embedder_registry
465            .read()
466            .map(|reg| reg.names())
467            .unwrap_or_default()
468    }
469
470    /// Get the lazily-initialized embedding service for the named model.
471    ///
472    /// Accepts both built-in lattice model names (e.g. `"all-minilm-l6-v2"`,
473    /// `"paraphrase"`) and custom provider names registered via
474    /// [`register_embedder`](Self::register_embedder).
475    ///
476    /// For lattice model names, aliases (e.g. `"paraphrase"`) are resolved to
477    /// their canonical key before looking up the registry. For custom providers
478    /// the name must match exactly as supplied during registration.
479    ///
480    /// First call for any name loads the underlying service (cold start cost);
481    /// subsequent calls are cheap (registry caches the `Arc`).
482    pub async fn embedder(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
483        // Try to resolve as a lattice alias first (normalises "paraphrase" →
484        // "paraphrase-multilingual-minilm-l12-v2", etc.).  If that succeeds,
485        // use the canonical key; otherwise fall back to the literal name so
486        // custom providers registered with non-lattice names are reachable.
487        let canonical_key = match parse_embedding_model_alias(name) {
488            Some(model) => model.to_string(),
489            None => name.to_owned(),
490        };
491        // Clone the entry before releasing the lock so we don't hold a
492        // RwLockGuard across the async OnceCell initialisation (Send bound).
493        let entry = {
494            let registry = self.embedder_registry.read().map_err(|_| {
495                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
496            })?;
497            registry
498                .get_entry(&canonical_key)
499                .ok_or_else(|| crate::RuntimeError::UnknownModel(name.to_string()))?
500        };
501        entry.resolve().await
502    }
503
504    /// Register a custom embedding provider with this runtime.
505    ///
506    /// The provider is added to the shared [`EmbedderRegistry`] so all clones
507    /// of this runtime see the new provider immediately. If a provider with the
508    /// same name already exists it is replaced (last-writer wins — see
509    /// [`crate::EmbedderRegistry::register`] for the rationale).
510    ///
511    /// Packs should call this from [`crate::PackRuntime::register_embedders`] (the
512    /// hook is invoked by the transport during pack initialisation, before the
513    /// first verb dispatch).
514    ///
515    /// [`EmbedderRegistry`]: crate::embedder_registry::EmbedderRegistry
516    pub fn register_embedder(
517        &self,
518        provider: impl crate::embedder_registry::EmbedderProvider + 'static,
519    ) {
520        if let Ok(mut registry) = self.embedder_registry.write() {
521            registry.register(provider);
522        } else {
523            tracing::warn!(
524                "embedder registry lock poisoned — embedder {} not registered",
525                std::any::type_name::<dyn crate::embedder_registry::EmbedderProvider>()
526            );
527        }
528    }
529
530    /// List registered embedding models via `SqlAccess`, routing through the
531    /// existing connection pool rather than opening a fresh `Connection` per call.
532    ///
533    /// Optionally filter by `engine_name`. Returns an empty vec when the
534    /// `_embedding_models` table does not yet exist (e.g. no migrations have run
535    /// or no models have been registered). All other SQL errors are propagated.
536    pub async fn list_embedding_models(
537        &self,
538        engine_filter: Option<&str>,
539    ) -> RuntimeResult<Vec<khive_db::EmbeddingModelRegistryRecord>> {
540        use khive_storage::{SqlStatement, SqlValue};
541
542        let (sql_text, params) = if let Some(engine) = engine_filter {
543            (
544                "SELECT engine_name, model_id, key_version, dim, status, \
545                 activated_at, superseded_at \
546                 FROM _embedding_models WHERE engine_name = ?1 \
547                 ORDER BY engine_name, activated_at IS NULL, activated_at"
548                    .to_string(),
549                vec![SqlValue::Text(engine.to_string())],
550            )
551        } else {
552            (
553                "SELECT engine_name, model_id, key_version, dim, status, \
554                 activated_at, superseded_at \
555                 FROM _embedding_models \
556                 ORDER BY engine_name, activated_at IS NULL, activated_at"
557                    .to_string(),
558                vec![],
559            )
560        };
561
562        let stmt = SqlStatement {
563            sql: sql_text,
564            params,
565            label: Some("list_embedding_models".into()),
566        };
567
568        let mut reader = self
569            .sql()
570            .reader()
571            .await
572            .map_err(crate::RuntimeError::Storage)?;
573
574        let rows = match reader.query_all(stmt).await {
575            Ok(rows) => rows,
576            Err(e) if e.to_string().contains("no such table: _embedding_models") => {
577                return Ok(Vec::new())
578            }
579            Err(e) => return Err(crate::RuntimeError::Storage(e)),
580        };
581
582        let mut records = Vec::with_capacity(rows.len());
583        for row in rows {
584            macro_rules! required_text {
585                ($col:expr) => {
586                    match row.get($col) {
587                        Some(SqlValue::Text(s)) => s.clone(),
588                        other => {
589                            tracing::warn!(column = $col, value = ?other, "skipping registry row: unexpected type");
590                            continue;
591                        }
592                    }
593                };
594            }
595            let engine_name = required_text!("engine_name");
596            let model_id = required_text!("model_id");
597            let key_version = required_text!("key_version");
598            let dimensions = match row.get("dim") {
599                Some(SqlValue::Integer(n)) => match u32::try_from(*n) {
600                    Ok(d) => d,
601                    Err(_) => {
602                        tracing::warn!(dim = n, "skipping registry row: dim out of u32 range");
603                        continue;
604                    }
605                },
606                other => {
607                    tracing::warn!(column = "dim", value = ?other, "skipping registry row: unexpected type");
608                    continue;
609                }
610            };
611            let status = required_text!("status");
612            let activated_at = match row.get("activated_at") {
613                Some(SqlValue::Integer(n)) => Some(*n),
614                _ => None,
615            };
616            let superseded_at = match row.get("superseded_at") {
617                Some(SqlValue::Integer(n)) => Some(*n),
618                _ => None,
619            };
620            records.push(khive_db::EmbeddingModelRegistryRecord {
621                engine_name,
622                model_id,
623                key_version,
624                dimensions,
625                status,
626                activated_at,
627                superseded_at,
628            });
629        }
630
631        Ok(records)
632    }
633}
634
635// INLINE TEST JUSTIFICATION: tests here cover KhiveRuntime construction helpers
636// (in-memory backend wiring, NamespaceToken::for_namespace) that are
637// pub(crate)-only and cannot be called from the integration test crate.
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use khive_gate::GateRef;
642
643    #[test]
644    fn memory_runtime_creates_successfully() {
645        let rt = KhiveRuntime::memory().expect("memory runtime should create");
646        assert!(rt.config().db_path.is_none());
647    }
648
649    #[test]
650    fn file_runtime_creates_successfully() {
651        let dir = tempfile::tempdir().unwrap();
652        let path = dir.path().join("test.db");
653        let config = RuntimeConfig {
654            db_path: Some(path.clone()),
655            default_namespace: Namespace::parse("test").unwrap(),
656            embedding_model: None,
657            additional_embedding_models: vec![],
658            gate: Arc::new(AllowAllGate),
659            packs: vec!["kg".to_string()],
660            backend_id: BackendId::main(),
661            brain_profile: None,
662        };
663        let rt = KhiveRuntime::new(config).expect("file runtime should create");
664        assert!(path.exists());
665        assert_eq!(rt.config().default_namespace.as_str(), "test");
666    }
667
668    #[test]
669    fn from_backend_uses_provided_backend() {
670        let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
671        let config = RuntimeConfig {
672            db_path: None,
673            default_namespace: Namespace::local(),
674            embedding_model: None,
675            additional_embedding_models: vec![],
676            gate: Arc::new(AllowAllGate),
677            packs: vec!["kg".to_string()],
678            backend_id: BackendId::new("lore"),
679            brain_profile: None,
680        };
681        let rt = KhiveRuntime::from_backend(backend, config);
682        assert_eq!(rt.backend_id().as_str(), "lore");
683        assert!(rt.config().db_path.is_none());
684    }
685
686    #[test]
687    fn backend_id_defaults_to_main() {
688        let rt = KhiveRuntime::memory().unwrap();
689        assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
690    }
691
692    #[test]
693    fn store_accessors_return_ok() {
694        let rt = KhiveRuntime::memory().unwrap();
695        let tok = NamespaceToken::local();
696        assert!(rt.entities(&tok).is_ok());
697        assert!(rt.graph(&tok).is_ok());
698        assert!(rt.notes(&tok).is_ok());
699        assert!(rt.events(&tok).is_ok());
700    }
701
702    #[test]
703    fn vectors_returns_unconfigured_without_model() {
704        let rt = KhiveRuntime::memory().unwrap();
705        let tok = NamespaceToken::local();
706        match rt.vectors(&tok) {
707            Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
708            Err(other) => panic!("expected Unconfigured, got {:?}", other),
709            Ok(_) => panic!("expected Err, got Ok"),
710        }
711    }
712
713    #[test]
714    fn vec_model_key_sanitizes_dots_and_dashes() {
715        assert_eq!(
716            vec_model_key(EmbeddingModel::BgeSmallEnV15),
717            "bge_small_en_v1_5"
718        );
719        assert_eq!(
720            vec_model_key(EmbeddingModel::BgeBaseEnV15),
721            "bge_base_en_v1_5"
722        );
723        assert_eq!(
724            vec_model_key(EmbeddingModel::AllMiniLmL6V2),
725            "all_minilm_l6_v2"
726        );
727    }
728
729    #[test]
730    fn default_config_uses_allow_all_gate() {
731        let cfg = RuntimeConfig::default();
732        assert_eq!(cfg.default_namespace.as_str(), "local");
733        let _: GateRef = cfg.gate.clone();
734    }
735
736    #[test]
737    fn parse_pack_list_handles_comma_and_whitespace() {
738        assert_eq!(parse_pack_list("kg"), vec!["kg".to_string()]);
739        assert_eq!(
740            parse_pack_list("kg,gtd"),
741            vec!["kg".to_string(), "gtd".to_string()]
742        );
743        assert_eq!(
744            parse_pack_list("  kg ,  gtd  "),
745            vec!["kg".to_string(), "gtd".to_string()]
746        );
747        assert_eq!(
748            parse_pack_list("kg gtd"),
749            vec!["kg".to_string(), "gtd".to_string()]
750        );
751        assert_eq!(parse_pack_list(",,"), Vec::<String>::new());
752        assert_eq!(parse_pack_list(""), Vec::<String>::new());
753    }
754
755    #[test]
756    fn default_config_packs_loads_all_production_packs() {
757        let prior = std::env::var("KHIVE_PACKS").ok();
758        // SAFETY: test function runs single-threaded; no other threads read or write KHIVE_PACKS.
759        unsafe {
760            std::env::remove_var("KHIVE_PACKS");
761        }
762        let cfg = RuntimeConfig::default();
763        assert!(cfg.packs.contains(&"kg".to_string()));
764        assert!(cfg.packs.contains(&"gtd".to_string()));
765        assert!(cfg.packs.contains(&"memory".to_string()));
766        assert!(cfg.packs.contains(&"brain".to_string()));
767        assert!(cfg.packs.contains(&"comm".to_string()));
768        assert!(cfg.packs.contains(&"schedule".to_string()));
769        assert!(cfg.packs.contains(&"knowledge".to_string()));
770        assert_eq!(cfg.packs.len(), 7);
771        if let Some(v) = prior {
772            // SAFETY: single-threaded test cleanup; restores KHIVE_PACKS to its prior value.
773            unsafe {
774                std::env::set_var("KHIVE_PACKS", v);
775            }
776        }
777    }
778
779    #[test]
780    fn default_config_uses_minilm_when_env_unset() {
781        let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
782        // SAFETY: tests are serial by default for env mutation here; if other tests
783        // mutate this var, mark them with the same scope.
784        unsafe {
785            std::env::remove_var("KHIVE_EMBEDDING_MODEL");
786        }
787        let cfg = RuntimeConfig::default();
788        assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
789        if let Some(v) = prior {
790            // SAFETY: single-threaded test cleanup; restores KHIVE_EMBEDDING_MODEL to its prior value.
791            unsafe {
792                std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
793            }
794        }
795    }
796
797    // ---- Actor config tests ----
798
799    use crate::engine_config::{ActorConfig, KhiveConfig, RuntimeSectionConfig};
800
801    fn khive_cfg_with_actor(id: &str) -> KhiveConfig {
802        KhiveConfig {
803            engines: vec![],
804            actor: ActorConfig {
805                id: Some(id.to_string()),
806                display_name: None,
807            },
808            runtime: RuntimeSectionConfig::default(),
809        }
810    }
811
812    #[test]
813    fn runtime_config_from_khive_config_applies_actor_id_as_default_namespace() {
814        let base = RuntimeConfig {
815            db_path: None,
816            default_namespace: Namespace::local(),
817            embedding_model: None,
818            additional_embedding_models: vec![],
819            gate: Arc::new(AllowAllGate),
820            packs: vec!["kg".to_string()],
821            backend_id: BackendId::main(),
822            brain_profile: None,
823        };
824        let cfg = khive_cfg_with_actor("lambda:khive");
825        let result = runtime_config_from_khive_config(&cfg, base);
826        assert_eq!(result.default_namespace.as_str(), "lambda:khive");
827    }
828
829    #[test]
830    fn runtime_config_from_khive_config_empty_actor_id_keeps_base_namespace() {
831        let base = RuntimeConfig {
832            db_path: None,
833            default_namespace: Namespace::parse("lambda:base").unwrap(),
834            embedding_model: None,
835            additional_embedding_models: vec![],
836            gate: Arc::new(AllowAllGate),
837            packs: vec!["kg".to_string()],
838            backend_id: BackendId::main(),
839            brain_profile: None,
840        };
841        let cfg = KhiveConfig {
842            engines: vec![],
843            actor: ActorConfig {
844                id: Some(String::new()),
845                display_name: None,
846            },
847            runtime: RuntimeSectionConfig::default(),
848        };
849        let result = runtime_config_from_khive_config(&cfg, base);
850        assert_eq!(
851            result.default_namespace.as_str(),
852            "lambda:base",
853            "empty actor.id must not override base namespace"
854        );
855    }
856
857    #[test]
858    fn runtime_config_from_khive_config_absent_actor_id_keeps_base_namespace() {
859        let base = RuntimeConfig {
860            db_path: None,
861            default_namespace: Namespace::parse("lambda:base").unwrap(),
862            embedding_model: None,
863            additional_embedding_models: vec![],
864            gate: Arc::new(AllowAllGate),
865            packs: vec!["kg".to_string()],
866            backend_id: BackendId::main(),
867            brain_profile: None,
868        };
869        let cfg = KhiveConfig::default(); // no actor.id
870        let result = runtime_config_from_khive_config(&cfg, base);
871        assert_eq!(
872            result.default_namespace.as_str(),
873            "lambda:base",
874            "absent actor.id must not override base namespace"
875        );
876    }
877
878    #[test]
879    fn runtime_config_from_khive_config_actor_id_with_engines() {
880        let base = RuntimeConfig {
881            db_path: None,
882            default_namespace: Namespace::local(),
883            embedding_model: None,
884            additional_embedding_models: vec![],
885            gate: Arc::new(AllowAllGate),
886            packs: vec!["kg".to_string()],
887            backend_id: BackendId::main(),
888            brain_profile: None,
889        };
890        let cfg = KhiveConfig {
891            engines: vec![crate::engine_config::EngineConfig {
892                name: "default".to_string(),
893                model: "all-minilm-l6-v2".to_string(),
894                default: true,
895                fusion_weight: None,
896                dims: None,
897            }],
898            actor: ActorConfig {
899                id: Some("lambda:test".to_string()),
900                display_name: None,
901            },
902            runtime: RuntimeSectionConfig::default(),
903        };
904        let result = runtime_config_from_khive_config(&cfg, base);
905        assert_eq!(result.default_namespace.as_str(), "lambda:test");
906        assert!(result.embedding_model.is_some());
907    }
908
909    // ---- list_embedding_models tests ----
910
911    #[tokio::test]
912    async fn list_embedding_models_returns_empty_when_table_absent() {
913        // A brand-new in-memory runtime has migrations applied, so _embedding_models
914        // IS created. But with no rows inserted, the result must be empty.
915        let rt = KhiveRuntime::memory().expect("memory runtime");
916        let records = rt
917            .list_embedding_models(None)
918            .await
919            .expect("list ok on empty table");
920        assert!(records.is_empty());
921    }
922
923    #[tokio::test]
924    async fn list_embedding_models_returns_row_after_insert() {
925        use khive_storage::{SqlStatement, SqlValue};
926
927        let rt = KhiveRuntime::memory().expect("memory runtime");
928        let sql = rt.sql();
929
930        let now = 1_000_000i64;
931        let id = uuid::Uuid::new_v4();
932        let canonical_key = b"test_engine:test-model-v1:v1:384".to_vec();
933
934        let mut writer = sql.writer().await.expect("writer");
935        writer
936            .execute(SqlStatement {
937                sql: "INSERT INTO _embedding_models \
938                      (id, engine_name, model_id, key_version, dim, output_dim, status, \
939                       activated_at, superseded_at, superseded_by, canonical_key, created_at) \
940                      VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, NULL, NULL, ?8, ?9)"
941                    .into(),
942                params: vec![
943                    SqlValue::Blob(id.as_bytes().to_vec()),
944                    SqlValue::Text("test_engine".into()),
945                    SqlValue::Text("test-model-v1".into()),
946                    SqlValue::Text("v1".into()),
947                    SqlValue::Integer(384),
948                    SqlValue::Text("active".into()),
949                    SqlValue::Integer(now),
950                    SqlValue::Blob(canonical_key),
951                    SqlValue::Integer(now),
952                ],
953                label: None,
954            })
955            .await
956            .expect("insert row");
957        drop(writer);
958
959        let records = rt.list_embedding_models(None).await.expect("list ok");
960        assert_eq!(records.len(), 1);
961        assert_eq!(records[0].engine_name, "test_engine");
962        assert_eq!(records[0].model_id, "test-model-v1");
963        assert_eq!(records[0].key_version, "v1");
964        assert_eq!(records[0].dimensions, 384);
965        assert_eq!(records[0].status, "active");
966
967        // engine filter — match
968        let filtered = rt
969            .list_embedding_models(Some("test_engine"))
970            .await
971            .expect("filter ok");
972        assert_eq!(filtered.len(), 1);
973
974        // engine filter — no match
975        let no_match = rt
976            .list_embedding_models(Some("other_engine"))
977            .await
978            .expect("no-match ok");
979        assert!(no_match.is_empty());
980    }
981}