1use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use futures::stream::{self, StreamExt};
8use meerkat_client::LlmClient;
9use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
10
11use crate::console_aggregator::{ConsoleLogStore, InMemoryConsoleLogStore, SqliteConsoleLogStore};
12use crate::contact_directory::ContactDirectory;
13use crate::identity_first::{
14 AgentCustomizer, AgentMemoryConfig, AgentMemoryCustomizer, AgentMemoryProvider,
15 AgentMemoryRuntimeInjector, AgentRuntimeServices, ContinuitySessionStoreAdapter,
16 DurabilityPolicy, IdentityFirstRuntimeContext, IdentityRuntime, IdentityRuntimeConfig,
17 LocalContinuityStore, LocalLeaseProvider, MarkdownAgentMemoryStore, RosterContext,
18 RosterProvider, TopologyProvider, lazy_register_flow, restore_flow,
19};
20use crate::mob_handle_runtime::{
21 CapabilityFlags, MobBootstrapOptions, MobBootstrapSpec, SessionHook,
22};
23use crate::runtime::{
24 InMemoryMetadataStore, PersistentMetadataStore, RuntimeOptions, SqliteMetadataStore,
25};
26use crate::types::{EventEnvelope, MobKitConfig, UnifiedEvent};
27
28use super::edge_types::{Discovery, EdgeDiscovery, PreSpawnHook};
29use super::types::{
30 UnifiedRuntimeBootstrapError, UnifiedRuntimeBuilderError, UnifiedRuntimeBuilderField,
31};
32use super::{
33 DEFAULT_DRAIN_TIMEOUT, ErrorHook, EventLogConfig, PostReconcileHook, PostSpawnHook,
34 UnifiedRuntime, discovery_spec_to_spawn_spec,
35};
36
37pub(crate) enum DefinitionSource {
39 Inline(Box<MobDefinition>),
40 TomlPath(PathBuf),
41}
42
43const DEFAULT_MAX_SESSIONS: usize = 64;
45
46const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub enum IdentityBootstrapMode {
53 #[default]
56 EagerMaterialize,
57 LazyMaterialize,
60 LazyWithBackgroundWarm { concurrency: usize },
63}
64
65#[derive(Default)]
66pub struct UnifiedRuntimeBuilder {
67 mob_spec: Option<MobBootstrapSpec>,
69
70 definition_source: Option<DefinitionSource>,
72 persistent_state_path: Option<PathBuf>,
73 session_hook: Option<Arc<dyn SessionHook>>,
74 custom_session_store: Option<Arc<dyn meerkat::SessionStore>>,
75 meerkat_config: Option<meerkat::Config>,
76 default_llm_client: Option<Arc<dyn LlmClient>>,
77 max_sessions: Option<usize>,
78 capability_flags: CapabilityFlags,
79
80 continuity_store: Option<Arc<dyn crate::identity_first::contracts::ContinuityStore>>,
82 lease_provider: Option<Arc<dyn crate::identity_first::contracts::LeaseProvider>>,
83 roster_provider: Option<Arc<dyn RosterProvider>>,
84 topology_provider: Option<Arc<dyn TopologyProvider>>,
85 agent_customizer: Option<Arc<dyn AgentCustomizer>>,
86 agent_memory_provider: Option<Arc<dyn AgentMemoryProvider>>,
87 agent_memory_config: Option<AgentMemoryConfig>,
88 agent_memory_from_persistent_state: bool,
89 identity_bootstrap_mode: IdentityBootstrapMode,
90 identity_runtime_instance_id: Option<String>,
91 scratch_dir: Option<PathBuf>,
92 blob_store: Option<Arc<dyn meerkat_core::BlobStore>>,
93 console_log_store: Option<Arc<dyn ConsoleLogStore>>,
94
95 module_config: Option<MobKitConfig>,
97 module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
98 timeout: Option<Duration>,
99 options: RuntimeOptions,
100 post_spawn_hook: Option<PostSpawnHook>,
101 post_reconcile_hook: Option<PostReconcileHook>,
102 error_hook: Option<ErrorHook>,
103 event_log_config: Option<EventLogConfig>,
104 drain_timeout: Option<Duration>,
105 discovery: Option<Box<dyn Discovery>>,
106 pre_spawn_hook: Option<PreSpawnHook>,
107 edge_discovery: Option<Box<dyn EdgeDiscovery>>,
108 contact_directory: Option<ContactDirectory>,
109 persistent_metadata: Option<Arc<dyn PersistentMetadataStore>>,
110 access_controller: Option<crate::access::AccessController>,
111}
112
113impl UnifiedRuntimeBuilder {
114 pub fn definition(mut self, def: MobDefinition) -> Self {
120 self.definition_source = Some(DefinitionSource::Inline(Box::new(def)));
121 self
122 }
123
124 pub fn definition_path(mut self, path: impl Into<PathBuf>) -> Self {
126 self.definition_source = Some(DefinitionSource::TomlPath(path.into()));
127 self
128 }
129
130 pub fn persistent_state(mut self, path: impl Into<PathBuf>) -> Self {
136 self.persistent_state_path = Some(path.into());
137 self
138 }
139
140 pub fn session_hook(mut self, hook: Arc<dyn SessionHook>) -> Self {
142 self.session_hook = Some(hook);
143 self
144 }
145
146 pub fn session_store(mut self, store: Arc<dyn meerkat::SessionStore>) -> Self {
151 self.custom_session_store = Some(store);
152 self
153 }
154
155 pub fn meerkat_config(mut self, config: meerkat::Config) -> Self {
162 self.meerkat_config = Some(config);
163 self
164 }
165
166 pub fn default_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
168 self.default_llm_client = Some(client);
169 self
170 }
171
172 pub fn max_sessions(mut self, max_sessions: usize) -> Self {
178 self.max_sessions = Some(max_sessions);
179 self
180 }
181
182 pub fn continuity_store(
186 mut self,
187 store: Arc<dyn crate::identity_first::contracts::ContinuityStore>,
188 ) -> Self {
189 self.continuity_store = Some(store);
190 self
191 }
192
193 pub fn lease_provider(
197 mut self,
198 provider: Arc<dyn crate::identity_first::contracts::LeaseProvider>,
199 ) -> Self {
200 self.lease_provider = Some(provider);
201 self
202 }
203
204 pub fn roster_provider(mut self, provider: Arc<dyn RosterProvider>) -> Self {
206 self.roster_provider = Some(provider);
207 self
208 }
209
210 pub fn topology_provider(mut self, provider: Arc<dyn TopologyProvider>) -> Self {
212 self.topology_provider = Some(provider);
213 self
214 }
215
216 pub fn agent_customizer(mut self, customizer: Arc<dyn AgentCustomizer>) -> Self {
218 self.agent_customizer = Some(customizer);
219 self
220 }
221
222 pub fn agent_memory(
224 mut self,
225 provider: Arc<dyn AgentMemoryProvider>,
226 config: AgentMemoryConfig,
227 ) -> Self {
228 self.agent_memory_provider = Some(provider);
229 self.agent_memory_config = Some(config);
230 self
231 }
232
233 pub fn persistent_agent_memory(mut self, config: AgentMemoryConfig) -> Self {
236 self.agent_memory_from_persistent_state = true;
237 self.agent_memory_config = Some(config);
238 self
239 }
240
241 fn composed_agent_customizer(
242 &self,
243 memory_provider: Option<Arc<dyn AgentMemoryProvider>>,
244 ) -> Option<Arc<dyn AgentCustomizer>> {
245 match memory_provider {
246 Some(provider) => Some(Arc::new(AgentMemoryCustomizer::wrap(
247 self.agent_customizer.clone(),
248 provider,
249 self.agent_memory_config.clone().unwrap_or_default(),
250 ))),
251 None => self.agent_customizer.clone(),
252 }
253 }
254
255 pub fn identity_bootstrap_mode(mut self, mode: IdentityBootstrapMode) -> Self {
257 self.identity_bootstrap_mode = mode;
258 self
259 }
260
261 pub fn identity_runtime_instance_id(mut self, id: impl Into<String>) -> Self {
263 self.identity_runtime_instance_id = Some(id.into());
264 self
265 }
266
267 pub fn scratch_dir(mut self, path: impl Into<PathBuf>) -> Self {
271 self.scratch_dir = Some(path.into());
272 self
273 }
274
275 pub fn blob_store(mut self, store: Arc<dyn meerkat_core::BlobStore>) -> Self {
280 self.blob_store = Some(store);
281 self
282 }
283
284 pub fn with_console_log_store(mut self, store: Arc<dyn ConsoleLogStore>) -> Self {
289 self.console_log_store = Some(store);
290 self
291 }
292
293 pub fn builtins(mut self, enabled: bool) -> Self {
295 self.capability_flags.builtins = enabled;
296 self
297 }
298
299 pub fn shell(mut self, enabled: bool) -> Self {
301 self.capability_flags.shell = enabled;
302 self
303 }
304
305 pub fn mob(mut self, enabled: bool) -> Self {
307 self.capability_flags.mob = enabled;
308 self
309 }
310
311 pub fn comms(mut self, enabled: bool) -> Self {
313 self.capability_flags.comms = enabled;
314 self
315 }
316
317 pub fn memory(mut self, enabled: bool) -> Self {
319 self.capability_flags.memory = enabled;
320 self
321 }
322
323 pub fn image_generation(mut self, enabled: bool) -> Self {
329 self.capability_flags.image_generation = enabled;
330 self
331 }
332
333 pub fn mob_spec(mut self, spec: MobBootstrapSpec) -> Self {
338 self.mob_spec = Some(spec);
339 self
340 }
341
342 pub fn module_config(mut self, config: MobKitConfig) -> Self {
343 self.module_config = Some(config);
344 self
345 }
346
347 pub fn module_agent_events(mut self, events: Vec<EventEnvelope<UnifiedEvent>>) -> Self {
348 self.module_agent_events = events;
349 self
350 }
351
352 pub fn timeout(mut self, timeout: Duration) -> Self {
353 self.timeout = Some(timeout);
354 self
355 }
356
357 pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
358 self.options = options;
359 self
360 }
361
362 pub fn post_spawn_hook(mut self, hook: PostSpawnHook) -> Self {
363 self.post_spawn_hook = Some(hook);
364 self
365 }
366
367 pub fn post_reconcile_hook(mut self, hook: PostReconcileHook) -> Self {
368 self.post_reconcile_hook = Some(hook);
369 self
370 }
371
372 pub fn on_error(mut self, hook: ErrorHook) -> Self {
373 self.error_hook = Some(hook);
374 self
375 }
376
377 pub fn event_log(mut self, config: EventLogConfig) -> Self {
378 self.event_log_config = Some(config);
379 self
380 }
381
382 pub fn drain_timeout(mut self, timeout: Duration) -> Self {
383 self.drain_timeout = Some(timeout);
384 self
385 }
386
387 pub fn discovery(mut self, discovery: impl Discovery + 'static) -> Self {
388 self.discovery = Some(Box::new(discovery));
389 self
390 }
391
392 pub fn pre_spawn_hook(mut self, hook: PreSpawnHook) -> Self {
393 self.pre_spawn_hook = Some(hook);
394 self
395 }
396
397 pub fn edge_discovery(mut self, edge_discovery: impl EdgeDiscovery + 'static) -> Self {
398 self.edge_discovery = Some(Box::new(edge_discovery));
399 self
400 }
401
402 pub fn contact_directory(mut self, directory: ContactDirectory) -> Self {
404 self.contact_directory = Some(directory);
405 self
406 }
407
408 pub fn persistent_metadata(mut self, store: Arc<dyn PersistentMetadataStore>) -> Self {
418 self.persistent_metadata = Some(store);
419 self
420 }
421
422 pub fn access_controller(mut self, controller: crate::access::AccessController) -> Self {
426 self.access_controller = Some(controller);
427 self
428 }
429
430 pub fn access_control_file(
434 mut self,
435 path: impl Into<PathBuf>,
436 ) -> Result<Self, crate::access::AccessConfigError> {
437 self.access_controller = Some(crate::access::AccessController::load_or_default(path)?);
438 Ok(self)
439 }
440
441 pub async fn build(mut self) -> Result<UnifiedRuntime, UnifiedRuntimeBuilderError> {
446 let has_persistent_state = self.persistent_state_path.is_some();
449 let has_continuity_store = self.continuity_store.is_some();
450 let has_lease_provider = self.lease_provider.is_some();
451 let has_roster_provider = self.roster_provider.is_some();
452 let has_topology_provider = self.topology_provider.is_some();
453 let has_agent_customizer = self.agent_customizer.is_some();
462 let has_identity_runtime_instance_id = self.identity_runtime_instance_id.is_some();
463 let has_scratch_dir = self.scratch_dir.is_some();
464 let has_external_identity_storage =
465 has_continuity_store || has_lease_provider || has_scratch_dir;
466 let wants_identity_first = has_external_identity_storage
467 || has_roster_provider
468 || has_topology_provider
469 || has_agent_customizer
470 || has_identity_runtime_instance_id;
471 if self.agent_memory_provider.is_some() && self.agent_memory_from_persistent_state {
472 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
473 "agent_memory() and persistent_agent_memory() are mutually exclusive".to_string(),
474 ));
475 }
476
477 if self.agent_memory_from_persistent_state && !has_persistent_state {
478 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
479 "persistent_agent_memory() requires persistent_state()".to_string(),
480 ));
481 }
482
483 if has_persistent_state && has_external_identity_storage {
487 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
488 "persistent_state() and identity-first continuity_store()/lease_provider()/scratch_dir() setters \
489 are mutually exclusive — use one storage authority"
490 .to_string(),
491 ));
492 }
493
494 if has_persistent_state && wants_identity_first && !has_roster_provider {
495 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
496 "persistent_state() identity-first path requires roster_provider()".to_string(),
497 ));
498 }
499
500 if !has_persistent_state
502 && wants_identity_first
503 && !(has_continuity_store
504 && has_lease_provider
505 && has_roster_provider
506 && has_scratch_dir)
507 {
508 let mut missing = Vec::new();
509 if !has_continuity_store {
510 missing.push("continuity_store");
511 }
512 if !has_lease_provider {
513 missing.push("lease_provider");
514 }
515 if !has_roster_provider {
516 missing.push("roster_provider");
517 }
518 if !has_scratch_dir {
519 missing.push("scratch_dir");
520 }
521 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
522 format!(
523 "identity-first path requires continuity_store() + lease_provider() + \
524 roster_provider() + scratch_dir(); missing: {}",
525 missing.join(", ")
526 ),
527 ));
528 }
529
530 let continuity_session_store = self
531 .continuity_store
532 .as_ref()
533 .map(|store| Arc::new(ContinuitySessionStoreAdapter::new(store.clone())));
534 if let Some(store) = continuity_session_store.as_ref() {
535 self.custom_session_store = Some(store.clone());
536 }
537
538 let mut mob_spec = match self.mob_spec.take() {
541 Some(spec) => {
542 if self.module_config.is_none() {
544 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
545 UnifiedRuntimeBuilderField::ModuleConfig,
546 ));
547 }
548 if self.timeout.is_none() {
549 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
550 UnifiedRuntimeBuilderField::Timeout,
551 ));
552 }
553 spec
554 }
555 None => self.resolve_mob_spec().await?,
556 };
557
558 let module_config = self.module_config.take().unwrap_or_else(|| MobKitConfig {
559 modules: Vec::new(),
560 discovery: crate::types::DiscoverySpec {
561 namespace: String::new(),
562 modules: Vec::new(),
563 },
564 pre_spawn: Vec::new(),
565 });
566 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
567 if let Some(state_path) = self.persistent_state_path.as_ref() {
568 std::fs::create_dir_all(state_path).map_err(|e| {
569 UnifiedRuntimeBuilderError::Io(format!(
570 "failed to create state directory at {}: {e}",
571 state_path.display()
572 ))
573 })?;
574 }
575 let persistent_agent_memory_provider: Option<Arc<dyn AgentMemoryProvider>> =
576 if self.agent_memory_from_persistent_state {
577 let Some(state_path) = self.persistent_state_path.as_ref() else {
578 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
579 "persistent_agent_memory() requires persistent_state()".to_string(),
580 ));
581 };
582 let memory_path = state_path.join("agent-memory");
583 Some(Arc::new(
584 MarkdownAgentMemoryStore::open(&memory_path).map_err(|e| {
585 UnifiedRuntimeBuilderError::Io(format!(
586 "failed to open agent memory store at {}: {e}",
587 memory_path.display()
588 ))
589 })?,
590 ))
591 } else {
592 None
593 };
594 let agent_memory_provider = self
595 .agent_memory_provider
596 .clone()
597 .or(persistent_agent_memory_provider);
598 let agent_memory_injector = agent_memory_provider.as_ref().map(|provider| {
599 AgentMemoryRuntimeInjector::new(
600 provider.clone(),
601 self.agent_memory_config.clone().unwrap_or_default(),
602 )
603 });
604 let agent_customizer = self.composed_agent_customizer(agent_memory_provider.clone());
605
606 let classic_agent_memory = if wants_identity_first {
613 None
614 } else {
615 agent_memory_provider.clone()
616 };
617 if let Some(provider) = classic_agent_memory.as_ref() {
618 mob_spec.spawn_member_customizer =
619 Some(Arc::new(crate::memory::MemorySpawnCustomizer::new(
620 provider.clone(),
621 self.agent_memory_config.clone().unwrap_or_default(),
622 )));
623 }
624
625 let persistent_metadata: Arc<dyn PersistentMetadataStore> =
634 if let Some(store) = self.persistent_metadata.clone() {
635 store
636 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
637 let metadata_path = state_path.join("mobkit_metadata.sqlite");
638 Arc::new(SqliteMetadataStore::open(&metadata_path).map_err(|e| {
639 UnifiedRuntimeBuilderError::Io(format!(
640 "failed to open mobkit_metadata.sqlite at {}: {e}",
641 metadata_path.display()
642 ))
643 })?)
644 } else {
645 Arc::new(InMemoryMetadataStore::new())
646 };
647 let console_log_store: Arc<dyn ConsoleLogStore> =
648 if let Some(store) = self.console_log_store.clone() {
649 store
650 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
651 let console_log_path = state_path.join("mobkit_console.sqlite");
652 Arc::new(SqliteConsoleLogStore::open(&console_log_path).map_err(|e| {
653 UnifiedRuntimeBuilderError::Io(format!(
654 "failed to open mobkit_console.sqlite at {}: {e}",
655 console_log_path.display()
656 ))
657 })?)
658 } else {
659 Arc::new(InMemoryConsoleLogStore::new())
660 };
661 let runtime = Box::pin(UnifiedRuntime::bootstrap_with_options(
662 mob_spec,
663 module_config,
664 self.module_agent_events,
665 timeout,
666 self.options,
667 persistent_metadata,
668 ))
669 .await
670 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
671
672 let session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>> = {
676 let handle = runtime.mob_runtime.handle();
677 let session_service = runtime.mob_runtime.session_service().cloned();
678 let session_store = self.custom_session_store.clone();
679 let bridge: Arc<dyn crate::identity_first::bridge::SessionBridge> =
680 if let Some(store) = continuity_session_store.clone() {
681 Arc::new(
682 crate::identity_first::bridge::MobSessionBridge::with_continuity_session_store(
683 handle,
684 store,
685 session_service,
686 ),
687 )
688 } else if let (Some(store), Some(service)) =
689 (session_store.clone(), session_service.clone())
690 {
691 Arc::new(
692 crate::identity_first::bridge::MobSessionBridge::with_session_store_and_service(
693 handle, store, service,
694 ),
695 )
696 } else if let Some(store) = session_store {
697 Arc::new(
698 crate::identity_first::bridge::MobSessionBridge::with_session_store(
699 handle, store,
700 ),
701 )
702 } else if let Some(service) = session_service {
703 Arc::new(
704 crate::identity_first::bridge::MobSessionBridge::with_session_service(
705 handle, service,
706 ),
707 )
708 } else {
709 Arc::new(crate::identity_first::bridge::MobSessionBridge::new(handle))
710 };
711 Some(bridge)
712 };
713
714 let identity_first_context = if wants_identity_first {
715 let (continuity_store, lease_provider): (
716 Arc<dyn crate::identity_first::contracts::ContinuityStore>,
717 Arc<dyn crate::identity_first::contracts::LeaseProvider>,
718 ) = if let Some(state_path) = self.persistent_state_path.as_ref() {
719 let continuity_path = state_path.join("identity_continuity.sqlite");
720 let local_store = LocalContinuityStore::open(&continuity_path).map_err(|e| {
721 UnifiedRuntimeBuilderError::Io(format!(
722 "failed to open identity_continuity.sqlite at {}: {e}",
723 continuity_path.display()
724 ))
725 })?;
726 let high_water = local_store.max_fencing_token().map_err(|e| {
727 UnifiedRuntimeBuilderError::Io(format!(
728 "failed to read identity continuity fencing high-water at {}: {e}",
729 continuity_path.display()
730 ))
731 })?;
732 (
733 Arc::new(local_store),
734 Arc::new(LocalLeaseProvider::with_floor(high_water)),
735 )
736 } else {
737 let Some(continuity_store) = self.continuity_store.clone() else {
738 return Err(UnifiedRuntimeBuilderError::Bootstrap(
739 UnifiedRuntimeBootstrapError::IdentityFirst(
740 "identity-first validation requires continuity_store".to_string(),
741 ),
742 ));
743 };
744 let Some(lease_provider) = self.lease_provider.clone() else {
745 return Err(UnifiedRuntimeBuilderError::Bootstrap(
746 UnifiedRuntimeBootstrapError::IdentityFirst(
747 "identity-first validation requires lease_provider".to_string(),
748 ),
749 ));
750 };
751 (continuity_store, lease_provider)
752 };
753 let Some(roster_provider) = self.roster_provider.clone() else {
754 return Err(UnifiedRuntimeBuilderError::Bootstrap(
755 UnifiedRuntimeBootstrapError::IdentityFirst(
756 "identity-first validation requires roster_provider".to_string(),
757 ),
758 ));
759 };
760 let bridge = session_bridge.clone();
761 let identity_runtime = Arc::new(
762 IdentityRuntime::new(IdentityRuntimeConfig {
763 continuity_store,
764 lease_provider,
765 runtime_instance_id: self
766 .identity_runtime_instance_id
767 .clone()
768 .unwrap_or_else(|| format!("mobkit-{}", std::process::id())),
769 has_runtime_store: true,
770 durability_policy: DurabilityPolicy::SyncWriteThrough,
771 bridge,
772 default_timeout: None,
773 })
774 .with_runtime_services(AgentRuntimeServices::new(runtime.mob_runtime.handle()))
775 .with_reset_roster_provider_context(
776 roster_provider.clone(),
777 Some(runtime.mob_runtime.handle().definition().clone()),
778 ),
779 );
780 identity_runtime
781 .set_agent_customizer(agent_customizer.clone())
782 .await;
783 identity_runtime
784 .set_agent_memory(agent_memory_injector.clone())
785 .await;
786 identity_runtime.set_error_hook(self.error_hook.clone());
787
788 let roster_specs = roster_provider
789 .roster(&RosterContext {
790 mob_definition: Some(runtime.mob_runtime.handle().definition().clone()),
791 previous_identities: Vec::new(),
792 })
793 .await
794 .map_err(|err| {
795 UnifiedRuntimeBuilderError::Bootstrap(
796 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
797 "roster provider failed: {err}"
798 )),
799 )
800 })?;
801
802 match self.identity_bootstrap_mode.clone() {
803 IdentityBootstrapMode::EagerMaterialize => {
804 restore_flow(
805 &identity_runtime,
806 &roster_specs,
807 self.topology_provider.as_deref(),
808 agent_customizer.as_deref(),
809 )
810 .await
811 .map_err(|err| {
812 UnifiedRuntimeBuilderError::Bootstrap(
813 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
814 "restore_flow failed: {err}"
815 )),
816 )
817 })?;
818 }
819 IdentityBootstrapMode::LazyMaterialize => {
820 lazy_register_flow(
821 &identity_runtime,
822 &roster_specs,
823 self.topology_provider.as_deref(),
824 )
825 .await
826 .map_err(|err| {
827 UnifiedRuntimeBuilderError::Bootstrap(
828 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
829 "lazy_register_flow failed: {err}"
830 )),
831 )
832 })?;
833 }
834 IdentityBootstrapMode::LazyWithBackgroundWarm { concurrency } => {
835 if concurrency == 0 {
836 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
837 "LazyWithBackgroundWarm concurrency must be greater than 0".to_string(),
838 ));
839 }
840 lazy_register_flow(
841 &identity_runtime,
842 &roster_specs,
843 self.topology_provider.as_deref(),
844 )
845 .await
846 .map_err(|err| {
847 UnifiedRuntimeBuilderError::Bootstrap(
848 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
849 "lazy_register_flow failed: {err}"
850 )),
851 )
852 })?;
853 let warm_runtime = identity_runtime.clone();
854 let warm_identities = roster_specs
855 .iter()
856 .map(|spec| spec.identity.clone())
857 .collect::<Vec<_>>();
858 tokio::spawn(async move {
859 stream::iter(warm_identities.into_iter().map(|identity| {
860 let runtime = warm_runtime.clone();
861 async move {
862 runtime.best_effort_background_warm_identity(identity).await;
863 }
864 }))
865 .buffer_unordered(concurrency)
866 .collect::<Vec<_>>()
867 .await;
868 });
869 }
870 }
871
872 Some(Arc::new(
873 IdentityFirstRuntimeContext::new_with_lazy_materialization(
874 identity_runtime,
875 roster_provider,
876 self.topology_provider.clone(),
877 agent_customizer.clone(),
878 Some(runtime.mob_runtime.handle().definition().clone()),
879 !matches!(
880 self.identity_bootstrap_mode,
881 IdentityBootstrapMode::EagerMaterialize
882 ),
883 ),
884 ))
885 } else {
886 None
887 };
888 let identity_lease_renewal_task = identity_first_context
889 .as_ref()
890 .map(|context| context.runtime.clone().spawn_lease_renewal_task());
891
892 let runtime = UnifiedRuntime {
894 access_controller: self.access_controller,
895 post_spawn_hook: self.post_spawn_hook,
896 post_reconcile_hook: self.post_reconcile_hook,
897 error_hook: self.error_hook,
898 drain_timeout: self.drain_timeout.unwrap_or(DEFAULT_DRAIN_TIMEOUT),
899 discovery: self.discovery,
900 edge_discovery: self.edge_discovery,
901 contact_directory: self.contact_directory,
902 session_bridge,
903 identity_first_context,
904 identity_lease_renewal_task: tokio::sync::Mutex::new(identity_lease_renewal_task),
905 console_log_store,
906 ..runtime
907 };
908
909 if let Some(store) = classic_agent_memory
915 .as_ref()
916 .and_then(|provider| provider.as_sqlite_store())
917 {
918 let llm_writes = self
919 .agent_memory_config
920 .as_ref()
921 .map(|config| config.llm_writes)
922 .unwrap_or_default();
923 store.set_llm_write_gate_if_absent(Arc::new(
924 crate::memory::taint::TaintLlmWriteGate::new(None, llm_writes),
925 ));
926 store.set_event_sink_if_absent(runtime.memory_event_sink());
927 runtime.set_memory_panel_store(store.clone());
928 }
929
930 let pre_spawn_context = if let Some(hook) = self.pre_spawn_hook {
931 hook().await.map_err(|err| {
932 UnifiedRuntimeBuilderError::Bootstrap(UnifiedRuntimeBootstrapError::PreSpawnHook(
933 err.to_string(),
934 ))
935 })?
936 } else {
937 serde_json::Value::Null
938 };
939 if runtime.identity_first_context.is_none()
940 && let Some(ref discovery) = runtime.discovery
941 {
942 let specs = discovery.discover(pre_spawn_context).await;
943 let spawn_specs: Vec<SpawnMemberSpec> =
944 specs.iter().map(discovery_spec_to_spawn_spec).collect();
945 runtime
946 .spawn_many(spawn_specs)
947 .await
948 .map_err(UnifiedRuntimeBootstrapError::Mob)
949 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
950 }
951
952 if runtime.edge_discovery.is_some() {
954 let report = runtime.reconcile_edges().await;
955 *runtime.bootstrap_edges_report.write().await = Some(report);
956 }
957
958 let mut runtime = runtime;
960 if let Some(event_log_config) = self.event_log_config {
961 runtime.start_event_log(event_log_config);
962 }
963
964 Ok(runtime)
965 }
966
967 async fn resolve_mob_spec(&self) -> Result<MobBootstrapSpec, UnifiedRuntimeBuilderError> {
970 let mut caps = self.capability_flags;
971 let definition = match self.definition_source {
972 Some(DefinitionSource::Inline(ref def)) => *def.clone(),
973 Some(DefinitionSource::TomlPath(ref path)) => {
974 let toml_content = std::fs::read_to_string(path).map_err(|e| {
975 UnifiedRuntimeBuilderError::Io(format!(
976 "failed to read definition TOML at {}: {e}",
977 path.display()
978 ))
979 })?;
980 MobDefinition::from_toml(&toml_content).map_err(|e| {
981 UnifiedRuntimeBuilderError::DefinitionLoad(format!(
982 "failed to parse definition TOML at {}: {e}",
983 path.display()
984 ))
985 })?
986 }
987 None => {
988 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
989 UnifiedRuntimeBuilderField::MobSpec,
990 ));
991 }
992 };
993 caps.image_generation |=
994 crate::mob_handle_runtime::mob_definition_may_use_image_generation(&definition);
995 let max_sessions = self.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS);
996 if max_sessions == 0 {
997 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
998 "max_sessions() must be greater than 0".to_string(),
999 ));
1000 }
1001
1002 let hook = self
1003 .session_hook
1004 .as_ref()
1005 .map(|h| -> crate::mob_handle_runtime::PreBuildHook {
1006 let hook = h.clone();
1007 Arc::new(
1008 move |req: &mut meerkat_core::service::CreateSessionRequest| {
1009 let hook = hook.clone();
1010 Box::pin(async move { hook.before_create(req).await })
1011 },
1012 )
1013 });
1014
1015 let after_hook: Option<crate::mob_handle_runtime::AfterCreateHook> = self
1016 .session_hook
1017 .as_ref()
1018 .map(|h| -> crate::mob_handle_runtime::AfterCreateHook {
1019 let hook = h.clone();
1020 Arc::new(move |session_id, ctx| {
1021 let hook = hook.clone();
1022 Box::pin(async move {
1023 hook.after_create(&session_id, &ctx).await;
1024 })
1025 })
1026 });
1027
1028 let mut spec = if let Some(ref state_path) = self.persistent_state_path {
1030 std::fs::create_dir_all(state_path).map_err(|e| {
1031 UnifiedRuntimeBuilderError::Io(format!(
1032 "failed to create state directory at {}: {e}",
1033 state_path.display()
1034 ))
1035 })?;
1036
1037 let session_store: Arc<dyn meerkat::SessionStore> =
1038 if let Some(ref store) = self.custom_session_store {
1039 store.clone()
1040 } else {
1041 let sqlite_path = state_path.join("sessions.db");
1042 Arc::new(
1043 meerkat_store::SqliteSessionStore::open(sqlite_path).map_err(|e| {
1044 UnifiedRuntimeBuilderError::Io(format!(
1045 "failed to open SQLite session store: {e}"
1046 ))
1047 })?,
1048 )
1049 };
1050 let mob_storage = MobStorage::in_memory();
1051
1052 MobBootstrapSpec::persistent_inner(
1053 definition,
1054 mob_storage,
1055 state_path.clone(),
1056 max_sessions,
1057 session_store,
1058 self.blob_store.clone(),
1059 hook,
1060 caps,
1061 after_hook.clone(),
1062 self.meerkat_config.clone(),
1063 )
1064 } else if let Some(ref scratch_dir) = self.scratch_dir {
1065 std::fs::create_dir_all(scratch_dir).map_err(|e| {
1066 UnifiedRuntimeBuilderError::Io(format!(
1067 "failed to create scratch directory at {}: {e}",
1068 scratch_dir.display()
1069 ))
1070 })?;
1071
1072 MobBootstrapSpec::ephemeral_runtime_backed_inner(
1073 definition,
1074 MobStorage::in_memory(),
1075 scratch_dir.clone(),
1076 max_sessions,
1077 self.custom_session_store.clone(),
1078 self.blob_store.clone(),
1079 hook,
1080 caps,
1081 after_hook,
1082 self.meerkat_config.clone(),
1083 )
1084 } else {
1085 let temp_dir = tempfile::tempdir().map_err(|e| {
1087 UnifiedRuntimeBuilderError::Io(format!("failed to create temp dir: {e}"))
1088 })?;
1089 let store_path = temp_dir.path().to_path_buf();
1090
1091 let mut spec = MobBootstrapSpec::ephemeral_runtime_backed_inner(
1092 definition,
1093 MobStorage::in_memory(),
1094 store_path,
1095 max_sessions,
1096 self.custom_session_store.clone(),
1097 self.blob_store.clone(),
1098 hook,
1099 caps,
1100 after_hook,
1101 self.meerkat_config.clone(),
1102 );
1103 spec._ephemeral_dir = Some(Arc::new(temp_dir));
1104 spec
1105 };
1106
1107 spec.options = MobBootstrapOptions {
1108 allow_ephemeral_sessions: true,
1109 notify_orchestrator_on_resume: true,
1110 default_llm_client: self.default_llm_client.clone(),
1111 };
1112
1113 Ok(spec)
1114 }
1115}
1116
1117#[cfg(test)]
1118#[allow(clippy::expect_used)]
1119mod tests {
1120 use super::*;
1121 use meerkat_core::service::{
1122 CreateSessionRequest, DeferredPromptPolicy, InitialTurnPolicy, SessionService,
1123 };
1124
1125 fn deferred_capacity_request(prompt: impl Into<String>) -> CreateSessionRequest {
1126 let build = meerkat_core::service::SessionBuildOptions {
1127 llm_client_override: Some(meerkat::encode_llm_client_override_for_service(Arc::new(
1128 meerkat_client::TestClient::default(),
1129 ))),
1130 ..Default::default()
1131 };
1132
1133 CreateSessionRequest {
1134 model: "gpt-5.5".to_string(),
1135 prompt: meerkat_core::ContentInput::Text(prompt.into()),
1136 system_prompt: meerkat_core::config::SystemPromptOverride::Inherit,
1137 max_tokens: None,
1138 event_tx: None,
1139 initial_turn: InitialTurnPolicy::Defer,
1140 deferred_prompt_policy: DeferredPromptPolicy::Discard,
1141 build: Some(build),
1142 labels: None,
1143 injected_context: Vec::new(),
1144 }
1145 }
1146
1147 #[tokio::test]
1148 async fn definition_based_ephemeral_spec_provides_runtime_adapter() {
1149 let definition = meerkat_mob::MobDefinition::from_toml(
1150 r#"
1151[mob]
1152id = "builder-ephemeral"
1153
1154[profiles.worker]
1155model = "gpt-5.5"
1156runtime_mode = "autonomous_host"
1157
1158[profiles.worker.tools]
1159comms = true
1160"#,
1161 )
1162 .expect("definition parses");
1163
1164 let builder = UnifiedRuntimeBuilder::default().definition(definition);
1165 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1166 assert!(
1167 spec.runtime_adapter.is_some(),
1168 "definition-based ephemeral specs should expose runtime authority",
1169 );
1170 }
1171
1172 #[tokio::test]
1173 async fn definition_based_ephemeral_spec_uses_configured_max_sessions() {
1174 let definition = meerkat_mob::MobDefinition::from_toml(
1175 r#"
1176[mob]
1177id = "builder-max-sessions"
1178
1179[profiles.worker]
1180model = "gpt-5.5"
1181runtime_mode = "autonomous_host"
1182"#,
1183 )
1184 .expect("definition parses");
1185
1186 let builder = UnifiedRuntimeBuilder::default()
1187 .definition(definition)
1188 .max_sessions(65);
1189 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1190
1191 for index in 0..65 {
1192 SessionService::create_session(
1193 spec.session_service.as_ref(),
1194 deferred_capacity_request(format!("session {index}")),
1195 )
1196 .await
1197 .expect("configured capacity should admit session");
1198 }
1199
1200 let blocked = SessionService::create_session(
1201 spec.session_service.as_ref(),
1202 deferred_capacity_request("one too many"),
1203 )
1204 .await
1205 .expect_err("configured capacity should block the next session");
1206 assert!(
1207 blocked.to_string().contains("Max sessions reached (65/65)"),
1208 "unexpected capacity error: {blocked}",
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn definition_based_spec_accepts_custom_meerkat_config() {
1214 let definition = meerkat_mob::MobDefinition::from_toml(
1215 r#"
1216[mob]
1217id = "builder-custom-config"
1218
1219[profiles.worker]
1220model = "gpt-5.5"
1221runtime_mode = "autonomous_host"
1222"#,
1223 )
1224 .expect("definition parses");
1225 let mut config = meerkat::Config::default();
1226 config.compaction.auto_compact_threshold = 42_000;
1227 config.compaction.auto_compact_threshold_explicit = true;
1228 config.compaction.recent_turn_budget = 2;
1229
1230 let builder = UnifiedRuntimeBuilder::default()
1231 .definition(definition)
1232 .meerkat_config(config)
1233 .max_sessions(1);
1234 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1235
1236 SessionService::create_session(
1237 spec.session_service.as_ref(),
1238 deferred_capacity_request("custom config session"),
1239 )
1240 .await
1241 .expect("custom Meerkat config should still build a usable session service");
1242 }
1243
1244 #[tokio::test]
1245 async fn definition_based_persistent_spec_uses_configured_max_sessions() {
1246 let definition = meerkat_mob::MobDefinition::from_toml(
1247 r#"
1248[mob]
1249id = "builder-persistent-max-sessions"
1250
1251[profiles.worker]
1252model = "gpt-5.5"
1253runtime_mode = "autonomous_host"
1254"#,
1255 )
1256 .expect("definition parses");
1257 let tmp = tempfile::tempdir().expect("temp dir");
1258
1259 let builder = UnifiedRuntimeBuilder::default()
1260 .definition(definition)
1261 .persistent_state(tmp.path().join("state"))
1262 .max_sessions(2);
1263 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1264
1265 for index in 0..2 {
1266 SessionService::create_session(
1267 spec.session_service.as_ref(),
1268 deferred_capacity_request(format!("persistent session {index}")),
1269 )
1270 .await
1271 .expect("configured persistent capacity should admit session");
1272 }
1273
1274 let blocked = SessionService::create_session(
1275 spec.session_service.as_ref(),
1276 deferred_capacity_request("persistent one too many"),
1277 )
1278 .await
1279 .expect_err("configured persistent capacity should block the next session");
1280 assert!(
1281 blocked.to_string().contains("Max sessions reached (2/2)"),
1282 "unexpected capacity error: {blocked}",
1283 );
1284 }
1285
1286 #[tokio::test]
1287 async fn definition_based_spec_rejects_zero_max_sessions() {
1288 let definition = meerkat_mob::MobDefinition::from_toml(
1289 r#"
1290[mob]
1291id = "builder-zero-max-sessions"
1292
1293[profiles.worker]
1294model = "gpt-5.5"
1295"#,
1296 )
1297 .expect("definition parses");
1298
1299 let result = UnifiedRuntimeBuilder::default()
1300 .definition(definition)
1301 .max_sessions(0)
1302 .resolve_mob_spec()
1303 .await;
1304 assert!(result.is_err(), "zero max sessions should be rejected");
1305 let err = result.err().expect("zero max sessions error");
1306
1307 assert!(
1308 err.to_string().contains("max_sessions"),
1309 "unexpected error: {err}",
1310 );
1311 }
1312
1313 #[test]
1314 fn builder_accepts_custom_console_log_store_for_ephemeral_mob_state() {
1315 let store: Arc<dyn ConsoleLogStore> = Arc::new(InMemoryConsoleLogStore::new());
1316 let builder = UnifiedRuntimeBuilder::default().with_console_log_store(store.clone());
1317
1318 assert!(
1319 Arc::ptr_eq(
1320 builder.console_log_store.as_ref().expect("custom store"),
1321 &store
1322 ),
1323 "builder should retain the exact console log store supplied by the app"
1324 );
1325 }
1326}