1use std::sync::{Arc, RwLock};
7
8use khive_db::StorageBackend;
9#[cfg(test)]
10use khive_gate::AllowAllGate;
11use khive_gate::GateRequest;
12use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
13use khive_types::{EdgeEndpointRule, Namespace};
14use lattice_embed::{EmbeddingModel, EmbeddingService};
15
16use crate::config::{
17 build_embedder_registry, parse_embedding_model_alias, register_configured_embedding_models,
18 sanitize_key, vec_model_key,
19};
20use crate::error::{RuntimeError, RuntimeResult};
21
22pub type EntityTypeValidatorFn =
28 Arc<dyn Fn(&str, Option<&str>) -> Result<Option<String>, RuntimeError> + Send + Sync>;
29
30pub type NoteMutationHookFn = Arc<
41 dyn Fn(String, uuid::Uuid) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
42 + Send
43 + Sync,
44>;
45
46pub use crate::config::{
47 assert_db_anchor_consistent, parse_pack_list, resolve_db_anchor, resolve_project_actor_id,
48 runtime_config_from_khive_config, BackendId, NamespaceToken, RuntimeConfig,
49};
50
51#[derive(Clone)]
58pub struct KhiveRuntime {
59 backend: Arc<StorageBackend>,
60 core_backend: Option<Arc<StorageBackend>>,
64 config: RuntimeConfig,
65 embedder_registry: Arc<std::sync::RwLock<crate::embedder_registry::EmbedderRegistry>>,
72 default_embedder_name: Arc<str>,
73 edge_rules: Arc<RwLock<Vec<EdgeEndpointRule>>>,
77 valid_entity_kinds: Arc<RwLock<Vec<String>>>,
85 valid_note_kinds: Arc<RwLock<Vec<String>>>,
86 entity_type_validator: Arc<RwLock<Option<EntityTypeValidatorFn>>>,
93 note_mutation_hook: Arc<RwLock<Option<NoteMutationHookFn>>>,
105}
106
107impl KhiveRuntime {
108 pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
114 let backend = match &config.db_path {
115 Some(path) => {
116 if let Some(parent) = path.parent() {
117 std::fs::create_dir_all(parent).ok();
118 }
119 StorageBackend::sqlite(path)?
120 }
121 None => StorageBackend::memory()?,
122 };
123 {
126 let mut writer = backend.pool().try_writer()?;
127 khive_db::run_migrations(writer.conn_mut())?;
128 }
129 register_configured_embedding_models(&backend, &config)?;
130 let (registry, default_embedder_name) = build_embedder_registry(&config);
131 Ok(Self {
132 backend: Arc::new(backend),
133 core_backend: None,
134 config,
135 embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
136 default_embedder_name,
137 edge_rules: Arc::new(RwLock::new(Vec::new())),
138 valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
139 valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
140 entity_type_validator: Arc::new(RwLock::new(None)),
141 note_mutation_hook: Arc::new(RwLock::new(None)),
142 })
143 }
144
145 pub fn new_readonly(config: RuntimeConfig) -> RuntimeResult<Self> {
151 let backend = match &config.db_path {
152 Some(path) => StorageBackend::sqlite(path)?,
153 None => StorageBackend::memory()?,
154 };
155 {
156 let mut writer = backend.pool().try_writer()?;
157 khive_db::run_migrations(writer.conn_mut())?;
158 }
159 let (registry, default_embedder_name) = build_embedder_registry(&config);
160 Ok(Self {
161 backend: Arc::new(backend),
162 core_backend: None,
163 config,
164 embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
165 default_embedder_name,
166 edge_rules: Arc::new(RwLock::new(Vec::new())),
167 valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
168 valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
169 entity_type_validator: Arc::new(RwLock::new(None)),
170 note_mutation_hook: Arc::new(RwLock::new(None)),
171 })
172 }
173
174 pub fn from_backend(backend: Arc<StorageBackend>, config: RuntimeConfig) -> Self {
184 if let Err(err) = register_configured_embedding_models(&backend, &config) {
185 tracing::warn!(error = %err, "failed to register configured embedding models");
186 }
187 let (registry, default_embedder_name) = build_embedder_registry(&config);
188 Self {
189 backend,
190 core_backend: None,
191 config,
192 embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
193 default_embedder_name,
194 edge_rules: Arc::new(RwLock::new(Vec::new())),
195 valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
196 valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
197 entity_type_validator: Arc::new(RwLock::new(None)),
198 note_mutation_hook: Arc::new(RwLock::new(None)),
199 }
200 }
201
202 pub fn with_core_backend(mut self, core: Arc<StorageBackend>) -> Self {
211 debug_assert_ne!(
212 self.config.backend_id.as_str(),
213 BackendId::MAIN,
214 "with_core_backend must not be called on the main runtime"
215 );
216 self.core_backend = Some(core);
217 self
218 }
219
220 pub fn core(&self) -> KhiveRuntime {
240 match &self.core_backend {
241 None => self.clone(),
242 Some(main_arc) => {
243 let mut core_config = self.config.clone();
244 core_config.backend_id = BackendId::main();
245 KhiveRuntime {
246 backend: main_arc.clone(),
247 core_backend: None,
248 config: core_config,
249 embedder_registry: self.embedder_registry.clone(),
250 default_embedder_name: self.default_embedder_name.clone(),
251 edge_rules: self.edge_rules.clone(),
252 valid_entity_kinds: self.valid_entity_kinds.clone(),
253 valid_note_kinds: self.valid_note_kinds.clone(),
254 entity_type_validator: self.entity_type_validator.clone(),
255 note_mutation_hook: self.note_mutation_hook.clone(),
256 }
257 }
258 }
259 }
260
261 pub fn memory() -> RuntimeResult<Self> {
263 Self::new(RuntimeConfig {
264 db_path: None,
265 packs: vec!["kg".to_string()],
266 brain_profile: None,
267 actor_id: None,
268 ..RuntimeConfig::no_embeddings()
269 })
270 }
271
272 pub fn backend_id(&self) -> &BackendId {
277 &self.config.backend_id
278 }
279
280 pub fn visible_namespaces(&self) -> &[Namespace] {
287 &self.config.visible_namespaces
288 }
289
290 pub fn config(&self) -> &RuntimeConfig {
292 &self.config
293 }
294
295 pub fn backend(&self) -> &StorageBackend {
297 &self.backend
298 }
299
300 pub fn backend_data_dir(&self) -> Option<std::path::PathBuf> {
303 self.backend.data_dir()
304 }
305
306 pub fn entities(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EntityStore>> {
310 Ok(self
311 .backend
312 .entities_for_namespace(token.namespace().as_str())?)
313 }
314
315 pub fn graph(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn GraphStore>> {
317 Ok(self
318 .backend
319 .graph_for_namespace(token.namespace().as_str())?)
320 }
321
322 pub fn notes(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn NoteStore>> {
324 Ok(self
325 .backend
326 .notes_for_namespace(token.namespace().as_str())?)
327 }
328
329 pub fn events(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EventStore>> {
331 Ok(self
332 .backend
333 .events_for_namespace(token.namespace().as_str())?)
334 }
335
336 pub fn sql(&self) -> Arc<dyn SqlAccess> {
338 self.backend.sql()
339 }
340
341 pub fn vectors(
345 &self,
346 token: &NamespaceToken,
347 ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
348 let model = self.resolve_embedding_model(None)?;
349 self.vectors_for_embedding_model(token, model)
350 }
351
352 pub fn vectors_for_model(
360 &self,
361 token: &NamespaceToken,
362 model_name: &str,
363 ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
364 if let Some(model) = parse_embedding_model_alias(model_name) {
365 let key = model.to_string();
368 let in_registry = self
369 .embedder_registry
370 .read()
371 .map(|reg| reg.contains(&key))
372 .unwrap_or(false);
373 if in_registry {
374 return self.vectors_for_embedding_model(token, model);
375 }
376 }
377 let dims = {
378 let registry = self.embedder_registry.read().map_err(|_| {
379 crate::RuntimeError::Internal("embedder registry lock poisoned".into())
380 })?;
381 registry
382 .get_provider(model_name)
383 .map(|p| p.dimensions())
384 .ok_or_else(|| crate::RuntimeError::UnknownModel(model_name.to_string()))?
385 };
386 let model_key = sanitize_key(model_name);
387 Ok(self.backend.vectors_for_namespace(
388 &model_key,
389 model_name,
390 dims,
391 token.namespace().as_str(),
392 )?)
393 }
394
395 fn vectors_for_embedding_model(
396 &self,
397 token: &NamespaceToken,
398 model: EmbeddingModel,
399 ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
400 Ok(self.backend.vectors_for_namespace(
401 &vec_model_key(model),
402 &model.to_string(),
403 model.dimensions(),
404 token.namespace().as_str(),
405 )?)
406 }
407
408 pub fn text(
410 &self,
411 token: &NamespaceToken,
412 ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
413 let _ = token;
414 Ok(self.backend.text("entities")?)
415 }
416
417 pub fn text_for_notes(
419 &self,
420 token: &NamespaceToken,
421 ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
422 let _ = token;
423 Ok(self.backend.text("notes")?)
424 }
425
426 pub fn authorize(&self, ns: Namespace) -> RuntimeResult<NamespaceToken> {
442 let actor = crate::actor_identity::resolve_actor(self.config.actor_id.as_deref());
443 let req = GateRequest::new(
444 actor.clone(),
445 ns.clone(),
446 "authorize",
447 serde_json::Value::Null,
448 );
449 match self.config.gate.check(&req) {
450 Ok(ref decision) if decision.is_allow() => {
451 if let khive_gate::GateDecision::Allow { ref obligations } = decision {
452 if !obligations.is_empty() {
453 tracing::debug!(
454 namespace = %ns.as_str(),
455 "authorize: obligations={:?}",
456 obligations
457 );
458 }
459 }
460 Ok(NamespaceToken::mint_authorized(ns, actor))
461 }
462 Ok(khive_gate::GateDecision::Deny { reason }) => {
463 Err(crate::RuntimeError::PermissionDenied {
464 verb: "authorize".to_string(),
465 reason,
466 })
467 }
468 Ok(_) => Err(crate::RuntimeError::PermissionDenied {
469 verb: "authorize".to_string(),
470 reason: "gate denied".to_string(),
471 }),
472 Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
473 }
474 }
475
476 pub fn authorize_with_visibility(
491 &self,
492 primary: Namespace,
493 extra_visible: Vec<Namespace>,
494 ) -> RuntimeResult<NamespaceToken> {
495 let actor = crate::actor_identity::resolve_actor(self.config.actor_id.as_deref());
496 let req = GateRequest::new(
497 actor.clone(),
498 primary.clone(),
499 "authorize",
500 serde_json::Value::Null,
501 );
502 match self.config.gate.check(&req) {
503 Ok(ref decision) if decision.is_allow() => {
504 if let khive_gate::GateDecision::Allow { ref obligations } = decision {
505 if !obligations.is_empty() {
506 tracing::debug!(
507 namespace = %primary.as_str(),
508 "authorize_with_visibility: obligations={:?}",
509 obligations
510 );
511 }
512 }
513 Ok(NamespaceToken::mint_with_visibility(
514 primary,
515 extra_visible,
516 actor,
517 ))
518 }
519 Ok(khive_gate::GateDecision::Deny { reason }) => {
520 Err(crate::RuntimeError::PermissionDenied {
521 verb: "authorize".to_string(),
522 reason,
523 })
524 }
525 Ok(_) => Err(crate::RuntimeError::PermissionDenied {
526 verb: "authorize".to_string(),
527 reason: "gate denied".to_string(),
528 }),
529 Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
530 }
531 }
532
533 pub fn install_edge_rules(&self, rules: Vec<EdgeEndpointRule>) {
539 if let Ok(mut guard) = self.edge_rules.write() {
540 *guard = rules;
541 }
542 }
543
544 pub fn install_kind_registry(&self, entity_kinds: Vec<String>, note_kinds: Vec<String>) {
554 if let Ok(mut guard) = self.valid_entity_kinds.write() {
555 *guard = entity_kinds;
556 }
557 if let Ok(mut guard) = self.valid_note_kinds.write() {
558 *guard = note_kinds;
559 }
560 }
561
562 pub(crate) fn validate_entity_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
567 let guard = self.valid_entity_kinds.read().map_err(|_| {
568 crate::RuntimeError::Internal("entity kind registry lock poisoned".into())
569 })?;
570 if guard.is_empty() {
571 return Ok(());
572 }
573 if guard.iter().any(|k| k == kind) {
574 Ok(())
575 } else {
576 Err(crate::RuntimeError::InvalidInput(format!(
577 "unknown entity kind {kind:?}; valid: {}",
578 guard.join(", ")
579 )))
580 }
581 }
582
583 pub(crate) fn validate_note_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
588 let guard = self.valid_note_kinds.read().map_err(|_| {
589 crate::RuntimeError::Internal("note kind registry lock poisoned".into())
590 })?;
591 if guard.is_empty() {
592 return Ok(());
593 }
594 if guard.iter().any(|k| k == kind) {
595 Ok(())
596 } else {
597 Err(crate::RuntimeError::InvalidInput(format!(
598 "unknown note kind {kind:?}; valid: {}",
599 guard.join(", ")
600 )))
601 }
602 }
603
604 pub fn install_entity_type_validator(&self, f: EntityTypeValidatorFn) {
614 if let Ok(mut guard) = self.entity_type_validator.write() {
615 *guard = Some(f);
616 }
617 }
618
619 pub(crate) fn validate_entity_type_for_kind(
624 &self,
625 kind: &str,
626 entity_type: Option<&str>,
627 ) -> crate::RuntimeResult<Option<String>> {
628 let guard = self.entity_type_validator.read().map_err(|_| {
629 crate::RuntimeError::Internal("entity type validator lock poisoned".into())
630 })?;
631 match guard.as_ref() {
632 None => Ok(entity_type.map(str::to_string)),
633 Some(validate) => validate(kind, entity_type),
634 }
635 }
636
637 pub fn install_note_mutation_hook(&self, f: NoteMutationHookFn) {
645 if let Ok(mut guard) = self.note_mutation_hook.write() {
646 *guard = Some(f);
647 }
648 }
649
650 pub(crate) async fn fire_note_mutation_hook(&self, kind: &str, id: uuid::Uuid) {
659 let hook = self
660 .note_mutation_hook
661 .read()
662 .ok()
663 .and_then(|guard| guard.clone());
664 if let Some(hook) = hook {
665 hook(kind.to_string(), id).await;
666 }
667 }
668
669 pub fn pack_edge_rules(&self) -> Vec<EdgeEndpointRule> {
678 self.edge_rules
679 .read()
680 .map(|g| g.clone())
681 .unwrap_or_default()
682 }
683
684 pub fn default_embedder_name(&self) -> &str {
686 self.default_embedder_name.as_ref()
687 }
688
689 pub fn resolve_embedding_model(&self, name: Option<&str>) -> RuntimeResult<EmbeddingModel> {
694 let model = match name {
695 Some(raw) => parse_embedding_model_alias(raw)
696 .ok_or_else(|| crate::RuntimeError::UnknownModel(raw.to_string()))?,
697 None => self
698 .config
699 .embedding_model
700 .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?,
701 };
702 let key = model.to_string();
703 let contains = self
704 .embedder_registry
705 .read()
706 .map(|reg| reg.contains(&key))
707 .unwrap_or(false);
708 if contains {
709 Ok(model)
710 } else {
711 Err(crate::RuntimeError::UnknownModel(
712 name.unwrap_or_else(|| self.default_embedder_name())
713 .to_string(),
714 ))
715 }
716 }
717
718 pub fn registered_embedding_model_names(&self) -> Vec<String> {
725 self.embedder_registry
726 .read()
727 .map(|reg| reg.names())
728 .unwrap_or_default()
729 }
730
731 pub async fn embedder(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
744 let canonical_key = match parse_embedding_model_alias(name) {
747 Some(model) => model.to_string(),
748 None => name.to_owned(),
749 };
750 let entry = {
753 let registry = self.embedder_registry.read().map_err(|_| {
754 crate::RuntimeError::Internal("embedder registry lock poisoned".into())
755 })?;
756 registry
757 .get_entry(&canonical_key)
758 .ok_or_else(|| crate::RuntimeError::UnknownModel(name.to_string()))?
759 };
760 entry.resolve().await
761 }
762
763 pub fn register_embedder(
776 &self,
777 provider: impl crate::embedder_registry::EmbedderProvider + 'static,
778 ) {
779 if let Ok(mut registry) = self.embedder_registry.write() {
780 registry.register(provider);
781 } else {
782 tracing::warn!(
783 "embedder registry lock poisoned — embedder {} not registered",
784 std::any::type_name::<dyn crate::embedder_registry::EmbedderProvider>()
785 );
786 }
787 }
788
789 pub async fn list_embedding_models(
796 &self,
797 engine_filter: Option<&str>,
798 ) -> RuntimeResult<Vec<khive_db::EmbeddingModelRegistryRecord>> {
799 use khive_storage::{SqlStatement, SqlValue};
800
801 let (sql_text, params) = if let Some(engine) = engine_filter {
802 (
803 "SELECT engine_name, model_id, key_version, dim, status, \
804 activated_at, superseded_at \
805 FROM _embedding_models WHERE engine_name = ?1 \
806 ORDER BY engine_name, activated_at IS NULL, activated_at"
807 .to_string(),
808 vec![SqlValue::Text(engine.to_string())],
809 )
810 } else {
811 (
812 "SELECT engine_name, model_id, key_version, dim, status, \
813 activated_at, superseded_at \
814 FROM _embedding_models \
815 ORDER BY engine_name, activated_at IS NULL, activated_at"
816 .to_string(),
817 vec![],
818 )
819 };
820
821 let stmt = SqlStatement {
822 sql: sql_text,
823 params,
824 label: Some("list_embedding_models".into()),
825 };
826
827 let mut reader = self
828 .sql()
829 .reader()
830 .await
831 .map_err(crate::RuntimeError::Storage)?;
832
833 let rows = match reader.query_all(stmt).await {
834 Ok(rows) => rows,
835 Err(e) if e.to_string().contains("no such table: _embedding_models") => {
836 return Ok(Vec::new())
837 }
838 Err(e) => return Err(crate::RuntimeError::Storage(e)),
839 };
840
841 let mut records = Vec::with_capacity(rows.len());
842 for row in rows {
843 macro_rules! required_text {
844 ($col:expr) => {
845 match row.get($col) {
846 Some(SqlValue::Text(s)) => s.clone(),
847 other => {
848 tracing::warn!(column = $col, value = ?other, "skipping registry row: unexpected type");
849 continue;
850 }
851 }
852 };
853 }
854 let engine_name = required_text!("engine_name");
855 let model_id = required_text!("model_id");
856 let key_version = required_text!("key_version");
857 let dimensions = match row.get("dim") {
858 Some(SqlValue::Integer(n)) => match u32::try_from(*n) {
859 Ok(d) => d,
860 Err(_) => {
861 tracing::warn!(dim = n, "skipping registry row: dim out of u32 range");
862 continue;
863 }
864 },
865 other => {
866 tracing::warn!(column = "dim", value = ?other, "skipping registry row: unexpected type");
867 continue;
868 }
869 };
870 let status = required_text!("status");
871 let activated_at = match row.get("activated_at") {
872 Some(SqlValue::Integer(n)) => Some(*n),
873 _ => None,
874 };
875 let superseded_at = match row.get("superseded_at") {
876 Some(SqlValue::Integer(n)) => Some(*n),
877 _ => None,
878 };
879 records.push(khive_db::EmbeddingModelRegistryRecord {
880 engine_name,
881 model_id,
882 key_version,
883 dimensions,
884 status,
885 activated_at,
886 superseded_at,
887 });
888 }
889
890 Ok(records)
891 }
892}
893
894#[cfg(test)]
898mod tests {
899 use super::*;
900 use khive_gate::GateRef;
901 use serial_test::serial;
902
903 #[test]
904 fn memory_runtime_creates_successfully() {
905 let rt = KhiveRuntime::memory().expect("memory runtime should create");
906 assert!(rt.config().db_path.is_none());
907 }
908
909 #[test]
910 fn backend_data_dir_returns_none_for_memory_backend() {
911 let rt = KhiveRuntime::memory().expect("memory runtime");
912 assert!(rt.backend_data_dir().is_none());
913 }
914
915 #[test]
916 fn backend_data_dir_returns_parent_dir_for_file_backend() {
917 let dir = tempfile::tempdir().unwrap();
918 let path = dir.path().join("test.db");
919 let config = RuntimeConfig {
920 db_path: Some(path),
921 default_namespace: Namespace::local(),
922 embedding_model: None,
923 additional_embedding_models: vec![],
924 gate: Arc::new(AllowAllGate),
925 packs: vec!["kg".to_string()],
926 backend_id: BackendId::main(),
927 brain_profile: None,
928 visible_namespaces: vec![],
929 allowed_outbound_namespaces: vec![],
930 actor_id: None,
931 };
932 let rt = KhiveRuntime::new(config).expect("file runtime");
933 let data_dir = rt
934 .backend_data_dir()
935 .expect("file backend must return Some");
936 assert_eq!(data_dir, dir.path());
937 }
938
939 #[test]
940 fn backend_data_dir_returns_none_for_from_backend_with_memory() {
941 let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
942 let config = RuntimeConfig {
943 db_path: None,
944 default_namespace: Namespace::local(),
945 embedding_model: None,
946 additional_embedding_models: vec![],
947 gate: Arc::new(AllowAllGate),
948 packs: vec!["kg".to_string()],
949 backend_id: BackendId::main(),
950 brain_profile: None,
951 visible_namespaces: vec![],
952 allowed_outbound_namespaces: vec![],
953 actor_id: None,
954 };
955 let rt = KhiveRuntime::from_backend(backend, config);
956 assert!(rt.backend_data_dir().is_none());
957 }
958
959 #[test]
960 fn file_runtime_creates_successfully() {
961 let dir = tempfile::tempdir().unwrap();
962 let path = dir.path().join("test.db");
963 let config = RuntimeConfig {
964 db_path: Some(path.clone()),
965 default_namespace: Namespace::parse("test").unwrap(),
966 embedding_model: None,
967 additional_embedding_models: vec![],
968 gate: Arc::new(AllowAllGate),
969 packs: vec!["kg".to_string()],
970 backend_id: BackendId::main(),
971 brain_profile: None,
972 visible_namespaces: vec![],
973 allowed_outbound_namespaces: vec![],
974 actor_id: None,
975 };
976 let rt = KhiveRuntime::new(config).expect("file runtime should create");
977 assert!(path.exists());
978 assert_eq!(rt.config().default_namespace.as_str(), "test");
979 }
980
981 #[test]
982 fn from_backend_uses_provided_backend() {
983 let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
984 let config = RuntimeConfig {
985 db_path: None,
986 default_namespace: Namespace::local(),
987 embedding_model: None,
988 additional_embedding_models: vec![],
989 gate: Arc::new(AllowAllGate),
990 packs: vec!["kg".to_string()],
991 backend_id: BackendId::new("lore"),
992 brain_profile: None,
993 visible_namespaces: vec![],
994 allowed_outbound_namespaces: vec![],
995 actor_id: None,
996 };
997 let rt = KhiveRuntime::from_backend(backend, config);
998 assert_eq!(rt.backend_id().as_str(), "lore");
999 assert!(rt.config().db_path.is_none());
1000 }
1001
1002 #[test]
1003 fn backend_id_defaults_to_main() {
1004 let rt = KhiveRuntime::memory().unwrap();
1005 assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
1006 }
1007
1008 #[test]
1009 fn store_accessors_return_ok() {
1010 let rt = KhiveRuntime::memory().unwrap();
1011 let tok = NamespaceToken::local();
1012 assert!(rt.entities(&tok).is_ok());
1013 assert!(rt.graph(&tok).is_ok());
1014 assert!(rt.notes(&tok).is_ok());
1015 assert!(rt.events(&tok).is_ok());
1016 }
1017
1018 #[test]
1019 fn vectors_returns_unconfigured_without_model() {
1020 let rt = KhiveRuntime::memory().unwrap();
1021 let tok = NamespaceToken::local();
1022 match rt.vectors(&tok) {
1023 Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
1024 Err(other) => panic!("expected Unconfigured, got {:?}", other),
1025 Ok(_) => panic!("expected Err, got Ok"),
1026 }
1027 }
1028
1029 #[test]
1030 fn vec_model_key_sanitizes_dots_and_dashes() {
1031 assert_eq!(
1032 vec_model_key(EmbeddingModel::BgeSmallEnV15),
1033 "bge_small_en_v1_5"
1034 );
1035 assert_eq!(
1036 vec_model_key(EmbeddingModel::BgeBaseEnV15),
1037 "bge_base_en_v1_5"
1038 );
1039 assert_eq!(
1040 vec_model_key(EmbeddingModel::AllMiniLmL6V2),
1041 "all_minilm_l6_v2"
1042 );
1043 }
1044
1045 #[test]
1046 fn default_config_uses_allow_all_gate() {
1047 let cfg = RuntimeConfig::default();
1048 assert_eq!(cfg.default_namespace.as_str(), "local");
1049 let _: GateRef = cfg.gate.clone();
1050 }
1051
1052 #[test]
1053 fn parse_pack_list_handles_comma_and_whitespace() {
1054 assert_eq!(parse_pack_list("kg"), vec!["kg".to_string()]);
1055 assert_eq!(
1056 parse_pack_list("kg,gtd"),
1057 vec!["kg".to_string(), "gtd".to_string()]
1058 );
1059 assert_eq!(
1060 parse_pack_list(" kg , gtd "),
1061 vec!["kg".to_string(), "gtd".to_string()]
1062 );
1063 assert_eq!(
1064 parse_pack_list("kg gtd"),
1065 vec!["kg".to_string(), "gtd".to_string()]
1066 );
1067 assert_eq!(parse_pack_list(",,"), Vec::<String>::new());
1068 assert_eq!(parse_pack_list(""), Vec::<String>::new());
1069 }
1070
1071 #[test]
1072 fn default_config_packs_loads_all_production_packs() {
1073 let prior = std::env::var("KHIVE_PACKS").ok();
1074 unsafe {
1076 std::env::remove_var("KHIVE_PACKS");
1077 }
1078 let cfg = RuntimeConfig::default();
1079 assert!(cfg.packs.contains(&"kg".to_string()));
1080 assert!(cfg.packs.contains(&"gtd".to_string()));
1081 assert!(cfg.packs.contains(&"memory".to_string()));
1082 assert!(cfg.packs.contains(&"brain".to_string()));
1083 assert!(cfg.packs.contains(&"comm".to_string()));
1084 assert!(cfg.packs.contains(&"schedule".to_string()));
1085 assert!(cfg.packs.contains(&"knowledge".to_string()));
1086 assert!(cfg.packs.contains(&"session".to_string()));
1089 assert!(cfg.packs.contains(&"git".to_string()));
1090 assert!(cfg.packs.contains(&"code".to_string()));
1091 assert!(cfg.packs.contains(&"workspace".to_string()));
1092 assert_eq!(cfg.packs.len(), 11);
1093 if let Some(v) = prior {
1094 unsafe {
1096 std::env::set_var("KHIVE_PACKS", v);
1097 }
1098 }
1099 }
1100
1101 #[test]
1102 fn default_config_uses_minilm_when_env_unset() {
1103 let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
1104 unsafe {
1107 std::env::remove_var("KHIVE_EMBEDDING_MODEL");
1108 }
1109 let cfg = RuntimeConfig::default();
1110 assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
1111 if let Some(v) = prior {
1112 unsafe {
1114 std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
1115 }
1116 }
1117 }
1118
1119 use crate::engine_config::{ActorConfig, KhiveConfig};
1122
1123 fn khive_cfg_with_actor(id: &str) -> KhiveConfig {
1124 KhiveConfig {
1125 engines: vec![],
1126 actor: ActorConfig {
1127 id: Some(id.to_string()),
1128 display_name: None,
1129 ..Default::default()
1130 },
1131 ..KhiveConfig::default()
1132 }
1133 }
1134
1135 #[test]
1136 fn runtime_config_from_khive_config_actor_id_does_not_override_default_namespace() {
1137 let base = RuntimeConfig {
1142 db_path: None,
1143 default_namespace: Namespace::local(),
1144 embedding_model: None,
1145 additional_embedding_models: vec![],
1146 gate: Arc::new(AllowAllGate),
1147 packs: vec!["kg".to_string()],
1148 backend_id: BackendId::main(),
1149 brain_profile: None,
1150 visible_namespaces: vec![],
1151 allowed_outbound_namespaces: vec![],
1152 actor_id: None,
1153 };
1154 let cfg = khive_cfg_with_actor("lambda:khive");
1155 let result = runtime_config_from_khive_config(&cfg, base);
1156 assert_eq!(
1157 result.default_namespace.as_str(),
1158 "local",
1159 "actor.id must not become default_namespace (ADR-007 Rev 4 Rule 0); writes pin to local"
1160 );
1161 }
1162
1163 #[test]
1164 fn runtime_config_from_khive_config_empty_actor_id_keeps_base_namespace() {
1165 let base = RuntimeConfig {
1166 db_path: None,
1167 default_namespace: Namespace::parse("lambda:base").unwrap(),
1168 embedding_model: None,
1169 additional_embedding_models: vec![],
1170 gate: Arc::new(AllowAllGate),
1171 packs: vec!["kg".to_string()],
1172 backend_id: BackendId::main(),
1173 brain_profile: None,
1174 visible_namespaces: vec![],
1175 allowed_outbound_namespaces: vec![],
1176 actor_id: None,
1177 };
1178 let cfg = KhiveConfig {
1179 engines: vec![],
1180 actor: ActorConfig {
1181 id: Some(String::new()),
1182 display_name: None,
1183 ..Default::default()
1184 },
1185 ..KhiveConfig::default()
1186 };
1187 let result = runtime_config_from_khive_config(&cfg, base);
1188 assert_eq!(
1189 result.default_namespace.as_str(),
1190 "lambda:base",
1191 "empty actor.id must not override base namespace"
1192 );
1193 }
1194
1195 #[test]
1196 fn runtime_config_from_khive_config_absent_actor_id_keeps_base_namespace() {
1197 let base = RuntimeConfig {
1198 db_path: None,
1199 default_namespace: Namespace::parse("lambda:base").unwrap(),
1200 embedding_model: None,
1201 additional_embedding_models: vec![],
1202 gate: Arc::new(AllowAllGate),
1203 packs: vec!["kg".to_string()],
1204 backend_id: BackendId::main(),
1205 brain_profile: None,
1206 visible_namespaces: vec![],
1207 allowed_outbound_namespaces: vec![],
1208 actor_id: None,
1209 };
1210 let cfg = KhiveConfig::default(); let result = runtime_config_from_khive_config(&cfg, base);
1212 assert_eq!(
1213 result.default_namespace.as_str(),
1214 "lambda:base",
1215 "absent actor.id must not override base namespace"
1216 );
1217 }
1218
1219 #[test]
1220 fn runtime_config_from_khive_config_actor_id_with_engines() {
1221 let base = RuntimeConfig {
1222 db_path: None,
1223 default_namespace: Namespace::local(),
1224 embedding_model: None,
1225 additional_embedding_models: vec![],
1226 gate: Arc::new(AllowAllGate),
1227 packs: vec!["kg".to_string()],
1228 backend_id: BackendId::main(),
1229 brain_profile: None,
1230 visible_namespaces: vec![],
1231 allowed_outbound_namespaces: vec![],
1232 actor_id: None,
1233 };
1234 let cfg = KhiveConfig {
1235 engines: vec![crate::engine_config::EngineConfig {
1236 name: "default".to_string(),
1237 model: "all-minilm-l6-v2".to_string(),
1238 default: true,
1239 fusion_weight: None,
1240 dims: None,
1241 }],
1242 actor: ActorConfig {
1243 id: Some("lambda:test".to_string()),
1244 display_name: None,
1245 ..Default::default()
1246 },
1247 ..KhiveConfig::default()
1248 };
1249 let result = runtime_config_from_khive_config(&cfg, base);
1250 assert_eq!(
1251 result.default_namespace.as_str(),
1252 "local",
1253 "actor.id must not override default_namespace (ADR-007 Rev 4 Rule 0); \
1254 writes pin to local; engine config is still applied"
1255 );
1256 assert!(result.embedding_model.is_some());
1257 }
1258
1259 #[test]
1268 #[serial]
1269 fn runtime_config_from_khive_config_engines_present_preserves_env_actor_when_toml_has_none() {
1270 let prior = std::env::var("KHIVE_ACTOR").ok();
1271 unsafe {
1273 std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1274 }
1275 let base = RuntimeConfig::default();
1276 assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1277
1278 let cfg = KhiveConfig {
1279 engines: vec![crate::engine_config::EngineConfig {
1280 name: "default".to_string(),
1281 model: "all-minilm-l6-v2".to_string(),
1282 default: true,
1283 fusion_weight: None,
1284 dims: None,
1285 }],
1286 actor: ActorConfig::default(), ..KhiveConfig::default()
1288 };
1289 let result = runtime_config_from_khive_config(&cfg, base);
1290 assert_eq!(
1291 result.actor_id.as_deref(),
1292 Some("lambda:test-env-actor"),
1293 "engines-present arm must preserve base.actor_id (env actor) when TOML has no [actor] id"
1294 );
1295
1296 unsafe {
1298 match prior {
1299 Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1300 None => std::env::remove_var("KHIVE_ACTOR"),
1301 }
1302 }
1303 }
1304
1305 #[test]
1306 #[serial]
1307 fn runtime_config_from_khive_config_engines_empty_preserves_env_actor_when_toml_has_none() {
1308 let prior = std::env::var("KHIVE_ACTOR").ok();
1309 unsafe {
1311 std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1312 }
1313 let base = RuntimeConfig::default();
1314 assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1315
1316 let cfg = KhiveConfig {
1317 engines: vec![],
1318 actor: ActorConfig::default(), ..KhiveConfig::default()
1320 };
1321 let result = runtime_config_from_khive_config(&cfg, base);
1322 assert_eq!(
1323 result.actor_id.as_deref(),
1324 Some("lambda:test-env-actor"),
1325 "engines-empty early-return arm must preserve base.actor_id (env actor) when TOML has no [actor] id"
1326 );
1327
1328 unsafe {
1330 match prior {
1331 Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1332 None => std::env::remove_var("KHIVE_ACTOR"),
1333 }
1334 }
1335 }
1336
1337 #[test]
1338 #[serial]
1339 fn runtime_config_from_khive_config_toml_actor_wins_over_env_actor() {
1340 let prior = std::env::var("KHIVE_ACTOR").ok();
1341 unsafe {
1343 std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1344 }
1345 let base = RuntimeConfig::default();
1346 assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1347
1348 let cfg = khive_cfg_with_actor("lambda:toml-actor");
1349 let result = runtime_config_from_khive_config(&cfg, base);
1350 assert_eq!(
1351 result.actor_id.as_deref(),
1352 Some("lambda:toml-actor"),
1353 "TOML [actor] id must win over the env-resolved base.actor_id"
1354 );
1355
1356 unsafe {
1358 match prior {
1359 Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1360 None => std::env::remove_var("KHIVE_ACTOR"),
1361 }
1362 }
1363 }
1364
1365 fn migrated_memory_backend() -> Arc<StorageBackend> {
1371 let backend = StorageBackend::memory().expect("memory backend");
1372 {
1373 let mut writer = backend.pool().try_writer().expect("writer");
1374 khive_db::run_migrations(writer.conn_mut()).expect("migrations");
1375 }
1376 Arc::new(backend)
1377 }
1378
1379 fn secondary_config() -> RuntimeConfig {
1380 RuntimeConfig {
1381 db_path: None,
1382 default_namespace: Namespace::local(),
1383 embedding_model: None,
1384 additional_embedding_models: vec![],
1385 gate: Arc::new(AllowAllGate),
1386 packs: vec!["kg".to_string()],
1387 backend_id: BackendId::new("lore"),
1388 brain_profile: None,
1389 visible_namespaces: vec![],
1390 allowed_outbound_namespaces: vec![],
1391 actor_id: None,
1392 }
1393 }
1394
1395 #[test]
1396 fn core_on_main_runtime_returns_same_backend_id() {
1397 let rt = KhiveRuntime::memory().unwrap();
1399 assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
1400 let core_rt = rt.core();
1401 assert_eq!(core_rt.backend_id().as_str(), BackendId::MAIN);
1402 }
1403
1404 #[tokio::test]
1405 async fn core_on_main_runtime_round_trips_note() {
1406 let rt = KhiveRuntime::memory().unwrap();
1409 let tok = NamespaceToken::local();
1410
1411 let note = rt
1412 .core()
1413 .create_note(
1414 &tok,
1415 "observation",
1416 None,
1417 "adr073-main-round-trip",
1418 None,
1419 None,
1420 vec![],
1421 )
1422 .await
1423 .expect("create_note via core()");
1424
1425 let found = rt
1426 .notes(&tok)
1427 .expect("notes store")
1428 .get_note(note.id)
1429 .await
1430 .expect("get_note");
1431
1432 assert!(
1433 found.is_some(),
1434 "note written via core() must be visible through original rt"
1435 );
1436 }
1437
1438 #[tokio::test]
1450 async fn cross_backend_split_note_to_main_aux_to_secondary() {
1451 use khive_storage::{SqlStatement, SqlValue};
1452
1453 let main_arc = migrated_memory_backend();
1454 let secondary_arc = migrated_memory_backend();
1455
1456 let main_config = RuntimeConfig {
1457 db_path: None,
1458 default_namespace: Namespace::local(),
1459 embedding_model: None,
1460 additional_embedding_models: vec![],
1461 gate: Arc::new(AllowAllGate),
1462 packs: vec!["kg".to_string()],
1463 backend_id: BackendId::main(),
1464 brain_profile: None,
1465 visible_namespaces: vec![],
1466 allowed_outbound_namespaces: vec![],
1467 actor_id: None,
1468 };
1469
1470 let rt_main = KhiveRuntime::from_backend(main_arc.clone(), main_config);
1471 let rt_secondary = KhiveRuntime::from_backend(secondary_arc, secondary_config())
1472 .with_core_backend(main_arc.clone());
1473
1474 let tok = NamespaceToken::local();
1475
1476 let note = rt_secondary
1479 .core()
1480 .create_note(
1481 &tok,
1482 "observation",
1483 None,
1484 "adr073-split-test",
1485 None,
1486 None,
1487 vec![],
1488 )
1489 .await
1490 .expect("create_note via core()");
1491 let note_id = note.id;
1492
1493 let in_main = rt_main
1495 .notes(&tok)
1496 .expect("main notes store")
1497 .get_note(note_id)
1498 .await
1499 .expect("get_note from main");
1500 assert!(
1501 in_main.is_some(),
1502 "note written via core() must appear in main backend A"
1503 );
1504
1505 let in_secondary = rt_secondary
1507 .notes(&tok)
1508 .expect("secondary notes store")
1509 .get_note(note_id)
1510 .await
1511 .expect("get_note from secondary");
1512 assert!(
1513 in_secondary.is_none(),
1514 "note written to main via core() must NOT appear in secondary backend B"
1515 );
1516
1517 {
1520 let mut writer = rt_secondary.sql().writer().await.expect("secondary writer");
1521 writer
1522 .execute(SqlStatement {
1523 sql: "CREATE TABLE IF NOT EXISTS _test_adr073_aux \
1524 (marker TEXT PRIMARY KEY)"
1525 .into(),
1526 params: vec![],
1527 label: None,
1528 })
1529 .await
1530 .expect("create aux table in B");
1531 writer
1532 .execute(SqlStatement {
1533 sql: "INSERT INTO _test_adr073_aux VALUES (?1)".into(),
1534 params: vec![SqlValue::Text("b-side-sentinel".into())],
1535 label: None,
1536 })
1537 .await
1538 .expect("insert into aux table in B");
1539 }
1540
1541 let mut reader_b = rt_secondary.sql().reader().await.expect("secondary reader");
1543 let rows_b = reader_b
1544 .query_all(SqlStatement {
1545 sql: "SELECT marker FROM _test_adr073_aux".into(),
1546 params: vec![],
1547 label: None,
1548 })
1549 .await
1550 .expect("select from B");
1551 assert_eq!(rows_b.len(), 1, "aux row must exist in B");
1552 match rows_b[0].get("marker") {
1553 Some(SqlValue::Text(s)) => {
1554 assert_eq!(s, "b-side-sentinel", "sentinel value must match")
1555 }
1556 other => panic!("expected Text('b-side-sentinel'), got {other:?}"),
1557 }
1558
1559 let mut reader_a = rt_main.sql().reader().await.expect("main reader");
1561 let result_a = reader_a
1562 .query_all(SqlStatement {
1563 sql: "SELECT marker FROM _test_adr073_aux".into(),
1564 params: vec![],
1565 label: None,
1566 })
1567 .await;
1568 match result_a {
1570 Err(e) => assert!(
1571 e.to_string().contains("no such table"),
1572 "expected 'no such table' error from A, got: {e}"
1573 ),
1574 Ok(rows) => assert!(
1575 rows.is_empty(),
1576 "aux table must not have rows in A, got {} rows",
1577 rows.len()
1578 ),
1579 }
1580 }
1581
1582 #[test]
1583 fn constructors_leave_core_backend_none_by_behavior() {
1584 let rt_mem = KhiveRuntime::memory().unwrap();
1587 assert_eq!(rt_mem.core().backend_id().as_str(), BackendId::MAIN);
1588
1589 let backend = migrated_memory_backend();
1590 let rt_from = KhiveRuntime::from_backend(
1591 backend,
1592 RuntimeConfig {
1593 db_path: None,
1594 default_namespace: Namespace::local(),
1595 embedding_model: None,
1596 additional_embedding_models: vec![],
1597 gate: Arc::new(AllowAllGate),
1598 packs: vec!["kg".to_string()],
1599 backend_id: BackendId::new("lore"),
1600 brain_profile: None,
1601 visible_namespaces: vec![],
1602 allowed_outbound_namespaces: vec![],
1603 actor_id: None,
1604 },
1605 );
1606 assert_eq!(rt_from.core().backend_id().as_str(), "lore");
1609 }
1610
1611 #[test]
1612 fn with_core_backend_sets_core_then_core_returns_main_id() {
1613 let main_arc = migrated_memory_backend();
1615 let secondary_arc = migrated_memory_backend();
1616
1617 let rt_secondary = KhiveRuntime::from_backend(secondary_arc, secondary_config())
1618 .with_core_backend(main_arc);
1619
1620 assert_eq!(rt_secondary.backend_id().as_str(), "lore");
1621 assert_eq!(
1622 rt_secondary.core().backend_id().as_str(),
1623 BackendId::MAIN,
1624 "core() on a secondary runtime must return a main-bound handle"
1625 );
1626 }
1627
1628 #[tokio::test]
1629 async fn list_embedding_models_returns_empty_when_table_absent() {
1630 let rt = KhiveRuntime::memory().expect("memory runtime");
1633 let records = rt
1634 .list_embedding_models(None)
1635 .await
1636 .expect("list ok on empty table");
1637 assert!(records.is_empty());
1638 }
1639
1640 #[tokio::test]
1641 async fn list_embedding_models_returns_row_after_insert() {
1642 use khive_storage::{SqlStatement, SqlValue};
1643
1644 let rt = KhiveRuntime::memory().expect("memory runtime");
1645 let sql = rt.sql();
1646
1647 let now = 1_000_000i64;
1648 let id = uuid::Uuid::new_v4();
1649 let canonical_key = b"test_engine:test-model-v1:v1:384".to_vec();
1650
1651 let mut writer = sql.writer().await.expect("writer");
1652 writer
1653 .execute(SqlStatement {
1654 sql: "INSERT INTO _embedding_models \
1655 (id, engine_name, model_id, key_version, dim, output_dim, status, \
1656 activated_at, superseded_at, superseded_by, canonical_key, created_at) \
1657 VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, NULL, NULL, ?8, ?9)"
1658 .into(),
1659 params: vec![
1660 SqlValue::Blob(id.as_bytes().to_vec()),
1661 SqlValue::Text("test_engine".into()),
1662 SqlValue::Text("test-model-v1".into()),
1663 SqlValue::Text("v1".into()),
1664 SqlValue::Integer(384),
1665 SqlValue::Text("active".into()),
1666 SqlValue::Integer(now),
1667 SqlValue::Blob(canonical_key),
1668 SqlValue::Integer(now),
1669 ],
1670 label: None,
1671 })
1672 .await
1673 .expect("insert row");
1674 drop(writer);
1675
1676 let records = rt.list_embedding_models(None).await.expect("list ok");
1677 assert_eq!(records.len(), 1);
1678 assert_eq!(records[0].engine_name, "test_engine");
1679 assert_eq!(records[0].model_id, "test-model-v1");
1680 assert_eq!(records[0].key_version, "v1");
1681 assert_eq!(records[0].dimensions, 384);
1682 assert_eq!(records[0].status, "active");
1683
1684 let filtered = rt
1686 .list_embedding_models(Some("test_engine"))
1687 .await
1688 .expect("filter ok");
1689 assert_eq!(filtered.len(), 1);
1690
1691 let no_match = rt
1693 .list_embedding_models(Some("other_engine"))
1694 .await
1695 .expect("no-match ok");
1696 assert!(no_match.is_empty());
1697 }
1698}