Skip to main content

lash_core/runtime/
state.rs

1//! Session state envelopes and persistence helpers.
2//!
3//! Extracted from `runtime/mod.rs`. `SessionStateEnvelope` and
4//! `PersistedSessionState` keep their original public paths via `pub use`
5//! in `mod.rs`; the helper functions are `pub(super)` so sibling runtime
6//! modules (`mod.rs`, `session_manager.rs`) can reach them via
7//! `super::*`.
8
9use lash_sansio::PromptUsage;
10
11use crate::session_model::{
12    Message, SessionEventRecord, SessionPolicy, TokenUsage, plugin_message_to_message,
13};
14use crate::{PersistedTurnState, ToolCallRecord};
15
16use super::usage::TokenLedgerEntry;
17
18/// Serializable session read-model exported to hosts and plugins.
19#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
20pub struct SessionStateEnvelope {
21    pub session_id: String,
22    #[serde(default)]
23    pub policy: SessionPolicy,
24    #[serde(default)]
25    pub session_graph: crate::SessionGraph,
26    #[serde(default)]
27    pub turn_index: usize,
28    #[serde(default)]
29    pub token_usage: TokenUsage,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub last_prompt_usage: Option<PromptUsage>,
32    #[serde(default)]
33    pub mode_turn_options: crate::ModeTurnOptions,
34}
35
36impl SessionStateEnvelope {
37    pub(crate) fn read_model(&self) -> crate::session_graph::SessionReadModel {
38        self.session_graph.read_model()
39    }
40
41    pub fn replace_active_read_state(
42        &mut self,
43        messages: &[Message],
44        tool_calls: &[ToolCallRecord],
45    ) {
46        self.session_graph
47            .replace_active_read_state(messages, tool_calls);
48    }
49
50    pub fn replace_active_tool_calls(&mut self, tool_calls: &[ToolCallRecord]) {
51        self.session_graph.replace_active_tool_calls(tool_calls);
52    }
53
54    pub fn append_active_read_delta(
55        &mut self,
56        messages: &[Message],
57        tool_calls: &[ToolCallRecord],
58    ) {
59        self.session_graph
60            .append_active_read_delta(messages, tool_calls);
61    }
62
63    pub fn read_view(&self) -> crate::SessionReadView {
64        crate::SessionReadView::from_exported_state(self)
65    }
66}
67
68impl Default for SessionStateEnvelope {
69    fn default() -> Self {
70        Self {
71            session_id: "root".to_string(),
72            policy: SessionPolicy::default(),
73            session_graph: crate::SessionGraph::default(),
74            turn_index: 0,
75            token_usage: TokenUsage::default(),
76            last_prompt_usage: None,
77            mode_turn_options: crate::ModeTurnOptions::default(),
78        }
79    }
80}
81
82/// Serializable persistence snapshot used by stores, resume, and child session snapshots.
83#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
84pub struct PersistedSessionState {
85    pub session_id: String,
86    #[serde(default)]
87    pub policy: SessionPolicy,
88    #[serde(default)]
89    pub session_graph: crate::SessionGraph,
90    #[serde(default)]
91    pub turn_index: usize,
92    #[serde(default)]
93    pub token_usage: TokenUsage,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub last_prompt_usage: Option<PromptUsage>,
96    #[serde(default)]
97    pub mode_turn_options: crate::ModeTurnOptions,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub tool_state_ref: Option<crate::store::BlobRef>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub tool_state_generation: Option<u64>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub tool_state_snapshot: Option<crate::ToolState>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub plugin_snapshot_ref: Option<crate::store::BlobRef>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub plugin_snapshot_revision: Option<u64>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub plugin_snapshot: Option<crate::PluginSessionSnapshot>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub execution_state_ref: Option<crate::store::BlobRef>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub execution_state_snapshot: Option<Vec<u8>>,
114    /// Cost-accounting ledger. Every LLM call (parent turns, subagent
115    /// children, compaction, observers, background helpers) contributes an
116    /// entry keyed by `(source, model)`. Separate from `token_usage`
117    /// which tracks context-window accounting only.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub token_ledger: Vec<TokenLedgerEntry>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub checkpoint_ref: Option<crate::store::BlobRef>,
122    /// Store head revision observed by the runtime. Commits use it for
123    /// optimistic concurrency; `None` means the runtime is creating the
124    /// first persisted head.
125    #[serde(skip)]
126    pub head_revision: Option<u64>,
127    /// Signals that the next commit must write the full graph (a
128    /// destructive rewrite happened, e.g. `heal_orphaned_leaf`). Cleared
129    /// after the next commit.
130    #[serde(skip)]
131    pub graph_replace_required: bool,
132}
133
134impl PersistedSessionState {
135    pub fn from_state(state: SessionStateEnvelope) -> Self {
136        Self {
137            session_id: state.session_id,
138            policy: state.policy,
139            session_graph: state.session_graph,
140            turn_index: state.turn_index,
141            token_usage: state.token_usage,
142            last_prompt_usage: state.last_prompt_usage,
143            mode_turn_options: state.mode_turn_options,
144            tool_state_ref: None,
145            tool_state_generation: None,
146            tool_state_snapshot: None,
147            plugin_snapshot_ref: None,
148            plugin_snapshot_revision: None,
149            plugin_snapshot: None,
150            execution_state_ref: None,
151            execution_state_snapshot: None,
152            token_ledger: Vec::new(),
153            checkpoint_ref: None,
154            head_revision: None,
155            graph_replace_required: false,
156        }
157    }
158
159    pub fn export_state(&self) -> SessionStateEnvelope {
160        SessionStateEnvelope {
161            session_id: self.session_id.clone(),
162            policy: self.policy.clone(),
163            session_graph: self.session_graph.clone(),
164            turn_index: self.turn_index,
165            token_usage: self.token_usage.clone(),
166            last_prompt_usage: self.last_prompt_usage.clone(),
167            mode_turn_options: self.mode_turn_options.clone(),
168        }
169    }
170
171    pub fn apply_exported_state(&mut self, state: &SessionStateEnvelope) {
172        self.session_id = state.session_id.clone();
173        self.policy = state.policy.clone();
174        self.session_graph = state.session_graph.clone();
175        self.turn_index = state.turn_index;
176        self.token_usage = state.token_usage.clone();
177        self.last_prompt_usage = state.last_prompt_usage.clone();
178        self.mode_turn_options = state.mode_turn_options.clone();
179    }
180
181    pub fn stamp_runtime_state(
182        &mut self,
183        tool_state: Option<&crate::ToolState>,
184        plugin_snapshot: Option<&crate::PluginSessionSnapshot>,
185    ) {
186        self.tool_state_snapshot = tool_state.cloned();
187        self.tool_state_generation = tool_state.map(|snapshot| snapshot.generation());
188        self.plugin_snapshot = plugin_snapshot.cloned();
189    }
190
191    pub fn usage_report(&self) -> super::usage::SessionUsageReport {
192        super::usage::SessionUsageReport::from_entries(&self.token_ledger)
193    }
194
195    pub(crate) fn read_model(&self) -> crate::session_graph::SessionReadModel {
196        self.session_graph.read_model()
197    }
198
199    pub fn replace_active_read_state(
200        &mut self,
201        messages: &[Message],
202        tool_calls: &[ToolCallRecord],
203    ) {
204        self.session_graph
205            .replace_active_read_state(messages, tool_calls);
206        self.graph_replace_required = false;
207    }
208
209    pub fn replace_active_tool_calls(&mut self, tool_calls: &[ToolCallRecord]) {
210        self.session_graph.replace_active_tool_calls(tool_calls);
211        self.graph_replace_required = false;
212    }
213
214    pub fn append_active_read_delta(
215        &mut self,
216        messages: &[Message],
217        tool_calls: &[ToolCallRecord],
218    ) {
219        self.session_graph
220            .append_active_read_delta(messages, tool_calls);
221    }
222
223    pub fn append_active_conversation_messages(&mut self, messages: &[Message]) {
224        self.session_graph
225            .append_active_conversation_messages(messages);
226    }
227
228    pub fn read_view(&self) -> crate::SessionReadView {
229        crate::SessionReadView::from_persisted_state(self)
230    }
231
232    pub fn session_graph(&self) -> &crate::SessionGraph {
233        &self.session_graph
234    }
235
236    pub fn policy(&self) -> &SessionPolicy {
237        &self.policy
238    }
239
240    pub fn turn_state(&self) -> PersistedTurnState {
241        PersistedTurnState {
242            turn_index: self.turn_index,
243            token_usage: self.token_usage.clone(),
244            last_prompt_usage: self.last_prompt_usage.clone(),
245            mode_turn_options: self.mode_turn_options.clone(),
246        }
247    }
248
249    pub fn token_ledger(&self) -> &[TokenLedgerEntry] {
250        &self.token_ledger
251    }
252
253    pub fn apply_persisted_commit_result(&mut self, result: crate::store::RuntimeCommitResult) {
254        self.head_revision = Some(result.head_revision);
255        self.checkpoint_ref = Some(result.checkpoint_ref);
256        self.tool_state_ref = result.manifest.tool_state_ref;
257        if let Some(snapshot) = self.tool_state_snapshot.as_ref() {
258            self.tool_state_generation = Some(snapshot.generation());
259        } else if self.tool_state_ref.is_none() {
260            self.tool_state_generation = None;
261        }
262        self.plugin_snapshot_ref = result.manifest.plugin_snapshot_ref;
263        self.plugin_snapshot_revision = result.manifest.plugin_snapshot_revision;
264        self.execution_state_ref = result.manifest.execution_state_ref;
265        self.graph_replace_required = false;
266        self.tool_state_snapshot = None;
267        self.plugin_snapshot = None;
268        self.execution_state_snapshot = None;
269    }
270
271    pub fn discard_runtime_snapshots(&mut self) {
272        self.tool_state_snapshot = None;
273        self.plugin_snapshot = None;
274        self.execution_state_snapshot = None;
275    }
276
277    pub fn set_execution_state_snapshot(&mut self, execution_state_snapshot: Option<Vec<u8>>) {
278        if execution_state_snapshot.is_none() {
279            self.execution_state_ref = None;
280        }
281        self.execution_state_snapshot = execution_state_snapshot;
282    }
283
284    pub fn execution_state_snapshot(&self) -> Option<&[u8]> {
285        self.execution_state_snapshot.as_deref()
286    }
287
288    pub fn refresh_plugin_snapshots(&mut self, plugins: &crate::PluginSession) {
289        let tool_registry = plugins.tool_registry();
290        let generation = tool_registry.generation();
291        if self.tool_state_ref.is_none() || self.tool_state_generation != Some(generation) {
292            let snapshot = tool_registry.export_state();
293            self.tool_state_generation = Some(snapshot.generation());
294            self.tool_state_snapshot = Some(snapshot);
295        }
296
297        let revision = plugins.snapshot_revision_fingerprint();
298        if self.plugin_snapshot_ref.is_none() || self.plugin_snapshot_revision != Some(revision) {
299            self.plugin_snapshot = plugins.snapshot().ok();
300        }
301        self.plugin_snapshot_revision = Some(revision);
302    }
303}
304
305impl Default for PersistedSessionState {
306    fn default() -> Self {
307        Self {
308            session_id: "root".to_string(),
309            policy: SessionPolicy::default(),
310            session_graph: crate::SessionGraph::default(),
311            turn_index: 0,
312            token_usage: TokenUsage::default(),
313            last_prompt_usage: None,
314            mode_turn_options: crate::ModeTurnOptions::default(),
315            tool_state_ref: None,
316            tool_state_generation: None,
317            tool_state_snapshot: None,
318            plugin_snapshot_ref: None,
319            plugin_snapshot_revision: None,
320            plugin_snapshot: None,
321            execution_state_ref: None,
322            execution_state_snapshot: None,
323            token_ledger: Vec::new(),
324            checkpoint_ref: None,
325            head_revision: None,
326            graph_replace_required: false,
327        }
328    }
329}
330
331pub(super) fn apply_persisted_session_config(
332    policy: &mut SessionPolicy,
333    config: &crate::PersistedSessionConfig,
334) {
335    if !config.configured_model.is_empty() {
336        policy.model = config.configured_model.clone();
337    }
338    if config.context_window > 0 {
339        policy.max_context_tokens = Some(config.context_window as usize);
340    }
341    policy.execution_mode = config.execution_mode.clone();
342    policy.standard_context_approach = config.standard_context_approach.clone();
343    policy.model_variant = config.model_variant.clone();
344}
345
346pub(super) fn apply_session_checkpoint(
347    state: &mut PersistedSessionState,
348    checkpoint: Option<crate::store::HydratedSessionCheckpoint>,
349) {
350    let Some(checkpoint) = checkpoint else {
351        state.tool_state_ref = None;
352        state.tool_state_generation = None;
353        state.tool_state_snapshot = None;
354        state.plugin_snapshot_ref = None;
355        state.plugin_snapshot_revision = None;
356        state.plugin_snapshot = None;
357        state.execution_state_ref = None;
358        state.execution_state_snapshot = None;
359        return;
360    };
361    state.turn_index = checkpoint.turn_state.turn_index;
362    state.token_usage = checkpoint.turn_state.token_usage;
363    state.last_prompt_usage = checkpoint.turn_state.last_prompt_usage;
364    state.mode_turn_options = checkpoint.turn_state.mode_turn_options;
365    state.tool_state_ref = checkpoint.tool_state_ref.clone();
366    state.tool_state_generation = checkpoint
367        .tool_state
368        .as_ref()
369        .map(|snapshot| snapshot.generation());
370    state.tool_state_snapshot = checkpoint.tool_state;
371    state.plugin_snapshot_ref = checkpoint.plugin_snapshot_ref.clone();
372    state.plugin_snapshot_revision = checkpoint.plugin_snapshot_revision;
373    state.plugin_snapshot = checkpoint.plugin_snapshot;
374    state.execution_state_ref = checkpoint.execution_state_ref.clone();
375    state.execution_state_snapshot = None;
376}
377
378pub(super) fn apply_session_head(
379    state: &mut PersistedSessionState,
380    head: &crate::store::SessionHead,
381) {
382    state.session_graph = head.graph.clone();
383    state.checkpoint_ref = head.checkpoint_ref.clone();
384    state.token_ledger = head.token_ledger.clone();
385    state.tool_state_ref = None;
386    state.tool_state_generation = None;
387    state.tool_state_snapshot = None;
388    state.plugin_snapshot_ref = None;
389    state.plugin_snapshot_revision = None;
390    state.plugin_snapshot = None;
391    state.execution_state_ref = None;
392    state.execution_state_snapshot = None;
393    state.head_revision = Some(head.head_revision);
394    state.graph_replace_required = false;
395    apply_persisted_session_config(&mut state.policy, &head.config);
396}
397
398pub(super) fn append_session_nodes_to_state(
399    state: &mut PersistedSessionState,
400    nodes: &[crate::SessionAppendNode],
401) -> Vec<String> {
402    let mut node_ids = Vec::with_capacity(nodes.len());
403    for node in nodes {
404        match node {
405            crate::SessionAppendNode::Message { message } => {
406                let message = plugin_message_to_message(message);
407                node_ids.push(
408                    state
409                        .session_graph
410                        .append_event(SessionEventRecord::Conversation(
411                            crate::session_model::ConversationRecord::from_message(message),
412                        )),
413                );
414            }
415            crate::SessionAppendNode::Event { event } => {
416                node_ids.push(state.session_graph.append_event(event.clone()));
417            }
418            crate::SessionAppendNode::Plugin { plugin_type, body } => {
419                node_ids.push(
420                    state
421                        .session_graph
422                        .append_plugin(plugin_type.clone(), body.clone()),
423                );
424            }
425        }
426    }
427    normalize_session_graph(state);
428    node_ids
429}
430
431/// Heal any graph corruption (orphaned leaf) on load.
432///
433/// Must run BEFORE any residency-based trim (phase-9 feature) because
434/// healing's fallback search relies on having the full node set in RAM.
435/// Under `Residency::ActivePathOnly`, the runtime loads only the active
436/// path; if the leaf doesn't resolve against that reduced set, the
437/// caller falls back to a full `load_session_graph()` + `normalize` +
438/// trim.
439pub(super) fn normalize_session_graph(state: &mut PersistedSessionState) {
440    if state.session_graph.heal_orphaned_leaf() {
441        state.graph_replace_required = true;
442    }
443}
444
445/// Trim the resident node set according to `Residency`. Called AFTER
446/// `normalize_session_graph` during `from_environment` load. Under
447/// `KeepAll` this is a no-op; under `ActivePathOnly` it replaces the
448/// resident graph with just the active path. Orphans remain on disk —
449/// the host decides whether/when to tombstone + vacuum them via
450/// `LashRuntime::orphaned_node_ids` + the store primitives.
451///
452pub(super) fn apply_residency_on_load(
453    state: &mut PersistedSessionState,
454    residency: crate::Residency,
455) {
456    match residency {
457        crate::Residency::KeepAll => {}
458        crate::Residency::ActivePathOnly => {
459            state.session_graph = state.session_graph.fork_current_path();
460        }
461    }
462}