a3s_code_core/store/
session_snapshot.rs1use 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
11pub const SESSION_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
13
14#[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 fork_for_session(
71 mut self,
72 session_id: impl Into<String>,
73 workspace: impl Into<String>,
74 ) -> Result<Self> {
75 let source_session_id = self.session.id.clone();
76 self.validate_for_session(&source_session_id)?;
77
78 let session_id = session_id.into();
79 if session_id.trim().is_empty() {
80 bail!("forked session id cannot be empty");
81 }
82
83 self.session.id = session_id.clone();
84 self.session.config.workspace = workspace.into();
85 for record in &mut self.run_records {
86 record.snapshot.session_id.clone_from(&session_id);
87 }
88 for task in &mut self.subagent_tasks {
89 if !task.parent_session_id.is_empty() {
90 task.parent_session_id.clone_from(&session_id);
91 }
92 }
93
94 self.validate_for_session(&session_id)?;
95 Ok(self)
96 }
97
98 pub fn artifact_store(&self) -> ArtifactStore {
99 artifact_store_from(&self.artifacts)
100 }
101
102 pub(crate) fn artifact_store_requirements(&self) -> ArtifactStoreLimits {
103 artifact_store_requirements(&self.artifacts)
104 }
105
106 pub fn ensure_loadable(&self) -> Result<()> {
107 if self.schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION {
108 bail!(
109 "unsupported session snapshot schema version {}; expected {}",
110 self.schema_version,
111 SESSION_SNAPSHOT_SCHEMA_VERSION
112 );
113 }
114 Ok(())
115 }
116
117 pub fn validate_invariants(&self) -> Result<()> {
124 if let Some(binding) = &self.session.cognitive_package_binding {
125 binding.validate().map_err(|error| {
126 anyhow::anyhow!(
127 "session snapshot {:?} has an invalid cognitive package binding: {error}",
128 self.session.id
129 )
130 })?;
131 }
132 let mut run_ids = HashSet::with_capacity(self.run_records.len());
133
134 for (run_index, record) in self.run_records.iter().enumerate() {
135 let run_id = &record.snapshot.id;
136 if !run_ids.insert(run_id.as_str()) {
137 bail!(
138 "session snapshot {:?} contains duplicate run id {:?} at run record {}",
139 self.session.id,
140 run_id,
141 run_index
142 );
143 }
144
145 if record.snapshot.session_id != self.session.id {
146 bail!(
147 "run {:?} at record {} belongs to session {:?}, but snapshot belongs to session {:?}",
148 run_id,
149 run_index,
150 record.snapshot.session_id,
151 self.session.id
152 );
153 }
154
155 let mut previous_sequence = None;
156 for (event_index, event) in record.events.iter().enumerate() {
157 if let Some(previous) = previous_sequence {
158 if event.sequence <= previous {
159 bail!(
160 "run {:?} event {} has sequence {}, which is not strictly greater than previous sequence {}",
161 run_id,
162 event_index,
163 event.sequence,
164 previous
165 );
166 }
167 }
168 previous_sequence = Some(event.sequence);
169 if let crate::agent::AgentEvent::CognitiveContextBound { binding } = &event.event {
170 match &self.session.cognitive_package_binding {
171 Some(expected) if expected == binding => {}
172 Some(_) => bail!(
173 "run {:?} event {} carries a cognitive generation different from session {:?}",
174 run_id,
175 event_index,
176 self.session.id
177 ),
178 None => bail!(
179 "run {:?} event {} carries cognitive context but session {:?} is unbound",
180 run_id,
181 event_index,
182 self.session.id
183 ),
184 }
185 }
186 }
187
188 if let Some(max_sequence) = previous_sequence {
189 let minimum_event_count = max_sequence.checked_add(1).ok_or_else(|| {
190 anyhow::anyhow!(
191 "run {:?} retained event sequence {} cannot be represented by event_count",
192 run_id,
193 max_sequence
194 )
195 })?;
196 if record.snapshot.event_count < minimum_event_count {
197 bail!(
198 "run {:?} event_count {} does not cover retained event sequence {}; expected at least {}",
199 run_id,
200 record.snapshot.event_count,
201 max_sequence,
202 minimum_event_count
203 );
204 }
205 }
206 }
207
208 for (task_index, task) in self.subagent_tasks.iter().enumerate() {
209 if !task.parent_session_id.is_empty() && task.parent_session_id != self.session.id {
213 bail!(
214 "subagent task {:?} at record {} belongs to parent session {:?}, but snapshot belongs to session {:?}",
215 task.task_id,
216 task_index,
217 task.parent_session_id,
218 self.session.id
219 );
220 }
221 }
222
223 Ok(())
224 }
225
226 pub fn validate_for_session(&self, session_id: &str) -> Result<()> {
228 self.ensure_loadable()?;
229 if self.session.id != session_id {
230 bail!(
231 "requested session {:?}, but snapshot payload belongs to session {:?}",
232 session_id,
233 self.session.id
234 );
235 }
236 self.validate_invariants()
237 }
238}
239
240pub(super) fn artifact_store_from(artifacts: &[ToolArtifact]) -> ArtifactStore {
241 let defaults = ArtifactStoreLimits::default();
245 let requirements = artifact_store_requirements(artifacts);
246 let store = ArtifactStore::with_limits(ArtifactStoreLimits {
247 max_artifacts: defaults.max_artifacts.max(requirements.max_artifacts),
248 max_bytes: defaults.max_bytes.max(requirements.max_bytes),
249 });
250 for artifact in artifacts {
251 store.put(artifact.clone());
252 }
253 store
254}
255
256fn artifact_store_requirements(artifacts: &[ToolArtifact]) -> ArtifactStoreLimits {
257 ArtifactStoreLimits {
258 max_artifacts: artifacts.len(),
259 max_bytes: artifacts.iter().fold(0usize, |total, artifact| {
260 total.saturating_add(artifact.content.len())
261 }),
262 }
263}