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, 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
37/// How the mob definition is supplied to the builder.
38pub(crate) enum DefinitionSource {
39    Inline(Box<MobDefinition>),
40    TomlPath(PathBuf),
41}
42
43/// Default max concurrent sessions for builder-created session services.
44const DEFAULT_MAX_SESSIONS: usize = 64;
45
46/// Default builder timeout.
47const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
48
49/// Controls how identity-first durable agents are materialized during
50/// `UnifiedRuntime::build()`.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub enum IdentityBootstrapMode {
53    /// Compatibility mode: `build()` synchronously creates/resumes every
54    /// identity in the roster.
55    #[default]
56    EagerMaterialize,
57    /// Register roster/topology/continuity metadata only; create/resume a
58    /// concrete member on first send/dispatch/explicit materialize.
59    LazyMaterialize,
60    /// Return from `build()` after lazy metadata registration and warm
61    /// identities in a background task with bounded concurrency.
62    LazyWithBackgroundWarm { concurrency: usize },
63}
64
65#[derive(Default)]
66pub struct UnifiedRuntimeBuilder {
67    // --- Legacy path (mob_spec directly) ---
68    mob_spec: Option<MobBootstrapSpec>,
69
70    // --- New convenience path ---
71    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    // --- Identity-first external path ---
81    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    agent_memory_engines: Option<crate::memory_wiring::MemoryEnginesConfig>,
90    identity_bootstrap_mode: IdentityBootstrapMode,
91    identity_runtime_instance_id: Option<String>,
92    scratch_dir: Option<PathBuf>,
93    blob_store: Option<Arc<dyn meerkat_core::BlobStore>>,
94    console_log_store: Option<Arc<dyn ConsoleLogStore>>,
95
96    // --- Common fields ---
97    module_config: Option<MobKitConfig>,
98    module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
99    timeout: Option<Duration>,
100    options: RuntimeOptions,
101    post_spawn_hook: Option<PostSpawnHook>,
102    post_reconcile_hook: Option<PostReconcileHook>,
103    error_hook: Option<ErrorHook>,
104    event_log_config: Option<EventLogConfig>,
105    drain_timeout: Option<Duration>,
106    discovery: Option<Box<dyn Discovery>>,
107    pre_spawn_hook: Option<PreSpawnHook>,
108    edge_discovery: Option<Box<dyn EdgeDiscovery>>,
109    contact_directory: Option<ContactDirectory>,
110    persistent_metadata: Option<Arc<dyn PersistentMetadataStore>>,
111    access_controller: Option<crate::access::AccessController>,
112    topology_control_policy: crate::topology_control::TopologyControlPolicy,
113}
114
115impl UnifiedRuntimeBuilder {
116    // -----------------------------------------------------------------------
117    // New convenience API
118    // -----------------------------------------------------------------------
119
120    /// Set the mob definition from an inline `MobDefinition`.
121    pub fn definition(mut self, def: MobDefinition) -> Self {
122        self.definition_source = Some(DefinitionSource::Inline(Box::new(def)));
123        self
124    }
125
126    /// Set the mob definition from a TOML file path.
127    pub fn definition_path(mut self, path: impl Into<PathBuf>) -> Self {
128        self.definition_source = Some(DefinitionSource::TomlPath(path.into()));
129        self
130    }
131
132    /// Enable persistent state at the given path. When set, the builder
133    /// creates a `SqliteSessionStore`, runtime store, metadata store, console
134    /// log store, and binary blob store under this directory. Mob storage stays
135    /// in-memory. When not set, the builder uses an ephemeral session service
136    /// with an auto-created temp directory.
137    pub fn persistent_state(mut self, path: impl Into<PathBuf>) -> Self {
138        self.persistent_state_path = Some(path.into());
139        self
140    }
141
142    /// Set a session lifecycle hook.
143    pub fn session_hook(mut self, hook: Arc<dyn SessionHook>) -> Self {
144        self.session_hook = Some(hook);
145        self
146    }
147
148    /// Set a custom session store. When set, the builder uses this store
149    /// instead of creating a default one. Works with both `.persistent_state()`
150    /// (overrides the auto-created SQLite store) and ephemeral builds
151    /// (provides durable sessions without local mob storage).
152    pub fn session_store(mut self, store: Arc<dyn meerkat::SessionStore>) -> Self {
153        self.custom_session_store = Some(store);
154        self
155    }
156
157    /// Set the Meerkat agent factory configuration used by builder-created
158    /// session services.
159    ///
160    /// Applications with long-lived durable coordinator agents can use this to
161    /// tune session compaction and other factory-level Meerkat behavior without
162    /// constructing a full `MobBootstrapSpec` by hand.
163    pub fn meerkat_config(mut self, config: meerkat::Config) -> Self {
164        self.meerkat_config = Some(config);
165        self
166    }
167
168    /// Set the default LLM client (used for test stubs).
169    pub fn default_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
170        self.default_llm_client = Some(client);
171        self
172    }
173
174    /// Set the maximum number of active sessions for builder-created session
175    /// services.
176    ///
177    /// This only applies to the definition-based path. A legacy `.mob_spec()`
178    /// supplies its own already-built session service and capacity.
179    pub fn max_sessions(mut self, max_sessions: usize) -> Self {
180        self.max_sessions = Some(max_sessions);
181        self
182    }
183
184    /// Set an external `ContinuityStore` for the identity-first path.
185    ///
186    /// Mutually exclusive with `persistent_state()`.
187    pub fn continuity_store(
188        mut self,
189        store: Arc<dyn crate::identity_first::contracts::ContinuityStore>,
190    ) -> Self {
191        self.continuity_store = Some(store);
192        self
193    }
194
195    /// Set an external `LeaseProvider` for the identity-first path.
196    ///
197    /// Mutually exclusive with `persistent_state()`.
198    pub fn lease_provider(
199        mut self,
200        provider: Arc<dyn crate::identity_first::contracts::LeaseProvider>,
201    ) -> Self {
202        self.lease_provider = Some(provider);
203        self
204    }
205
206    /// Set the desired identity roster provider for identity-first bootstrap.
207    pub fn roster_provider(mut self, provider: Arc<dyn RosterProvider>) -> Self {
208        self.roster_provider = Some(provider);
209        self
210    }
211
212    /// Set the managed topology provider for identity-first bootstrap and refresh.
213    pub fn topology_provider(mut self, provider: Arc<dyn TopologyProvider>) -> Self {
214        self.topology_provider = Some(provider);
215        self
216    }
217
218    /// Set the identity-first build customizer.
219    pub fn agent_customizer(mut self, customizer: Arc<dyn AgentCustomizer>) -> Self {
220        self.agent_customizer = Some(customizer);
221        self
222    }
223
224    /// Enable identity-first agent memory injection using the provided memory provider.
225    pub fn agent_memory(
226        mut self,
227        provider: Arc<dyn AgentMemoryProvider>,
228        config: AgentMemoryConfig,
229    ) -> Self {
230        self.agent_memory_provider = Some(provider);
231        self.agent_memory_config = Some(config);
232        self
233    }
234
235    /// Enable identity-first agent memory using the bundled markdown store
236    /// under `persistent_state()/agent-memory`.
237    pub fn persistent_agent_memory(mut self, config: AgentMemoryConfig) -> Self {
238        self.agent_memory_from_persistent_state = true;
239        self.agent_memory_config = Some(config);
240        self
241    }
242
243    /// Enable the FULL agent-memory stack (bundled SQLite store + the taint
244    /// firewall + the enabled judgment-plane engines) — the same stack the
245    /// rpc gateway assembles, reachable from the Rust builder (the OB3
246    /// deployment shape). Requires `persistent_state()`; the store lives at
247    /// `<persistent_state>/agent-memory-sqlite`.
248    ///
249    /// v1 boundaries (documented in `memory_wiring`): the Hygienist stays
250    /// gateway-only; engines are driven by the member-event observe stream
251    /// (their primary trigger path — the gateway's additional injector-side
252    /// rotation hooks are not wired here yet); the steward dream runs on the
253    /// in-process loop (no schedule host in library mode).
254    pub fn persistent_agent_memory_stack(
255        mut self,
256        config: AgentMemoryConfig,
257        engines: crate::memory_wiring::MemoryEnginesConfig,
258    ) -> Self {
259        self.agent_memory_config = Some(config);
260        self.agent_memory_engines = Some(engines);
261        self
262    }
263
264    fn composed_agent_customizer(
265        &self,
266        memory_provider: Option<Arc<dyn AgentMemoryProvider>>,
267    ) -> Option<Arc<dyn AgentCustomizer>> {
268        match memory_provider {
269            Some(provider) => Some(Arc::new(AgentMemoryCustomizer::wrap(
270                self.agent_customizer.clone(),
271                provider,
272                self.agent_memory_config.clone().unwrap_or_default(),
273            ))),
274            None => self.agent_customizer.clone(),
275        }
276    }
277
278    /// Set how identity-first durable agents are materialized during build.
279    pub fn identity_bootstrap_mode(mut self, mode: IdentityBootstrapMode) -> Self {
280        self.identity_bootstrap_mode = mode;
281        self
282    }
283
284    /// Set the identity runtime instance id used when acquiring leases.
285    pub fn identity_runtime_instance_id(mut self, id: impl Into<String>) -> Self {
286        self.identity_runtime_instance_id = Some(id.into());
287        self
288    }
289
290    /// Set a scratch directory for the external-authoritative path.
291    ///
292    /// Required when using `continuity_store()` + `lease_provider()`.
293    pub fn scratch_dir(mut self, path: impl Into<PathBuf>) -> Self {
294        self.scratch_dir = Some(path.into());
295        self
296    }
297
298    /// Set an optional blob store for custom blob persistence.
299    ///
300    /// The same store is used for runtime image blobs and for MobKit's
301    /// `/blobs/{id}` and `mobkit/blob/*` serving/upload paths.
302    pub fn blob_store(mut self, store: Arc<dyn meerkat_core::BlobStore>) -> Self {
303        self.blob_store = Some(store);
304        self
305    }
306
307    /// Set a custom console log store.
308    ///
309    /// This lets applications pair a durable console timeline (for fast
310    /// cursor-based history replay) with otherwise ephemeral mob state.
311    pub fn with_console_log_store(mut self, store: Arc<dyn ConsoleLogStore>) -> Self {
312        self.console_log_store = Some(store);
313        self
314    }
315
316    /// Enable or disable builtin tools (default: true).
317    pub fn builtins(mut self, enabled: bool) -> Self {
318        self.capability_flags.builtins = enabled;
319        self
320    }
321
322    /// Enable or disable shell tool (default: true).
323    pub fn shell(mut self, enabled: bool) -> Self {
324        self.capability_flags.shell = enabled;
325        self
326    }
327
328    /// Enable or disable mob tools (default: true).
329    pub fn mob(mut self, enabled: bool) -> Self {
330        self.capability_flags.mob = enabled;
331        self
332    }
333
334    /// Enable or disable comms (default: true).
335    pub fn comms(mut self, enabled: bool) -> Self {
336        self.capability_flags.comms = enabled;
337        self
338    }
339
340    /// Enable or disable memory tools (default: true).
341    pub fn memory(mut self, enabled: bool) -> Self {
342        self.capability_flags.memory = enabled;
343        self
344    }
345
346    /// Force image-generation runtime substrate wiring.
347    ///
348    /// Definition-based builders also infer this from
349    /// `profiles.<name>.tools.image_generation`; Meerkat owns the per-profile
350    /// visibility decision.
351    pub fn image_generation(mut self, enabled: bool) -> Self {
352        self.capability_flags.image_generation = enabled;
353        self
354    }
355
356    // -----------------------------------------------------------------------
357    // Legacy API (preserved for backward compat)
358    // -----------------------------------------------------------------------
359
360    pub fn mob_spec(mut self, spec: MobBootstrapSpec) -> Self {
361        self.mob_spec = Some(spec);
362        self
363    }
364
365    pub fn module_config(mut self, config: MobKitConfig) -> Self {
366        self.module_config = Some(config);
367        self
368    }
369
370    pub fn module_agent_events(mut self, events: Vec<EventEnvelope<UnifiedEvent>>) -> Self {
371        self.module_agent_events = events;
372        self
373    }
374
375    pub fn timeout(mut self, timeout: Duration) -> Self {
376        self.timeout = Some(timeout);
377        self
378    }
379
380    pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
381        self.options = options;
382        self
383    }
384
385    pub fn post_spawn_hook(mut self, hook: PostSpawnHook) -> Self {
386        self.post_spawn_hook = Some(hook);
387        self
388    }
389
390    pub fn post_reconcile_hook(mut self, hook: PostReconcileHook) -> Self {
391        self.post_reconcile_hook = Some(hook);
392        self
393    }
394
395    pub fn on_error(mut self, hook: ErrorHook) -> Self {
396        self.error_hook = Some(hook);
397        self
398    }
399
400    pub fn event_log(mut self, config: EventLogConfig) -> Self {
401        self.event_log_config = Some(config);
402        self
403    }
404
405    pub fn drain_timeout(mut self, timeout: Duration) -> Self {
406        self.drain_timeout = Some(timeout);
407        self
408    }
409
410    pub fn discovery(mut self, discovery: impl Discovery + 'static) -> Self {
411        self.discovery = Some(Box::new(discovery));
412        self
413    }
414
415    pub fn pre_spawn_hook(mut self, hook: PreSpawnHook) -> Self {
416        self.pre_spawn_hook = Some(hook);
417        self
418    }
419
420    pub fn edge_discovery(mut self, edge_discovery: impl EdgeDiscovery + 'static) -> Self {
421        self.edge_discovery = Some(Box::new(edge_discovery));
422        self
423    }
424
425    /// Set the contact directory for cross-mob address resolution.
426    pub fn contact_directory(mut self, directory: ContactDirectory) -> Self {
427        self.contact_directory = Some(directory);
428        self
429    }
430
431    /// Install a persistent metadata store. Used for the structural-events
432    /// subscription cursor — see `runtime::metadata::PersistentMetadataStore`.
433    /// When unset, the builder defaults to an `InMemoryMetadataStore`, which
434    /// is correct for in-memory mob deployments. Production gateways with a
435    /// SQLite mob storage should pass `SqliteMetadataStore::open(path)`
436    /// against the same database the mob uses, so the structural-events
437    /// subscription can resume from the last-projected cursor on restart
438    /// rather than jumping forward to "latest" and dropping events emitted
439    /// between processes.
440    pub fn persistent_metadata(mut self, store: Arc<dyn PersistentMetadataStore>) -> Self {
441        self.persistent_metadata = Some(store);
442        self
443    }
444
445    /// Install a pre-built access controller (ABAC enforcement for the
446    /// console and SSE surfaces). Absent — the default — access control is
447    /// off and every surface behaves exactly as before.
448    pub fn access_controller(mut self, controller: crate::access::AccessController) -> Self {
449        self.access_controller = Some(controller);
450        self
451    }
452
453    /// Enable access control backed by a TOML file (conventionally
454    /// `config/access.toml`). A missing file starts disabled; admin edits
455    /// from the console persist back to the same path.
456    pub fn access_control_file(
457        mut self,
458        path: impl Into<PathBuf>,
459    ) -> Result<Self, crate::access::AccessConfigError> {
460        self.access_controller = Some(crate::access::AccessController::load_or_default(path)?);
461        Ok(self)
462    }
463
464    /// Configure the optional topology control plane. The default policy is
465    /// disabled, single-operation, local-authority only.
466    pub fn topology_control(
467        mut self,
468        policy: crate::topology_control::TopologyControlPolicy,
469    ) -> Result<Self, crate::topology_control::TopologyControlError> {
470        policy.validate()?;
471        self.topology_control_policy = policy;
472        Ok(self)
473    }
474
475    // -----------------------------------------------------------------------
476    // Build
477    // -----------------------------------------------------------------------
478
479    pub async fn build(mut self) -> Result<UnifiedRuntime, UnifiedRuntimeBuilderError> {
480        // --- Identity-first builder validation ---
481
482        let has_persistent_state = self.persistent_state_path.is_some();
483        let has_continuity_store = self.continuity_store.is_some();
484        let has_lease_provider = self.lease_provider.is_some();
485        let has_roster_provider = self.roster_provider.is_some();
486        let has_topology_provider = self.topology_provider.is_some();
487        // A2 decouple: agent memory is keyed by AgentIdentity, which every
488        // mob member already has, so enabling it must NOT pull in the
489        // identity-first orchestration layer (roster/continuity/leases).
490        // Without a roster the BASIC memory surface (recorder tool +
491        // build-time injection + panel store) rides the classic path via a
492        // `MemorySpawnCustomizer`; with a roster, memory composes into the
493        // IdentityRuntime customizer exactly as before (advanced lifecycle
494        // features included).
495        let has_agent_customizer = self.agent_customizer.is_some();
496        let has_identity_runtime_instance_id = self.identity_runtime_instance_id.is_some();
497        let has_scratch_dir = self.scratch_dir.is_some();
498        let has_external_identity_storage =
499            has_continuity_store || has_lease_provider || has_scratch_dir;
500        let wants_identity_first = has_external_identity_storage
501            || has_roster_provider
502            || has_topology_provider
503            || has_agent_customizer
504            || has_identity_runtime_instance_id;
505        if self.agent_memory_provider.is_some() && self.agent_memory_from_persistent_state {
506            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
507                "agent_memory() and persistent_agent_memory() are mutually exclusive".to_string(),
508            ));
509        }
510
511        if self.agent_memory_from_persistent_state && !has_persistent_state {
512            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
513                "persistent_agent_memory() requires persistent_state()".to_string(),
514            ));
515        }
516
517        // REQ-23: persistent_state and explicit continuity/lease/scratch
518        // providers are mutually exclusive. Roster/topology/customizers are
519        // identity inputs and can use the bundled persistent identity store.
520        if has_persistent_state && has_external_identity_storage {
521            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
522                "persistent_state() and identity-first continuity_store()/lease_provider()/scratch_dir() setters \
523                 are mutually exclusive — use one storage authority"
524                    .to_string(),
525            ));
526        }
527
528        if has_persistent_state && wants_identity_first && !has_roster_provider {
529            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
530                "persistent_state() identity-first path requires roster_provider()".to_string(),
531            ));
532        }
533
534        // REQ-24: external path requires all three storage inputs plus a roster.
535        if !has_persistent_state
536            && wants_identity_first
537            && !(has_continuity_store
538                && has_lease_provider
539                && has_roster_provider
540                && has_scratch_dir)
541        {
542            let mut missing = Vec::new();
543            if !has_continuity_store {
544                missing.push("continuity_store");
545            }
546            if !has_lease_provider {
547                missing.push("lease_provider");
548            }
549            if !has_roster_provider {
550                missing.push("roster_provider");
551            }
552            if !has_scratch_dir {
553                missing.push("scratch_dir");
554            }
555            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
556                format!(
557                    "identity-first path requires continuity_store() + lease_provider() + \
558                     roster_provider() + scratch_dir(); missing: {}",
559                    missing.join(", ")
560                ),
561            ));
562        }
563
564        let continuity_session_store = self
565            .continuity_store
566            .as_ref()
567            .map(|store| Arc::new(ContinuitySessionStoreAdapter::new(store.clone())));
568        if let Some(store) = continuity_session_store.as_ref() {
569            self.custom_session_store = Some(store.clone());
570        }
571
572        // Legacy mob_spec path takes precedence — must be consumed before
573        // resolve_mob_spec (which borrows &self for the definition path).
574        let mut mob_spec = match self.mob_spec.take() {
575            Some(spec) => {
576                // Legacy path: require module_config and timeout as before.
577                if self.module_config.is_none() {
578                    return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
579                        UnifiedRuntimeBuilderField::ModuleConfig,
580                    ));
581                }
582                if self.timeout.is_none() {
583                    return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
584                        UnifiedRuntimeBuilderField::Timeout,
585                    ));
586                }
587                spec
588            }
589            None => self.resolve_mob_spec().await?,
590        };
591
592        let module_config = self.module_config.take().unwrap_or_else(|| MobKitConfig {
593            modules: Vec::new(),
594            discovery: crate::types::DiscoverySpec {
595                namespace: String::new(),
596                modules: Vec::new(),
597            },
598            pre_spawn: Vec::new(),
599        });
600        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
601        if let Some(state_path) = self.persistent_state_path.as_ref() {
602            std::fs::create_dir_all(state_path).map_err(|e| {
603                UnifiedRuntimeBuilderError::Io(format!(
604                    "failed to create state directory at {}: {e}",
605                    state_path.display()
606                ))
607            })?;
608        }
609        let persistent_agent_memory_provider: Option<Arc<dyn AgentMemoryProvider>> =
610            if self.agent_memory_from_persistent_state {
611                let Some(state_path) = self.persistent_state_path.as_ref() else {
612                    return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
613                        "persistent_agent_memory() requires persistent_state()".to_string(),
614                    ));
615                };
616                let memory_path = state_path.join("agent-memory");
617                Some(Arc::new(
618                    MarkdownAgentMemoryStore::open(&memory_path).map_err(|e| {
619                        UnifiedRuntimeBuilderError::Io(format!(
620                            "failed to open agent memory store at {}: {e}",
621                            memory_path.display()
622                        ))
623                    })?,
624                ))
625            } else {
626                None
627            };
628        // Full-stack path: the bundled SQLite store is opened pre-runtime so
629        // it can serve as the provider (recorder + recall) from the first
630        // spawn; the firewall + engines attach post-construction, when the
631        // memory event sink and mob handle exist.
632        let stack_sqlite_store = if self.agent_memory_engines.is_some() {
633            let Some(state_path) = self.persistent_state_path.as_ref() else {
634                return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
635                    "persistent_agent_memory_stack() requires persistent_state()".to_string(),
636                ));
637            };
638            let memory_path = state_path.join("agent-memory-sqlite");
639            Some(
640                crate::memory::sqlite_store::SqliteAgentMemoryStore::open(&memory_path).map_err(
641                    |e| {
642                        UnifiedRuntimeBuilderError::Io(format!(
643                            "failed to open agent memory store at {}: {e}",
644                            memory_path.display()
645                        ))
646                    },
647                )?,
648            )
649        } else {
650            None
651        };
652        let agent_memory_provider = self
653            .agent_memory_provider
654            .clone()
655            .or_else(|| {
656                stack_sqlite_store
657                    .clone()
658                    .map(|store| Arc::new(store) as Arc<dyn AgentMemoryProvider>)
659            })
660            .or(persistent_agent_memory_provider);
661        let agent_memory_injector = agent_memory_provider.as_ref().map(|provider| {
662            AgentMemoryRuntimeInjector::new(
663                provider.clone(),
664                self.agent_memory_config.clone().unwrap_or_default(),
665            )
666        });
667        let agent_customizer = self.composed_agent_customizer(agent_memory_provider.clone());
668
669        // Classic (roster-less) agent memory: register the per-spawn memory
670        // customizer on the mob runtime itself, so every member spawn —
671        // consumer, agent-tool, policy, respawn, resume — gets the recorder
672        // tool and the build-time injection keyed on its AgentIdentity. The
673        // identity-first path keeps its AgentCustomizer instead (composing
674        // both would double-inject on identity-first materializations).
675        let classic_agent_memory = if wants_identity_first {
676            None
677        } else {
678            agent_memory_provider.clone()
679        };
680        if let Some(provider) = classic_agent_memory.as_ref() {
681            mob_spec.spawn_member_customizer =
682                Some(Arc::new(crate::memory::MemorySpawnCustomizer::new(
683                    provider.clone(),
684                    self.agent_memory_config.clone().unwrap_or_default(),
685                )));
686        }
687
688        // The structural-events subscription cursor lives in the
689        // persistent metadata adapter. For ephemeral builds this can be
690        // in-memory (the ledger itself isn't durable, so there's nothing
691        // to resume from). For persistent_state builds we MUST default
692        // to SQLite — otherwise after a gateway restart the subscriber
693        // resumes from `latest_cursor` and silently skips every event
694        // that was written to the durable mob ledger while MobKit was
695        // down. Callers can still override via `.persistent_metadata()`.
696        let persistent_metadata: Arc<dyn PersistentMetadataStore> =
697            if let Some(store) = self.persistent_metadata.clone() {
698                store
699            } else if let Some(state_path) = self.persistent_state_path.as_ref() {
700                let metadata_path = state_path.join("mobkit_metadata.sqlite");
701                Arc::new(SqliteMetadataStore::open(&metadata_path).map_err(|e| {
702                    UnifiedRuntimeBuilderError::Io(format!(
703                        "failed to open mobkit_metadata.sqlite at {}: {e}",
704                        metadata_path.display()
705                    ))
706                })?)
707            } else {
708                Arc::new(InMemoryMetadataStore::new())
709            };
710        let console_log_store: Arc<dyn ConsoleLogStore> =
711            if let Some(store) = self.console_log_store.clone() {
712                store
713            } else if let Some(state_path) = self.persistent_state_path.as_ref() {
714                let console_log_path = state_path.join("mobkit_console.sqlite");
715                Arc::new(SqliteConsoleLogStore::open(&console_log_path).map_err(|e| {
716                    UnifiedRuntimeBuilderError::Io(format!(
717                        "failed to open mobkit_console.sqlite at {}: {e}",
718                        console_log_path.display()
719                    ))
720                })?)
721            } else {
722                Arc::new(InMemoryConsoleLogStore::new())
723            };
724        let runtime = Box::pin(UnifiedRuntime::bootstrap_with_options(
725            mob_spec,
726            module_config,
727            self.module_agent_events,
728            timeout,
729            self.options,
730            persistent_metadata,
731        ))
732        .await
733        .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
734
735        // Construct session bridge from the mob handle for identity-first wiring.
736        // Available for BOTH persistent_state and external-authoritative paths —
737        // the bridge connects the identity-first control plane to real sessions.
738        let session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>> = {
739            let handle = runtime.mob_runtime.handle();
740            let session_service = runtime.mob_runtime.session_service().cloned();
741            let session_store = self.custom_session_store.clone();
742            let bridge: Arc<dyn crate::identity_first::bridge::SessionBridge> =
743                if let Some(store) = continuity_session_store.clone() {
744                    Arc::new(
745                    crate::identity_first::bridge::MobSessionBridge::with_continuity_session_store(
746                        handle,
747                        store,
748                        session_service,
749                    ),
750                )
751                } else if let (Some(store), Some(service)) =
752                    (session_store.clone(), session_service.clone())
753                {
754                    Arc::new(
755                    crate::identity_first::bridge::MobSessionBridge::with_session_store_and_service(
756                        handle, store, service,
757                    ),
758                )
759                } else if let Some(store) = session_store {
760                    Arc::new(
761                        crate::identity_first::bridge::MobSessionBridge::with_session_store(
762                            handle, store,
763                        ),
764                    )
765                } else if let Some(service) = session_service {
766                    Arc::new(
767                        crate::identity_first::bridge::MobSessionBridge::with_session_service(
768                            handle, service,
769                        ),
770                    )
771                } else {
772                    Arc::new(crate::identity_first::bridge::MobSessionBridge::new(handle))
773                };
774            Some(bridge)
775        };
776
777        // Construct the durable control-plane authority before identity
778        // restore. TopologyProvider output is only a declaration; the
779        // IdentityRuntime composes this controller's additions/suppressions
780        // under the same admission lock before it touches peer wiring.
781        let topology_controller = if let Some(state_path) = self.persistent_state_path.as_ref() {
782            crate::topology_control::TopologyController::load_or_default(
783                self.topology_control_policy.clone(),
784                state_path.join("topology-control.json"),
785            )
786            .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?
787        } else {
788            crate::topology_control::TopologyController::new(self.topology_control_policy.clone())
789                .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?
790        };
791        topology_controller
792            .bind_authority(runtime.mob_id())
793            .await
794            .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?;
795
796        let identity_first_context = if wants_identity_first {
797            let (continuity_store, lease_provider): (
798                Arc<dyn crate::identity_first::contracts::ContinuityStore>,
799                Arc<dyn crate::identity_first::contracts::LeaseProvider>,
800            ) = if let Some(state_path) = self.persistent_state_path.as_ref() {
801                let continuity_path = state_path.join("identity_continuity.sqlite");
802                let local_store = LocalContinuityStore::open(&continuity_path).map_err(|e| {
803                    UnifiedRuntimeBuilderError::Io(format!(
804                        "failed to open identity_continuity.sqlite at {}: {e}",
805                        continuity_path.display()
806                    ))
807                })?;
808                let high_water = local_store.max_fencing_token().map_err(|e| {
809                    UnifiedRuntimeBuilderError::Io(format!(
810                        "failed to read identity continuity fencing high-water at {}: {e}",
811                        continuity_path.display()
812                    ))
813                })?;
814                (
815                    Arc::new(local_store),
816                    Arc::new(LocalLeaseProvider::with_floor(high_water)),
817                )
818            } else {
819                let Some(continuity_store) = self.continuity_store.clone() else {
820                    return Err(UnifiedRuntimeBuilderError::Bootstrap(
821                        UnifiedRuntimeBootstrapError::IdentityFirst(
822                            "identity-first validation requires continuity_store".to_string(),
823                        ),
824                    ));
825                };
826                let Some(lease_provider) = self.lease_provider.clone() else {
827                    return Err(UnifiedRuntimeBuilderError::Bootstrap(
828                        UnifiedRuntimeBootstrapError::IdentityFirst(
829                            "identity-first validation requires lease_provider".to_string(),
830                        ),
831                    ));
832                };
833                (continuity_store, lease_provider)
834            };
835            let Some(roster_provider) = self.roster_provider.clone() else {
836                return Err(UnifiedRuntimeBuilderError::Bootstrap(
837                    UnifiedRuntimeBootstrapError::IdentityFirst(
838                        "identity-first validation requires roster_provider".to_string(),
839                    ),
840                ));
841            };
842            let bridge = session_bridge.clone();
843            let identity_runtime = Arc::new(
844                IdentityRuntime::new(IdentityRuntimeConfig {
845                    continuity_store,
846                    lease_provider,
847                    runtime_instance_id: self
848                        .identity_runtime_instance_id
849                        .clone()
850                        .unwrap_or_else(|| format!("mobkit-{}", std::process::id())),
851                    has_runtime_store: true,
852                    durability_policy: DurabilityPolicy::SyncWriteThrough,
853                    bridge,
854                    default_timeout: None,
855                })
856                .with_runtime_services(AgentRuntimeServices::new(runtime.mob_runtime.handle()))
857                .with_reset_roster_provider_context(
858                    roster_provider.clone(),
859                    Some(runtime.mob_runtime.handle().definition().clone()),
860                ),
861            );
862            identity_runtime
863                .set_agent_customizer(agent_customizer.clone())
864                .await;
865            identity_runtime
866                .set_agent_memory(agent_memory_injector.clone())
867                .await;
868            identity_runtime.set_error_hook(self.error_hook.clone());
869            identity_runtime.set_topology_controller(topology_controller.clone());
870
871            let roster_specs = roster_provider
872                .roster(&RosterContext {
873                    mob_definition: Some(runtime.mob_runtime.handle().definition().clone()),
874                    previous_identities: Vec::new(),
875                })
876                .await
877                .map_err(|err| {
878                    UnifiedRuntimeBuilderError::Bootstrap(
879                        UnifiedRuntimeBootstrapError::IdentityFirst(format!(
880                            "roster provider failed: {err}"
881                        )),
882                    )
883                })?;
884
885            match self.identity_bootstrap_mode.clone() {
886                IdentityBootstrapMode::EagerMaterialize => {
887                    restore_flow(
888                        &identity_runtime,
889                        &roster_specs,
890                        self.topology_provider.as_deref(),
891                        agent_customizer.as_deref(),
892                    )
893                    .await
894                    .map_err(|err| {
895                        UnifiedRuntimeBuilderError::Bootstrap(
896                            UnifiedRuntimeBootstrapError::IdentityFirst(format!(
897                                "restore_flow failed: {err}"
898                            )),
899                        )
900                    })?;
901                }
902                IdentityBootstrapMode::LazyMaterialize => {
903                    lazy_register_flow(
904                        &identity_runtime,
905                        &roster_specs,
906                        self.topology_provider.as_deref(),
907                    )
908                    .await
909                    .map_err(|err| {
910                        UnifiedRuntimeBuilderError::Bootstrap(
911                            UnifiedRuntimeBootstrapError::IdentityFirst(format!(
912                                "lazy_register_flow failed: {err}"
913                            )),
914                        )
915                    })?;
916                }
917                IdentityBootstrapMode::LazyWithBackgroundWarm { concurrency } => {
918                    if concurrency == 0 {
919                        return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
920                            "LazyWithBackgroundWarm concurrency must be greater than 0".to_string(),
921                        ));
922                    }
923                    lazy_register_flow(
924                        &identity_runtime,
925                        &roster_specs,
926                        self.topology_provider.as_deref(),
927                    )
928                    .await
929                    .map_err(|err| {
930                        UnifiedRuntimeBuilderError::Bootstrap(
931                            UnifiedRuntimeBootstrapError::IdentityFirst(format!(
932                                "lazy_register_flow failed: {err}"
933                            )),
934                        )
935                    })?;
936                    let warm_runtime = identity_runtime.clone();
937                    let warm_identities = roster_specs
938                        .iter()
939                        .map(|spec| spec.identity.clone())
940                        .collect::<Vec<_>>();
941                    tokio::spawn(async move {
942                        stream::iter(warm_identities.into_iter().map(|identity| {
943                            let runtime = warm_runtime.clone();
944                            async move {
945                                runtime.best_effort_background_warm_identity(identity).await;
946                            }
947                        }))
948                        .buffer_unordered(concurrency)
949                        .collect::<Vec<_>>()
950                        .await;
951                    });
952                }
953            }
954
955            Some(Arc::new(
956                IdentityFirstRuntimeContext::new_with_lazy_materialization(
957                    identity_runtime,
958                    roster_provider,
959                    self.topology_provider.clone(),
960                    agent_customizer.clone(),
961                    Some(runtime.mob_runtime.handle().definition().clone()),
962                    !matches!(
963                        self.identity_bootstrap_mode,
964                        IdentityBootstrapMode::EagerMaterialize
965                    ),
966                ),
967            ))
968        } else {
969            None
970        };
971        let identity_lease_renewal_task = identity_first_context
972            .as_ref()
973            .map(|context| context.runtime.clone().spawn_lease_renewal_task());
974        let identity_continuity_repair_task = identity_first_context.as_ref().map(|context| {
975            context
976                .clone()
977                .spawn_broken_identity_repair_task(Default::default())
978        });
979
980        // Set immutable outer fields by rebuilding the struct
981        let runtime = UnifiedRuntime {
982            access_controller: self.access_controller,
983            topology_controller,
984            post_spawn_hook: self.post_spawn_hook,
985            post_reconcile_hook: self.post_reconcile_hook,
986            error_hook: self.error_hook,
987            drain_timeout: self.drain_timeout.unwrap_or(DEFAULT_DRAIN_TIMEOUT),
988            discovery: self.discovery,
989            // A custom embedder policy overrides the definition-derived
990            // default `bootstrap_with_options` installed (HomeCore,
991            // 2026-07-09); with none supplied the default is preserved.
992            edge_discovery: self
993                .edge_discovery
994                .map(std::sync::Arc::<dyn EdgeDiscovery>::from)
995                .or(runtime.edge_discovery),
996            contact_directory: self.contact_directory,
997            session_bridge,
998            identity_first_context,
999            identity_lease_renewal_task: tokio::sync::Mutex::new(identity_lease_renewal_task),
1000            identity_continuity_repair_task: tokio::sync::Mutex::new(
1001                identity_continuity_repair_task,
1002            ),
1003            console_log_store,
1004            ..runtime
1005        };
1006
1007        // Classic-path bundled-store wiring: the console Memory panel (§9.3),
1008        // the §10.1 posture write gate (only when the embedder did not
1009        // install a taint-tracking gate already), and the §9.3 timeline sink
1010        // for quarantined writes. Providers other than the bundled SQLite
1011        // store keep injection + recorder without a panel.
1012        if let Some(store) = classic_agent_memory
1013            .as_ref()
1014            .and_then(|provider| provider.as_sqlite_store())
1015        {
1016            let llm_writes = self
1017                .agent_memory_config
1018                .as_ref()
1019                .map(|config| config.llm_writes)
1020                .unwrap_or_default();
1021            store.set_llm_write_gate_if_absent(Arc::new(
1022                crate::memory::taint::TaintLlmWriteGate::new(None, llm_writes),
1023            ));
1024            store.set_event_sink_if_absent(runtime.memory_event_sink());
1025            runtime.set_memory_panel_store(store.clone());
1026        }
1027
1028        // Full-stack path (persistent_agent_memory_stack): firewall + engines
1029        // + observer over the pre-opened SQLite store.
1030        if let (Some(store), Some(engines)) =
1031            (stack_sqlite_store, self.agent_memory_engines.as_ref())
1032        {
1033            let config = self.agent_memory_config.clone().unwrap_or_default();
1034            let persistent_state = self.persistent_state_path.clone();
1035            let transcript_store: Option<Arc<dyn meerkat::SessionStore>> =
1036                if engines.distiller.enabled || engines.steward.enabled {
1037                    let state = persistent_state.as_ref().ok_or_else(|| {
1038                        UnifiedRuntimeBuilderError::ConflictingConfiguration(
1039                            "agent memory engines require persistent_state()".to_string(),
1040                        )
1041                    })?;
1042                    Some(Arc::new(
1043                        meerkat_store::SqliteSessionStore::open(state.join("sessions.db"))
1044                            .map_err(|e| {
1045                                UnifiedRuntimeBuilderError::Io(format!(
1046                                    "agent memory session store: {e}"
1047                                ))
1048                            })?,
1049                    ))
1050                } else {
1051                    None
1052                };
1053            let stack = crate::memory_wiring::attach_memory_engines(
1054                store,
1055                &config,
1056                engines,
1057                crate::memory_wiring::MemoryStackSeams {
1058                    persistent_state,
1059                    transcript_store,
1060                    event_sink: Some(runtime.memory_event_sink()),
1061                    ..Default::default()
1062                },
1063            )
1064            .map_err(UnifiedRuntimeBuilderError::Io)?;
1065            runtime.set_memory_panel_store(stack.store.clone());
1066            // Observe-stream feed lives for the runtime's lifetime.
1067            std::mem::forget(crate::spawn_member_event_observer(
1068                runtime.mob_handle(),
1069                stack.sinks,
1070            ));
1071            if let Some(steward) = stack.steward.as_ref() {
1072                // Library mode has no schedule host; the guarded interval
1073                // loop drives dreams.
1074                std::mem::forget(steward.spawn_dream_loop());
1075            }
1076            tracing::info!(
1077                distiller = stack.distiller.is_some(),
1078                steward = stack.steward.is_some(),
1079                "agent memory stack installed (builder path)"
1080            );
1081        }
1082
1083        let pre_spawn_context = if let Some(hook) = self.pre_spawn_hook {
1084            hook().await.map_err(|err| {
1085                UnifiedRuntimeBuilderError::Bootstrap(UnifiedRuntimeBootstrapError::PreSpawnHook(
1086                    err.to_string(),
1087                ))
1088            })?
1089        } else {
1090            serde_json::Value::Null
1091        };
1092        if runtime.identity_first_context.is_none()
1093            && let Some(ref discovery) = runtime.discovery
1094        {
1095            let specs = discovery.discover(pre_spawn_context).await;
1096            let spawn_specs: Vec<SpawnMemberSpec> =
1097                specs.iter().map(discovery_spec_to_spawn_spec).collect();
1098            runtime
1099                .spawn_many(spawn_specs)
1100                .await
1101                .map_err(UnifiedRuntimeBootstrapError::Mob)
1102                .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
1103        }
1104
1105        // Run initial edge reconciliation after spawn completes
1106        if runtime.edge_discovery.is_some()
1107            || runtime.topology_controller.revision().await > 0
1108            || runtime.topology_controller.has_pending().await
1109        {
1110            let report = runtime.reconcile_edges().await;
1111            *runtime.bootstrap_edges_report.write().await = Some(report);
1112        }
1113
1114        // Start event log ingestion if configured
1115        let mut runtime = runtime;
1116        if let Some(event_log_config) = self.event_log_config {
1117            runtime.start_event_log(event_log_config);
1118        }
1119
1120        Ok(runtime)
1121    }
1122
1123    /// Resolve the mob spec from the definition-based path.
1124    /// Called only when `mob_spec` is not set (legacy path handled in `build()`).
1125    async fn resolve_mob_spec(&self) -> Result<MobBootstrapSpec, UnifiedRuntimeBuilderError> {
1126        let mut caps = self.capability_flags;
1127        let definition = match self.definition_source {
1128            Some(DefinitionSource::Inline(ref def)) => *def.clone(),
1129            Some(DefinitionSource::TomlPath(ref path)) => {
1130                let toml_content = std::fs::read_to_string(path).map_err(|e| {
1131                    UnifiedRuntimeBuilderError::Io(format!(
1132                        "failed to read definition TOML at {}: {e}",
1133                        path.display()
1134                    ))
1135                })?;
1136                MobDefinition::from_toml(&toml_content).map_err(|e| {
1137                    UnifiedRuntimeBuilderError::DefinitionLoad(format!(
1138                        "failed to parse definition TOML at {}: {e}",
1139                        path.display()
1140                    ))
1141                })?
1142            }
1143            None => {
1144                return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
1145                    UnifiedRuntimeBuilderField::MobSpec,
1146                ));
1147            }
1148        };
1149        caps.image_generation |=
1150            crate::mob_handle_runtime::mob_definition_may_use_image_generation(&definition);
1151        let max_sessions = self.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS);
1152        if max_sessions == 0 {
1153            return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
1154                "max_sessions() must be greater than 0".to_string(),
1155            ));
1156        }
1157
1158        let hook = self
1159            .session_hook
1160            .as_ref()
1161            .map(|h| -> crate::mob_handle_runtime::PreBuildHook {
1162                let hook = h.clone();
1163                Arc::new(
1164                    move |req: &mut meerkat_core::service::CreateSessionRequest| {
1165                        let hook = hook.clone();
1166                        Box::pin(async move { hook.before_create(req).await })
1167                    },
1168                )
1169            });
1170
1171        let after_hook: Option<crate::mob_handle_runtime::AfterCreateHook> = self
1172            .session_hook
1173            .as_ref()
1174            .map(|h| -> crate::mob_handle_runtime::AfterCreateHook {
1175                let hook = h.clone();
1176                Arc::new(move |session_id, ctx| {
1177                    let hook = hook.clone();
1178                    Box::pin(async move {
1179                        hook.after_create(&session_id, &ctx).await;
1180                    })
1181                })
1182            });
1183
1184        // Note: blocking I/O (fs, SQLite) — acceptable at startup.
1185        let mut spec = if let Some(ref state_path) = self.persistent_state_path {
1186            std::fs::create_dir_all(state_path).map_err(|e| {
1187                UnifiedRuntimeBuilderError::Io(format!(
1188                    "failed to create state directory at {}: {e}",
1189                    state_path.display()
1190                ))
1191            })?;
1192
1193            let session_store: Arc<dyn meerkat::SessionStore> =
1194                if let Some(ref store) = self.custom_session_store {
1195                    store.clone()
1196                } else {
1197                    let sqlite_path = state_path.join("sessions.db");
1198                    Arc::new(
1199                        meerkat_store::SqliteSessionStore::open(sqlite_path).map_err(|e| {
1200                            UnifiedRuntimeBuilderError::Io(format!(
1201                                "failed to open SQLite session store: {e}"
1202                            ))
1203                        })?,
1204                    )
1205                };
1206            let mob_storage = MobStorage::in_memory();
1207
1208            MobBootstrapSpec::persistent_inner(
1209                definition,
1210                mob_storage,
1211                state_path.clone(),
1212                max_sessions,
1213                session_store,
1214                self.blob_store.clone(),
1215                hook,
1216                caps,
1217                after_hook.clone(),
1218                self.meerkat_config.clone(),
1219            )
1220        } else if let Some(ref scratch_dir) = self.scratch_dir {
1221            std::fs::create_dir_all(scratch_dir).map_err(|e| {
1222                UnifiedRuntimeBuilderError::Io(format!(
1223                    "failed to create scratch directory at {}: {e}",
1224                    scratch_dir.display()
1225                ))
1226            })?;
1227
1228            MobBootstrapSpec::ephemeral_runtime_backed_inner(
1229                definition,
1230                MobStorage::in_memory(),
1231                scratch_dir.clone(),
1232                max_sessions,
1233                self.custom_session_store.clone(),
1234                self.blob_store.clone(),
1235                hook,
1236                caps,
1237                after_hook,
1238                self.meerkat_config.clone(),
1239            )
1240        } else {
1241            // Ephemeral: create a temp dir that lives as long as the runtime.
1242            let temp_dir = tempfile::tempdir().map_err(|e| {
1243                UnifiedRuntimeBuilderError::Io(format!("failed to create temp dir: {e}"))
1244            })?;
1245            let store_path = temp_dir.path().to_path_buf();
1246
1247            let mut spec = MobBootstrapSpec::ephemeral_runtime_backed_inner(
1248                definition,
1249                MobStorage::in_memory(),
1250                store_path,
1251                max_sessions,
1252                self.custom_session_store.clone(),
1253                self.blob_store.clone(),
1254                hook,
1255                caps,
1256                after_hook,
1257                self.meerkat_config.clone(),
1258            );
1259            spec._ephemeral_dir = Some(Arc::new(temp_dir));
1260            spec
1261        };
1262
1263        spec.options = MobBootstrapOptions {
1264            allow_ephemeral_sessions: true,
1265            notify_orchestrator_on_resume: true,
1266            default_llm_client: self.default_llm_client.clone(),
1267        };
1268
1269        Ok(spec)
1270    }
1271}
1272
1273#[cfg(test)]
1274#[allow(clippy::expect_used)]
1275mod tests {
1276    use super::*;
1277    use meerkat_core::service::{
1278        CreateSessionRequest, DeferredPromptPolicy, InitialTurnPolicy, SessionService,
1279    };
1280
1281    fn deferred_capacity_request(prompt: impl Into<String>) -> CreateSessionRequest {
1282        let build = meerkat_core::service::SessionBuildOptions {
1283            llm_client_override: Some(meerkat::encode_llm_client_override_for_service(Arc::new(
1284                meerkat_client::TestClient::default(),
1285            ))),
1286            ..Default::default()
1287        };
1288
1289        CreateSessionRequest {
1290            model: "gpt-5.5".to_string(),
1291            prompt: meerkat_core::ContentInput::Text(prompt.into()),
1292            system_prompt: meerkat_core::config::SystemPromptOverride::Inherit,
1293            max_tokens: None,
1294            event_tx: None,
1295            initial_turn: InitialTurnPolicy::Defer,
1296            deferred_prompt_policy: DeferredPromptPolicy::Discard,
1297            build: Some(build),
1298            labels: None,
1299            injected_context: Vec::new(),
1300        }
1301    }
1302
1303    #[tokio::test]
1304    async fn definition_based_ephemeral_spec_provides_runtime_adapter() {
1305        let definition = meerkat_mob::MobDefinition::from_toml(
1306            r#"
1307[mob]
1308id = "builder-ephemeral"
1309
1310[profiles.worker]
1311model = "gpt-5.5"
1312runtime_mode = "autonomous_host"
1313
1314[profiles.worker.tools]
1315comms = true
1316"#,
1317        )
1318        .expect("definition parses");
1319
1320        let builder = UnifiedRuntimeBuilder::default().definition(definition);
1321        let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1322        assert!(
1323            spec.runtime_adapter.is_some(),
1324            "definition-based ephemeral specs should expose runtime authority",
1325        );
1326    }
1327
1328    #[tokio::test]
1329    async fn definition_based_ephemeral_spec_uses_configured_max_sessions() {
1330        let definition = meerkat_mob::MobDefinition::from_toml(
1331            r#"
1332[mob]
1333id = "builder-max-sessions"
1334
1335[profiles.worker]
1336model = "gpt-5.5"
1337runtime_mode = "autonomous_host"
1338"#,
1339        )
1340        .expect("definition parses");
1341
1342        let builder = UnifiedRuntimeBuilder::default()
1343            .definition(definition)
1344            .max_sessions(65);
1345        let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1346
1347        for index in 0..65 {
1348            SessionService::create_session(
1349                spec.session_service.as_ref(),
1350                deferred_capacity_request(format!("session {index}")),
1351            )
1352            .await
1353            .expect("configured capacity should admit session");
1354        }
1355
1356        let blocked = SessionService::create_session(
1357            spec.session_service.as_ref(),
1358            deferred_capacity_request("one too many"),
1359        )
1360        .await
1361        .expect_err("configured capacity should block the next session");
1362        assert!(
1363            blocked.to_string().contains("Max sessions reached (65/65)"),
1364            "unexpected capacity error: {blocked}",
1365        );
1366    }
1367
1368    #[tokio::test]
1369    async fn definition_based_spec_accepts_custom_meerkat_config() {
1370        let definition = meerkat_mob::MobDefinition::from_toml(
1371            r#"
1372[mob]
1373id = "builder-custom-config"
1374
1375[profiles.worker]
1376model = "gpt-5.5"
1377runtime_mode = "autonomous_host"
1378"#,
1379        )
1380        .expect("definition parses");
1381        let mut config = meerkat::Config::default();
1382        config.compaction.auto_compact_threshold = 42_000;
1383        config.compaction.auto_compact_threshold_explicit = true;
1384        config.compaction.recent_turn_budget = 2;
1385
1386        let builder = UnifiedRuntimeBuilder::default()
1387            .definition(definition)
1388            .meerkat_config(config)
1389            .max_sessions(1);
1390        let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1391
1392        SessionService::create_session(
1393            spec.session_service.as_ref(),
1394            deferred_capacity_request("custom config session"),
1395        )
1396        .await
1397        .expect("custom Meerkat config should still build a usable session service");
1398    }
1399
1400    #[tokio::test]
1401    async fn definition_based_persistent_spec_uses_configured_max_sessions() {
1402        let definition = meerkat_mob::MobDefinition::from_toml(
1403            r#"
1404[mob]
1405id = "builder-persistent-max-sessions"
1406
1407[profiles.worker]
1408model = "gpt-5.5"
1409runtime_mode = "autonomous_host"
1410"#,
1411        )
1412        .expect("definition parses");
1413        let tmp = tempfile::tempdir().expect("temp dir");
1414
1415        let builder = UnifiedRuntimeBuilder::default()
1416            .definition(definition)
1417            .persistent_state(tmp.path().join("state"))
1418            .max_sessions(2);
1419        let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1420
1421        for index in 0..2 {
1422            SessionService::create_session(
1423                spec.session_service.as_ref(),
1424                deferred_capacity_request(format!("persistent session {index}")),
1425            )
1426            .await
1427            .expect("configured persistent capacity should admit session");
1428        }
1429
1430        let blocked = SessionService::create_session(
1431            spec.session_service.as_ref(),
1432            deferred_capacity_request("persistent one too many"),
1433        )
1434        .await
1435        .expect_err("configured persistent capacity should block the next session");
1436        assert!(
1437            blocked.to_string().contains("Max sessions reached (2/2)"),
1438            "unexpected capacity error: {blocked}",
1439        );
1440    }
1441
1442    #[tokio::test]
1443    async fn definition_based_spec_rejects_zero_max_sessions() {
1444        let definition = meerkat_mob::MobDefinition::from_toml(
1445            r#"
1446[mob]
1447id = "builder-zero-max-sessions"
1448
1449[profiles.worker]
1450model = "gpt-5.5"
1451"#,
1452        )
1453        .expect("definition parses");
1454
1455        let result = UnifiedRuntimeBuilder::default()
1456            .definition(definition)
1457            .max_sessions(0)
1458            .resolve_mob_spec()
1459            .await;
1460        assert!(result.is_err(), "zero max sessions should be rejected");
1461        let err = result.err().expect("zero max sessions error");
1462
1463        assert!(
1464            err.to_string().contains("max_sessions"),
1465            "unexpected error: {err}",
1466        );
1467    }
1468
1469    #[test]
1470    fn builder_accepts_custom_console_log_store_for_ephemeral_mob_state() {
1471        let store: Arc<dyn ConsoleLogStore> = Arc::new(InMemoryConsoleLogStore::new());
1472        let builder = UnifiedRuntimeBuilder::default().with_console_log_store(store.clone());
1473
1474        assert!(
1475            Arc::ptr_eq(
1476                builder.console_log_store.as_ref().expect("custom store"),
1477                &store
1478            ),
1479            "builder should retain the exact console log store supplied by the app"
1480        );
1481    }
1482}