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    /// Rebind a complete snapshot to a new session and workspace.
66    ///
67    /// Session forks retain historical artifacts, traces, run ids, and child
68    /// session ids. Top-level run ownership and subagent parent ownership must
69    /// move with the new session or the aggregate would no longer be loadable.
70    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    /// Validate relationships that must hold within one persisted generation.
118    ///
119    /// Event buffers may be FIFO-trimmed, so their first sequence is allowed
120    /// to be greater than zero and `event_count` is allowed to exceed the
121    /// retained length. It must, however, remain a valid next-sequence cursor
122    /// for every retained event.
123    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            // Snapshots written before Run-level binding evidence was added
213            // may still carry repeated cognitive events. Keep those loadable
214            // when the events agree with each other; new snapshots bind the
215            // value directly on RunSnapshot and no longer compare old Runs to
216            // the Session's latest catalog generation.
217            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            // Older snapshots can contain an empty parent when progress/end
290            // arrived before SubagentStart. A non-empty parent is authoritative
291            // and must identify the session that owns this task tracker.
292            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    /// Validate this snapshot for a load request targeting `session_id`.
307    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    // A snapshot is an authoritative persisted generation. Rehydrating it
384    // through the default in-memory limits must not silently evict records
385    // that were accepted by a store configured with larger limits.
386    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}