vtcode_core/utils/session_archive/
archive.rs1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7
8use super::identifier::{generate_unique_archive_path, reserve_new_session_archive_path};
9use super::message::SessionMessage;
10use super::metadata::{SessionArchiveMetadata, SessionContinuationMetadata};
11use super::query::{find_session_by_identifier, resolve_sessions_dir_for_archive_writes, write_private_json_file_sync};
12use super::retention::apply_session_retention_best_effort;
13use super::settings::history_persistence_enabled;
14use super::shrink::prepare_snapshot_for_write;
15use super::snapshot::{
16 SessionListing, SessionProgress, SessionProgressArgs, SessionProgressPersistenceStatus, SessionSnapshot,
17 VerifiedSessionArchiveIdentifier,
18};
19use super::transcript::{clean_transcript_lines, progress_transcript_from_recent_messages};
20use crate::config::constants::defaults;
21use crate::core::agent::snapshots::SnapshotTurnDiagnostics;
22use crate::telemetry::perf::PerfSpan;
23use crate::utils::session_transcript_norm::normalize_distinct_tools_for_summary;
24use vtcode_commons::fs::write_private_json_file;
25
26#[derive(Debug, Clone)]
27pub struct SessionArchive {
28 path: PathBuf,
29 metadata: SessionArchiveMetadata,
30 started_at: DateTime<Utc>,
31 progress_throttle: Arc<Mutex<ProgressThrottle>>,
32}
33
34#[derive(Debug)]
35struct ProgressThrottle {
36 last_written: Instant,
37 last_turn: usize,
38}
39
40impl ProgressThrottle {
41 fn new() -> Self {
42 let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
43 let last_written = Instant::now().checked_sub(min_interval).unwrap_or_else(Instant::now);
44 Self { last_written, last_turn: 0 }
45 }
46}
47
48impl SessionArchive {
49 fn from_path(path: PathBuf, metadata: SessionArchiveMetadata, started_at: DateTime<Utc>) -> Self {
50 Self {
51 path,
52 metadata,
53 started_at,
54 progress_throttle: Arc::new(Mutex::new(ProgressThrottle::new())),
55 }
56 }
57
58 fn build_snapshot(
59 &self,
60 total_messages: usize,
61 distinct_tools: Vec<String>,
62 transcript: Vec<String>,
63 messages: Vec<SessionMessage>,
64 progress: Option<SessionProgress>,
65 ) -> SessionSnapshot {
66 use crate::utils::error_log_collector::drain_error_logs;
67
68 SessionSnapshot {
69 metadata: self.metadata.clone(),
70 started_at: self.started_at,
71 ended_at: Utc::now(),
72 total_messages,
73 distinct_tools,
74 transcript,
75 messages,
76 progress: progress.map(Box::new),
77 error_logs: drain_error_logs(),
78 }
79 }
80
81 pub fn set_primary_agent(&mut self, primary_agent: impl Into<String>) {
85 self.metadata.primary_agent = Some(primary_agent.into());
86 }
87
88 pub fn metadata(&self) -> &SessionArchiveMetadata {
89 &self.metadata
90 }
91
92 fn build_final_snapshot(
93 &self,
94 transcript: Vec<String>,
95 total_messages: usize,
96 distinct_tools: Vec<String>,
97 messages: Vec<SessionMessage>,
98 turn_diagnostics: Option<SnapshotTurnDiagnostics>,
99 ) -> SessionSnapshot {
100 self.build_snapshot(
101 total_messages,
102 normalize_distinct_tools_for_summary(&distinct_tools),
103 clean_transcript_lines(&transcript),
104 messages,
105 turn_diagnostics.map(|turn_diagnostics| SessionProgress {
106 turn_diagnostics: Some(turn_diagnostics),
107 ..SessionProgress::default()
108 }),
109 )
110 }
111
112 fn build_progress_snapshot(&self, args: SessionProgressArgs) -> SessionSnapshot {
113 let SessionProgressArgs {
114 total_messages,
115 distinct_tools,
116 messages,
117 recent_messages,
118 turn_number,
119 token_usage,
120 max_context_tokens,
121 loaded_skills,
122 turn_diagnostics,
123 } = args;
124
125 let transcript = progress_transcript_from_recent_messages(&recent_messages);
126 let distinct_tools = normalize_distinct_tools_for_summary(&distinct_tools);
127 let tool_summaries = distinct_tools.clone();
128
129 self.build_snapshot(
130 total_messages,
131 distinct_tools,
132 transcript,
133 messages,
134 Some(SessionProgress {
135 turn_number,
136 recent_messages,
137 tool_summaries,
138 token_usage,
139 max_context_tokens,
140 loaded_skills: loaded_skills.unwrap_or_default(),
141 turn_diagnostics,
142 }),
143 )
144 }
145
146 pub async fn new(metadata: SessionArchiveMetadata, custom_suffix: Option<String>) -> Result<Self> {
147 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
148 let started_at = Utc::now();
149 let path = generate_unique_archive_path(&sessions_dir, &metadata, started_at, custom_suffix.as_deref());
150
151 Ok(Self::from_path(path, metadata, started_at))
152 }
153
154 pub async fn new_with_identifier(metadata: SessionArchiveMetadata, session_identifier: String) -> Result<Self> {
156 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
157 let path = reserve_new_session_archive_path(&sessions_dir, &session_identifier)?;
158
159 Ok(Self::from_path(path, metadata, Utc::now()))
160 }
161
162 pub fn resume_from_listing(listing: &SessionListing, metadata: SessionArchiveMetadata) -> Self {
164 Self::from_path(listing.path.clone(), metadata, listing.snapshot.started_at)
165 }
166
167 pub fn finalize(
168 &self,
169 transcript: Vec<String>,
170 total_messages: usize,
171 distinct_tools: Vec<String>,
172 messages: Vec<SessionMessage>,
173 ) -> Result<PathBuf> {
174 self.finalize_with_diagnostics(transcript, total_messages, distinct_tools, messages, None)
175 }
176
177 pub fn finalize_with_diagnostics(
180 &self,
181 transcript: Vec<String>,
182 total_messages: usize,
183 distinct_tools: Vec<String>,
184 messages: Vec<SessionMessage>,
185 turn_diagnostics: Option<SnapshotTurnDiagnostics>,
186 ) -> Result<PathBuf> {
187 let snapshot =
188 self.build_final_snapshot(transcript, total_messages, distinct_tools, messages, turn_diagnostics);
189
190 let path = self.write_snapshot(snapshot)?;
191 if let Some(parent) = path.parent() {
192 apply_session_retention_best_effort(parent);
193 }
194 Ok(path)
195 }
196
197 pub fn persist_progress(&self, args: SessionProgressArgs) -> Result<PathBuf> {
198 let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
199 perf.tag("mode", "sync");
200
201 let snapshot = self.build_progress_snapshot(args);
202
203 self.write_snapshot(snapshot)
204 }
205
206 pub async fn persist_progress_async_with_status(
213 &self,
214 args: SessionProgressArgs,
215 ) -> Result<SessionProgressPersistenceStatus> {
216 self.persist_progress_async_with_status_inner(args, false).await
217 }
218
219 async fn persist_progress_async_with_status_inner(
220 &self,
221 args: SessionProgressArgs,
222 bypass_throttle: bool,
223 ) -> Result<SessionProgressPersistenceStatus> {
224 let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
225 perf.tag("mode", if bypass_throttle { "async_forced" } else { "async" });
226
227 if !history_persistence_enabled() {
228 return Ok(SessionProgressPersistenceStatus::Disabled(self.path.clone()));
229 }
230 if !bypass_throttle && !self.should_persist_progress(args.turn_number)? {
231 return Ok(SessionProgressPersistenceStatus::Throttled(self.path.clone()));
232 }
233
234 let snapshot = self.build_progress_snapshot(args);
235 let path = self.write_snapshot_async(snapshot).await?;
236 Ok(SessionProgressPersistenceStatus::Persisted(path))
237 }
238
239 pub async fn persist_progress_async_with_status_forced(
244 &self,
245 args: SessionProgressArgs,
246 ) -> Result<SessionProgressPersistenceStatus> {
247 self.persist_progress_async_with_status_inner(args, true).await
248 }
249
250 pub async fn persist_progress_async(&self, args: SessionProgressArgs) -> Result<PathBuf> {
251 Ok(self.persist_progress_async_with_status(args).await?.path().to_path_buf())
252 }
253
254 fn write_snapshot(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
255 let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
256 return Ok(self.path.clone());
257 };
258
259 write_private_json_file_sync(&self.path, &snapshot)?;
260 Ok(self.path.clone())
261 }
262
263 async fn write_snapshot_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
264 let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
265 return Ok(self.path.clone());
266 };
267
268 write_private_json_file(&self.path, &snapshot).await?;
269 Ok(self.path.clone())
270 }
271
272 fn should_persist_progress(&self, turn_number: usize) -> Result<bool> {
273 let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
274 let min_turns = defaults::DEFAULT_SESSION_PROGRESS_MIN_TURN_DELTA;
275
276 let mut throttle = self
277 .progress_throttle
278 .lock()
279 .map_err(|err| anyhow::anyhow!("session progress throttle lock poisoned: {err}"))
280 .context("Failed to evaluate session progress persistence throttle")?;
281 if turn_number <= throttle.last_turn {
282 return Ok(false);
283 }
284 if throttle.last_written.elapsed() < min_interval && turn_number.saturating_sub(throttle.last_turn) < min_turns
285 {
286 return Ok(false);
287 }
288
289 throttle.last_written = Instant::now();
290 throttle.last_turn = turn_number;
291 Ok(true)
292 }
293 pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
295 self.metadata.loaded_skills = skills;
296 }
297
298 pub fn set_continuation_metadata(&mut self, continuation_metadata: Option<SessionContinuationMetadata>) {
300 self.metadata.continuation_metadata = continuation_metadata;
301 }
302
303 pub fn path(&self) -> &Path {
304 &self.path
305 }
306
307 pub async fn verify_persisted_resume_identifier(
310 &self,
311 persisted_path: &Path,
312 ) -> Result<Option<VerifiedSessionArchiveIdentifier>> {
313 if self.path != persisted_path {
314 return Ok(None);
315 }
316
317 let Some(identifier) = persisted_path.file_stem().and_then(|stem| stem.to_str()) else {
318 return Ok(None);
319 };
320 let Some(listing) = find_session_by_identifier(identifier).await? else {
321 return Ok(None);
322 };
323 if listing.path != self.path {
324 return Ok(None);
325 }
326
327 Ok(Some(listing.verified_identifier()?))
328 }
329
330 pub async fn fork(source_snapshot: &SessionSnapshot, custom_suffix: Option<String>) -> Result<Self> {
343 create_fork_archive(source_snapshot, custom_suffix, None).await
344 }
345}
346
347async fn create_fork_archive(
348 source_snapshot: &SessionSnapshot,
349 custom_suffix: Option<String>,
350 explicit_identifier: Option<String>,
351) -> Result<SessionArchive> {
352 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
353 let started_at = Utc::now();
354
355 let forked_metadata = source_snapshot.metadata.fork_seed();
356
357 let path = if let Some(session_identifier) = explicit_identifier {
358 reserve_new_session_archive_path(&sessions_dir, &session_identifier)?
359 } else {
360 generate_unique_archive_path(&sessions_dir, &forked_metadata, started_at, custom_suffix.as_deref())
361 };
362
363 Ok(SessionArchive::from_path(path, forked_metadata, started_at))
364}