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
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 self.session
125 .config
126 .tool_result_transform_policy
127 .validate()
128 .map_err(|error| {
129 anyhow::anyhow!(
130 "session snapshot {:?} has an invalid Tool result transform policy: {error}",
131 self.session.id
132 )
133 })?;
134 if let Some(binding) = &self.session.durable_memory_binding {
135 binding.validate().map_err(|error| {
136 anyhow::anyhow!(
137 "session snapshot {:?} has an invalid durable-memory binding: {error}",
138 self.session.id
139 )
140 })?;
141 if self.session.tenant_id.as_deref() != Some(binding.namespace().tenant_id()) {
142 bail!(
143 "session snapshot {:?} tenant identity does not match its durable-memory binding",
144 self.session.id
145 );
146 }
147 if self.session.principal.as_deref() != Some(binding.namespace().principal_id()) {
148 bail!(
149 "session snapshot {:?} principal identity does not match its durable-memory binding",
150 self.session.id
151 );
152 }
153 }
154 if let Some(binding) = &self.session.cognitive_package_binding {
155 binding.validate().map_err(|error| {
156 anyhow::anyhow!(
157 "session snapshot {:?} has an invalid cognitive package binding: {error}",
158 self.session.id
159 )
160 })?;
161 }
162 if let Some(binding) = &self.session.immutable_content_adapter_binding {
163 binding.validate().map_err(|error| {
164 anyhow::anyhow!(
165 "session snapshot {:?} has an invalid immutable-content adapter binding: {error}",
166 self.session.id
167 )
168 })?;
169 }
170 let mut run_ids = HashSet::with_capacity(self.run_records.len());
171
172 for (run_index, record) in self.run_records.iter().enumerate() {
173 let run_id = &record.snapshot.id;
174 if let Some(binding) = &record.snapshot.cognitive_package_binding {
175 binding.validate().map_err(|error| {
176 anyhow::anyhow!(
177 "run {:?} at record {} has an invalid cognitive package binding: {error}",
178 run_id,
179 run_index
180 )
181 })?;
182 }
183 if let Some(binding) = &record.snapshot.capability_binding {
184 binding.validate().map_err(|error| {
185 anyhow::anyhow!(
186 "run {:?} at record {} has an invalid capability binding: {error}",
187 run_id,
188 run_index
189 )
190 })?;
191 }
192 if !run_ids.insert(run_id.as_str()) {
193 bail!(
194 "session snapshot {:?} contains duplicate run id {:?} at run record {}",
195 self.session.id,
196 run_id,
197 run_index
198 );
199 }
200
201 if record.snapshot.session_id != self.session.id {
202 bail!(
203 "run {:?} at record {} belongs to session {:?}, but snapshot belongs to session {:?}",
204 run_id,
205 run_index,
206 record.snapshot.session_id,
207 self.session.id
208 );
209 }
210
211 let mut previous_sequence = None;
212 let mut legacy_event_binding = None;
218 for (event_index, event) in record.events.iter().enumerate() {
219 if let Some(previous) = previous_sequence {
220 if event.sequence <= previous {
221 bail!(
222 "run {:?} event {} has sequence {}, which is not strictly greater than previous sequence {}",
223 run_id,
224 event_index,
225 event.sequence,
226 previous
227 );
228 }
229 }
230 previous_sequence = Some(event.sequence);
231 if let crate::agent::AgentEvent::CognitiveContextBound { binding } = &event.event {
232 binding.validate().map_err(|error| {
233 anyhow::anyhow!(
234 "run {:?} event {} has an invalid cognitive package binding: {error}",
235 run_id,
236 event_index
237 )
238 })?;
239 match &record.snapshot.cognitive_package_binding {
240 Some(expected) if expected == binding => {}
241 Some(_) => bail!(
242 "run {:?} event {} carries a cognitive generation different from its admitted Run binding",
243 run_id,
244 event_index
245 ),
246 None => match &legacy_event_binding {
247 Some(expected) if expected == binding => {}
248 Some(_) => bail!(
249 "legacy run {:?} event {} changes cognitive generation within one Run",
250 run_id,
251 event_index
252 ),
253 None => legacy_event_binding = Some(binding.clone()),
254 },
255 }
256 }
257 if let crate::agent::AgentEvent::ToolEnd { metadata, .. } = &event.event {
258 validate_tool_result_transform_metadata(
259 &self.session.id,
260 run_id,
261 event_index,
262 metadata.as_ref(),
263 &self.session.config.tool_result_transform_policy,
264 )?;
265 }
266 }
267
268 if let Some(max_sequence) = previous_sequence {
269 let minimum_event_count = max_sequence.checked_add(1).ok_or_else(|| {
270 anyhow::anyhow!(
271 "run {:?} retained event sequence {} cannot be represented by event_count",
272 run_id,
273 max_sequence
274 )
275 })?;
276 if record.snapshot.event_count < minimum_event_count {
277 bail!(
278 "run {:?} event_count {} does not cover retained event sequence {}; expected at least {}",
279 run_id,
280 record.snapshot.event_count,
281 max_sequence,
282 minimum_event_count
283 );
284 }
285 }
286 }
287
288 for (task_index, task) in self.subagent_tasks.iter().enumerate() {
289 if !task.parent_session_id.is_empty() && task.parent_session_id != self.session.id {
293 bail!(
294 "subagent task {:?} at record {} belongs to parent session {:?}, but snapshot belongs to session {:?}",
295 task.task_id,
296 task_index,
297 task.parent_session_id,
298 self.session.id
299 );
300 }
301 }
302
303 Ok(())
304 }
305
306 pub fn validate_for_session(&self, session_id: &str) -> Result<()> {
308 self.ensure_loadable()?;
309 if self.session.id != session_id {
310 bail!(
311 "requested session {:?}, but snapshot payload belongs to session {:?}",
312 session_id,
313 self.session.id
314 );
315 }
316 self.validate_invariants()
317 }
318}
319
320fn validate_tool_result_transform_metadata(
321 session_id: &str,
322 run_id: &str,
323 event_index: usize,
324 metadata: Option<&serde_json::Value>,
325 policy: &crate::tools::ToolResultTransformPolicyV1,
326) -> Result<()> {
327 let Some(encoded_binding) = metadata
328 .and_then(|value| value.get(crate::tools::TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY))
329 else {
330 return Ok(());
331 };
332 let binding: crate::tools::ToolResultTransformBindingV1 =
333 serde_json::from_value(encoded_binding.clone()).map_err(|error| {
334 anyhow::anyhow!(
335 "run {:?} event {} in session {:?} has malformed Tool result transform binding: {error}",
336 run_id,
337 event_index,
338 session_id
339 )
340 })?;
341 binding.validate_for_policy(policy).map_err(|error| {
342 anyhow::anyhow!(
343 "run {:?} event {} in session {:?} has invalid Tool result transform binding: {error}",
344 run_id,
345 event_index,
346 session_id
347 )
348 })?;
349
350 let encoded_evidence = metadata
351 .and_then(|value| value.get("a3s_tool_result_evidence"))
352 .ok_or_else(|| {
353 anyhow::anyhow!(
354 "run {:?} event {} in session {:?} has a Tool result transform binding without Tool result evidence",
355 run_id,
356 event_index,
357 session_id
358 )
359 })?;
360 let evidence: crate::tools::ToolResultEvidenceV1 =
361 serde_json::from_value(encoded_evidence.clone()).map_err(|error| {
362 anyhow::anyhow!(
363 "run {:?} event {} in session {:?} has malformed Tool result evidence: {error}",
364 run_id,
365 event_index,
366 session_id
367 )
368 })?;
369 if evidence.schema != crate::tools::TOOL_RESULT_EVIDENCE_SCHEMA_V1
370 || evidence.transform_algorithm.as_deref() != Some(binding.transform_algorithm.as_str())
371 {
372 anyhow::bail!(
373 "run {:?} event {} in session {:?} has Tool result evidence that does not match its transform binding",
374 run_id,
375 event_index,
376 session_id
377 );
378 }
379 Ok(())
380}
381
382pub(super) fn artifact_store_from(artifacts: &[ToolArtifact]) -> ArtifactStore {
383 let defaults = ArtifactStoreLimits::default();
387 let requirements = artifact_store_requirements(artifacts);
388 let store = ArtifactStore::with_limits(ArtifactStoreLimits {
389 max_artifacts: defaults.max_artifacts.max(requirements.max_artifacts),
390 max_bytes: defaults.max_bytes.max(requirements.max_bytes),
391 });
392 for artifact in artifacts {
393 store.put(artifact.clone());
394 }
395 store
396}
397
398fn artifact_store_requirements(artifacts: &[ToolArtifact]) -> ArtifactStoreLimits {
399 ArtifactStoreLimits {
400 max_artifacts: artifacts.len(),
401 max_bytes: artifacts.iter().fold(0usize, |total, artifact| {
402 total.saturating_add(artifact.content.len())
403 }),
404 }
405}