Skip to main content

a3s_code_core/store/
session_snapshot.rs

1use super::SessionData;
2use crate::run::RunRecord;
3use crate::subagent_task_tracker::SubagentTaskSnapshot;
4use crate::tools::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
5use crate::trace::TraceEvent;
6use crate::verification::VerificationReport;
7use anyhow::{bail, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11/// Schema version written by [`SessionSnapshotV1`].
12pub const SESSION_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
13
14/// A complete, versioned persistence generation for one session.
15///
16/// Stores commit this value as a unit so conversation state and its related
17/// runtime records cannot be observed from different save generations.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SessionSnapshotV1 {
20    pub schema_version: u32,
21    pub session: SessionData,
22    #[serde(default)]
23    pub artifacts: Vec<ToolArtifact>,
24    #[serde(default)]
25    pub trace_events: Vec<TraceEvent>,
26    #[serde(default)]
27    pub run_records: Vec<RunRecord>,
28    #[serde(default)]
29    pub verification_reports: Vec<VerificationReport>,
30    #[serde(default)]
31    pub subagent_tasks: Vec<SubagentTaskSnapshot>,
32}
33
34impl SessionSnapshotV1 {
35    pub fn new(
36        session: SessionData,
37        artifacts: &ArtifactStore,
38        trace_events: Vec<TraceEvent>,
39        run_records: Vec<RunRecord>,
40        verification_reports: Vec<VerificationReport>,
41        subagent_tasks: Vec<SubagentTaskSnapshot>,
42    ) -> Self {
43        Self {
44            schema_version: SESSION_SNAPSHOT_SCHEMA_VERSION,
45            session,
46            artifacts: artifacts.artifacts(),
47            trace_events,
48            run_records,
49            verification_reports,
50            subagent_tasks,
51        }
52    }
53
54    pub fn session_only(session: SessionData) -> Self {
55        Self::new(
56            session,
57            &ArtifactStore::new(),
58            Vec::new(),
59            Vec::new(),
60            Vec::new(),
61            Vec::new(),
62        )
63    }
64
65    pub fn artifact_store(&self) -> ArtifactStore {
66        artifact_store_from(&self.artifacts)
67    }
68
69    pub(crate) fn artifact_store_requirements(&self) -> ArtifactStoreLimits {
70        artifact_store_requirements(&self.artifacts)
71    }
72
73    pub fn ensure_loadable(&self) -> Result<()> {
74        if self.schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION {
75            bail!(
76                "unsupported session snapshot schema version {}; expected {}",
77                self.schema_version,
78                SESSION_SNAPSHOT_SCHEMA_VERSION
79            );
80        }
81        Ok(())
82    }
83
84    /// Validate relationships that must hold within one persisted generation.
85    ///
86    /// Event buffers may be FIFO-trimmed, so their first sequence is allowed
87    /// to be greater than zero and `event_count` is allowed to exceed the
88    /// retained length. It must, however, remain a valid next-sequence cursor
89    /// for every retained event.
90    pub fn validate_invariants(&self) -> Result<()> {
91        let mut run_ids = HashSet::with_capacity(self.run_records.len());
92
93        for (run_index, record) in self.run_records.iter().enumerate() {
94            let run_id = &record.snapshot.id;
95            if !run_ids.insert(run_id.as_str()) {
96                bail!(
97                    "session snapshot {:?} contains duplicate run id {:?} at run record {}",
98                    self.session.id,
99                    run_id,
100                    run_index
101                );
102            }
103
104            if record.snapshot.session_id != self.session.id {
105                bail!(
106                    "run {:?} at record {} belongs to session {:?}, but snapshot belongs to session {:?}",
107                    run_id,
108                    run_index,
109                    record.snapshot.session_id,
110                    self.session.id
111                );
112            }
113
114            let mut previous_sequence = None;
115            for (event_index, event) in record.events.iter().enumerate() {
116                if let Some(previous) = previous_sequence {
117                    if event.sequence <= previous {
118                        bail!(
119                            "run {:?} event {} has sequence {}, which is not strictly greater than previous sequence {}",
120                            run_id,
121                            event_index,
122                            event.sequence,
123                            previous
124                        );
125                    }
126                }
127                previous_sequence = Some(event.sequence);
128            }
129
130            if let Some(max_sequence) = previous_sequence {
131                let minimum_event_count = max_sequence.checked_add(1).ok_or_else(|| {
132                    anyhow::anyhow!(
133                        "run {:?} retained event sequence {} cannot be represented by event_count",
134                        run_id,
135                        max_sequence
136                    )
137                })?;
138                if record.snapshot.event_count < minimum_event_count {
139                    bail!(
140                        "run {:?} event_count {} does not cover retained event sequence {}; expected at least {}",
141                        run_id,
142                        record.snapshot.event_count,
143                        max_sequence,
144                        minimum_event_count
145                    );
146                }
147            }
148        }
149
150        for (task_index, task) in self.subagent_tasks.iter().enumerate() {
151            // Older snapshots can contain an empty parent when progress/end
152            // arrived before SubagentStart. A non-empty parent is authoritative
153            // and must identify the session that owns this task tracker.
154            if !task.parent_session_id.is_empty() && task.parent_session_id != self.session.id {
155                bail!(
156                    "subagent task {:?} at record {} belongs to parent session {:?}, but snapshot belongs to session {:?}",
157                    task.task_id,
158                    task_index,
159                    task.parent_session_id,
160                    self.session.id
161                );
162            }
163        }
164
165        Ok(())
166    }
167
168    /// Validate this snapshot for a load request targeting `session_id`.
169    pub fn validate_for_session(&self, session_id: &str) -> Result<()> {
170        self.ensure_loadable()?;
171        if self.session.id != session_id {
172            bail!(
173                "requested session {:?}, but snapshot payload belongs to session {:?}",
174                session_id,
175                self.session.id
176            );
177        }
178        self.validate_invariants()
179    }
180}
181
182pub(super) fn artifact_store_from(artifacts: &[ToolArtifact]) -> ArtifactStore {
183    // A snapshot is an authoritative persisted generation. Rehydrating it
184    // through the default in-memory limits must not silently evict records
185    // that were accepted by a store configured with larger limits.
186    let defaults = ArtifactStoreLimits::default();
187    let requirements = artifact_store_requirements(artifacts);
188    let store = ArtifactStore::with_limits(ArtifactStoreLimits {
189        max_artifacts: defaults.max_artifacts.max(requirements.max_artifacts),
190        max_bytes: defaults.max_bytes.max(requirements.max_bytes),
191    });
192    for artifact in artifacts {
193        store.put(artifact.clone());
194    }
195    store
196}
197
198fn artifact_store_requirements(artifacts: &[ToolArtifact]) -> ArtifactStoreLimits {
199    ArtifactStoreLimits {
200        max_artifacts: artifacts.len(),
201        max_bytes: artifacts.iter().fold(0usize, |total, artifact| {
202            total.saturating_add(artifact.content.len())
203        }),
204    }
205}