Skip to main content

lash/
core.rs

1use crate::support::*;
2use lash_core::runtime::{
3    ProcessCommand, ProcessEffectOutcome, RuntimeEffectCommand, RuntimeEffectEnvelope,
4    RuntimeEffectKind, RuntimeEffectLocalExecutor, RuntimeEffectOutcome, RuntimeInvocation,
5    RuntimeScope,
6};
7
8#[derive(Clone)]
9pub struct LashCore {
10    pub(crate) env: RuntimeEnvironment,
11    pub(crate) policy: SessionPolicy,
12    pub(crate) protocol_factory: Option<Arc<dyn PluginFactory>>,
13    pub(crate) store_factory: Option<Arc<dyn SessionStoreFactory>>,
14    pub(crate) plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
15    pub(crate) provider: Option<ProviderHandle>,
16    pub(crate) live_replay_store: Arc<dyn LiveReplayStore>,
17    /// Whether this deployment has process lifecycle available (a process
18    /// registry is wired). Threaded to every plugin host so core can install the
19    /// same plugin-contributed process engines when it rebuilds a runtime.
20    pub(crate) process_lifecycle_available: bool,
21    /// Per-worker bound used when this core constructs an inline process worker.
22    pub(crate) process_execution_concurrency: usize,
23    /// Shared resolution of host-owned work drivers. Shared across `LashCore`
24    /// clones so inline process and queued drivers are constructed at most once.
25    pub(crate) work_driver: Arc<InlineWorkDriverSlot>,
26}
27
28/// How a [`LashCore`] resolves its process work driver, decided at `build()`
29/// and shared across clones.
30pub(crate) enum ProcessWorkDriverSetup {
31    /// No process registry is wired; there is nothing to run.
32    None,
33    /// Lazily construct the default inline process driver on first
34    /// `session().open()`. A store factory is required to build the config (the
35    /// worker rebuilds a session runtime per process); a registry with no store
36    /// factory is rejected at build with
37    /// [`EmbedError::ProcessRegistryRequiresStoreFactory`].
38    LazyDefault {
39        config: Box<DurableProcessWorkerConfig>,
40    },
41    /// The host wired an external driver.
42    External { driver: ProcessWorkDriver },
43}
44
45#[derive(Clone, Default)]
46pub(crate) enum ProcessWorkSource {
47    #[default]
48    None,
49    Inline {
50        registry: Arc<dyn ProcessRegistry>,
51        hub: Option<lash_core::ProcessChangeHub>,
52    },
53    External(ProcessWorkDriver),
54}
55
56impl ProcessWorkSource {
57    fn process_registry(&self) -> Option<Arc<dyn ProcessRegistry>> {
58        match self {
59            Self::None => None,
60            Self::Inline { registry, .. } => Some(Arc::clone(registry)),
61            Self::External(driver) => Some(driver.process_registry()),
62        }
63    }
64
65    fn has_registry(&self) -> bool {
66        !matches!(self, Self::None)
67    }
68
69    fn watched(self, sink: Option<Arc<dyn lash_core::ProcessEventSink>>) -> Self {
70        match self {
71            Self::Inline {
72                registry,
73                hub: None,
74            } => {
75                let (registry, hub) = lash_core::watch_process_registry_with_sink(registry, sink);
76                Self::Inline {
77                    registry,
78                    hub: Some(hub),
79                }
80            }
81            // An external driver was wrapped by its host, which installs any
82            // sink through the driver constructor; the inline sink does not
83            // apply here. Already-watched inline sources keep their wrap.
84            other => other,
85        }
86    }
87}
88
89#[derive(Clone, Default)]
90pub(crate) enum QueuedWorkSource {
91    None,
92    #[default]
93    LazyDefault,
94    External(QueuedWorkDriver),
95}
96
97pub(crate) enum QueuedWorkDriverSetup {
98    None,
99    LazyDefault {
100        config: Arc<InlineQueuedWorkRunConfig>,
101    },
102    External {
103        driver: QueuedWorkDriver,
104    },
105}
106
107pub(crate) struct InlineWorkDriverSetup {
108    process: ProcessWorkDriverSetup,
109    queued: QueuedWorkDriverSetup,
110}
111
112#[derive(Clone, Default)]
113pub(crate) struct ResolvedWorkDrivers {
114    pub(crate) process: Option<ProcessWorkDriver>,
115    pub(crate) queued: Option<QueuedWorkDriver>,
116    pub(crate) drive_process_on_open: bool,
117}
118
119/// Shared, lazily-initialized host-work state for a [`LashCore`].
120///
121/// The once-guard ([`tokio::sync::OnceCell`]) constructs inline drivers exactly
122/// once across `LashCore` clones, on the first `session().open()` or admin path
123/// that needs them.
124pub(crate) struct InlineWorkDriverSlot {
125    setup: InlineWorkDriverSetup,
126    drivers: tokio::sync::OnceCell<ResolvedWorkDrivers>,
127    phase_probe_slot: Option<lash_core::runtime::RuntimeTurnPhaseProbeSlot>,
128}
129
130impl InlineWorkDriverSlot {
131    fn new(setup: InlineWorkDriverSetup) -> Self {
132        let phase_probe_slot = match &setup.process {
133            ProcessWorkDriverSetup::LazyDefault { config } => {
134                Some(config.turn_phase_probe_slot.clone())
135            }
136            ProcessWorkDriverSetup::None | ProcessWorkDriverSetup::External { .. } => None,
137        };
138        Self {
139            setup,
140            drivers: tokio::sync::OnceCell::new(),
141            phase_probe_slot,
142        }
143    }
144
145    /// Resolve host work drivers for a session host. Idempotent: the once-guard
146    /// ensures inline drivers are constructed once.
147    pub(crate) async fn drivers(&self) -> ResolvedWorkDrivers {
148        self.drivers
149            .get_or_init(|| async {
150                let queued = match &self.setup.queued {
151                    QueuedWorkDriverSetup::None => None,
152                    QueuedWorkDriverSetup::External { driver } => Some(driver.clone()),
153                    QueuedWorkDriverSetup::LazyDefault { config } => Some(QueuedWorkDriver::new(
154                        Arc::new(InlineQueuedWorkRunHandle::new(Arc::clone(config))),
155                    )),
156                };
157                let (process, drive_process_on_open) = match &self.setup.process {
158                    ProcessWorkDriverSetup::None => (None, false),
159                    ProcessWorkDriverSetup::External { driver } => (Some(driver.clone()), false),
160                    ProcessWorkDriverSetup::LazyDefault { config } => {
161                        let mut config = (**config).clone();
162                        if let Some(driver) = queued.clone() {
163                            config = config.with_queued_work_driver(driver);
164                        }
165                        let registry = Arc::clone(&config.process_registry);
166                        let hub = config.process_change_hub.clone();
167                        let worker = DurableProcessWorker::new(config);
168                        let driver = if let Some(hub) = hub {
169                            ProcessWorkDriver::from_watched(
170                                registry,
171                                hub,
172                                Arc::new(lash_core::InlineProcessRunHandle::new(worker)),
173                            )
174                        } else {
175                            ProcessWorkDriver::inline(registry, worker)
176                        };
177                        (Some(driver), true)
178                    }
179                };
180                ResolvedWorkDrivers {
181                    process,
182                    queued,
183                    drive_process_on_open,
184                }
185            })
186            .await
187            .clone()
188    }
189
190    pub(crate) fn phase_probe_slot(&self) -> Option<lash_core::runtime::RuntimeTurnPhaseProbeSlot> {
191        self.phase_probe_slot.clone()
192    }
193
194    fn configured_process_work_driver(&self) -> Option<ProcessWorkDriver> {
195        match &self.setup.process {
196            ProcessWorkDriverSetup::External { driver } => Some(driver.clone()),
197            ProcessWorkDriverSetup::None | ProcessWorkDriverSetup::LazyDefault { .. } => None,
198        }
199    }
200
201    fn configured_queued_work_driver(&self) -> Option<QueuedWorkDriver> {
202        match &self.setup.queued {
203            QueuedWorkDriverSetup::External { driver } => Some(driver.clone()),
204            QueuedWorkDriverSetup::None | QueuedWorkDriverSetup::LazyDefault { .. } => None,
205        }
206    }
207}
208
209pub(crate) struct InlineQueuedWorkRunConfig {
210    env: RuntimeEnvironment,
211    policy: SessionPolicy,
212    protocol_factory: Option<Arc<dyn PluginFactory>>,
213    plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
214    store_factory: Arc<dyn SessionStoreFactory>,
215    live_replay_store: Arc<dyn LiveReplayStore>,
216    process_lifecycle_available: bool,
217}
218
219impl InlineQueuedWorkRunConfig {
220    fn new(
221        env: RuntimeEnvironment,
222        policy: SessionPolicy,
223        protocol_factory: Option<Arc<dyn PluginFactory>>,
224        plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
225        store_factory: Arc<dyn SessionStoreFactory>,
226        live_replay_store: Arc<dyn LiveReplayStore>,
227        process_lifecycle_available: bool,
228    ) -> Self {
229        Self {
230            env,
231            policy,
232            protocol_factory,
233            plugin_factories,
234            store_factory,
235            live_replay_store,
236            process_lifecycle_available,
237        }
238    }
239}
240
241struct InlineQueuedWorkRunHandle {
242    config: Arc<InlineQueuedWorkRunConfig>,
243}
244
245impl InlineQueuedWorkRunHandle {
246    fn new(config: Arc<InlineQueuedWorkRunConfig>) -> Self {
247        Self { config }
248    }
249}
250
251#[async_trait]
252impl QueuedWorkRunHandle for InlineQueuedWorkRunHandle {
253    async fn run_queued_work(
254        &self,
255        request: QueuedWorkRunRequest,
256    ) -> std::result::Result<(), lash_core::QueuedWorkRunError> {
257        let Some(session_id) = request.session_id else {
258            return Ok(());
259        };
260        let reason = request.reason;
261        let mut policy = self.config.policy.clone();
262        policy.session_id = Some(session_id.clone());
263        let store = self
264            .config
265            .store_factory
266            .create_store(&SessionStoreCreateRequest {
267                session_id: session_id.clone(),
268                relation: SessionRelation::default(),
269                policy: policy.clone(),
270            })
271            .await
272            .map_err(|error| {
273                lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(error))
274            })?;
275        let state = crate::session::load_state_for_residency(
276            self.config.env.residency,
277            &session_id,
278            &policy,
279            store.as_ref(),
280        )
281        .await
282        .map_err(|error| {
283            lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(
284                error.to_string(),
285            ))
286        })?;
287        let plugin_host = build_plugin_host(
288            self.config.protocol_factory.as_ref(),
289            self.config.plugin_factories.as_ref(),
290            Vec::new(),
291        )
292        .map_err(|error| {
293            lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(
294                error.to_string(),
295            ))
296        })?;
297        let mut env = self.config.env.clone();
298        env.core = plugin_host
299            .install_process_engine_contributions(
300                env.core.clone(),
301                self.config.process_lifecycle_available,
302            )
303            .map_err(|error| {
304                lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(
305                    error.to_string(),
306                ))
307            })?;
308        env.plugin_host = Some(Arc::new(plugin_host));
309        let effect_host = Arc::clone(&env.core.control.effect_host);
310        let runtime = LashRuntime::from_environment(&env, policy, state, Some(store))
311            .await
312            .map_err(|error| {
313                lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(
314                    error.to_string(),
315                ))
316            })?;
317        let handle = RuntimeHandle::with_live_replay_store(
318            runtime,
319            Arc::clone(&self.config.live_replay_store),
320        );
321        let scope = lash_core::ExecutionScope::queue_drain(session_id, reason);
322        let scoped = effect_host.scoped(scope).map_err(|error| {
323            lash_core::QueuedWorkRunError::terminal(lash_core::PluginError::Session(
324                error.to_string(),
325            ))
326        })?;
327        crate::turn::stream_next_queued_prepared_turn(
328            &handle,
329            crate::turn::TurnSinks::default(),
330            scoped,
331            CancellationToken::new(),
332            lash_core::TurnCancelOriginHint::default(),
333            &[],
334        )
335        .await
336        .map_err(|error| {
337            let plugin_error = lash_core::PluginError::Session(error.to_string());
338            if error.is_retryable() {
339                lash_core::QueuedWorkRunError::transient(plugin_error)
340            } else {
341                lash_core::QueuedWorkRunError::terminal(plugin_error)
342            }
343        })?;
344        Ok(())
345    }
346}
347
348#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
349pub struct SessionDeleteReport {
350    pub session_id: String,
351    pub process: Option<lash_core::ProcessSessionDeleteReport>,
352}
353
354impl LashCore {
355    pub fn builder() -> LashCoreBuilder {
356        LashCoreBuilder::default()
357    }
358
359    /// Sugar entry point: a [`LashCoreBuilder`] pre-seeded with the standard
360    /// protocol plugin and the default runtime plugin stack.
361    pub fn standard_builder() -> LashCoreBuilder {
362        LashCore::builder()
363            .protocol_plugin(Arc::new(
364                lash_protocol_standard::StandardProtocolPluginFactory::new(),
365            ))
366            .plugins(default_runtime_stack())
367    }
368
369    /// Sugar entry point: a [`LashCoreBuilder`] pre-seeded with a
370    /// host-configured RLM protocol factory and the default runtime plugin
371    /// stack.
372    ///
373    /// The host configures the factory (projection resolver, deferred tool
374    /// resolver, execution sink/jsonl path, and — required at construction — the
375    /// Lashlang artifact store) before passing it in.
376    #[cfg(feature = "rlm")]
377    pub fn rlm_builder(factory: crate::rlm::RlmProtocolPluginFactory) -> LashCoreBuilder {
378        LashCore::builder()
379            .protocol_plugin(Arc::new(factory))
380            .plugins(default_runtime_stack())
381    }
382
383    pub fn session(&self, session_id: impl Into<String>) -> SessionBuilder {
384        SessionBuilder {
385            core: self.clone(),
386            session_id: session_id.into(),
387            spec: SessionSpec::inherit(),
388            parent_session_id: None,
389            session_execution_owner: None,
390            store: None,
391            provider: None,
392            active_plugins: Vec::new(),
393            plugin_factories: Vec::new(),
394            plugin_options: PluginOptions::default(),
395        }
396    }
397
398    /// Rebuild a live session from a [`ParkedSession`](crate::ParkedSession)
399    /// handle produced by [`LashSession::park`](crate::LashSession::park).
400    ///
401    /// Resume reloads the flushed state from the parked store (honoring this
402    /// core's residency), reinstalls this core's plugin configuration and work
403    /// drivers, and returns a ready [`LashSession`]. The parked store instance
404    /// is reused directly, so the transcript the session flushed at park time is
405    /// visible again after resume.
406    ///
407    /// This restores the core-level plugin stack. Session-specific plugins added
408    /// per open via [`SessionBuilder::plugin`] are not re-applied here; parking
409    /// is the round-trip for the core's own configuration.
410    pub async fn resume(&self, parked: ParkedSession) -> Result<LashSession> {
411        // Build the per-session env exactly like `SessionBuilder::open_resolved`
412        // (minus builder-scoped plugins): a fresh plugin host with this core's
413        // factories, the shared work drivers, and the core provider resolver
414        // already carried on `self.env`.
415        let plugin_host = build_plugin_host(
416            self.protocol_factory.as_ref(),
417            self.plugin_factories.as_ref(),
418            Vec::new(),
419        )?;
420        let mut env = self.env.clone();
421        env.core = plugin_host.install_process_engine_contributions(
422            env.core.clone(),
423            self.process_lifecycle_available,
424        )?;
425        env.plugin_host = Some(Arc::new(plugin_host));
426        let effect_host = Arc::clone(&env.core.control.effect_host);
427        let drivers = self.work_driver.drivers().await;
428        env.process_work_driver = drivers.process.clone();
429        env.queued_work_driver = drivers.queued.clone();
430        let runtime = LashRuntime::resume(parked.inner, &env).await?;
431        let handle =
432            RuntimeHandle::with_live_replay_store(runtime, Arc::clone(&self.live_replay_store));
433        Ok(LashSession {
434            runtime: handle,
435            effect_host,
436            parent_session_id: None,
437            active_plugins: Vec::new(),
438            process_phase_probe_slot: self.work_driver.phase_probe_slot(),
439            turn_cancels: crate::turn::TurnCancelRegistry::default(),
440        })
441    }
442
443    /// Flush this core's configured trace sink, if any.
444    ///
445    /// Hosts that hand `lash` a trace sink via
446    /// [`LashCoreBuilder::trace_sink`] already hold their own `Arc` and can
447    /// flush it directly; this is the equivalent lever for hosts that did not
448    /// retain the handle. It flushes the core's copy — for a
449    /// [`JsonlTraceSink`](lash_trace::JsonlTraceSink) that fsyncs the file, and
450    /// for an OTel sink it is a no-op (the host still owns provider flush; see
451    /// the tracing docs). Call it before process exit alongside the host's own
452    /// exporter/provider shutdown.
453    pub fn flush_trace_sink(&self) -> Result<()> {
454        if let Some(sink) = self.env.core.tracing.trace_sink.as_ref() {
455            sink.flush()?;
456        }
457        Ok(())
458    }
459
460    pub fn triggers(&self) -> crate::admin::CoreTriggerAdmin {
461        crate::admin::CoreTriggerAdmin { core: self.clone() }
462    }
463
464    pub fn processes(&self) -> crate::process_admin::Processes {
465        crate::process_admin::Processes { core: self.clone() }
466    }
467
468    pub fn completions(&self) -> crate::admin::Completions {
469        crate::admin::Completions { core: self.clone() }
470    }
471
472    pub fn effect_host(&self) -> Arc<dyn EffectHost> {
473        Arc::clone(&self.env.core.control.effect_host)
474    }
475
476    /// Exact-turn cooperative control for this deployment's effect host.
477    ///
478    /// The returned driver is independently usable from any session handle.
479    /// Session and turn ids are routing identity, not authorization; authorize
480    /// requests in the host API before forwarding them to Lash.
481    pub fn turn_work_driver(&self) -> lash_core::TurnWorkDriver {
482        lash_core::TurnWorkDriver::new(self.effect_host())
483    }
484
485    /// Persist host input without opening a competing session writer.
486    ///
487    /// This is the downstream-host ingress seam for input submitted while a
488    /// durable turn may already own the session execution lease. Active-turn
489    /// input is claimed by that exact turn at a checkpoint; next-turn input is
490    /// handed to the configured queued-work driver after it is durably stored.
491    /// Success acknowledges durable acceptance only; queue dispatch is a
492    /// separate best-effort wake and is reconciled from the pending row.
493    pub async fn enqueue_turn_input(
494        &self,
495        session_id: impl Into<String>,
496        input: lash_core::TurnInput,
497        ingress: lash_core::TurnInputIngress,
498        id: Option<String>,
499    ) -> Result<lash_core::TurnInputAcceptanceReceipt> {
500        lash_core::ensure_durable_effect_input(&input).map_err(EmbedError::Runtime)?;
501        let session_id = session_id.into();
502        let Some(store_factory) = self.store_factory.as_ref() else {
503            return Err(EmbedError::MissingSessionStoreFactory);
504        };
505        let mut policy = self.policy.clone();
506        policy.session_id = Some(session_id.clone());
507        let store = store_factory
508            .create_store(&SessionStoreCreateRequest {
509                session_id: session_id.clone(),
510                relation: SessionRelation::default(),
511                policy,
512            })
513            .await
514            .map_err(|message| EmbedError::StoreFactory {
515                session_id: session_id.clone(),
516                message,
517            })?;
518        let is_next_turn = matches!(ingress, lash_core::TurnInputIngress::NextTurn);
519        let mut draft = lash_core::PendingTurnInputDraft::new(session_id, ingress, input);
520        draft.source_key = id.map(|id| format!("host:{id}"));
521        let enqueued = store
522            .enqueue_pending_turn_input(draft)
523            .await
524            .map_err(|err| {
525                EmbedError::Runtime(lash_core::RuntimeError::new(
526                    lash_core::RuntimeErrorCode::StoreCommitFailed,
527                    err.to_string(),
528                ))
529            })?;
530        if is_next_turn && let Some(driver) = self.work_driver.drivers().await.queued.as_ref() {
531            drop(driver.wake_pending(Some(&enqueued.session_id), "queued_turn_input"));
532        }
533        Ok(lash_core::TurnInputAcceptanceReceipt::from(&enqueued))
534    }
535
536    pub async fn delete_session(
537        &self,
538        session_id: impl AsRef<str>,
539        scoped_effect_controller: ScopedEffectController<'_>,
540    ) -> Result<SessionDeleteReport> {
541        let session_id = session_id.as_ref().to_string();
542        let Some(store_factory) = self.store_factory.as_ref() else {
543            return Err(EmbedError::MissingSessionStoreFactory);
544        };
545        let process = if let Some(process_registry) = self.env.process_registry.as_ref() {
546            let invocation = RuntimeInvocation::effect(
547                RuntimeScope::new(session_id.clone()),
548                format!("process:delete-session:{session_id}"),
549                RuntimeEffectKind::Process,
550                format!("{session_id}:delete-session"),
551            );
552            let outcome = scoped_effect_controller
553                .controller()
554                .execute_effect(
555                    RuntimeEffectEnvelope::new(
556                        invocation,
557                        RuntimeEffectCommand::process(ProcessCommand::DeleteSession {
558                            session_id: session_id.clone(),
559                        }),
560                    ),
561                    RuntimeEffectLocalExecutor::processes(
562                        Arc::clone(process_registry),
563                        self.env.process_work_driver.clone(),
564                    ),
565                )
566                .await
567                .map_err(|err| EmbedError::SessionDeleteProcess {
568                    session_id: session_id.clone(),
569                    message: err.to_string(),
570                })?;
571            match outcome {
572                RuntimeEffectOutcome::Process {
573                    result: ProcessEffectOutcome::DeleteSession { report },
574                } => Some(report),
575                other => {
576                    return Err(EmbedError::SessionDeleteProcess {
577                        session_id,
578                        message: format!(
579                            "process delete returned the wrong outcome: {}",
580                            other.kind().as_str()
581                        ),
582                    });
583                }
584            }
585        } else {
586            None
587        };
588        if let Some(trigger_store) = self.env.trigger_store.as_ref() {
589            trigger_store
590                .delete_session_subscriptions(&session_id)
591                .await
592                .map_err(|err| EmbedError::SessionDeleteProcess {
593                    session_id: session_id.clone(),
594                    message: err.to_string(),
595                })?;
596        }
597        self.env
598            .core
599            .control
600            .effect_host
601            .revoke_await_events_for_session(&session_id)
602            .await
603            .map_err(|err| EmbedError::SessionDeleteProcess {
604                session_id: session_id.clone(),
605                message: err.to_string(),
606            })?;
607        store_factory
608            .delete_session(&session_id)
609            .await
610            .map_err(|message| EmbedError::StoreFactory {
611                session_id: session_id.clone(),
612                message,
613            })?;
614        Ok(SessionDeleteReport {
615            session_id,
616            process,
617        })
618    }
619
620    pub fn process_registry(&self) -> Option<Arc<dyn ProcessRegistry>> {
621        self.env.process_registry.as_ref().cloned()
622    }
623
624    pub fn durable_process_worker_config(&self) -> Result<DurableProcessWorkerConfig> {
625        self.durable_process_worker_config_with_plugins(std::iter::empty::<Arc<dyn PluginFactory>>())
626    }
627
628    pub fn durable_process_worker_config_with_plugins(
629        &self,
630        extra_plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
631    ) -> Result<DurableProcessWorkerConfig> {
632        let Some(process_registry) = self.process_registry() else {
633            return Err(EmbedError::MissingProcessRegistry);
634        };
635        let Some(store_factory) = self.store_factory.as_ref() else {
636            return Err(EmbedError::MissingProcessWorkerStoreFactory);
637        };
638        let plugin_host = build_plugin_host(
639            self.protocol_factory.as_ref(),
640            self.plugin_factories.as_ref(),
641            extra_plugin_factories.into_iter().collect(),
642        )?;
643        let runtime_host = plugin_host.install_process_engine_contributions(
644            self.env.core.clone(),
645            self.process_lifecycle_available,
646        )?;
647        let mut config = DurableProcessWorkerConfig::new(
648            Arc::new(plugin_host),
649            runtime_host,
650            Arc::clone(store_factory),
651            process_registry,
652        )
653        .with_session_policy(self.policy.clone())
654        .with_residency(self.env.residency)
655        .with_process_execution_concurrency(self.process_execution_concurrency)?;
656        if let Some(trigger_store) = self.env.trigger_store.as_ref() {
657            config = config.with_trigger_store(Arc::clone(trigger_store));
658        }
659        if let Some(driver) = self.work_driver.configured_process_work_driver() {
660            config = config
661                .with_change_hub(driver.change_hub())
662                .with_process_work_driver(driver);
663        }
664        if let Some(driver) = self.work_driver.configured_queued_work_driver() {
665            config = config.with_queued_work_driver(driver);
666        }
667        Ok(config)
668    }
669}
670
671fn default_runtime_stack() -> PluginStack {
672    lash_plugin_tool_output_budget::tool_output_budget_stack()
673}
674
675#[derive(Default)]
676pub struct LashCoreBuilder {
677    pub(crate) protocol_factory: Option<Arc<dyn PluginFactory>>,
678    session_spec: SessionSpec,
679    provider: Option<ProviderHandle>,
680    pub(crate) store_factory: Option<Arc<dyn SessionStoreFactory>>,
681    child_store_factory: Option<Arc<dyn SessionStoreFactory>>,
682    // `RuntimeHostConfig` has no `Default`: the generic host-owned durability
683    // dependencies must be named. They are collected here and resolved in
684    // `build()`, which errors if any is unset.
685    effect_host: Option<Arc<dyn EffectHost>>,
686    attachment_store: Option<Arc<dyn AttachmentStore>>,
687    process_env_store: Option<Arc<dyn ProcessExecutionEnvStore>>,
688    trigger_store: Option<Arc<dyn lash_core::TriggerStore>>,
689    // Benign core overrides applied on top of the resolved core.
690    prompt: Option<PromptLayer>,
691    trace_sink: Option<Arc<dyn lash_trace::TraceSink>>,
692    trace_level: Option<lash_trace::TraceLevel>,
693    trace_context: Option<lash_trace::TraceContext>,
694    termination: Option<TerminationPolicy>,
695    // Advanced full-config override; used as the base core when present.
696    runtime_host_config: Option<RuntimeHostConfig>,
697    tool_providers: Vec<Arc<dyn ToolProvider>>,
698    plugin_stack: PluginStack,
699    plugin_host: Option<PluginHost>,
700    residency: Option<Residency>,
701    lease_timings: Option<lash_core::LeaseTimings>,
702    clock: Option<Arc<dyn lash_core::Clock>>,
703    // Single source of truth for process lifecycle support and process-work
704    // consumption.
705    process_work_source: ProcessWorkSource,
706    // Per-worker bound for the default inline process executor.
707    process_execution_concurrency: Option<usize>,
708    // Optional host-facing best-effort feed of appended process events,
709    // installed on the inline process-registry decorator at build time.
710    process_event_sink: Option<Arc<dyn lash_core::ProcessEventSink>>,
711    queued_work_source: QueuedWorkSource,
712    live_replay_store: Option<Arc<dyn LiveReplayStore>>,
713}
714
715impl LashCoreBuilder {
716    pub fn protocol_plugin(mut self, plugin: Arc<dyn PluginFactory>) -> Self {
717        self.protocol_factory = Some(plugin);
718        self
719    }
720
721    pub fn provider(mut self, provider: ProviderHandle) -> Self {
722        self.session_spec = self.session_spec.provider_id(provider.kind());
723        self.provider = Some(provider);
724        self
725    }
726
727    pub fn model(mut self, model: lash_core::ModelSpec) -> Self {
728        self.session_spec = self.session_spec.model(model);
729        self
730    }
731
732    pub fn max_turns(mut self, max_turns: usize) -> Self {
733        self.session_spec = self.session_spec.max_turns(max_turns);
734        self
735    }
736
737    pub fn session_spec(mut self, spec: SessionSpec) -> Self {
738        self.session_spec = spec;
739        self
740    }
741
742    /// Configure a factory that can create a persistence store for any root
743    /// session opened from this core.
744    ///
745    /// The factory must honor `SessionStoreCreateRequest::session_id` and
746    /// return a store for that specific session. Do not use this to wrap one
747    /// pre-opened root store; pass root-only stores with
748    /// `LashCore::session(...).store(store)` instead.
749    ///
750    /// Durable attachment GC never guesses process-registry co-location. Hosts
751    /// using owner-aware GC must explicitly call
752    /// `SqliteSessionStoreFactory::new_with_process_registry(...)` or
753    /// `PostgresStorage::session_store_factory_with_shared_process_registry()`
754    /// on the concrete factory before erasing it behind this trait object.
755    pub fn store_factory(mut self, store_factory: Arc<dyn SessionStoreFactory>) -> Self {
756        self.store_factory = Some(store_factory);
757        self
758    }
759
760    /// Configure the persistence factory used by managed child sessions, such
761    /// as local subagents.
762    ///
763    /// Child factories must return a distinct store bound to the requested
764    /// child session id. Hosts that pass an explicit root store with
765    /// `SessionBuilder::store` should set this when child sessions need
766    /// persistence.
767    /// The same explicit process-registry wiring required by `store_factory`
768    /// applies when this factory participates in attachment GC.
769    pub fn child_store_factory(mut self, store_factory: Arc<dyn SessionStoreFactory>) -> Self {
770        self.child_store_factory = Some(store_factory);
771        self
772    }
773
774    pub fn attachment_store(mut self, attachment_store: Arc<dyn AttachmentStore>) -> Self {
775        self.attachment_store = Some(attachment_store);
776        self
777    }
778
779    pub fn process_env_store(
780        mut self,
781        process_env_store: Arc<dyn ProcessExecutionEnvStore>,
782    ) -> Self {
783        self.process_env_store = Some(process_env_store);
784        self
785    }
786
787    /// Set the number of processes each default inline worker may execute at
788    /// once. A running process releases its slot while parked on another
789    /// process or external completion and reacquires it before resuming.
790    ///
791    /// This is a per-worker bound: two workers sharing one process registry may
792    /// execute up to twice this number. The default is
793    /// [`DEFAULT_PROCESS_EXECUTION_CONCURRENCY`](lash_core::DEFAULT_PROCESS_EXECUTION_CONCURRENCY).
794    /// Invalid values are reported by [`build`](Self::build).
795    pub fn process_execution_concurrency(mut self, concurrency: usize) -> Self {
796        self.process_execution_concurrency = Some(concurrency);
797        self
798    }
799
800    /// Set the deployment effect host — the durability boundary every operation
801    /// crosses. Pass [`InlineEffectHost`](crate::durability::InlineEffectHost)
802    /// for in-process execution, or a workflow-backed host for durable
803    /// execution.
804    pub fn effect_host(mut self, effect_host: Arc<dyn EffectHost>) -> Self {
805        self.effect_host = Some(effect_host);
806        self
807    }
808
809    pub fn tools(mut self, tools: Arc<dyn ToolProvider>) -> Self {
810        self.tool_providers.push(tools);
811        self
812    }
813
814    pub fn plugin(mut self, plugin: Arc<dyn PluginFactory>) -> Self {
815        self.plugin_stack.push(plugin);
816        self
817    }
818
819    pub fn plugins(mut self, stack: PluginStack) -> Self {
820        self.plugin_stack = stack;
821        self
822    }
823
824    pub fn configure_plugins(mut self, configure: impl FnOnce(&mut PluginStack)) -> Self {
825        configure(&mut self.plugin_stack);
826        self
827    }
828
829    pub fn trace_sink(mut self, trace_sink: Arc<dyn lash_trace::TraceSink>) -> Self {
830        self.trace_sink = Some(trace_sink);
831        self
832    }
833
834    pub fn trace_jsonl_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
835        self.trace_sink = Some(Arc::new(lash_trace::JsonlTraceSink::new(path.into())));
836        self
837    }
838
839    pub fn trace_level(mut self, trace_level: lash_trace::TraceLevel) -> Self {
840        self.trace_level = Some(trace_level);
841        self
842    }
843
844    pub fn trace_context(mut self, trace_context: lash_trace::TraceContext) -> Self {
845        self.trace_context = Some(trace_context);
846        self
847    }
848
849    pub fn termination(mut self, termination: TerminationPolicy) -> Self {
850        self.termination = Some(termination);
851        self
852    }
853
854    pub fn residency(mut self, residency: Residency) -> Self {
855        self.residency = Some(residency);
856        self
857    }
858
859    /// Configure the lease timing capability for every durable single-writer
860    /// lane this deployment claims: session execution leases, turn-input and
861    /// queued-work claims, and process leases.
862    ///
863    /// This is the failover-latency vs false-takeover-risk knob. Like
864    /// [`residency`](Self::residency) it is an operational deployment decision,
865    /// so it lives on the main builder tier rather than behind
866    /// [`advanced`](Self::advanced). Construct the value with
867    /// [`LeaseTimings::new`](lash_core::LeaseTimings::new), which enforces
868    /// `ttl >= 3 * renew_interval`. Effect hosts accept the same type at
869    /// construction (e.g. SQLite/Postgres effect-replay options), so a host can
870    /// share one timing decision across both boundaries.
871    pub fn lease_timings(mut self, lease_timings: lash_core::LeaseTimings) -> Self {
872        self.lease_timings = Some(lease_timings);
873        self
874    }
875
876    /// Use one host clock for runtime sleeps and embedded-store time.
877    pub fn clock(mut self, clock: Arc<dyn lash_core::Clock>) -> Self {
878        self.clock = Some(clock);
879        self
880    }
881
882    /// Configure the bounded live replay buffer used by session observation
883    /// cursors. This is best-effort reconnect recovery only; durable state
884    /// still comes from the session store and [`SessionReadView`].
885    pub fn live_replay_store(mut self, live_replay_store: Arc<dyn LiveReplayStore>) -> Self {
886        self.live_replay_store = Some(live_replay_store);
887        self
888    }
889
890    /// Resolve the runtime host config, requiring the generic host-owned
891    /// durability dependencies to have been named.
892    fn resolve_runtime_host_config(&mut self) -> Result<RuntimeHostConfig> {
893        if let Some(base) = self.runtime_host_config.take() {
894            return Ok(self.apply_core_overrides(base));
895        }
896        let effect_host = self
897            .effect_host
898            .take()
899            .ok_or(EmbedError::MissingEffectHost)?;
900        let attachment_store = self
901            .attachment_store
902            .take()
903            .ok_or(EmbedError::MissingAttachmentStore)?;
904        let process_env_store = self
905            .process_env_store
906            .take()
907            .ok_or(EmbedError::MissingProcessEnvStore)?;
908        let core = RuntimeHostConfig::new(effect_host, attachment_store, process_env_store);
909        Ok(self.apply_core_overrides(core))
910    }
911
912    /// Apply benign + still-set dependency overrides on top of a base core.
913    fn apply_core_overrides(&mut self, mut core: RuntimeHostConfig) -> RuntimeHostConfig {
914        if let Some(effect_host) = self.effect_host.take() {
915            core.control.effect_host = effect_host;
916        }
917        if let Some(attachment_store) = self.attachment_store.take() {
918            core.durability.attachment_store = Arc::new(
919                lash_core::SessionAttachmentStore::ephemeral(attachment_store),
920            );
921        }
922        if let Some(process_env_store) = self.process_env_store.take() {
923            core.durability.process_env_store = process_env_store;
924        }
925        if let Some(prompt) = self.prompt.take() {
926            core.prompt.prompt = prompt;
927        }
928        if let Some(trace_sink) = self.trace_sink.take() {
929            core.tracing.trace_sink = Some(trace_sink);
930        }
931        if let Some(trace_level) = self.trace_level.take() {
932            core.tracing.trace_level = trace_level;
933        }
934        if let Some(trace_context) = self.trace_context.take() {
935            core.tracing.trace_context = trace_context;
936        }
937        if let Some(termination) = self.termination.take() {
938            core.control.termination = termination;
939        }
940        if let Some(lease_timings) = self.lease_timings.take() {
941            core.control.lease_timings = lease_timings;
942        }
943        if let Some(clock) = self.clock.take() {
944            core.clock = clock;
945        }
946        core
947    }
948
949    /// Validate store peer-coherence of the wired durability dependencies.
950    ///
951    /// Durability is established by what the host wired; the per-invocation
952    /// durable controller is not visible here (the build-time controller is
953    /// inline by construction), so this checks the stores against each other
954    /// only — never the controller (see A5 in the durable-first wiring spec):
955    ///
956    /// - a durable session store factory requires a durable attachment and
957    ///   artifact store (they back the same session state);
958    /// - a durable process registry requires a session store factory that is
959    ///   itself durable (the registry's process records are meaningless without
960    ///   a durable session behind them).
961    fn effective_session_store_tier(&self) -> Option<DurabilityTier> {
962        self.child_store_factory
963            .as_ref()
964            .or(self.store_factory.as_ref())
965            .map(|factory| factory.durability_tier())
966    }
967
968    /// Validate store peer-coherence, sweeping every registered process engine.
969    ///
970    /// Runs after the runtime host is resolved and process-engine contributions
971    /// are installed, so it reads the effective effect host / attachment /
972    /// process-env tiers off `core`, and the session-store / trigger-store /
973    /// process-registry tiers captured from the builder before its plugin stack
974    /// was consumed. A durable session store requires every registered engine to
975    /// be durable too.
976    fn ensure_store_peer_coherence(
977        session_store_tier: Option<DurabilityTier>,
978        trigger_store_tier: Option<DurabilityTier>,
979        process_registry_tier: Option<DurabilityTier>,
980        core: &RuntimeHostConfig,
981    ) -> Result<()> {
982        let attachment_tier = Some(
983            core.durability
984                .attachment_store
985                .persistence()
986                .durability_tier(),
987        );
988        let process_env_tier = Some(core.durability.process_env_store.durability_tier());
989        let effect_host_tier = Some(core.control.effect_host.durability_tier());
990
991        if session_store_tier == Some(DurabilityTier::Durable) {
992            if attachment_tier == Some(DurabilityTier::Inline) {
993                return Err(EmbedError::DurableStorePeerRequired {
994                    facet: "attachment store",
995                });
996            }
997            if process_env_tier == Some(DurabilityTier::Inline) {
998                return Err(EmbedError::DurableStorePeerRequired {
999                    facet: "process execution environment store",
1000                });
1001            }
1002            // Every registered process engine must be durable behind a durable
1003            // session store, regardless of how it was contributed.
1004            for engine in core.process_engines.engines() {
1005                if engine.durability_tier() == DurabilityTier::Inline {
1006                    return Err(EmbedError::DurableStorePeerRequired {
1007                        facet: engine.kind(),
1008                    });
1009                }
1010            }
1011        }
1012
1013        if process_registry_tier == Some(DurabilityTier::Durable) {
1014            if session_store_tier != Some(DurabilityTier::Durable) {
1015                return Err(EmbedError::DurableProcessRegistryRequiresStoreFactory);
1016            }
1017            if trigger_store_tier != Some(DurabilityTier::Durable) {
1018                return Err(EmbedError::DurableStorePeerRequired {
1019                    facet: "trigger store",
1020                });
1021            }
1022            if process_env_tier != Some(DurabilityTier::Durable) {
1023                return Err(EmbedError::DurableStorePeerRequired {
1024                    facet: "process execution environment store",
1025                });
1026            }
1027        }
1028
1029        if trigger_store_tier == Some(DurabilityTier::Durable) {
1030            if session_store_tier != Some(DurabilityTier::Durable) {
1031                return Err(EmbedError::DurableStorePeerRequired {
1032                    facet: "session store factory",
1033                });
1034            }
1035            if process_env_tier != Some(DurabilityTier::Durable) {
1036                return Err(EmbedError::DurableStorePeerRequired {
1037                    facet: "process execution environment store",
1038                });
1039            }
1040            if process_registry_tier == Some(DurabilityTier::Inline) {
1041                return Err(EmbedError::DurableStorePeerRequired {
1042                    facet: "process registry",
1043                });
1044            }
1045        }
1046
1047        if effect_host_tier == Some(DurabilityTier::Durable) {
1048            if attachment_tier != Some(DurabilityTier::Durable) {
1049                return Err(EmbedError::DurableStorePeerRequired {
1050                    facet: "attachment store",
1051                });
1052            }
1053            if process_env_tier != Some(DurabilityTier::Durable) {
1054                return Err(EmbedError::DurableStorePeerRequired {
1055                    facet: "process execution environment store",
1056                });
1057            }
1058        }
1059
1060        Ok(())
1061    }
1062
1063    pub fn build(mut self) -> Result<LashCore> {
1064        let process_execution_concurrency = self
1065            .process_execution_concurrency
1066            .unwrap_or(lash_core::DEFAULT_PROCESS_EXECUTION_CONCURRENCY);
1067        DurableProcessWorkerConfig::validate_process_execution_concurrency(
1068            process_execution_concurrency,
1069        )?;
1070        let protocol_factory = self.protocol_factory.clone();
1071        if protocol_factory.is_none() && self.plugin_host.is_none() {
1072            return Err(EmbedError::MissingProtocolPlugin);
1073        }
1074        let provider_id = self
1075            .session_spec
1076            .provider_id
1077            .clone()
1078            .or_else(|| {
1079                self.provider
1080                    .as_ref()
1081                    .map(|provider| provider.kind().to_string())
1082            })
1083            .unwrap_or_default();
1084        let model = self
1085            .session_spec
1086            .model
1087            .clone()
1088            .ok_or(EmbedError::MissingModelSpec)?;
1089
1090        let base_policy = SessionPolicy {
1091            provider_id,
1092            model,
1093            max_turns: self.session_spec.max_turns.flatten(),
1094            ..SessionPolicy::default()
1095        };
1096        let policy = self.session_spec.resolve_against(&base_policy);
1097
1098        // Capture the store-peer tiers that live on builder fields before the
1099        // plugin stack is consumed below; the engine sweep happens after install.
1100        let session_store_tier = self.effective_session_store_tier();
1101        let trigger_store_tier = self
1102            .trigger_store
1103            .as_ref()
1104            .map(|store| store.durability_tier());
1105        let process_work_source = self
1106            .process_work_source
1107            .clone()
1108            .watched(self.process_event_sink.clone());
1109        let process_registry_tier = process_work_source
1110            .process_registry()
1111            .map(|registry| registry.durability_tier());
1112
1113        let mut core = self.resolve_runtime_host_config()?;
1114        if let Some(provider) = self.provider.clone() {
1115            core.providers.provider_resolver =
1116                Arc::new(lash_core::SingleProviderResolver::new(provider));
1117        }
1118        let plugin_factories = if let Some(plugin_host) = self.plugin_host {
1119            plugin_host.factories().to_vec()
1120        } else {
1121            let mut factories = Vec::new();
1122            if !self.tool_providers.is_empty() {
1123                let spec = self
1124                    .tool_providers
1125                    .into_iter()
1126                    .fold(PluginSpec::new(), PluginSpec::with_tool_provider);
1127                factories.push(Arc::new(StaticPluginFactory::new("embed_tools", spec))
1128                    as Arc<dyn PluginFactory>);
1129            }
1130            factories.extend(self.plugin_stack.into_factories());
1131            factories
1132        };
1133        let default_plugin_host =
1134            build_plugin_host(protocol_factory.as_ref(), &plugin_factories, Vec::new())?;
1135        // Whether process lifecycle is available (a process registry is wired).
1136        // Threaded to every plugin host so core installs the same
1137        // plugin-contributed process engines wherever it rebuilds a runtime.
1138        let process_lifecycle_available = process_work_source.has_registry();
1139        // Install onto a throwaway clone purely to sweep every registered
1140        // engine's durability tier for the coherence check. `env.core` stays
1141        // free of plugin-contributed engines so that each runtime-construction
1142        // site (session open, queued-work drain, durable process worker)
1143        // installs them fresh onto a clean registry — keeping the unique-kind
1144        // enforcement in `try_with_engine` a genuine cross-factory check rather
1145        // than a self-collision on a registry that already carries them.
1146        let core_with_engines = default_plugin_host
1147            .install_process_engine_contributions(core.clone(), process_lifecycle_available)?;
1148        // Coherence runs after engines are installed so it can sweep every
1149        // registered engine's durability tier.
1150        Self::ensure_store_peer_coherence(
1151            session_store_tier,
1152            trigger_store_tier,
1153            process_registry_tier,
1154            &core_with_engines,
1155        )?;
1156
1157        let process_registry = process_work_source.process_registry();
1158
1159        // Resolve process work before the process source is moved into the
1160        // environment. The default inline driver's config is built
1161        // eagerly so a missing store factory fails loudly at build, not at
1162        // first open. It is built from the same single-protocol plugin host the
1163        // live runtime uses, so the worker can rebuild a runtime for a process.
1164        let process_work_driver = Self::resolve_process_work_driver(
1165            &process_work_source,
1166            &default_plugin_host,
1167            &core,
1168            process_lifecycle_available,
1169            // The worker rebuilds sessions with the same factory `build()` wires
1170            // below: `child_store_factory.or(store_factory)`.
1171            self.child_store_factory
1172                .as_ref()
1173                .or(self.store_factory.as_ref()),
1174            &policy,
1175            self.residency.unwrap_or_default(),
1176            self.trigger_store.as_ref(),
1177            process_execution_concurrency,
1178        )?;
1179
1180        let live_replay_clock = Arc::clone(&core.clock);
1181        let mut env_builder = RuntimeEnvironment::builder()
1182            .with_plugin_host(Arc::new(default_plugin_host))
1183            .with_runtime_host_config(core);
1184        if let Some(process_registry) = process_registry.as_ref() {
1185            env_builder = env_builder.with_process_registry(Arc::clone(process_registry));
1186        }
1187        if let Some(residency) = self.residency {
1188            env_builder = env_builder.with_residency(residency);
1189        }
1190        if let Some(child_store_factory) = self
1191            .child_store_factory
1192            .as_ref()
1193            .or(self.store_factory.as_ref())
1194        {
1195            env_builder = env_builder.with_session_store_factory(Arc::clone(child_store_factory));
1196        }
1197        if let Some(trigger_store) = self.trigger_store.as_ref() {
1198            env_builder = env_builder.with_trigger_store(Arc::clone(trigger_store));
1199        }
1200        let live_replay_store = self.live_replay_store.take().unwrap_or_else(|| {
1201            Arc::new(InMemoryLiveReplayStore::with_clock(
1202                lash_core::InMemoryLiveReplayStoreConfig::default(),
1203                live_replay_clock,
1204            ))
1205        });
1206        let env = env_builder.build();
1207        let queued_work_driver = Self::resolve_queued_work_driver(
1208            &self.queued_work_source,
1209            env.clone(),
1210            policy.clone(),
1211            protocol_factory.clone(),
1212            Arc::new(plugin_factories.clone()),
1213            self.child_store_factory
1214                .as_ref()
1215                .or(self.store_factory.as_ref()),
1216            Arc::clone(&live_replay_store),
1217            process_lifecycle_available,
1218        );
1219        let work_driver = InlineWorkDriverSetup {
1220            process: process_work_driver,
1221            queued: queued_work_driver,
1222        };
1223
1224        Ok(LashCore {
1225            env,
1226            policy,
1227            store_factory: self.store_factory,
1228            plugin_factories: Arc::new(plugin_factories),
1229            provider: self.provider,
1230            live_replay_store,
1231            protocol_factory,
1232            process_lifecycle_available,
1233            process_execution_concurrency,
1234            work_driver: Arc::new(InlineWorkDriverSlot::new(work_driver)),
1235        })
1236    }
1237
1238    /// Decide how a built [`LashCore`] sources its process work driver.
1239    ///
1240    /// - no registry => nothing to run ([`ProcessWorkDriverSetup::None`]);
1241    /// - external driver wired => use it ([`ProcessWorkDriverSetup::External`]);
1242    /// - inline registry wired => lazily construct the default inline driver on first open. Its
1243    ///   [`DurableProcessWorkerConfig`] is built eagerly when a store factory is
1244    ///   present; without one the inline worker cannot rebuild session runtimes.
1245    // Mirrors the sibling `resolve_queued_work_driver`: a builder helper whose
1246    // inputs are the heterogeneous, all-required driver-resolution state and
1247    // have no cohesive sub-grouping.
1248    #[allow(clippy::too_many_arguments)]
1249    fn resolve_process_work_driver(
1250        process_work_source: &ProcessWorkSource,
1251        worker_plugin_host: &PluginHost,
1252        core: &RuntimeHostConfig,
1253        process_lifecycle_available: bool,
1254        store_factory: Option<&Arc<dyn SessionStoreFactory>>,
1255        policy: &SessionPolicy,
1256        residency: lash_core::Residency,
1257        trigger_store: Option<&Arc<dyn lash_core::TriggerStore>>,
1258        process_execution_concurrency: usize,
1259    ) -> Result<ProcessWorkDriverSetup> {
1260        let (process_registry, process_change_hub) = match process_work_source {
1261            ProcessWorkSource::None => return Ok(ProcessWorkDriverSetup::None),
1262            ProcessWorkSource::External(driver) => {
1263                return Ok(ProcessWorkDriverSetup::External {
1264                    driver: driver.clone(),
1265                });
1266            }
1267            ProcessWorkSource::Inline { registry, hub } => (Arc::clone(registry), hub.clone()),
1268        };
1269        // The worker rebuilds a session runtime per process, so it needs a store
1270        // factory; without one the default runner could not execute anything, so
1271        // fail loudly rather than silently leave processes unexecuted.
1272        let Some(store_factory) = store_factory else {
1273            return Err(EmbedError::ProcessRegistryRequiresStoreFactory);
1274        };
1275        // The worker rebuilds with the same plugin host the live runtime uses,
1276        // including the protocol plugin that supplies the protocol session
1277        // capability. Install its plugin-contributed process engines onto a
1278        // clean copy of the base host — `core` deliberately carries none.
1279        let runtime_host = worker_plugin_host
1280            .install_process_engine_contributions(core.clone(), process_lifecycle_available)?;
1281        let phase_probe_slot = lash_core::runtime::RuntimeTurnPhaseProbeSlot::default();
1282        let mut config = DurableProcessWorkerConfig::new(
1283            Arc::new(worker_plugin_host.clone()),
1284            runtime_host,
1285            Arc::clone(store_factory),
1286            process_registry,
1287        )
1288        .with_session_policy(policy.clone())
1289        .with_trigger_store(trigger_store.cloned().unwrap_or_else(|| {
1290            Arc::new(lash_core::InMemoryTriggerStore::with_clock(Arc::clone(
1291                &core.clock,
1292            )))
1293        }))
1294        .with_residency(residency)
1295        .with_turn_phase_probe_slot(phase_probe_slot)
1296        .with_process_execution_concurrency(process_execution_concurrency)?;
1297        if let Some(hub) = process_change_hub {
1298            config = config.with_change_hub(hub);
1299        }
1300        let config = Box::new(config);
1301        Ok(ProcessWorkDriverSetup::LazyDefault { config })
1302    }
1303
1304    #[allow(clippy::too_many_arguments)]
1305    fn resolve_queued_work_driver(
1306        queued_work_source: &QueuedWorkSource,
1307        env: RuntimeEnvironment,
1308        policy: SessionPolicy,
1309        protocol_factory: Option<Arc<dyn PluginFactory>>,
1310        plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
1311        store_factory: Option<&Arc<dyn SessionStoreFactory>>,
1312        live_replay_store: Arc<dyn LiveReplayStore>,
1313        process_lifecycle_available: bool,
1314    ) -> QueuedWorkDriverSetup {
1315        match queued_work_source {
1316            QueuedWorkSource::None => QueuedWorkDriverSetup::None,
1317            QueuedWorkSource::External(driver) => QueuedWorkDriverSetup::External {
1318                driver: driver.clone(),
1319            },
1320            QueuedWorkSource::LazyDefault => match store_factory {
1321                Some(store_factory) => QueuedWorkDriverSetup::LazyDefault {
1322                    config: Arc::new(InlineQueuedWorkRunConfig::new(
1323                        env,
1324                        policy,
1325                        protocol_factory,
1326                        plugin_factories,
1327                        Arc::clone(store_factory),
1328                        live_replay_store,
1329                        process_lifecycle_available,
1330                    )),
1331                },
1332                None => QueuedWorkDriverSetup::None,
1333            },
1334        }
1335    }
1336
1337    pub fn advanced(self) -> AdvancedLashCoreBuilder {
1338        AdvancedLashCoreBuilder { builder: self }
1339    }
1340
1341    pub fn process_registry(mut self, process_registry: Arc<dyn ProcessRegistry>) -> Self {
1342        self.process_work_source = ProcessWorkSource::Inline {
1343            registry: process_registry,
1344            hub: None,
1345        };
1346        self
1347    }
1348
1349    /// Install a best-effort, host-facing [`ProcessEventSink`] on the inline
1350    /// process registry.
1351    ///
1352    /// Each appended process event is pushed to the sink after its durable
1353    /// write, in per-process append order. This is freshness, not truth: it
1354    /// never buffers or retries, terminal events are not emitted through it
1355    /// (observe completion via the await seam), and consumers reconcile from
1356    /// the durable event log. See [`ProcessEventSink`] for the full contract.
1357    ///
1358    /// Applies to the inline registry path ([`Self::process_registry`]); a host
1359    /// that supplies its own [`ProcessWorkDriver`](lash_core::ProcessWorkDriver)
1360    /// installs the sink through the driver's constructor instead.
1361    ///
1362    /// [`ProcessEventSink`]: lash_core::ProcessEventSink
1363    pub fn process_event_sink(mut self, sink: Arc<dyn lash_core::ProcessEventSink>) -> Self {
1364        self.process_event_sink = Some(sink);
1365        self
1366    }
1367
1368    pub fn trigger_store(mut self, store: Arc<dyn lash_core::TriggerStore>) -> Self {
1369        self.trigger_store = Some(store);
1370        self
1371    }
1372
1373    /// Configure an externally owned process work runner.
1374    ///
1375    /// Durable hosts construct a [`ProcessWorkDriver`] from the same process
1376    /// registry and wake handle used by their deployment runner, then pass it
1377    /// here. The driver registry becomes the core's process registry and no
1378    /// inline runner is spawned.
1379    pub fn process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
1380        self.process_work_source = ProcessWorkSource::External(driver);
1381        self
1382    }
1383
1384    /// Configure an externally owned queued-work driver.
1385    pub fn queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
1386        self.queued_work_source = QueuedWorkSource::External(driver);
1387        self
1388    }
1389
1390    pub fn disable_queued_work_driver(mut self) -> Self {
1391        self.queued_work_source = QueuedWorkSource::None;
1392        self
1393    }
1394}
1395
1396pub(crate) fn build_plugin_host(
1397    protocol_factory: Option<&Arc<dyn PluginFactory>>,
1398    common_factories: &[Arc<dyn PluginFactory>],
1399    extra_factories: Vec<Arc<dyn PluginFactory>>,
1400) -> Result<PluginHost> {
1401    let mut factories = Vec::with_capacity(
1402        usize::from(protocol_factory.is_some()) + common_factories.len() + extra_factories.len(),
1403    );
1404    if let Some(protocol_factory) = protocol_factory {
1405        factories.push(Arc::clone(protocol_factory));
1406    }
1407    factories.extend(common_factories.iter().cloned());
1408    factories.extend(extra_factories);
1409    Ok(PluginHost::new(factories))
1410}
1411
1412impl PromptLayerSink for LashCoreBuilder {
1413    fn prompt_layer_mut(&mut self) -> &mut PromptLayer {
1414        self.prompt.get_or_insert_with(PromptLayer::new)
1415    }
1416}
1417
1418pub struct AdvancedLashCoreBuilder {
1419    builder: LashCoreBuilder,
1420}
1421
1422impl AdvancedLashCoreBuilder {
1423    pub fn runtime_host_config(mut self, core: lash_core::RuntimeHostConfig) -> Self {
1424        self.builder.runtime_host_config = Some(core);
1425        self
1426    }
1427
1428    pub fn plugin_host(mut self, plugin_host: PluginHost) -> Self {
1429        self.builder.plugin_host = Some(plugin_host);
1430        self
1431    }
1432
1433    pub fn build(self) -> Result<LashCore> {
1434        self.builder.build()
1435    }
1436}