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)]
26 pub retained_artifact_uris: Vec<String>,
27 #[serde(default)]
28 pub trace_events: Vec<TraceEvent>,
29 #[serde(default)]
30 pub run_records: Vec<RunRecord>,
31 #[serde(default)]
32 pub verification_reports: Vec<VerificationReport>,
33 #[serde(default)]
34 pub subagent_tasks: Vec<SubagentTaskSnapshot>,
35}
36
37impl SessionSnapshotV1 {
38 pub fn new(
39 session: SessionData,
40 artifacts: &ArtifactStore,
41 trace_events: Vec<TraceEvent>,
42 run_records: Vec<RunRecord>,
43 verification_reports: Vec<VerificationReport>,
44 subagent_tasks: Vec<SubagentTaskSnapshot>,
45 ) -> Self {
46 let mut retained_artifact_uris: Vec<String> =
47 artifacts.retained_uris().into_iter().collect();
48 retained_artifact_uris.sort();
49 Self {
50 schema_version: SESSION_SNAPSHOT_SCHEMA_VERSION,
51 session,
52 artifacts: artifacts.artifacts(),
53 retained_artifact_uris,
54 trace_events,
55 run_records,
56 verification_reports,
57 subagent_tasks,
58 }
59 }
60
61 pub fn session_only(session: SessionData) -> Self {
62 Self::new(
63 session,
64 &ArtifactStore::new(),
65 Vec::new(),
66 Vec::new(),
67 Vec::new(),
68 Vec::new(),
69 )
70 }
71
72 pub fn fork_for_session(
78 mut self,
79 session_id: impl Into<String>,
80 workspace: impl Into<String>,
81 ) -> Result<Self> {
82 let source_session_id = self.session.id.clone();
83 self.validate_for_session(&source_session_id)?;
84
85 let session_id = session_id.into();
86 if session_id.trim().is_empty() {
87 bail!("forked session id cannot be empty");
88 }
89
90 self.session.id = session_id.clone();
91 self.session.config.workspace = workspace.into();
92 for record in &mut self.run_records {
93 record.snapshot.session_id.clone_from(&session_id);
94 }
95 for task in &mut self.subagent_tasks {
96 if !task.parent_session_id.is_empty() {
97 task.parent_session_id.clone_from(&session_id);
98 }
99 }
100
101 self.validate_for_session(&session_id)?;
102 Ok(self)
103 }
104
105 pub fn artifact_store(&self) -> ArtifactStore {
106 artifact_store_from_with_retention(&self.artifacts, &self.retained_artifact_uris)
107 }
108
109 pub(crate) fn artifact_store_requirements(&self) -> ArtifactStoreLimits {
110 artifact_store_requirements(&self.artifacts)
111 }
112
113 pub fn ensure_loadable(&self) -> Result<()> {
114 if self.schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION {
115 bail!(
116 "unsupported session snapshot schema version {}; expected {}",
117 self.schema_version,
118 SESSION_SNAPSHOT_SCHEMA_VERSION
119 );
120 }
121 Ok(())
122 }
123
124 pub fn validate_invariants(&self) -> Result<()> {
131 self.session
132 .config
133 .tool_result_transform_policy
134 .validate()
135 .map_err(|error| {
136 anyhow::anyhow!(
137 "session snapshot {:?} has an invalid Tool result transform policy: {error}",
138 self.session.id
139 )
140 })?;
141 if let Some(binding) = &self.session.durable_memory_binding {
142 binding.validate().map_err(|error| {
143 anyhow::anyhow!(
144 "session snapshot {:?} has an invalid durable-memory binding: {error}",
145 self.session.id
146 )
147 })?;
148 if self.session.tenant_id.as_deref() != Some(binding.namespace().tenant_id()) {
149 bail!(
150 "session snapshot {:?} tenant identity does not match its durable-memory binding",
151 self.session.id
152 );
153 }
154 if self.session.principal.as_deref() != Some(binding.namespace().principal_id()) {
155 bail!(
156 "session snapshot {:?} principal identity does not match its durable-memory binding",
157 self.session.id
158 );
159 }
160 }
161 if let Some(binding) = &self.session.cognitive_package_binding {
162 binding.validate().map_err(|error| {
163 anyhow::anyhow!(
164 "session snapshot {:?} has an invalid cognitive package binding: {error}",
165 self.session.id
166 )
167 })?;
168 }
169 if let Some(binding) = &self.session.immutable_content_adapter_binding {
170 binding.validate().map_err(|error| {
171 anyhow::anyhow!(
172 "session snapshot {:?} has an invalid immutable-content adapter binding: {error}",
173 self.session.id
174 )
175 })?;
176 }
177 let mut run_ids = HashSet::with_capacity(self.run_records.len());
178
179 for (run_index, record) in self.run_records.iter().enumerate() {
180 let run_id = &record.snapshot.id;
181 if let Some(binding) = &record.snapshot.cognitive_package_binding {
182 binding.validate().map_err(|error| {
183 anyhow::anyhow!(
184 "run {:?} at record {} has an invalid cognitive package binding: {error}",
185 run_id,
186 run_index
187 )
188 })?;
189 }
190 if let Some(binding) = &record.snapshot.capability_binding {
191 binding.validate().map_err(|error| {
192 anyhow::anyhow!(
193 "run {:?} at record {} has an invalid capability binding: {error}",
194 run_id,
195 run_index
196 )
197 })?;
198 }
199 if !run_ids.insert(run_id.as_str()) {
200 bail!(
201 "session snapshot {:?} contains duplicate run id {:?} at run record {}",
202 self.session.id,
203 run_id,
204 run_index
205 );
206 }
207
208 if record.snapshot.session_id != self.session.id {
209 bail!(
210 "run {:?} at record {} belongs to session {:?}, but snapshot belongs to session {:?}",
211 run_id,
212 run_index,
213 record.snapshot.session_id,
214 self.session.id
215 );
216 }
217
218 let mut previous_sequence = None;
219 let mut legacy_event_binding = None;
225 for (event_index, event) in record.events.iter().enumerate() {
226 if let Some(previous) = previous_sequence {
227 if event.sequence <= previous {
228 bail!(
229 "run {:?} event {} has sequence {}, which is not strictly greater than previous sequence {}",
230 run_id,
231 event_index,
232 event.sequence,
233 previous
234 );
235 }
236 }
237 previous_sequence = Some(event.sequence);
238 if let crate::agent::AgentEvent::CognitiveContextBound { binding } = &event.event {
239 binding.validate().map_err(|error| {
240 anyhow::anyhow!(
241 "run {:?} event {} has an invalid cognitive package binding: {error}",
242 run_id,
243 event_index
244 )
245 })?;
246 match &record.snapshot.cognitive_package_binding {
247 Some(expected) if expected == binding => {}
248 Some(_) => bail!(
249 "run {:?} event {} carries a cognitive generation different from its admitted Run binding",
250 run_id,
251 event_index
252 ),
253 None => match &legacy_event_binding {
254 Some(expected) if expected == binding => {}
255 Some(_) => bail!(
256 "legacy run {:?} event {} changes cognitive generation within one Run",
257 run_id,
258 event_index
259 ),
260 None => legacy_event_binding = Some(binding.clone()),
261 },
262 }
263 }
264 if let crate::agent::AgentEvent::ToolEnd { metadata, .. } = &event.event {
265 validate_tool_result_transform_metadata(
266 &self.session.id,
267 run_id,
268 event_index,
269 metadata.as_ref(),
270 &self.session.config.tool_result_transform_policy,
271 )?;
272 }
273 }
274
275 if let Some(max_sequence) = previous_sequence {
276 let minimum_event_count = max_sequence.checked_add(1).ok_or_else(|| {
277 anyhow::anyhow!(
278 "run {:?} retained event sequence {} cannot be represented by event_count",
279 run_id,
280 max_sequence
281 )
282 })?;
283 if record.snapshot.event_count < minimum_event_count {
284 bail!(
285 "run {:?} event_count {} does not cover retained event sequence {}; expected at least {}",
286 run_id,
287 record.snapshot.event_count,
288 max_sequence,
289 minimum_event_count
290 );
291 }
292 }
293 }
294
295 for (task_index, task) in self.subagent_tasks.iter().enumerate() {
296 if !task.parent_session_id.is_empty() && task.parent_session_id != self.session.id {
300 bail!(
301 "subagent task {:?} at record {} belongs to parent session {:?}, but snapshot belongs to session {:?}",
302 task.task_id,
303 task_index,
304 task.parent_session_id,
305 self.session.id
306 );
307 }
308 }
309
310 Ok(())
311 }
312
313 pub fn validate_for_session(&self, session_id: &str) -> Result<()> {
315 self.ensure_loadable()?;
316 if self.session.id != session_id {
317 bail!(
318 "requested session {:?}, but snapshot payload belongs to session {:?}",
319 session_id,
320 self.session.id
321 );
322 }
323 self.validate_invariants()
324 }
325}
326
327fn validate_tool_result_transform_metadata(
328 session_id: &str,
329 run_id: &str,
330 event_index: usize,
331 metadata: Option<&serde_json::Value>,
332 policy: &crate::tools::ToolResultTransformPolicyV1,
333) -> Result<()> {
334 let Some(encoded_binding) = metadata
335 .and_then(|value| value.get(crate::tools::TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY))
336 else {
337 return Ok(());
338 };
339 let binding: crate::tools::ToolResultTransformBindingV1 =
340 serde_json::from_value(encoded_binding.clone()).map_err(|error| {
341 anyhow::anyhow!(
342 "run {:?} event {} in session {:?} has malformed Tool result transform binding: {error}",
343 run_id,
344 event_index,
345 session_id
346 )
347 })?;
348 binding.validate_for_policy(policy).map_err(|error| {
349 anyhow::anyhow!(
350 "run {:?} event {} in session {:?} has invalid Tool result transform binding: {error}",
351 run_id,
352 event_index,
353 session_id
354 )
355 })?;
356
357 let encoded_evidence = metadata
358 .and_then(|value| value.get("a3s_tool_result_evidence"))
359 .ok_or_else(|| {
360 anyhow::anyhow!(
361 "run {:?} event {} in session {:?} has a Tool result transform binding without Tool result evidence",
362 run_id,
363 event_index,
364 session_id
365 )
366 })?;
367 let evidence: crate::tools::ToolResultEvidenceV1 =
368 serde_json::from_value(encoded_evidence.clone()).map_err(|error| {
369 anyhow::anyhow!(
370 "run {:?} event {} in session {:?} has malformed Tool result evidence: {error}",
371 run_id,
372 event_index,
373 session_id
374 )
375 })?;
376 if evidence.schema != crate::tools::TOOL_RESULT_EVIDENCE_SCHEMA_V1
377 || evidence.transform_algorithm.as_deref() != Some(binding.transform_algorithm.as_str())
378 {
379 anyhow::bail!(
380 "run {:?} event {} in session {:?} has Tool result evidence that does not match its transform binding",
381 run_id,
382 event_index,
383 session_id
384 );
385 }
386 Ok(())
387}
388
389pub(super) fn artifact_store_from_with_retention(
390 artifacts: &[ToolArtifact],
391 retained_uris: &[String],
392) -> ArtifactStore {
393 let defaults = ArtifactStoreLimits::default();
397 let requirements = artifact_store_requirements(artifacts);
398 let store = ArtifactStore::with_limits(ArtifactStoreLimits {
399 max_artifacts: defaults.max_artifacts.max(requirements.max_artifacts),
400 max_bytes: defaults.max_bytes.max(requirements.max_bytes),
401 });
402 for artifact in artifacts {
403 store.put(artifact.clone());
404 }
405 if !retained_uris.is_empty() {
406 store.set_retained_uris(retained_uris.iter().cloned());
407 }
408 store
409}
410
411fn artifact_store_requirements(artifacts: &[ToolArtifact]) -> ArtifactStoreLimits {
412 ArtifactStoreLimits {
413 max_artifacts: artifacts.len(),
414 max_bytes: artifacts.iter().fold(0usize, |total, artifact| {
415 total.saturating_add(artifact.content.len())
416 }),
417 }
418}