1use std::io::{Read, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use flate2::Compression;
22use flate2::read::GzDecoder;
23use flate2::write::GzEncoder;
24
25use crate::backend::blockcontroller::session_stats::{
26 META_SESSION_LAST_ACTIVITY_MS, META_SESSION_LINE_COUNT,
27};
28use crate::backend::obj::{Block, MetaMapType};
29use crate::backend::storage::filestore::{FileStore, FileMeta, FileOpts};
30use crate::backend::storage::store::Store;
31
32pub const META_SESSION_ARCHIVED_AT: &str = "session:archived_at";
36pub const META_SESSION_ARCHIVED_BYTES: &str = "session:archived_bytes";
38pub const META_SESSION_ARCHIVE_PATH: &str = "session:archive_path";
40
41const OUTPUT_FILENAME: &str = "output";
43const TSIDX_FILENAME: &str = crate::backend::agent_session::session_io::TSIDX_FILE;
49
50use agentmux_common::time::now_ms;
55
56fn write_gz(data: &[u8], dest_path: &Path) -> Result<(), String> {
58 let file = std::fs::File::create(dest_path)
59 .map_err(|e| format!("create archive file {}: {e}", dest_path.display()))?;
60 let mut encoder = GzEncoder::new(file, Compression::default());
61 encoder.write_all(data)
62 .map_err(|e| format!("compress: {e}"))?;
63 encoder.finish()
64 .map_err(|e| format!("gz finish: {e}"))?;
65 Ok(())
66}
67
68fn read_gz(src_path: &Path) -> Result<Vec<u8>, String> {
70 let file = std::fs::File::open(src_path)
71 .map_err(|e| format!("open archive {}: {e}", src_path.display()))?;
72 let mut decoder = GzDecoder::new(file);
73 let mut out = Vec::new();
74 decoder.read_to_end(&mut out)
75 .map_err(|e| format!("decompress: {e}"))?;
76 Ok(out)
77}
78
79fn ensure_archive_dir(archive_dir: &Path) -> Result<(), String> {
81 std::fs::create_dir_all(archive_dir)
82 .map_err(|e| format!("create archive dir {}: {e}", archive_dir.display()))
83}
84
85pub fn archive_session_output(
98 mstore: &Arc<Store>,
99 filestore: &Arc<FileStore>,
100 block_id: &str,
101 archive_dir: &Path,
102) -> Result<(u64, i64), String> {
103 let raw_bytes = match filestore.read_file(block_id, OUTPUT_FILENAME) {
105 Ok(Some(b)) if !b.is_empty() => b,
106 Ok(_) => {
107 return Ok((0, now_ms()));
109 }
110 Err(e) => return Err(format!("filestore read: {e}")),
111 };
112
113 let archived_bytes = raw_bytes.len() as u64;
114
115 ensure_archive_dir(archive_dir)?;
116
117 let archive_path = archive_dir.join(format!("{}.jsonl.gz", block_id));
118 write_gz(&raw_bytes, &archive_path)?;
119
120 let archived_at = now_ms();
121
122 let mut meta = MetaMapType::new();
127 meta.insert(META_SESSION_ARCHIVED_AT.to_string(), serde_json::json!(archived_at));
128 meta.insert(META_SESSION_ARCHIVED_BYTES.to_string(), serde_json::json!(archived_bytes));
129 meta.insert(
130 META_SESSION_ARCHIVE_PATH.to_string(),
131 serde_json::json!(archive_path.to_string_lossy().as_ref()),
132 );
133
134 let oref_str = format!("block:{}", block_id);
135 if let Err(e) = crate::server::service::update_object_meta(mstore, &oref_str, &meta) {
136 let _ = std::fs::remove_file(&archive_path);
138 return Err(format!("update_object_meta: {e}"));
139 }
140
141 if let Ok(Some(ts_bytes)) = filestore.read_file(block_id, TSIDX_FILENAME) {
144 if !ts_bytes.is_empty() {
145 let ts_path = archive_dir.join(format!("{}.tsidx.gz", block_id));
146 if let Err(e) = write_gz(&ts_bytes, &ts_path) {
147 tracing::warn!(block_id = %block_id, error = %e, "session_archive: tsidx archive write failed (output archive unaffected)");
148 }
149 }
150 }
151 if let Err(e) = filestore.delete_files(block_id, &[OUTPUT_FILENAME, TSIDX_FILENAME, "output.idx"]) {
156 tracing::warn!(
157 block_id = %block_id,
158 error = %e,
159 "session_archive: failed to delete filestore entry after archiving (meta already updated)"
160 );
161 }
162
163 tracing::info!(
164 block_id = %block_id,
165 archived_bytes = archived_bytes,
166 archive_path = %archive_path.display(),
167 "session archived"
168 );
169
170 Ok((archived_bytes, archived_at))
171}
172
173pub fn restore_session_output(
182 mstore: &Arc<Store>,
183 filestore: &Arc<FileStore>,
184 block_id: &str,
185) -> Result<u64, String> {
186 let block: Block = mstore
188 .get(block_id)
189 .map_err(|e| format!("mstore.get: {e}"))?
190 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", block_id))?;
191
192 let archive_path_str = block
193 .meta
194 .get(META_SESSION_ARCHIVE_PATH)
195 .and_then(|v| v.as_str())
196 .ok_or_else(|| format!("session:archive_path not set for block {}", block_id))?
197 .to_string();
198
199 let archive_path = PathBuf::from(&archive_path_str);
200 let raw_bytes = read_gz(&archive_path)?;
201 let restored_bytes = raw_bytes.len() as u64;
202
203 filestore
208 .replace_file(block_id, OUTPUT_FILENAME, &raw_bytes, &[TSIDX_FILENAME, "output.idx"])
209 .map_err(|e| format!("replace_file: {e}"))?;
210
211 let ts_path = archive_path
215 .parent()
216 .map(|d| d.join(format!("{}.tsidx.gz", block_id)))
217 .filter(|p| p.exists());
218 if let Some(ts_path) = ts_path {
219 match read_gz(&ts_path) {
220 Ok(ts_bytes) if !ts_bytes.is_empty() => {
221 let created = filestore
222 .make_file(block_id, TSIDX_FILENAME, FileMeta::default(), FileOpts::default());
223 if created.is_ok() {
224 if let Err(e) = filestore.append_data(block_id, TSIDX_FILENAME, &ts_bytes) {
225 tracing::warn!(block_id = %block_id, error = %e, "session_archive: tsidx restore append failed");
226 }
227 }
228 }
229 Ok(_) => {}
230 Err(e) => {
231 tracing::warn!(block_id = %block_id, error = %e, "session_archive: tsidx restore read failed");
232 }
233 }
234 }
235
236 let mut meta = MetaMapType::new();
238 meta.insert(META_SESSION_ARCHIVED_AT.to_string(), serde_json::Value::Null);
239 meta.insert(META_SESSION_ARCHIVED_BYTES.to_string(), serde_json::Value::Null);
240 meta.insert(META_SESSION_ARCHIVE_PATH.to_string(), serde_json::Value::Null);
241
242 let oref_str = format!("block:{}", block_id);
243 crate::server::service::update_object_meta(mstore, &oref_str, &meta)
244 .map_err(|e| format!("update_object_meta: {e}"))?;
245
246 tracing::info!(
247 block_id = %block_id,
248 restored_bytes = restored_bytes,
249 archive_path = %archive_path_str,
250 "session restored"
251 );
252
253 Ok(restored_bytes)
254}
255
256pub fn read_session_output(
263 mstore: &Arc<Store>,
264 filestore: &Arc<FileStore>,
265 block_id: &str,
266) -> Result<(Vec<u8>, u64), String> {
267 let block: Block = mstore
269 .get(block_id)
270 .map_err(|e| format!("mstore.get: {e}"))?
271 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", block_id))?;
272
273 let is_archived = block
274 .meta
275 .get(META_SESSION_ARCHIVED_AT)
276 .and_then(|v| v.as_i64())
277 .map(|v| v > 0)
278 .unwrap_or(false);
279
280 let raw_bytes = if is_archived {
281 let archive_path_str = block
282 .meta
283 .get(META_SESSION_ARCHIVE_PATH)
284 .and_then(|v| v.as_str())
285 .ok_or_else(|| "session:archive_path not set".to_string())?
286 .to_string();
287 read_gz(Path::new(&archive_path_str))?
288 } else {
289 filestore
290 .read_file(block_id, OUTPUT_FILENAME)
291 .map_err(|e| format!("filestore read: {e}"))?
292 .unwrap_or_default()
293 };
294
295 let line_count = bytecount_lines(&raw_bytes);
296 Ok((raw_bytes, line_count))
297}
298
299fn bytecount_lines(data: &[u8]) -> u64 {
301 let text = String::from_utf8_lossy(data);
302 text.lines().filter(|l| !l.trim().is_empty()).count() as u64
303}
304
305pub fn default_archive_dir() -> Option<PathBuf> {
311 agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("archives"))
316}
317
318pub struct SessionArchiver {
324 mstore: Arc<Store>,
325 filestore: Arc<FileStore>,
326 pub inactive_days: u64,
328 pub max_total_bytes: u64,
330 pub archive_dir: PathBuf,
332}
333
334#[derive(Debug, Clone, Default)]
336pub struct SessionArchiverStats {
337 pub archived_count: u32,
338 pub pruned_count: u32,
339 pub bytes_freed: u64,
340}
341
342impl SessionArchiver {
343 pub fn new(
344 mstore: Arc<Store>,
345 filestore: Arc<FileStore>,
346 inactive_days: u64,
347 max_total_bytes: u64,
348 archive_dir: PathBuf,
349 ) -> Self {
350 Self { mstore, filestore, inactive_days, max_total_bytes, archive_dir }
351 }
352
353 pub async fn sweep(&self) -> Result<SessionArchiverStats, String> {
358 let mut stats = SessionArchiverStats::default();
359 let now = now_ms();
360 let cutoff_ms = self.inactive_days as i64 * 86_400_000;
361
362 let all_blocks: Vec<Block> = self
364 .mstore
365 .get_all::<Block>()
366 .map_err(|e| format!("get_all blocks: {e}"))?;
367
368 for block in &all_blocks {
369 let view = block.meta.get("view").and_then(|v| v.as_str()).unwrap_or("");
371 if view != "agent" {
372 continue;
373 }
374
375 let archived_at = block
377 .meta
378 .get(META_SESSION_ARCHIVED_AT)
379 .and_then(|v| v.as_i64())
380 .unwrap_or(0);
381 if archived_at > 0 {
382 continue;
383 }
384
385 let last_activity = match block
387 .meta
388 .get(META_SESSION_LAST_ACTIVITY_MS)
389 .and_then(|v| v.as_i64())
390 {
391 Some(v) if v > 0 => v,
392 _ => continue,
393 };
394
395 let line_count = block
397 .meta
398 .get(META_SESSION_LINE_COUNT)
399 .and_then(|v| v.as_u64())
400 .unwrap_or(0);
401 if line_count == 0 {
402 continue;
403 }
404
405 if now - last_activity < cutoff_ms {
407 continue;
408 }
409
410 match archive_session_output(
412 &self.mstore,
413 &self.filestore,
414 &block.oid,
415 &self.archive_dir,
416 ) {
417 Ok((bytes, _)) => {
418 stats.archived_count += 1;
419 stats.bytes_freed += bytes;
420 tracing::info!(
421 block_id = %block.oid,
422 bytes_freed = bytes,
423 "session archiver: auto-archived inactive session"
424 );
425 }
426 Err(e) => {
427 tracing::warn!(
428 block_id = %block.oid,
429 error = %e,
430 "session archiver: failed to archive session"
431 );
432 }
433 }
434 }
435
436 stats.pruned_count = self.prune_archives(&mut stats.bytes_freed)?;
438
439 Ok(stats)
440 }
441
442 fn prune_archives(&self, bytes_freed: &mut u64) -> Result<u32, String> {
445 let Ok(entries) = std::fs::read_dir(&self.archive_dir) else {
446 return Ok(0);
447 };
448
449 let mut files: Vec<(std::time::SystemTime, PathBuf, u64)> = entries
451 .flatten()
452 .filter(|e| {
453 e.path()
454 .extension()
455 .map(|x| x == "gz")
456 .unwrap_or(false)
457 })
458 .filter_map(|e| {
459 let meta = e.metadata().ok()?;
460 let mtime = meta.modified().ok()?;
461 let size = meta.len();
462 Some((mtime, e.path(), size))
463 })
464 .collect();
465
466 files.sort_by_key(|(mtime, _, _)| *mtime);
468
469 let total: u64 = files.iter().map(|(_, _, s)| s).sum();
470 if total <= self.max_total_bytes {
471 return Ok(0);
472 }
473
474 let mut remaining = total;
475 let mut pruned = 0u32;
476
477 for (_, path, size) in &files {
478 if remaining <= self.max_total_bytes {
479 break;
480 }
481 if let Err(e) = std::fs::remove_file(path) {
482 tracing::warn!(path = %path.display(), error = %e, "session archiver: failed to prune archive");
483 continue;
484 }
485 remaining = remaining.saturating_sub(*size);
486 *bytes_freed += size;
487 pruned += 1;
488 tracing::info!(
489 path = %path.display(),
490 size_freed = size,
491 "session archiver: pruned archive"
492 );
493 }
494
495 Ok(pruned)
496 }
497}
498
499#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::sync::Arc;
507 use crate::backend::storage::filestore::FileStore;
508 use crate::backend::storage::store::Store;
509
510 #[tokio::test]
513 async fn test_sweep_archives_inactive_session() {
514 let tmp_dir = tempfile::tempdir().expect("tempdir");
516 let archive_dir = tmp_dir.path().to_path_buf();
517
518 let filestore = Arc::new(
520 FileStore::open_in_memory().expect("filestore"),
521 );
522 let block_id_owned = uuid::Uuid::new_v4().to_string();
530 let block_id = block_id_owned.as_str();
531 filestore
532 .make_file(block_id, OUTPUT_FILENAME, FileMeta::default(), FileOpts::default())
533 .expect("make_file");
534 filestore
535 .append_data(block_id, OUTPUT_FILENAME, b"line1\nline2\n")
536 .expect("append_data");
537
538 let db_dir = tmp_dir.path().join("wdb");
540 std::fs::create_dir_all(&db_dir).unwrap();
541 let mstore = Arc::new(
542 Store::open(&db_dir.join("objects.db")).expect("mstore"),
543 );
544
545 use crate::backend::obj::{Block, MetaMapType};
547 let mut meta = MetaMapType::new();
548 meta.insert("view".to_string(), serde_json::json!("agent"));
549 let ten_days_ago = now_ms() - 10 * 86_400_000;
551 meta.insert(
552 META_SESSION_LAST_ACTIVITY_MS.to_string(),
553 serde_json::json!(ten_days_ago),
554 );
555 meta.insert(META_SESSION_LINE_COUNT.to_string(), serde_json::json!(2u64));
556
557 let mut block = Block {
558 oid: block_id.to_string(),
559 parentoref: String::new(),
560 version: 1,
561 runtimeopts: None,
562 stickers: None,
563 meta,
564 subblockids: None,
565 };
566 mstore.insert(&mut block).expect("mstore insert");
567
568 let archiver = SessionArchiver::new(
570 mstore.clone(),
571 filestore.clone(),
572 1, 2 * 1024 * 1024 * 1024,
574 archive_dir.clone(),
575 );
576
577 let stats = archiver.sweep().await.expect("sweep");
578 assert_eq!(stats.archived_count, 1, "should archive 1 session");
579 assert!(stats.bytes_freed > 0, "should free bytes");
580
581 let remaining = filestore.stat(block_id, OUTPUT_FILENAME).unwrap();
583 assert!(remaining.is_none(), "filestore entry should be deleted after archive");
584
585 let gz_path = archive_dir.join(format!("{}.jsonl.gz", block_id));
587 assert!(gz_path.exists(), ".gz file should exist");
588
589 let updated_block: Option<Block> = mstore.get(block_id).unwrap();
591 let updated_block = updated_block.expect("block still in store");
592 let archived_at = updated_block
593 .meta
594 .get(META_SESSION_ARCHIVED_AT)
595 .and_then(|v| v.as_i64())
596 .unwrap_or(0);
597 assert!(archived_at > 0, "session:archived_at should be set");
598 }
599
600 #[test]
601 fn test_bytecount_lines() {
602 assert_eq!(bytecount_lines(b""), 0);
603 assert_eq!(bytecount_lines(b"line1\n"), 1);
604 assert_eq!(bytecount_lines(b"line1\nline2\n"), 2);
605 assert_eq!(bytecount_lines(b" \n\nline3\n"), 1); }
607
608 #[test]
609 fn test_gz_roundtrip() {
610 let tmp = tempfile::NamedTempFile::new().unwrap();
611 let data = b"hello compressed world\nline2\n";
612 write_gz(data, tmp.path()).unwrap();
613 let out = read_gz(tmp.path()).unwrap();
614 assert_eq!(out, data);
615 }
616}