Skip to main content

lash/
session.rs

1use std::pin::Pin;
2use std::task::{Context, Poll};
3
4use crate::support::*;
5use futures_util::Stream;
6use lash_core::runtime::{PendingTurnInput, QueuedWorkBatch, TurnInputIngress};
7use lash_core::{LiveReplayGap, LiveReplayStoreError, SessionObservationEvent};
8use lash_remote_protocol::{
9    RemoteLiveReplayGap, RemoteSessionCursor, RemoteSessionObservation,
10    RemoteSessionObservationEvent,
11};
12
13pub struct SessionBuilder {
14    pub(crate) core: LashCore,
15    pub(crate) session_id: String,
16    pub(crate) spec: SessionSpec,
17    pub(crate) parent_session_id: Option<String>,
18    pub(crate) session_execution_owner: Option<lash_core::LeaseOwnerIdentity>,
19    pub(crate) store: Option<Arc<dyn RuntimePersistence>>,
20    pub(crate) provider: Option<ProviderHandle>,
21    pub(crate) active_plugins: Vec<ActivePluginBinding>,
22    pub(crate) plugin_factories: Vec<Arc<dyn PluginFactory>>,
23}
24
25#[cfg(feature = "rlm")]
26pub struct RlmSessionBuilder {
27    pub(crate) builder: SessionBuilder,
28    pub(crate) rlm_final_answer_format: Option<lash_rlm_types::RlmFinalAnswerFormat>,
29}
30
31impl SessionBuilder {
32    pub fn provider(mut self, provider: ProviderHandle) -> Self {
33        self.spec = self.spec.provider_id(provider.kind());
34        self.provider = Some(provider);
35        self
36    }
37
38    pub fn session_spec(mut self, spec: SessionSpec) -> Self {
39        self.spec = spec;
40        self
41    }
42
43    pub fn parent(mut self, parent_session_id: impl Into<String>) -> Self {
44        self.parent_session_id = Some(parent_session_id.into());
45        self
46    }
47
48    /// Use an explicit owner identity for durable session execution leases.
49    ///
50    /// This is only for hosts that already serialize one logical execution lane
51    /// and intentionally choose stable owner + incarnation values. Normal
52    /// embedders should keep the default per-open identity.
53    pub fn session_execution_owner(mut self, owner: lash_core::LeaseOwnerIdentity) -> Self {
54        self.session_execution_owner = Some(owner);
55        self
56    }
57
58    /// Use a specific persistence store for this root session.
59    ///
60    /// This is the right API for a host-owned, pre-opened session database.
61    /// Managed child sessions never reuse this store; configure
62    /// `LashCoreBuilder::child_store_factory` when child sessions should also
63    /// persist.
64    pub fn store(mut self, store: Arc<dyn RuntimePersistence>) -> Self {
65        self.store = Some(store);
66        self
67    }
68
69    pub fn plugin<P: PluginBinding>(mut self, config: P::SessionConfig) -> Self {
70        self.active_plugins.push(ActivePluginBinding {
71            id: P::ID,
72            requires_turn_input: P::requires_turn_input(&config),
73        });
74        self.plugin_factories.push(P::factory(&config));
75        self
76    }
77
78    pub async fn open(self) -> Result<LashSession> {
79        let policy = self.session_policy();
80        let store = self.create_store(&policy).await?;
81        let state = self
82            .load_or_default_state(&policy, store.as_deref())
83            .await?;
84        self.open_resolved(policy, state, store).await
85    }
86
87    /// Open this session with a fresh resident graph, ignoring any persisted
88    /// session graph/checkpoint state that may already exist for the same
89    /// session id.
90    ///
91    /// The next successful commit writes a full replacement graph, so normal
92    /// embedders can use this to start over without manually calling
93    /// `load_persisted_session_state` or constructing a `RuntimeSessionState`.
94    /// Use [`Self::open`] for resume and [`Self::open_with_state`] only when
95    /// restoring explicit host-owned state.
96    pub async fn open_fresh(self) -> Result<LashSession> {
97        let policy = self.session_policy();
98        let store = self.create_store(&policy).await?;
99        let state = RuntimeSessionState {
100            session_id: self.session_id.clone(),
101            policy: policy.clone(),
102            graph_replace_required: true,
103            ..RuntimeSessionState::default()
104        };
105        self.open_resolved(policy, state, store).await
106    }
107
108    /// Open with an explicitly supplied runtime state.
109    ///
110    /// This is for advanced hosts that already own a complete state snapshot.
111    /// Normal embedders should use [`Self::open`] to resume according to Lash's
112    /// residency policy or [`Self::open_fresh`] to start over and replace prior
113    /// persisted state on the next commit.
114    pub async fn open_with_state(self, mut state: RuntimeSessionState) -> Result<LashSession> {
115        let policy = self.session_policy();
116        let store = self.create_store(&policy).await?;
117        if state.session_id != self.session_id {
118            return Err(EmbedError::StoreSessionMismatch {
119                loaded: state.session_id,
120                requested: self.session_id,
121            });
122        }
123        let recorded_provider_id = state.policy.recorded_provider_id().to_string();
124        state.policy = policy.clone();
125        state.policy.provider_id = recorded_provider_id;
126        self.open_resolved(policy, state, store).await
127    }
128
129    fn session_policy(&self) -> SessionPolicy {
130        let mut policy = self.spec.resolve_against(&self.core.policy);
131        policy.session_id = Some(self.session_id.clone());
132        policy
133    }
134
135    async fn load_or_default_state(
136        &self,
137        policy: &SessionPolicy,
138        store: Option<&dyn RuntimePersistence>,
139    ) -> Result<RuntimeSessionState> {
140        let state = match store {
141            Some(store) => {
142                let loaded = self.load_persisted_state_for_residency(store).await?;
143                let mut state = loaded.unwrap_or_else(|| RuntimeSessionState {
144                    session_id: self.session_id.clone(),
145                    policy: policy.clone(),
146                    ..RuntimeSessionState::default()
147                });
148                if state.session_id != self.session_id {
149                    return Err(EmbedError::StoreSessionMismatch {
150                        loaded: state.session_id,
151                        requested: self.session_id.clone(),
152                    });
153                }
154                let recorded_provider_id = state.policy.recorded_provider_id().to_string();
155                state.policy = policy.clone();
156                state.policy.provider_id = recorded_provider_id;
157                state
158            }
159            None => RuntimeSessionState {
160                session_id: self.session_id.clone(),
161                policy: policy.clone(),
162                ..RuntimeSessionState::default()
163            },
164        };
165        Ok(state)
166    }
167
168    async fn load_persisted_state_for_residency(
169        &self,
170        store: &dyn RuntimePersistence,
171    ) -> Result<Option<RuntimeSessionState>> {
172        load_persisted_state_for_residency(self.core.env.residency, store).await
173    }
174
175    async fn open_resolved(
176        self,
177        policy: SessionPolicy,
178        state: RuntimeSessionState,
179        store: Option<Arc<dyn RuntimePersistence>>,
180    ) -> Result<LashSession> {
181        let mut env = self.core.env.clone();
182        if let Some(provider) = self.provider.clone().or_else(|| self.core.provider.clone()) {
183            env.core.providers.provider_resolver =
184                Arc::new(lash_core::SingleProviderResolver::new(provider));
185        }
186        let plugin_host = build_plugin_host(
187            self.core.protocol_factory.as_ref(),
188            self.core.plugin_factories.as_ref(),
189            self.plugin_factories,
190        )?;
191        env.core = self
192            .core
193            .runtime_host_for_plugin_host(env.core.clone(), &plugin_host)?;
194        env.plugin_host = Some(Arc::new(plugin_host));
195        let effect_host = Arc::clone(&env.core.control.effect_host);
196        let drivers = self.core.work_driver.drivers().await;
197        env.process_work_driver = drivers.process.clone();
198        env.queued_work_driver = drivers.queued.clone();
199        let mut runtime = LashRuntime::from_environment(&env, policy, state, store).await?;
200        if let Some(owner) = self.session_execution_owner {
201            runtime.set_runtime_lease_owner(owner);
202        }
203        if drivers.drive_process_on_open
204            && let Some(driver) = drivers.process.as_ref()
205        {
206            driver.claim_and_run_pending("session_open").await?;
207        }
208        let handle = RuntimeHandle::with_live_replay_store(
209            runtime,
210            Arc::clone(&self.core.live_replay_store),
211        );
212        Ok(LashSession {
213            runtime: handle,
214            effect_host,
215            parent_session_id: self.parent_session_id,
216            active_plugins: self.active_plugins,
217            process_phase_probe_slot: self.core.work_driver.phase_probe_slot(),
218            turn_cancels: crate::turn::TurnCancelRegistry::default(),
219        })
220    }
221
222    async fn create_store(
223        &self,
224        policy: &SessionPolicy,
225    ) -> Result<Option<Arc<dyn RuntimePersistence>>> {
226        if let Some(store) = self.store.as_ref() {
227            return Ok(Some(Arc::clone(store)));
228        }
229        let Some(factory) = self.core.store_factory.as_ref() else {
230            return Ok(None);
231        };
232        let request = SessionStoreCreateRequest {
233            session_id: self.session_id.clone(),
234            relation: self
235                .parent_session_id
236                .as_ref()
237                .map(|parent_session_id| lash_core::SessionRelation::Child {
238                    parent_session_id: parent_session_id.clone(),
239                    caused_by: None,
240                })
241                .unwrap_or_default(),
242            policy: policy.clone(),
243        };
244        factory
245            .create_store(&request)
246            .await
247            .map(Some)
248            .map_err(|message| EmbedError::StoreFactory {
249                session_id: self.session_id.clone(),
250                message,
251            })
252    }
253}
254
255pub(crate) async fn load_state_for_residency(
256    residency: Residency,
257    session_id: &str,
258    policy: &SessionPolicy,
259    store: &dyn RuntimePersistence,
260) -> Result<RuntimeSessionState> {
261    let mut state = load_persisted_state_for_residency(residency, store)
262        .await?
263        .unwrap_or_else(|| RuntimeSessionState {
264            session_id: session_id.to_string(),
265            policy: policy.clone(),
266            ..RuntimeSessionState::default()
267        });
268    if state.session_id != session_id {
269        return Err(EmbedError::StoreSessionMismatch {
270            loaded: state.session_id,
271            requested: session_id.to_string(),
272        });
273    }
274    let recorded_provider_id = state.policy.recorded_provider_id().to_string();
275    state.policy = policy.clone();
276    state.policy.provider_id = recorded_provider_id;
277    Ok(state)
278}
279
280async fn load_persisted_state_for_residency(
281    residency: Residency,
282    store: &dyn RuntimePersistence,
283) -> Result<Option<RuntimeSessionState>> {
284    match residency {
285        Residency::KeepAll => {
286            let loaded = lash_core::store::load_persisted_session_state(store)
287                .await
288                .map_err(|err| SessionError::Protocol(format!("failed to load store: {err}")))?;
289            Ok(loaded)
290        }
291        Residency::ActivePathOnly => {
292            let active = lash_core::store::load_persisted_session_state_active_path(store, None)
293                .await
294                .map_err(|err| {
295                    SessionError::Protocol(format!("failed to load active-path store: {err}"))
296                })?;
297            if active
298                .as_ref()
299                .is_some_and(|state| state.session_graph.nodes.is_empty())
300            {
301                let mut full = lash_core::store::load_persisted_session_state(store)
302                    .await
303                    .map_err(|err| {
304                        SessionError::Protocol(format!(
305                            "failed to heal active-path store from full graph: {err}"
306                        ))
307                    })?;
308                if let Some(state) = full.as_mut() {
309                    state.graph_replace_required = true;
310                }
311                return Ok(full);
312            }
313            Ok(active)
314        }
315    }
316}
317
318impl PromptLayerSink for SessionBuilder {
319    fn prompt_layer_mut(&mut self) -> &mut PromptLayer {
320        self.spec.prompt.get_or_insert_with(PromptLayer::new)
321    }
322}
323
324#[cfg(feature = "rlm")]
325impl RlmSessionBuilder {
326    pub fn provider(mut self, provider: ProviderHandle) -> Self {
327        self.builder = self.builder.provider(provider);
328        self
329    }
330
331    pub fn session_spec(mut self, spec: SessionSpec) -> Self {
332        self.builder = self.builder.session_spec(spec);
333        self
334    }
335
336    pub fn parent(mut self, parent_session_id: impl Into<String>) -> Self {
337        self.builder = self.builder.parent(parent_session_id);
338        self
339    }
340
341    pub fn session_execution_owner(mut self, owner: lash_core::LeaseOwnerIdentity) -> Self {
342        self.builder = self.builder.session_execution_owner(owner);
343        self
344    }
345
346    pub fn store(mut self, store: Arc<dyn RuntimePersistence>) -> Self {
347        self.builder = self.builder.store(store);
348        self
349    }
350
351    pub fn plugin<P: PluginBinding>(mut self, config: P::SessionConfig) -> Self {
352        self.builder = self.builder.plugin::<P>(config);
353        self
354    }
355
356    pub async fn open(self) -> Result<LashSession> {
357        self.open_resolved(RlmOpenState::Resume).await
358    }
359
360    pub async fn open_fresh(self) -> Result<LashSession> {
361        self.open_resolved(RlmOpenState::Fresh).await
362    }
363
364    pub async fn open_with_state(self, state: RuntimeSessionState) -> Result<LashSession> {
365        self.open_resolved(RlmOpenState::Explicit(state)).await
366    }
367
368    async fn open_resolved(self, open_state: RlmOpenState) -> Result<LashSession> {
369        let Self {
370            builder,
371            rlm_final_answer_format,
372        } = self;
373        let policy = builder.session_policy();
374        let store = builder.create_store(&policy).await?;
375        let mut state = match open_state {
376            RlmOpenState::Resume => {
377                builder
378                    .load_or_default_state(&policy, store.as_deref())
379                    .await?
380            }
381            RlmOpenState::Fresh => RuntimeSessionState {
382                session_id: builder.session_id.clone(),
383                policy: policy.clone(),
384                graph_replace_required: true,
385                ..RuntimeSessionState::default()
386            },
387            RlmOpenState::Explicit(mut state) => {
388                if state.session_id != builder.session_id {
389                    return Err(EmbedError::StoreSessionMismatch {
390                        loaded: state.session_id,
391                        requested: builder.session_id.clone(),
392                    });
393                }
394                let recorded_provider_id = state.policy.recorded_provider_id().to_string();
395                state.policy = policy.clone();
396                state.policy.provider_id = recorded_provider_id;
397                state
398            }
399        };
400        apply_rlm_session_options(
401            builder.parent_session_id.is_none(),
402            rlm_final_answer_format,
403            &mut state,
404        )?;
405        builder.open_resolved(policy, state, store).await
406    }
407}
408
409#[cfg(feature = "rlm")]
410impl PromptLayerSink for RlmSessionBuilder {
411    fn prompt_layer_mut(&mut self) -> &mut PromptLayer {
412        self.builder.prompt_layer_mut()
413    }
414}
415
416#[cfg(feature = "rlm")]
417#[allow(clippy::large_enum_variant)]
418enum RlmOpenState {
419    Resume,
420    Fresh,
421    Explicit(RuntimeSessionState),
422}
423
424#[cfg(feature = "rlm")]
425fn apply_rlm_session_options(
426    is_root_session: bool,
427    explicit_format: Option<lash_rlm_types::RlmFinalAnswerFormat>,
428    state: &mut RuntimeSessionState,
429) -> Result<()> {
430    let final_answer_format = explicit_format.unwrap_or({
431        if is_root_session {
432            lash_rlm_types::RlmFinalAnswerFormat::Markdown
433        } else {
434            lash_rlm_types::RlmFinalAnswerFormat::RawFinalValue
435        }
436    });
437    let mut extras = if state.protocol_turn_options.is_empty() {
438        lash_rlm_types::RlmCreateExtras::default()
439    } else {
440        state.protocol_turn_options.decode()?
441    };
442    extras.final_answer_format = Some(final_answer_format);
443    let options = ProtocolTurnOptions::typed(extras)?;
444    state.protocol_turn_options = options.clone();
445    for frame in &mut state.agent_frames {
446        frame.protocol_turn_options = options.clone();
447    }
448    Ok(())
449}
450
451#[cfg(all(test, feature = "rlm"))]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn apply_rlm_session_options_preserves_existing_termination() -> Result<()> {
457        let mut state = RuntimeSessionState {
458            protocol_turn_options: ProtocolTurnOptions::typed(lash_rlm_types::RlmCreateExtras {
459                termination: lash_rlm_types::RlmTermination::Natural,
460                final_answer_format: None,
461            })?,
462            ..Default::default()
463        };
464
465        apply_rlm_session_options(true, None, &mut state)?;
466
467        let extras: lash_rlm_types::RlmCreateExtras = state.protocol_turn_options.decode()?;
468        assert_eq!(extras.termination, lash_rlm_types::RlmTermination::Natural);
469        assert_eq!(
470            extras.final_answer_format,
471            Some(lash_rlm_types::RlmFinalAnswerFormat::Markdown)
472        );
473        Ok(())
474    }
475}
476
477#[derive(Clone)]
478pub struct LashSession {
479    pub(crate) runtime: RuntimeHandle,
480    pub(crate) effect_host: Arc<dyn EffectHost>,
481    pub(crate) parent_session_id: Option<String>,
482    pub(crate) active_plugins: Vec<ActivePluginBinding>,
483    pub(crate) process_phase_probe_slot: Option<lash_core::runtime::RuntimeTurnPhaseProbeSlot>,
484    pub(crate) turn_cancels: crate::turn::TurnCancelRegistry,
485}
486
487#[derive(Clone, Debug, Default)]
488pub struct SessionConfigPatch {
489    pub provider: Option<ProviderHandle>,
490    pub model: Option<ModelSpec>,
491    pub prompt: Option<PromptLayer>,
492}
493
494impl LashSession {
495    pub async fn close(self) -> Result<()> {
496        let runtime = self.runtime.writer();
497        let runtime = runtime.lock().await;
498        runtime.unregister_plugin_session()?;
499        Ok(())
500    }
501
502    pub fn session_id(&self) -> String {
503        self.runtime.observe().session_id().to_string()
504    }
505
506    pub fn policy_snapshot(&self) -> SessionPolicy {
507        self.runtime.observe().policy.clone()
508    }
509
510    pub fn observe(&self) -> ObservableSession {
511        ObservableSession {
512            runtime: self.runtime.clone(),
513        }
514    }
515
516    pub fn parent_session_id(&self) -> Option<&str> {
517        self.parent_session_id.as_deref()
518    }
519
520    pub fn effect_host(&self) -> Arc<dyn EffectHost> {
521        Arc::clone(&self.effect_host)
522    }
523
524    pub fn turn(&self, input: TurnInput) -> TurnBuilder {
525        TurnBuilder {
526            runtime: self.runtime.clone(),
527            effect_host: Arc::clone(&self.effect_host),
528            active_plugins: self.active_plugins.clone(),
529            input,
530            cancel: CancellationToken::new(),
531            cancels: self.turn_cancels.clone(),
532            protocol_turn_options: None,
533            provider: None,
534            model: None,
535            turn_id: None,
536        }
537    }
538
539    pub fn queued_turn(&self) -> QueuedTurnBuilder {
540        QueuedTurnBuilder {
541            runtime: self.runtime.clone(),
542            effect_host: Arc::clone(&self.effect_host),
543            cancel: CancellationToken::new(),
544            cancels: self.turn_cancels.clone(),
545            batch_ids: Vec::new(),
546            drain_id: None,
547        }
548    }
549
550    /// Cancel every turn currently executing through this opened session
551    /// (including its clones) and report how many were signalled.
552    ///
553    /// This is the affordance behind a UI "stop" control: hold a clone of the
554    /// session wherever the stop arrives and call this, instead of threading a
555    /// [`CancellationToken`](crate::CancellationToken) into every turn call
556    /// ([`TurnBuilder::cancel`](crate::TurnBuilder::cancel) remains the
557    /// per-turn hook when you need one). A cancelled turn finishes with
558    /// `TurnOutcome::Stopped(TurnStop::Cancelled)` and commits like any other
559    /// turn; the session stays usable.
560    ///
561    /// Scope: turns started from this `LashSession` instance and its clones.
562    /// A handle opened separately for the same session id has its own
563    /// registry and is not reached.
564    pub fn cancel_running_turns(&self) -> usize {
565        self.turn_cancels.cancel_all()
566    }
567
568    pub fn admin(&self) -> SessionAdmin {
569        SessionAdmin {
570            runtime: self.runtime.clone(),
571        }
572    }
573
574    pub async fn configure(&self, patch: SessionConfigPatch) -> Result<()> {
575        self.admin().config().update(patch).await
576    }
577
578    pub fn tools(&self) -> ToolAdmin {
579        ToolAdmin::new(self.admin())
580    }
581
582    pub fn commands(&self) -> SessionCommandAdmin {
583        self.admin().commands()
584    }
585
586    pub fn triggers(&self) -> SessionTriggerAdmin {
587        self.admin().triggers()
588    }
589
590    pub fn processes(&self) -> SessionProcessAdmin {
591        SessionProcessAdmin::new(self.admin())
592    }
593
594    pub fn plugin_operations(&self) -> PluginOperations {
595        PluginOperations {
596            control: self.admin(),
597        }
598    }
599
600    pub fn enqueue(&self, input: TurnInput) -> EnqueueTurnBuilder<'_> {
601        EnqueueTurnBuilder {
602            session: self,
603            input,
604            id: None,
605            ingress: TurnInputIngress::NextTurn,
606        }
607    }
608
609    /// Return all pending durable queued-work batches for this session.
610    ///
611    /// This is an admin/introspection view for non-user queued work such as
612    /// process wakes and session commands. User-visible model input is stored
613    /// separately as pending turn input and is exposed by
614    /// [`pending_turn_inputs`](Self::pending_turn_inputs).
615    pub async fn queued_work(&self) -> Result<Vec<QueuedWorkBatch>> {
616        let observation = self.runtime.observe();
617        let store = observation.queue_store.as_ref().ok_or_else(|| {
618            EmbedError::Runtime(lash_core::RuntimeError::new(
619                lash_core::RuntimeErrorCode::StoreCommitFailed,
620                "queued work inspection requires a persistent runtime store",
621            ))
622        })?;
623        store
624            .list_pending_queued_work(observation.session_id())
625            .await
626            .map_err(|err| {
627                EmbedError::Runtime(lash_core::RuntimeError::new(
628                    lash_core::RuntimeErrorCode::StoreCommitFailed,
629                    err.to_string(),
630                ))
631            })
632    }
633
634    pub async fn pending_turn_inputs(&self) -> Result<Vec<PendingTurnInput>> {
635        let observation = self.runtime.observe();
636        let store = observation.queue_store.as_ref().ok_or_else(|| {
637            EmbedError::Runtime(lash_core::RuntimeError::new(
638                lash_core::RuntimeErrorCode::StoreCommitFailed,
639                "pending turn input inspection requires a persistent runtime store",
640            ))
641        })?;
642        store
643            .list_pending_turn_inputs(observation.session_id())
644            .await
645            .map_err(|err| {
646                EmbedError::Runtime(lash_core::RuntimeError::new(
647                    lash_core::RuntimeErrorCode::StoreCommitFailed,
648                    err.to_string(),
649                ))
650            })
651    }
652
653    pub async fn cancel_pending_turn_input(
654        &self,
655        input_id: &str,
656    ) -> Result<Option<PendingTurnInput>> {
657        let session_id = self.session_id();
658        self.runtime
659            .cancel_pending_turn_input(&session_id, input_id)
660            .await
661            .map_err(EmbedError::Runtime)
662    }
663
664    pub async fn cancel_queued_work_batch(
665        &self,
666        batch_id: &str,
667    ) -> Result<Option<QueuedWorkBatch>> {
668        let session_id = self.session_id();
669        self.runtime
670            .cancel_queued_work_batch(&session_id, batch_id)
671            .await
672            .map_err(EmbedError::Runtime)
673    }
674
675    /// Resolve once `batch_id` is no longer pending in the queue store —
676    /// drained by whoever runs queued work (a queued-work runner, a durable
677    /// worker, or another handle's [`queued_turn`](Self::queued_turn)) or
678    /// cancelled. This is the enqueue-and-observe side of the queue: the
679    /// caller never claims the work itself.
680    ///
681    /// Completion is read from the persistent queue store, so it observes
682    /// drains performed by other session handles and other processes alike.
683    /// There is no built-in deadline — nothing resolves if nothing drains the
684    /// queue, so bound it with `tokio::time::timeout` when the worker may be
685    /// unavailable. A batch id the store has never seen resolves immediately.
686    pub async fn await_queued_work_batch(&self, batch_id: &str) -> Result<()> {
687        let observation = self.runtime.observe();
688        let store = observation.queue_store.clone().ok_or_else(|| {
689            EmbedError::Runtime(lash_core::RuntimeError::new(
690                lash_core::RuntimeErrorCode::StoreCommitFailed,
691                "queued work inspection requires a persistent runtime store",
692            ))
693        })?;
694        let session_id = observation.session_id().to_string();
695        drop(observation);
696        let mut delay = std::time::Duration::from_millis(25);
697        loop {
698            let pending = store
699                .list_pending_queued_work(&session_id)
700                .await
701                .map_err(|err| {
702                    EmbedError::Runtime(lash_core::RuntimeError::new(
703                        lash_core::RuntimeErrorCode::StoreCommitFailed,
704                        err.to_string(),
705                    ))
706                })?;
707            if !pending.iter().any(|batch| batch.batch_id == batch_id) {
708                return Ok(());
709            }
710            tokio::time::sleep(delay).await;
711            delay = (delay * 2).min(std::time::Duration::from_millis(400));
712        }
713    }
714
715    pub fn read_view(&self) -> SessionReadView {
716        self.runtime.observe().read_view.clone()
717    }
718
719    pub fn usage_report(&self) -> SessionUsageReport {
720        self.runtime.observe().usage_report.clone()
721    }
722
723    pub async fn set_turn_phase_probe(
724        &self,
725        probe: Arc<dyn lash_core::runtime::RuntimeTurnPhaseProbe>,
726    ) {
727        let writer = self.runtime.writer();
728        let mut runtime = writer.lock().await;
729        runtime.set_turn_phase_probe(Arc::clone(&probe));
730        self.runtime.publish_from(&runtime);
731        if let Some(slot) = &self.process_phase_probe_slot {
732            let observation = self.runtime.observe();
733            slot.set_for_session(observation.session_id(), Arc::clone(&probe));
734            let current_frame = observation.persisted_state.current_agent_frame_id.as_str();
735            if !current_frame.is_empty() {
736                let scope = lash_core::SessionScope::for_agent_frame(
737                    observation.session_id(),
738                    current_frame,
739                );
740                slot.set_for_scope(&scope, probe);
741            }
742        }
743    }
744}
745
746#[derive(Clone)]
747pub struct ObservableSession {
748    pub(crate) runtime: RuntimeHandle,
749}
750
751impl ObservableSession {
752    fn snapshot(&self) -> Arc<RuntimeObservation> {
753        self.runtime.observe()
754    }
755
756    pub fn current_observation(&self) -> SessionObservation {
757        self.runtime.current_session_observation()
758    }
759
760    pub fn current_remote_observation(&self) -> RemoteSessionObservation {
761        RemoteSessionObservation::from_core(self.current_observation())
762    }
763
764    pub fn resume_from_cursor(&self, cursor: &SessionCursor) -> Result<SessionResume> {
765        self.runtime
766            .resume_session_observation(cursor)
767            .map_err(live_replay_error)
768    }
769
770    pub fn subscribe_from_cursor(
771        &self,
772        cursor: &SessionCursor,
773    ) -> Result<SessionObservationSubscription> {
774        self.runtime
775            .subscribe_session_observation(cursor)
776            .map_err(live_replay_error)
777    }
778
779    pub fn subscribe_from_remote_cursor(
780        &self,
781        cursor: &RemoteSessionCursor,
782    ) -> Result<RemoteSessionObservationSubscription> {
783        cursor.validate()?;
784        let cursor = lash_core::SessionCursor::try_from(cursor.clone())?;
785        match self.subscribe_from_cursor(&cursor)? {
786            SessionObservationSubscription::Subscribed(subscription) => {
787                Ok(RemoteSessionObservationSubscription::Subscribed(
788                    RemoteSessionObservationEventStream::new(subscription),
789                ))
790            }
791            SessionObservationSubscription::Gap { observation, gap } => {
792                Ok(RemoteSessionObservationSubscription::Gap {
793                    observation: observation.into(),
794                    gap: gap.into(),
795                })
796            }
797        }
798    }
799
800    /// Subscribe to session observation events and keep the subscription alive
801    /// across recoverable live-replay gaps.
802    ///
803    /// The returned stream yields [`SessionObservationStreamItem::Gap`] when
804    /// the cursor missed the bounded replay window. Callers should replace
805    /// their UI/projection from the included fresh observation, persist
806    /// `gap.latest_cursor`, and keep polling the same stream; it resubscribes
807    /// from that cursor internally.
808    pub fn subscribe_and_recover(&self, cursor: SessionCursor) -> SessionObservationStream {
809        SessionObservationStream {
810            observable: self.clone(),
811            cursor,
812            subscription: None,
813            done: false,
814        }
815    }
816
817    /// Subscribe to remote DTO session observation events and keep the
818    /// subscription alive across recoverable live-replay gaps.
819    pub fn subscribe_and_recover_remote(
820        &self,
821        cursor: RemoteSessionCursor,
822    ) -> Result<RemoteSessionObservationStream> {
823        cursor.validate()?;
824        let cursor = lash_core::SessionCursor::try_from(cursor)?;
825        Ok(RemoteSessionObservationStream {
826            inner: self.subscribe_and_recover(cursor),
827            next_sequence: 0,
828        })
829    }
830
831    pub fn session_id(&self) -> String {
832        self.snapshot().session_id().to_string()
833    }
834
835    pub fn policy_snapshot(&self) -> SessionPolicy {
836        self.snapshot().policy.clone()
837    }
838
839    pub fn read_view(&self) -> SessionReadView {
840        self.snapshot().read_view.clone()
841    }
842
843    pub fn usage_report(&self) -> SessionUsageReport {
844        self.snapshot().usage_report.clone()
845    }
846
847    pub fn tool_state(&self) -> Option<ToolState> {
848        self.snapshot().tool_state.clone()
849    }
850
851    pub fn active_tool_manifests(&self) -> Vec<ToolManifest> {
852        self.snapshot()
853            .tool_state
854            .as_ref()
855            .map(ToolState::tool_manifests)
856            .unwrap_or_default()
857    }
858
859    pub async fn list_process_handles(&self) -> Vec<ProcessHandleSummary> {
860        self.snapshot().list_process_handles().await
861    }
862
863    pub async fn list_all_process_handles(&self) -> Vec<ProcessHandleSummary> {
864        self.snapshot().list_all_process_handles().await
865    }
866
867    pub fn process_scope(&self) -> SessionScope {
868        self.snapshot().process_scope()
869    }
870}
871
872#[derive(Clone, Debug)]
873pub enum SessionObservationStreamItem {
874    /// A replayed or live session observation event.
875    Event(SessionObservationEvent),
876    /// A recoverable replay gap with a fresh durable observation.
877    Gap {
878        observation: SessionObservation,
879        gap: LiveReplayGap,
880    },
881}
882
883pub enum RemoteSessionObservationSubscription {
884    Subscribed(RemoteSessionObservationEventStream),
885    Gap {
886        observation: RemoteSessionObservation,
887        gap: RemoteLiveReplayGap,
888    },
889}
890
891#[derive(Clone, Debug)]
892pub enum RemoteSessionObservationStreamItem {
893    /// A replayed or live session observation event encoded as remote DTOs.
894    Event(RemoteSessionObservationEvent),
895    /// A recoverable replay gap with a fresh remote observation snapshot.
896    Gap {
897        observation: RemoteSessionObservation,
898        gap: RemoteLiveReplayGap,
899    },
900}
901
902pub struct RemoteSessionObservationEventStream {
903    inner: lash_core::LiveReplaySubscription,
904    next_sequence: u64,
905}
906
907impl RemoteSessionObservationEventStream {
908    fn new(inner: lash_core::LiveReplaySubscription) -> Self {
909        Self {
910            inner,
911            next_sequence: 0,
912        }
913    }
914
915    pub async fn next_event(&mut self) -> Result<RemoteSessionObservationEvent> {
916        futures_util::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx))
917            .await
918            .transpose()?
919            .ok_or_else(|| live_replay_error(LiveReplayStoreError::Closed))
920    }
921}
922
923impl Stream for RemoteSessionObservationEventStream {
924    type Item = Result<RemoteSessionObservationEvent>;
925
926    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
927        match Pin::new(&mut self.inner).poll_next(cx) {
928            Poll::Pending => Poll::Pending,
929            Poll::Ready(Some(Ok(event))) => {
930                let remote = RemoteSessionObservationEvent::from_core(self.next_sequence, event);
931                self.next_sequence = self.next_sequence.saturating_add(1);
932                Poll::Ready(Some(Ok(remote)))
933            }
934            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(live_replay_error(err)))),
935            Poll::Ready(None) => Poll::Ready(None),
936        }
937    }
938}
939
940/// Remote DTO stream returned by [`ObservableSession::subscribe_and_recover_remote`].
941pub struct RemoteSessionObservationStream {
942    inner: SessionObservationStream,
943    next_sequence: u64,
944}
945
946impl RemoteSessionObservationStream {
947    pub fn cursor(&self) -> RemoteSessionCursor {
948        RemoteSessionCursor::from(self.inner.cursor())
949    }
950}
951
952impl Stream for RemoteSessionObservationStream {
953    type Item = Result<RemoteSessionObservationStreamItem>;
954
955    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
956        match Pin::new(&mut self.inner).poll_next(cx) {
957            Poll::Pending => Poll::Pending,
958            Poll::Ready(Some(Ok(SessionObservationStreamItem::Event(event)))) => {
959                let remote = RemoteSessionObservationEvent::from_core(self.next_sequence, event);
960                self.next_sequence = self.next_sequence.saturating_add(1);
961                Poll::Ready(Some(Ok(RemoteSessionObservationStreamItem::Event(remote))))
962            }
963            Poll::Ready(Some(Ok(SessionObservationStreamItem::Gap { observation, gap }))) => {
964                Poll::Ready(Some(Ok(RemoteSessionObservationStreamItem::Gap {
965                    observation: observation.into(),
966                    gap: gap.into(),
967                })))
968            }
969            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
970            Poll::Ready(None) => Poll::Ready(None),
971        }
972    }
973}
974
975/// Stream returned by [`ObservableSession::subscribe_and_recover`].
976pub struct SessionObservationStream {
977    observable: ObservableSession,
978    cursor: SessionCursor,
979    subscription: Option<lash_core::LiveReplaySubscription>,
980    done: bool,
981}
982
983impl SessionObservationStream {
984    pub fn cursor(&self) -> &SessionCursor {
985        &self.cursor
986    }
987}
988
989impl Stream for SessionObservationStream {
990    type Item = Result<SessionObservationStreamItem>;
991
992    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
993        loop {
994            if self.done {
995                return Poll::Ready(None);
996            }
997            if self.subscription.is_none() {
998                match self.observable.subscribe_from_cursor(&self.cursor) {
999                    Ok(SessionObservationSubscription::Subscribed(subscription)) => {
1000                        self.subscription = Some(subscription);
1001                    }
1002                    Ok(SessionObservationSubscription::Gap { observation, gap }) => {
1003                        self.cursor = gap.latest_cursor.clone();
1004                        return Poll::Ready(Some(Ok(SessionObservationStreamItem::Gap {
1005                            observation,
1006                            gap,
1007                        })));
1008                    }
1009                    Err(err) => {
1010                        self.done = true;
1011                        return Poll::Ready(Some(Err(err)));
1012                    }
1013                }
1014            }
1015
1016            let Some(subscription) = self.subscription.as_mut() else {
1017                continue;
1018            };
1019            match Pin::new(subscription).poll_next(cx) {
1020                Poll::Pending => return Poll::Pending,
1021                Poll::Ready(Some(Ok(event))) => {
1022                    self.cursor = event.cursor.clone();
1023                    return Poll::Ready(Some(Ok(SessionObservationStreamItem::Event(event))));
1024                }
1025                Poll::Ready(Some(Err(LiveReplayStoreError::SubscriberLagged(_)))) => {
1026                    self.subscription = None;
1027                    continue;
1028                }
1029                Poll::Ready(Some(Err(err))) => {
1030                    self.done = true;
1031                    return Poll::Ready(Some(Err(live_replay_error(err))));
1032                }
1033                Poll::Ready(None) => {
1034                    self.done = true;
1035                    return Poll::Ready(None);
1036                }
1037            }
1038        }
1039    }
1040}
1041
1042fn live_replay_error(err: lash_core::LiveReplayStoreError) -> EmbedError {
1043    EmbedError::Runtime(lash_core::RuntimeError::new(
1044        RuntimeErrorCode::Other("live_replay".to_string()),
1045        err.to_string(),
1046    ))
1047}
1048
1049pub struct EnqueueTurnBuilder<'a> {
1050    session: &'a LashSession,
1051    input: TurnInput,
1052    id: Option<String>,
1053    ingress: TurnInputIngress,
1054}
1055
1056impl<'a> EnqueueTurnBuilder<'a> {
1057    pub fn id(mut self, id: impl Into<String>) -> Self {
1058        self.id = Some(id.into());
1059        self
1060    }
1061
1062    pub fn ingress(mut self, ingress: TurnInputIngress) -> Self {
1063        self.ingress = ingress;
1064        self
1065    }
1066
1067    pub async fn send(self) -> Result<PendingTurnInput> {
1068        let source_key = self.id.map(|id| format!("host:{id}"));
1069        self.session
1070            .runtime
1071            .enqueue_turn_input(self.input, self.ingress, source_key)
1072            .await
1073            .map_err(EmbedError::Runtime)
1074    }
1075}
1076
1077impl<'a> std::future::IntoFuture for EnqueueTurnBuilder<'a> {
1078    type Output = Result<PendingTurnInput>;
1079    type IntoFuture =
1080        std::pin::Pin<Box<dyn std::future::Future<Output = Result<PendingTurnInput>> + 'a>>;
1081
1082    fn into_future(self) -> Self::IntoFuture {
1083        Box::pin(self.send())
1084    }
1085}