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, AgentRuntimeServices, ContinuitySessionStoreAdapter, DurabilityPolicy,
15 IdentityFirstRuntimeContext, IdentityRuntime, IdentityRuntimeConfig, RosterContext,
16 RosterProvider, TopologyProvider, lazy_register_flow, restore_flow,
17};
18use crate::mob_handle_runtime::{
19 CapabilityFlags, MobBootstrapOptions, MobBootstrapSpec, SessionHook,
20};
21use crate::runtime::{
22 InMemoryMetadataStore, PersistentMetadataStore, RuntimeOptions, SqliteMetadataStore,
23};
24use crate::types::{EventEnvelope, MobKitConfig, UnifiedEvent};
25
26use super::edge_types::{Discovery, EdgeDiscovery, PreSpawnHook};
27use super::types::{
28 UnifiedRuntimeBootstrapError, UnifiedRuntimeBuilderError, UnifiedRuntimeBuilderField,
29};
30use super::{
31 DEFAULT_DRAIN_TIMEOUT, ErrorHook, EventLogConfig, PostReconcileHook, PostSpawnHook,
32 UnifiedRuntime, discovery_spec_to_spawn_spec,
33};
34
35pub(crate) enum DefinitionSource {
37 Inline(Box<MobDefinition>),
38 TomlPath(PathBuf),
39}
40
41const DEFAULT_MAX_SESSIONS: usize = 64;
43
44const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub enum IdentityBootstrapMode {
51 #[default]
54 EagerMaterialize,
55 LazyMaterialize,
58 LazyWithBackgroundWarm { concurrency: usize },
61}
62
63#[derive(Default)]
64pub struct UnifiedRuntimeBuilder {
65 mob_spec: Option<MobBootstrapSpec>,
67
68 definition_source: Option<DefinitionSource>,
70 persistent_state_path: Option<PathBuf>,
71 session_hook: Option<Arc<dyn SessionHook>>,
72 custom_session_store: Option<Arc<dyn meerkat::SessionStore>>,
73 meerkat_config: Option<meerkat::Config>,
74 default_llm_client: Option<Arc<dyn LlmClient>>,
75 max_sessions: Option<usize>,
76 capability_flags: CapabilityFlags,
77
78 continuity_store: Option<Arc<dyn crate::identity_first::contracts::ContinuityStore>>,
80 lease_provider: Option<Arc<dyn crate::identity_first::contracts::LeaseProvider>>,
81 roster_provider: Option<Arc<dyn RosterProvider>>,
82 topology_provider: Option<Arc<dyn TopologyProvider>>,
83 agent_customizer: Option<Arc<dyn AgentCustomizer>>,
84 identity_bootstrap_mode: IdentityBootstrapMode,
85 identity_runtime_instance_id: Option<String>,
86 scratch_dir: Option<PathBuf>,
87 blob_store: Option<Arc<dyn meerkat_core::BlobStore>>,
88 console_log_store: Option<Arc<dyn ConsoleLogStore>>,
89
90 module_config: Option<MobKitConfig>,
92 module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
93 timeout: Option<Duration>,
94 options: RuntimeOptions,
95 post_spawn_hook: Option<PostSpawnHook>,
96 post_reconcile_hook: Option<PostReconcileHook>,
97 error_hook: Option<ErrorHook>,
98 event_log_config: Option<EventLogConfig>,
99 drain_timeout: Option<Duration>,
100 discovery: Option<Box<dyn Discovery>>,
101 pre_spawn_hook: Option<PreSpawnHook>,
102 edge_discovery: Option<Box<dyn EdgeDiscovery>>,
103 contact_directory: Option<ContactDirectory>,
104 persistent_metadata: Option<Arc<dyn PersistentMetadataStore>>,
105 access_controller: Option<crate::access::AccessController>,
106}
107
108impl UnifiedRuntimeBuilder {
109 pub fn definition(mut self, def: MobDefinition) -> Self {
115 self.definition_source = Some(DefinitionSource::Inline(Box::new(def)));
116 self
117 }
118
119 pub fn definition_path(mut self, path: impl Into<PathBuf>) -> Self {
121 self.definition_source = Some(DefinitionSource::TomlPath(path.into()));
122 self
123 }
124
125 pub fn persistent_state(mut self, path: impl Into<PathBuf>) -> Self {
131 self.persistent_state_path = Some(path.into());
132 self
133 }
134
135 pub fn session_hook(mut self, hook: Arc<dyn SessionHook>) -> Self {
137 self.session_hook = Some(hook);
138 self
139 }
140
141 pub fn session_store(mut self, store: Arc<dyn meerkat::SessionStore>) -> Self {
146 self.custom_session_store = Some(store);
147 self
148 }
149
150 pub fn meerkat_config(mut self, config: meerkat::Config) -> Self {
157 self.meerkat_config = Some(config);
158 self
159 }
160
161 pub fn default_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
163 self.default_llm_client = Some(client);
164 self
165 }
166
167 pub fn max_sessions(mut self, max_sessions: usize) -> Self {
173 self.max_sessions = Some(max_sessions);
174 self
175 }
176
177 pub fn continuity_store(
181 mut self,
182 store: Arc<dyn crate::identity_first::contracts::ContinuityStore>,
183 ) -> Self {
184 self.continuity_store = Some(store);
185 self
186 }
187
188 pub fn lease_provider(
192 mut self,
193 provider: Arc<dyn crate::identity_first::contracts::LeaseProvider>,
194 ) -> Self {
195 self.lease_provider = Some(provider);
196 self
197 }
198
199 pub fn roster_provider(mut self, provider: Arc<dyn RosterProvider>) -> Self {
201 self.roster_provider = Some(provider);
202 self
203 }
204
205 pub fn topology_provider(mut self, provider: Arc<dyn TopologyProvider>) -> Self {
207 self.topology_provider = Some(provider);
208 self
209 }
210
211 pub fn agent_customizer(mut self, customizer: Arc<dyn AgentCustomizer>) -> Self {
213 self.agent_customizer = Some(customizer);
214 self
215 }
216
217 pub fn identity_bootstrap_mode(mut self, mode: IdentityBootstrapMode) -> Self {
219 self.identity_bootstrap_mode = mode;
220 self
221 }
222
223 pub fn identity_runtime_instance_id(mut self, id: impl Into<String>) -> Self {
225 self.identity_runtime_instance_id = Some(id.into());
226 self
227 }
228
229 pub fn scratch_dir(mut self, path: impl Into<PathBuf>) -> Self {
233 self.scratch_dir = Some(path.into());
234 self
235 }
236
237 pub fn blob_store(mut self, store: Arc<dyn meerkat_core::BlobStore>) -> Self {
242 self.blob_store = Some(store);
243 self
244 }
245
246 pub fn with_console_log_store(mut self, store: Arc<dyn ConsoleLogStore>) -> Self {
251 self.console_log_store = Some(store);
252 self
253 }
254
255 pub fn builtins(mut self, enabled: bool) -> Self {
257 self.capability_flags.builtins = enabled;
258 self
259 }
260
261 pub fn shell(mut self, enabled: bool) -> Self {
263 self.capability_flags.shell = enabled;
264 self
265 }
266
267 pub fn mob(mut self, enabled: bool) -> Self {
269 self.capability_flags.mob = enabled;
270 self
271 }
272
273 pub fn comms(mut self, enabled: bool) -> Self {
275 self.capability_flags.comms = enabled;
276 self
277 }
278
279 pub fn memory(mut self, enabled: bool) -> Self {
281 self.capability_flags.memory = enabled;
282 self
283 }
284
285 pub fn image_generation(mut self, enabled: bool) -> Self {
291 self.capability_flags.image_generation = enabled;
292 self
293 }
294
295 pub fn mob_spec(mut self, spec: MobBootstrapSpec) -> Self {
300 self.mob_spec = Some(spec);
301 self
302 }
303
304 pub fn module_config(mut self, config: MobKitConfig) -> Self {
305 self.module_config = Some(config);
306 self
307 }
308
309 pub fn module_agent_events(mut self, events: Vec<EventEnvelope<UnifiedEvent>>) -> Self {
310 self.module_agent_events = events;
311 self
312 }
313
314 pub fn timeout(mut self, timeout: Duration) -> Self {
315 self.timeout = Some(timeout);
316 self
317 }
318
319 pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
320 self.options = options;
321 self
322 }
323
324 pub fn post_spawn_hook(mut self, hook: PostSpawnHook) -> Self {
325 self.post_spawn_hook = Some(hook);
326 self
327 }
328
329 pub fn post_reconcile_hook(mut self, hook: PostReconcileHook) -> Self {
330 self.post_reconcile_hook = Some(hook);
331 self
332 }
333
334 pub fn on_error(mut self, hook: ErrorHook) -> Self {
335 self.error_hook = Some(hook);
336 self
337 }
338
339 pub fn event_log(mut self, config: EventLogConfig) -> Self {
340 self.event_log_config = Some(config);
341 self
342 }
343
344 pub fn drain_timeout(mut self, timeout: Duration) -> Self {
345 self.drain_timeout = Some(timeout);
346 self
347 }
348
349 pub fn discovery(mut self, discovery: impl Discovery + 'static) -> Self {
350 self.discovery = Some(Box::new(discovery));
351 self
352 }
353
354 pub fn pre_spawn_hook(mut self, hook: PreSpawnHook) -> Self {
355 self.pre_spawn_hook = Some(hook);
356 self
357 }
358
359 pub fn edge_discovery(mut self, edge_discovery: impl EdgeDiscovery + 'static) -> Self {
360 self.edge_discovery = Some(Box::new(edge_discovery));
361 self
362 }
363
364 pub fn contact_directory(mut self, directory: ContactDirectory) -> Self {
366 self.contact_directory = Some(directory);
367 self
368 }
369
370 pub fn persistent_metadata(mut self, store: Arc<dyn PersistentMetadataStore>) -> Self {
380 self.persistent_metadata = Some(store);
381 self
382 }
383
384 pub fn access_controller(mut self, controller: crate::access::AccessController) -> Self {
388 self.access_controller = Some(controller);
389 self
390 }
391
392 pub fn access_control_file(
396 mut self,
397 path: impl Into<PathBuf>,
398 ) -> Result<Self, crate::access::AccessConfigError> {
399 self.access_controller = Some(crate::access::AccessController::load_or_default(path)?);
400 Ok(self)
401 }
402
403 pub async fn build(mut self) -> Result<UnifiedRuntime, UnifiedRuntimeBuilderError> {
408 let has_persistent_state = self.persistent_state_path.is_some();
411 let has_continuity_store = self.continuity_store.is_some();
412 let has_lease_provider = self.lease_provider.is_some();
413 let has_roster_provider = self.roster_provider.is_some();
414 let has_topology_provider = self.topology_provider.is_some();
415 let has_agent_customizer = self.agent_customizer.is_some();
416 let has_identity_runtime_instance_id = self.identity_runtime_instance_id.is_some();
417 let has_scratch_dir = self.scratch_dir.is_some();
418 let has_any_external = has_continuity_store
419 || has_lease_provider
420 || has_roster_provider
421 || has_topology_provider
422 || has_agent_customizer
423 || has_identity_runtime_instance_id
424 || has_scratch_dir;
425
426 if has_persistent_state && has_any_external {
428 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
429 "persistent_state() and identity-first provider/customizer/scratch_dir setters \
430 are mutually exclusive — use one path or the other"
431 .to_string(),
432 ));
433 }
434
435 if has_any_external
437 && !(has_continuity_store
438 && has_lease_provider
439 && has_roster_provider
440 && has_scratch_dir)
441 {
442 let mut missing = Vec::new();
443 if !has_continuity_store {
444 missing.push("continuity_store");
445 }
446 if !has_lease_provider {
447 missing.push("lease_provider");
448 }
449 if !has_roster_provider {
450 missing.push("roster_provider");
451 }
452 if !has_scratch_dir {
453 missing.push("scratch_dir");
454 }
455 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
456 format!(
457 "identity-first path requires continuity_store() + lease_provider() + \
458 roster_provider() + scratch_dir(); missing: {}",
459 missing.join(", ")
460 ),
461 ));
462 }
463
464 let continuity_session_store = self
465 .continuity_store
466 .as_ref()
467 .map(|store| Arc::new(ContinuitySessionStoreAdapter::new(store.clone())));
468 if let Some(store) = continuity_session_store.as_ref() {
469 self.custom_session_store = Some(store.clone());
470 }
471
472 let mob_spec = match self.mob_spec.take() {
475 Some(spec) => {
476 if self.module_config.is_none() {
478 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
479 UnifiedRuntimeBuilderField::ModuleConfig,
480 ));
481 }
482 if self.timeout.is_none() {
483 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
484 UnifiedRuntimeBuilderField::Timeout,
485 ));
486 }
487 spec
488 }
489 None => self.resolve_mob_spec().await?,
490 };
491
492 let module_config = self.module_config.unwrap_or_else(|| MobKitConfig {
493 modules: Vec::new(),
494 discovery: crate::types::DiscoverySpec {
495 namespace: String::new(),
496 modules: Vec::new(),
497 },
498 pre_spawn: Vec::new(),
499 });
500 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
501 if let Some(state_path) = self.persistent_state_path.as_ref() {
502 std::fs::create_dir_all(state_path).map_err(|e| {
503 UnifiedRuntimeBuilderError::Io(format!(
504 "failed to create state directory at {}: {e}",
505 state_path.display()
506 ))
507 })?;
508 }
509
510 let persistent_metadata: Arc<dyn PersistentMetadataStore> =
519 if let Some(store) = self.persistent_metadata.clone() {
520 store
521 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
522 let metadata_path = state_path.join("mobkit_metadata.sqlite");
523 Arc::new(SqliteMetadataStore::open(&metadata_path).map_err(|e| {
524 UnifiedRuntimeBuilderError::Io(format!(
525 "failed to open mobkit_metadata.sqlite at {}: {e}",
526 metadata_path.display()
527 ))
528 })?)
529 } else {
530 Arc::new(InMemoryMetadataStore::new())
531 };
532 let console_log_store: Arc<dyn ConsoleLogStore> =
533 if let Some(store) = self.console_log_store.clone() {
534 store
535 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
536 let console_log_path = state_path.join("mobkit_console.sqlite");
537 Arc::new(SqliteConsoleLogStore::open(&console_log_path).map_err(|e| {
538 UnifiedRuntimeBuilderError::Io(format!(
539 "failed to open mobkit_console.sqlite at {}: {e}",
540 console_log_path.display()
541 ))
542 })?)
543 } else {
544 Arc::new(InMemoryConsoleLogStore::new())
545 };
546
547 let runtime = Box::pin(UnifiedRuntime::bootstrap_with_options(
548 mob_spec,
549 module_config,
550 self.module_agent_events,
551 timeout,
552 self.options,
553 persistent_metadata,
554 ))
555 .await
556 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
557
558 let session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>> = {
562 let handle = runtime.mob_runtime.handle();
563 let session_service = runtime.mob_runtime.session_service().cloned();
564 let session_store = self.custom_session_store.clone();
565 let bridge: Arc<dyn crate::identity_first::bridge::SessionBridge> =
566 if let Some(store) = continuity_session_store.clone() {
567 Arc::new(
568 crate::identity_first::bridge::MobSessionBridge::with_continuity_session_store(
569 handle,
570 store,
571 session_service,
572 ),
573 )
574 } else if let (Some(store), Some(service)) =
575 (session_store.clone(), session_service.clone())
576 {
577 Arc::new(
578 crate::identity_first::bridge::MobSessionBridge::with_session_store_and_service(
579 handle, store, service,
580 ),
581 )
582 } else if let Some(store) = session_store {
583 Arc::new(
584 crate::identity_first::bridge::MobSessionBridge::with_session_store(
585 handle, store,
586 ),
587 )
588 } else if let Some(service) = session_service {
589 Arc::new(
590 crate::identity_first::bridge::MobSessionBridge::with_session_service(
591 handle, service,
592 ),
593 )
594 } else {
595 Arc::new(crate::identity_first::bridge::MobSessionBridge::new(handle))
596 };
597 Some(bridge)
598 };
599
600 let identity_first_context = if has_any_external {
601 let Some(continuity_store) = self.continuity_store.clone() else {
602 return Err(UnifiedRuntimeBuilderError::Bootstrap(
603 UnifiedRuntimeBootstrapError::IdentityFirst(
604 "identity-first validation requires continuity_store".to_string(),
605 ),
606 ));
607 };
608 let Some(lease_provider) = self.lease_provider.clone() else {
609 return Err(UnifiedRuntimeBuilderError::Bootstrap(
610 UnifiedRuntimeBootstrapError::IdentityFirst(
611 "identity-first validation requires lease_provider".to_string(),
612 ),
613 ));
614 };
615 let Some(roster_provider) = self.roster_provider.clone() else {
616 return Err(UnifiedRuntimeBuilderError::Bootstrap(
617 UnifiedRuntimeBootstrapError::IdentityFirst(
618 "identity-first validation requires roster_provider".to_string(),
619 ),
620 ));
621 };
622 let bridge = session_bridge.clone();
623 let identity_runtime = Arc::new(
624 IdentityRuntime::new(IdentityRuntimeConfig {
625 continuity_store,
626 lease_provider,
627 runtime_instance_id: self
628 .identity_runtime_instance_id
629 .clone()
630 .unwrap_or_else(|| format!("mobkit-{}", std::process::id())),
631 has_runtime_store: true,
632 durability_policy: DurabilityPolicy::SyncWriteThrough,
633 bridge,
634 default_timeout: None,
635 })
636 .with_runtime_services(AgentRuntimeServices::new(runtime.mob_runtime.handle())),
637 );
638 identity_runtime
639 .set_agent_customizer(self.agent_customizer.clone())
640 .await;
641 identity_runtime.set_error_hook(self.error_hook.clone());
642
643 let roster_specs = roster_provider
644 .roster(&RosterContext {
645 mob_definition: Some(runtime.mob_runtime.handle().definition().clone()),
646 previous_identities: Vec::new(),
647 })
648 .await
649 .map_err(|err| {
650 UnifiedRuntimeBuilderError::Bootstrap(
651 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
652 "roster provider failed: {err}"
653 )),
654 )
655 })?;
656
657 match self.identity_bootstrap_mode.clone() {
658 IdentityBootstrapMode::EagerMaterialize => {
659 restore_flow(
660 &identity_runtime,
661 &roster_specs,
662 self.topology_provider.as_deref(),
663 self.agent_customizer.as_deref(),
664 )
665 .await
666 .map_err(|err| {
667 UnifiedRuntimeBuilderError::Bootstrap(
668 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
669 "restore_flow failed: {err}"
670 )),
671 )
672 })?;
673 }
674 IdentityBootstrapMode::LazyMaterialize => {
675 lazy_register_flow(
676 &identity_runtime,
677 &roster_specs,
678 self.topology_provider.as_deref(),
679 )
680 .await
681 .map_err(|err| {
682 UnifiedRuntimeBuilderError::Bootstrap(
683 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
684 "lazy_register_flow failed: {err}"
685 )),
686 )
687 })?;
688 }
689 IdentityBootstrapMode::LazyWithBackgroundWarm { concurrency } => {
690 if concurrency == 0 {
691 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
692 "LazyWithBackgroundWarm concurrency must be greater than 0".to_string(),
693 ));
694 }
695 lazy_register_flow(
696 &identity_runtime,
697 &roster_specs,
698 self.topology_provider.as_deref(),
699 )
700 .await
701 .map_err(|err| {
702 UnifiedRuntimeBuilderError::Bootstrap(
703 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
704 "lazy_register_flow failed: {err}"
705 )),
706 )
707 })?;
708 let warm_runtime = identity_runtime.clone();
709 let warm_identities = roster_specs
710 .iter()
711 .map(|spec| spec.identity.clone())
712 .collect::<Vec<_>>();
713 tokio::spawn(async move {
714 stream::iter(warm_identities.into_iter().map(|identity| {
715 let runtime = warm_runtime.clone();
716 async move {
717 runtime.best_effort_background_warm_identity(identity).await;
718 }
719 }))
720 .buffer_unordered(concurrency)
721 .collect::<Vec<_>>()
722 .await;
723 });
724 }
725 }
726
727 Some(Arc::new(
728 IdentityFirstRuntimeContext::new_with_lazy_materialization(
729 identity_runtime,
730 roster_provider,
731 self.topology_provider.clone(),
732 self.agent_customizer.clone(),
733 Some(runtime.mob_runtime.handle().definition().clone()),
734 !matches!(
735 self.identity_bootstrap_mode,
736 IdentityBootstrapMode::EagerMaterialize
737 ),
738 ),
739 ))
740 } else {
741 None
742 };
743 let identity_lease_renewal_task = identity_first_context
744 .as_ref()
745 .map(|context| context.runtime.clone().spawn_lease_renewal_task());
746
747 let runtime = UnifiedRuntime {
749 access_controller: self.access_controller,
750 post_spawn_hook: self.post_spawn_hook,
751 post_reconcile_hook: self.post_reconcile_hook,
752 error_hook: self.error_hook,
753 drain_timeout: self.drain_timeout.unwrap_or(DEFAULT_DRAIN_TIMEOUT),
754 discovery: self.discovery,
755 edge_discovery: self.edge_discovery,
756 contact_directory: self.contact_directory,
757 session_bridge,
758 identity_first_context,
759 identity_lease_renewal_task: tokio::sync::Mutex::new(identity_lease_renewal_task),
760 console_log_store,
761 ..runtime
762 };
763
764 let pre_spawn_context = if let Some(hook) = self.pre_spawn_hook {
765 hook().await.map_err(|err| {
766 UnifiedRuntimeBuilderError::Bootstrap(UnifiedRuntimeBootstrapError::PreSpawnHook(
767 err.to_string(),
768 ))
769 })?
770 } else {
771 serde_json::Value::Null
772 };
773 if runtime.identity_first_context.is_none()
774 && let Some(ref discovery) = runtime.discovery
775 {
776 let specs = discovery.discover(pre_spawn_context).await;
777 let spawn_specs: Vec<SpawnMemberSpec> =
778 specs.iter().map(discovery_spec_to_spawn_spec).collect();
779 runtime
780 .spawn_many(spawn_specs)
781 .await
782 .map_err(UnifiedRuntimeBootstrapError::Mob)
783 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
784 }
785
786 if runtime.edge_discovery.is_some() {
788 let report = runtime.reconcile_edges().await;
789 *runtime.bootstrap_edges_report.write().await = Some(report);
790 }
791
792 let mut runtime = runtime;
794 if let Some(event_log_config) = self.event_log_config {
795 runtime.start_event_log(event_log_config);
796 }
797
798 Ok(runtime)
799 }
800
801 async fn resolve_mob_spec(&self) -> Result<MobBootstrapSpec, UnifiedRuntimeBuilderError> {
804 let mut caps = self.capability_flags;
805 let definition = match self.definition_source {
806 Some(DefinitionSource::Inline(ref def)) => *def.clone(),
807 Some(DefinitionSource::TomlPath(ref path)) => {
808 let toml_content = std::fs::read_to_string(path).map_err(|e| {
809 UnifiedRuntimeBuilderError::Io(format!(
810 "failed to read definition TOML at {}: {e}",
811 path.display()
812 ))
813 })?;
814 MobDefinition::from_toml(&toml_content).map_err(|e| {
815 UnifiedRuntimeBuilderError::DefinitionLoad(format!(
816 "failed to parse definition TOML at {}: {e}",
817 path.display()
818 ))
819 })?
820 }
821 None => {
822 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
823 UnifiedRuntimeBuilderField::MobSpec,
824 ));
825 }
826 };
827 caps.image_generation |=
828 crate::mob_handle_runtime::mob_definition_may_use_image_generation(&definition);
829 let max_sessions = self.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS);
830 if max_sessions == 0 {
831 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
832 "max_sessions() must be greater than 0".to_string(),
833 ));
834 }
835
836 let hook = self
837 .session_hook
838 .as_ref()
839 .map(|h| -> crate::mob_handle_runtime::PreBuildHook {
840 let hook = h.clone();
841 Arc::new(
842 move |req: &mut meerkat_core::service::CreateSessionRequest| {
843 let hook = hook.clone();
844 Box::pin(async move { hook.before_create(req).await })
845 },
846 )
847 });
848
849 let after_hook: Option<crate::mob_handle_runtime::AfterCreateHook> = self
850 .session_hook
851 .as_ref()
852 .map(|h| -> crate::mob_handle_runtime::AfterCreateHook {
853 let hook = h.clone();
854 Arc::new(move |session_id, ctx| {
855 let hook = hook.clone();
856 Box::pin(async move {
857 hook.after_create(&session_id, &ctx).await;
858 })
859 })
860 });
861
862 let mut spec = if let Some(ref state_path) = self.persistent_state_path {
864 std::fs::create_dir_all(state_path).map_err(|e| {
865 UnifiedRuntimeBuilderError::Io(format!(
866 "failed to create state directory at {}: {e}",
867 state_path.display()
868 ))
869 })?;
870
871 let session_store: Arc<dyn meerkat::SessionStore> =
872 if let Some(ref store) = self.custom_session_store {
873 store.clone()
874 } else {
875 let sqlite_path = state_path.join("sessions.db");
876 Arc::new(
877 meerkat_store::SqliteSessionStore::open(sqlite_path).map_err(|e| {
878 UnifiedRuntimeBuilderError::Io(format!(
879 "failed to open SQLite session store: {e}"
880 ))
881 })?,
882 )
883 };
884 let mob_storage = MobStorage::in_memory();
885
886 MobBootstrapSpec::persistent_inner(
887 definition,
888 mob_storage,
889 state_path.clone(),
890 max_sessions,
891 session_store,
892 self.blob_store.clone(),
893 hook,
894 caps,
895 after_hook.clone(),
896 self.meerkat_config.clone(),
897 )
898 } else if let Some(ref scratch_dir) = self.scratch_dir {
899 std::fs::create_dir_all(scratch_dir).map_err(|e| {
900 UnifiedRuntimeBuilderError::Io(format!(
901 "failed to create scratch directory at {}: {e}",
902 scratch_dir.display()
903 ))
904 })?;
905
906 MobBootstrapSpec::ephemeral_runtime_backed_inner(
907 definition,
908 MobStorage::in_memory(),
909 scratch_dir.clone(),
910 max_sessions,
911 self.custom_session_store.clone(),
912 self.blob_store.clone(),
913 hook,
914 caps,
915 after_hook,
916 self.meerkat_config.clone(),
917 )
918 } else {
919 let temp_dir = tempfile::tempdir().map_err(|e| {
921 UnifiedRuntimeBuilderError::Io(format!("failed to create temp dir: {e}"))
922 })?;
923 let store_path = temp_dir.path().to_path_buf();
924
925 let mut spec = MobBootstrapSpec::ephemeral_runtime_backed_inner(
926 definition,
927 MobStorage::in_memory(),
928 store_path,
929 max_sessions,
930 self.custom_session_store.clone(),
931 self.blob_store.clone(),
932 hook,
933 caps,
934 after_hook,
935 self.meerkat_config.clone(),
936 );
937 spec._ephemeral_dir = Some(Arc::new(temp_dir));
938 spec
939 };
940
941 spec.options = MobBootstrapOptions {
942 allow_ephemeral_sessions: true,
943 notify_orchestrator_on_resume: true,
944 default_llm_client: self.default_llm_client.clone(),
945 };
946
947 Ok(spec)
948 }
949}
950
951#[cfg(test)]
952#[allow(clippy::expect_used)]
953mod tests {
954 use super::*;
955 use meerkat_core::service::{
956 CreateSessionRequest, DeferredPromptPolicy, InitialTurnPolicy, SessionService,
957 };
958
959 fn deferred_capacity_request(prompt: impl Into<String>) -> CreateSessionRequest {
960 let build = meerkat_core::service::SessionBuildOptions {
961 llm_client_override: Some(meerkat::encode_llm_client_override_for_service(Arc::new(
962 meerkat_client::TestClient::default(),
963 ))),
964 ..Default::default()
965 };
966
967 CreateSessionRequest {
968 model: "gpt-5.5".to_string(),
969 prompt: meerkat_core::ContentInput::Text(prompt.into()),
970 system_prompt: meerkat_core::config::SystemPromptOverride::Inherit,
971 max_tokens: None,
972 event_tx: None,
973 initial_turn: InitialTurnPolicy::Defer,
974 deferred_prompt_policy: DeferredPromptPolicy::Discard,
975 build: Some(build),
976 labels: None,
977 }
978 }
979
980 #[tokio::test]
981 async fn definition_based_ephemeral_spec_provides_runtime_adapter() {
982 let definition = meerkat_mob::MobDefinition::from_toml(
983 r#"
984[mob]
985id = "builder-ephemeral"
986
987[profiles.worker]
988model = "gpt-5.5"
989runtime_mode = "autonomous_host"
990
991[profiles.worker.tools]
992comms = true
993"#,
994 )
995 .expect("definition parses");
996
997 let builder = UnifiedRuntimeBuilder::default().definition(definition);
998 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
999 assert!(
1000 spec.runtime_adapter.is_some(),
1001 "definition-based ephemeral specs should expose runtime authority",
1002 );
1003 }
1004
1005 #[tokio::test]
1006 async fn definition_based_ephemeral_spec_uses_configured_max_sessions() {
1007 let definition = meerkat_mob::MobDefinition::from_toml(
1008 r#"
1009[mob]
1010id = "builder-max-sessions"
1011
1012[profiles.worker]
1013model = "gpt-5.5"
1014runtime_mode = "autonomous_host"
1015"#,
1016 )
1017 .expect("definition parses");
1018
1019 let builder = UnifiedRuntimeBuilder::default()
1020 .definition(definition)
1021 .max_sessions(65);
1022 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1023
1024 for index in 0..65 {
1025 SessionService::create_session(
1026 spec.session_service.as_ref(),
1027 deferred_capacity_request(format!("session {index}")),
1028 )
1029 .await
1030 .expect("configured capacity should admit session");
1031 }
1032
1033 let blocked = SessionService::create_session(
1034 spec.session_service.as_ref(),
1035 deferred_capacity_request("one too many"),
1036 )
1037 .await
1038 .expect_err("configured capacity should block the next session");
1039 assert!(
1040 blocked.to_string().contains("Max sessions reached (65/65)"),
1041 "unexpected capacity error: {blocked}",
1042 );
1043 }
1044
1045 #[tokio::test]
1046 async fn definition_based_spec_accepts_custom_meerkat_config() {
1047 let definition = meerkat_mob::MobDefinition::from_toml(
1048 r#"
1049[mob]
1050id = "builder-custom-config"
1051
1052[profiles.worker]
1053model = "gpt-5.5"
1054runtime_mode = "autonomous_host"
1055"#,
1056 )
1057 .expect("definition parses");
1058 let mut config = meerkat::Config::default();
1059 config.compaction.auto_compact_threshold = 42_000;
1060 config.compaction.auto_compact_threshold_explicit = true;
1061 config.compaction.recent_turn_budget = 2;
1062
1063 let builder = UnifiedRuntimeBuilder::default()
1064 .definition(definition)
1065 .meerkat_config(config)
1066 .max_sessions(1);
1067 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1068
1069 SessionService::create_session(
1070 spec.session_service.as_ref(),
1071 deferred_capacity_request("custom config session"),
1072 )
1073 .await
1074 .expect("custom Meerkat config should still build a usable session service");
1075 }
1076
1077 #[tokio::test]
1078 async fn definition_based_persistent_spec_uses_configured_max_sessions() {
1079 let definition = meerkat_mob::MobDefinition::from_toml(
1080 r#"
1081[mob]
1082id = "builder-persistent-max-sessions"
1083
1084[profiles.worker]
1085model = "gpt-5.5"
1086runtime_mode = "autonomous_host"
1087"#,
1088 )
1089 .expect("definition parses");
1090 let tmp = tempfile::tempdir().expect("temp dir");
1091
1092 let builder = UnifiedRuntimeBuilder::default()
1093 .definition(definition)
1094 .persistent_state(tmp.path().join("state"))
1095 .max_sessions(2);
1096 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1097
1098 for index in 0..2 {
1099 SessionService::create_session(
1100 spec.session_service.as_ref(),
1101 deferred_capacity_request(format!("persistent session {index}")),
1102 )
1103 .await
1104 .expect("configured persistent capacity should admit session");
1105 }
1106
1107 let blocked = SessionService::create_session(
1108 spec.session_service.as_ref(),
1109 deferred_capacity_request("persistent one too many"),
1110 )
1111 .await
1112 .expect_err("configured persistent capacity should block the next session");
1113 assert!(
1114 blocked.to_string().contains("Max sessions reached (2/2)"),
1115 "unexpected capacity error: {blocked}",
1116 );
1117 }
1118
1119 #[tokio::test]
1120 async fn definition_based_spec_rejects_zero_max_sessions() {
1121 let definition = meerkat_mob::MobDefinition::from_toml(
1122 r#"
1123[mob]
1124id = "builder-zero-max-sessions"
1125
1126[profiles.worker]
1127model = "gpt-5.5"
1128"#,
1129 )
1130 .expect("definition parses");
1131
1132 let result = UnifiedRuntimeBuilder::default()
1133 .definition(definition)
1134 .max_sessions(0)
1135 .resolve_mob_spec()
1136 .await;
1137 assert!(result.is_err(), "zero max sessions should be rejected");
1138 let err = result.err().expect("zero max sessions error");
1139
1140 assert!(
1141 err.to_string().contains("max_sessions"),
1142 "unexpected error: {err}",
1143 );
1144 }
1145
1146 #[test]
1147 fn builder_accepts_custom_console_log_store_for_ephemeral_mob_state() {
1148 let store: Arc<dyn ConsoleLogStore> = Arc::new(InMemoryConsoleLogStore::new());
1149 let builder = UnifiedRuntimeBuilder::default().with_console_log_store(store.clone());
1150
1151 assert!(
1152 Arc::ptr_eq(
1153 builder.console_log_store.as_ref().expect("custom store"),
1154 &store
1155 ),
1156 "builder should retain the exact console log store supplied by the app"
1157 );
1158 }
1159}