Skip to main content

meerkat_mobkit/unified_runtime/
builder.rs

1//! Builder for constructing a configured UnifiedRuntime instance.
2
3use 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
35/// How the mob definition is supplied to the builder.
36pub(crate) enum DefinitionSource {
37    Inline(Box<MobDefinition>),
38    TomlPath(PathBuf),
39}
40
41/// Default max concurrent sessions for builder-created session services.
42const DEFAULT_MAX_SESSIONS: usize = 64;
43
44/// Default builder timeout.
45const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
46
47/// Controls how identity-first durable agents are materialized during
48/// `UnifiedRuntime::build()`.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub enum IdentityBootstrapMode {
51    /// Compatibility mode: `build()` synchronously creates/resumes every
52    /// identity in the roster.
53    #[default]
54    EagerMaterialize,
55    /// Register roster/topology/continuity metadata only; create/resume a
56    /// concrete member on first send/dispatch/explicit materialize.
57    LazyMaterialize,
58    /// Return from `build()` after lazy metadata registration and warm
59    /// identities in a background task with bounded concurrency.
60    LazyWithBackgroundWarm { concurrency: usize },
61}
62
63#[derive(Default)]
64pub struct UnifiedRuntimeBuilder {
65    // --- Legacy path (mob_spec directly) ---
66    mob_spec: Option<MobBootstrapSpec>,
67
68    // --- New convenience path ---
69    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    // --- Identity-first external path ---
79    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    // --- Common fields ---
91    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    // -----------------------------------------------------------------------
110    // New convenience API
111    // -----------------------------------------------------------------------
112
113    /// Set the mob definition from an inline `MobDefinition`.
114    pub fn definition(mut self, def: MobDefinition) -> Self {
115        self.definition_source = Some(DefinitionSource::Inline(Box::new(def)));
116        self
117    }
118
119    /// Set the mob definition from a TOML file path.
120    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    /// Enable persistent state at the given path. When set, the builder
126    /// creates a `SqliteSessionStore`, runtime store, metadata store, console
127    /// log store, and binary blob store under this directory. Mob storage stays
128    /// in-memory. When not set, the builder uses an ephemeral session service
129    /// with an auto-created temp directory.
130    pub fn persistent_state(mut self, path: impl Into<PathBuf>) -> Self {
131        self.persistent_state_path = Some(path.into());
132        self
133    }
134
135    /// Set a session lifecycle hook.
136    pub fn session_hook(mut self, hook: Arc<dyn SessionHook>) -> Self {
137        self.session_hook = Some(hook);
138        self
139    }
140
141    /// Set a custom session store. When set, the builder uses this store
142    /// instead of creating a default one. Works with both `.persistent_state()`
143    /// (overrides the auto-created SQLite store) and ephemeral builds
144    /// (provides durable sessions without local mob storage).
145    pub fn session_store(mut self, store: Arc<dyn meerkat::SessionStore>) -> Self {
146        self.custom_session_store = Some(store);
147        self
148    }
149
150    /// Set the Meerkat agent factory configuration used by builder-created
151    /// session services.
152    ///
153    /// Applications with long-lived durable coordinator agents can use this to
154    /// tune session compaction and other factory-level Meerkat behavior without
155    /// constructing a full `MobBootstrapSpec` by hand.
156    pub fn meerkat_config(mut self, config: meerkat::Config) -> Self {
157        self.meerkat_config = Some(config);
158        self
159    }
160
161    /// Set the default LLM client (used for test stubs).
162    pub fn default_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
163        self.default_llm_client = Some(client);
164        self
165    }
166
167    /// Set the maximum number of active sessions for builder-created session
168    /// services.
169    ///
170    /// This only applies to the definition-based path. A legacy `.mob_spec()`
171    /// supplies its own already-built session service and capacity.
172    pub fn max_sessions(mut self, max_sessions: usize) -> Self {
173        self.max_sessions = Some(max_sessions);
174        self
175    }
176
177    /// Set an external `ContinuityStore` for the identity-first path.
178    ///
179    /// Mutually exclusive with `persistent_state()`.
180    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    /// Set an external `LeaseProvider` for the identity-first path.
189    ///
190    /// Mutually exclusive with `persistent_state()`.
191    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    /// Set the desired identity roster provider for identity-first bootstrap.
200    pub fn roster_provider(mut self, provider: Arc<dyn RosterProvider>) -> Self {
201        self.roster_provider = Some(provider);
202        self
203    }
204
205    /// Set the managed topology provider for identity-first bootstrap and refresh.
206    pub fn topology_provider(mut self, provider: Arc<dyn TopologyProvider>) -> Self {
207        self.topology_provider = Some(provider);
208        self
209    }
210
211    /// Set the identity-first build customizer.
212    pub fn agent_customizer(mut self, customizer: Arc<dyn AgentCustomizer>) -> Self {
213        self.agent_customizer = Some(customizer);
214        self
215    }
216
217    /// Set how identity-first durable agents are materialized during build.
218    pub fn identity_bootstrap_mode(mut self, mode: IdentityBootstrapMode) -> Self {
219        self.identity_bootstrap_mode = mode;
220        self
221    }
222
223    /// Set the identity runtime instance id used when acquiring leases.
224    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    /// Set a scratch directory for the external-authoritative path.
230    ///
231    /// Required when using `continuity_store()` + `lease_provider()`.
232    pub fn scratch_dir(mut self, path: impl Into<PathBuf>) -> Self {
233        self.scratch_dir = Some(path.into());
234        self
235    }
236
237    /// Set an optional blob store for custom blob persistence.
238    ///
239    /// The same store is used for runtime image blobs and for MobKit's
240    /// `/blobs/{id}` and `mobkit/blob/*` serving/upload paths.
241    pub fn blob_store(mut self, store: Arc<dyn meerkat_core::BlobStore>) -> Self {
242        self.blob_store = Some(store);
243        self
244    }
245
246    /// Set a custom console log store.
247    ///
248    /// This lets applications pair a durable console timeline (for fast
249    /// cursor-based history replay) with otherwise ephemeral mob state.
250    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    /// Enable or disable builtin tools (default: true).
256    pub fn builtins(mut self, enabled: bool) -> Self {
257        self.capability_flags.builtins = enabled;
258        self
259    }
260
261    /// Enable or disable shell tool (default: true).
262    pub fn shell(mut self, enabled: bool) -> Self {
263        self.capability_flags.shell = enabled;
264        self
265    }
266
267    /// Enable or disable mob tools (default: true).
268    pub fn mob(mut self, enabled: bool) -> Self {
269        self.capability_flags.mob = enabled;
270        self
271    }
272
273    /// Enable or disable comms (default: true).
274    pub fn comms(mut self, enabled: bool) -> Self {
275        self.capability_flags.comms = enabled;
276        self
277    }
278
279    /// Enable or disable memory tools (default: true).
280    pub fn memory(mut self, enabled: bool) -> Self {
281        self.capability_flags.memory = enabled;
282        self
283    }
284
285    /// Force image-generation runtime substrate wiring.
286    ///
287    /// Definition-based builders also infer this from
288    /// `profiles.<name>.tools.image_generation`; Meerkat owns the per-profile
289    /// visibility decision.
290    pub fn image_generation(mut self, enabled: bool) -> Self {
291        self.capability_flags.image_generation = enabled;
292        self
293    }
294
295    // -----------------------------------------------------------------------
296    // Legacy API (preserved for backward compat)
297    // -----------------------------------------------------------------------
298
299    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    /// Set the contact directory for cross-mob address resolution.
365    pub fn contact_directory(mut self, directory: ContactDirectory) -> Self {
366        self.contact_directory = Some(directory);
367        self
368    }
369
370    /// Install a persistent metadata store. Used for the structural-events
371    /// subscription cursor — see `runtime::metadata::PersistentMetadataStore`.
372    /// When unset, the builder defaults to an `InMemoryMetadataStore`, which
373    /// is correct for in-memory mob deployments. Production gateways with a
374    /// SQLite mob storage should pass `SqliteMetadataStore::open(path)`
375    /// against the same database the mob uses, so the structural-events
376    /// subscription can resume from the last-projected cursor on restart
377    /// rather than jumping forward to "latest" and dropping events emitted
378    /// between processes.
379    pub fn persistent_metadata(mut self, store: Arc<dyn PersistentMetadataStore>) -> Self {
380        self.persistent_metadata = Some(store);
381        self
382    }
383
384    /// Install a pre-built access controller (ABAC enforcement for the
385    /// console and SSE surfaces). Absent — the default — access control is
386    /// off and every surface behaves exactly as before.
387    pub fn access_controller(mut self, controller: crate::access::AccessController) -> Self {
388        self.access_controller = Some(controller);
389        self
390    }
391
392    /// Enable access control backed by a TOML file (conventionally
393    /// `config/access.toml`). A missing file starts disabled; admin edits
394    /// from the console persist back to the same path.
395    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    // -----------------------------------------------------------------------
404    // Build
405    // -----------------------------------------------------------------------
406
407    pub async fn build(mut self) -> Result<UnifiedRuntime, UnifiedRuntimeBuilderError> {
408        // --- Identity-first builder validation ---
409
410        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        // REQ-23: persistent_state and explicit providers are mutually exclusive
427        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        // REQ-24: external path requires all three
436        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        // Legacy mob_spec path takes precedence — must be consumed before
473        // resolve_mob_spec (which borrows &self for the definition path).
474        let mob_spec = match self.mob_spec.take() {
475            Some(spec) => {
476                // Legacy path: require module_config and timeout as before.
477                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        // The structural-events subscription cursor lives in the
511        // persistent metadata adapter. For ephemeral builds this can be
512        // in-memory (the ledger itself isn't durable, so there's nothing
513        // to resume from). For persistent_state builds we MUST default
514        // to SQLite — otherwise after a gateway restart the subscriber
515        // resumes from `latest_cursor` and silently skips every event
516        // that was written to the durable mob ledger while MobKit was
517        // down. Callers can still override via `.persistent_metadata()`.
518        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        // Construct session bridge from the mob handle for identity-first wiring.
559        // Available for BOTH persistent_state and external-authoritative paths —
560        // the bridge connects the identity-first control plane to real sessions.
561        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        // Set immutable outer fields by rebuilding the struct
748        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        // Run initial edge reconciliation after spawn completes
787        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        // Start event log ingestion if configured
793        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    /// Resolve the mob spec from the definition-based path.
802    /// Called only when `mob_spec` is not set (legacy path handled in `build()`).
803    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        // Note: blocking I/O (fs, SQLite) — acceptable at startup.
863        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            // Ephemeral: create a temp dir that lives as long as the runtime.
920            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}