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    /// Retention roots for reference-aware artifact GC (STORE-GC2).
25    #[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    /// Rebind a complete snapshot to a new session and workspace.
73    ///
74    /// Session forks retain historical artifacts, traces, run ids, and child
75    /// session ids. Top-level run ownership and subagent parent ownership must
76    /// move with the new session or the aggregate would no longer be loadable.
77    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    /// Validate relationships that must hold within one persisted generation.
125    ///
126    /// Event buffers may be FIFO-trimmed, so their first sequence is allowed
127    /// to be greater than zero and `event_count` is allowed to exceed the
128    /// retained length. It must, however, remain a valid next-sequence cursor
129    /// for every retained event.
130    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            // Snapshots written before Run-level binding evidence was added
220            // may still carry repeated cognitive events. Keep those loadable
221            // when the events agree with each other; new snapshots bind the
222            // value directly on RunSnapshot and no longer compare old Runs to
223            // the Session's latest catalog generation.
224            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            // Older snapshots can contain an empty parent when progress/end
297            // arrived before SubagentStart. A non-empty parent is authoritative
298            // and must identify the session that owns this task tracker.
299            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    /// Validate this snapshot for a load request targeting `session_id`.
314    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    // A snapshot is an authoritative persisted generation. Rehydrating it
394    // through the default in-memory limits must not silently evict records
395    // that were accepted by a store configured with larger limits.
396    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}