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