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