Skip to main content

agentmux_srv/backend/
session_archive.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Session archival and cleanup (Phase 3.3 — ultra-long-sessions).
5//!
6//! Responsibilities:
7//!   1. Periodic sweep: archive sessions inactive for `inactive_days` days by
8//!      compressing their FileStore "output" file to `archive_dir/<block_id>.jsonl.gz`
9//!      and freeing the FileStore entry.
10//!   2. Storage cap: after archiving, prune oldest `.gz` files until total archive
11//!      disk usage is below `max_total_bytes` (default 2 GB).
12//!
13//! The sweep archives through the same `archive_session_output` the RPC
14//! handlers in `server/app_api/session.rs` call; those handlers also use
15//! `restore_session_output` and `read_session_output` from this module.
16
17use 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
32// ---- Meta key constants for archival state ----
33
34/// Unix ms when the session was last archived.
35pub const META_SESSION_ARCHIVED_AT: &str = "session:archived_at";
36/// Byte count before compression (original output size).
37pub const META_SESSION_ARCHIVED_BYTES: &str = "session:archived_bytes";
38/// Absolute path to the `.jsonl.gz` archive file.
39pub const META_SESSION_ARCHIVE_PATH: &str = "session:archive_path";
40
41/// FileStore filename for session output.
42const OUTPUT_FILENAME: &str = "output";
43/// Receive-time sidecar (see `agent_session::TSIDX_FILE`) — archived,
44/// deleted, and restored in lockstep with `output` (codex P2 on PR #2508):
45/// leaving it behind would mis-time a fresh session's lines (new output
46/// restarts at offset 0 under stale entries), and dropping it from the
47/// archive loses the timestamps the sidecar exists to preserve.
48const TSIDX_FILENAME: &str = crate::backend::agent_session::session_io::TSIDX_FILE;
49
50// ---------------------------------------------------------------------------
51// Shared helpers — used by both the RPC handlers and the sweep
52// ---------------------------------------------------------------------------
53
54use agentmux_common::time::now_ms;
55
56/// Compress `data` with gzip and write to `dest_path`.
57fn 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
68/// Decompress a `.gz` file and return raw bytes.
69fn 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
79/// Ensure the archive directory exists.
80fn 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
85// ---------------------------------------------------------------------------
86// archive_session_output
87// ---------------------------------------------------------------------------
88
89/// Archive the FileStore "output" file for `block_id`:
90///   1. Read bytes from FileStore.
91///   2. Compress to `archive_dir/<block_id>.jsonl.gz`.
92///   3. Delete the FileStore entry to reclaim SQLite space.
93///   4. Write archive meta keys to the block.
94///
95/// Returns `(archived_bytes, archived_at_ms)`.
96/// If the FileStore entry is missing or empty, returns `(0, now_ms)` — no-op.
97pub 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    // Read existing data from FileStore
104    let raw_bytes = match filestore.read_file(block_id, OUTPUT_FILENAME) {
105        Ok(Some(b)) if !b.is_empty() => b,
106        Ok(_) => {
107            // Nothing to archive
108            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    // Write archive metadata BEFORE deleting the FileStore entry. If the meta
123    // update fails, the session data is still retrievable from FileStore and
124    // the orphaned .gz can be cleaned up by the next sweep. Deleting first
125    // would orphan the session if the meta write then failed.
126    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        // Roll back the archive file so we don't leak disk on retry
137        let _ = std::fs::remove_file(&archive_path);
138        return Err(format!("update_object_meta: {e}"));
139    }
140
141    // Sidecar follows output: archive its bytes next to the .jsonl.gz
142    // (best-effort — auxiliary timing data) before it is deleted below.
143    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    // Meta is now persisted; safe to reclaim the FileStore entry, with both
152    // sidecars, in one transaction (5a-2b): a restarted session must not
153    // inherit stale offset stamps, and a stale output.idx could pass
154    // read_range's covered_size check by coincidence.
155    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
173// ---------------------------------------------------------------------------
174// restore_session_output
175// ---------------------------------------------------------------------------
176
177/// Restore an archived session back into FileStore:
178///   1. Read `session:archive_path` from block meta.
179///   2. Decompress and write bytes back via `make_file` + `append_data`.
180///   3. Clear archive meta keys (keep the .gz file as backup).
181pub fn restore_session_output(
182    mstore: &Arc<Store>,
183    filestore: &Arc<FileStore>,
184    block_id: &str,
185) -> Result<u64, String> {
186    // Read archive path from block meta
187    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    // Recreate the FileStore entry (may already be gone after archival) in
204    // one transaction (5a-2b): the restored content with a new generation,
205    // and no sidecar left describing whatever `output` held before. A reader
206    // never sees the file missing or half-restored.
207    filestore
208        .replace_file(block_id, OUTPUT_FILENAME, &raw_bytes, &[TSIDX_FILENAME, "output.idx"])
209        .map_err(|e| format!("replace_file: {e}"))?;
210
211    // Restore the tsidx sidecar when its archive exists (best-effort —
212    // pre-sidecar archives simply have none, and restored history without
213    // stamps degrades to the carry-forward day rule, not an error).
214    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    // Clear archive meta keys (keep the .gz as backup — don't delete it)
237    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
256// ---------------------------------------------------------------------------
257// read_session_output — used by session:export
258// ---------------------------------------------------------------------------
259
260/// Read the raw session output bytes, whether from FileStore (live) or archive.
261/// Returns `(bytes, line_count)`.
262pub fn read_session_output(
263    mstore: &Arc<Store>,
264    filestore: &Arc<FileStore>,
265    block_id: &str,
266) -> Result<(Vec<u8>, u64), String> {
267    // Check if session is archived
268    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
299/// Count non-empty lines in a byte buffer.
300fn 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
305// ---------------------------------------------------------------------------
306// Default archive directory
307// ---------------------------------------------------------------------------
308
309/// Returns `~/.agentmux/archives/`, or `None` if the home directory cannot be determined.
310pub fn default_archive_dir() -> Option<PathBuf> {
311    // Stays fallible on purpose. bootstrap/background.rs treats None as
312    // "disable the archiver", with an explicit comment saying a relative
313    // fallback would otherwise write archives under the CWD. Routing this
314    // through the infallible get_mux_data_dir() silently defeated that guard.
315    agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("archives"))
316}
317
318// ---------------------------------------------------------------------------
319// SessionArchiver — periodic sweep
320// ---------------------------------------------------------------------------
321
322/// Periodic session archival + storage cap enforcement.
323pub struct SessionArchiver {
324    mstore: Arc<Store>,
325    filestore: Arc<FileStore>,
326    /// Sessions with no activity for this many days get archived.
327    pub inactive_days: u64,
328    /// Maximum total bytes across all `.jsonl.gz` archives.
329    pub max_total_bytes: u64,
330    /// Directory where `.jsonl.gz` files are written.
331    pub archive_dir: PathBuf,
332}
333
334/// Statistics from a single sweep run.
335#[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    /// Run one sweep:
354    ///   1. Find all agent blocks inactive for `inactive_days`.
355    ///   2. Archive each one (compress → delete FileStore).
356    ///   3. Prune oldest archives if total disk usage exceeds `max_total_bytes`.
357    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        // Collect all blocks
363        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            // Only agent panes
370            let view = block.meta.get("view").and_then(|v| v.as_str()).unwrap_or("");
371            if view != "agent" {
372                continue;
373            }
374
375            // Skip if already archived
376            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            // Skip if session:last_activity_ms is missing (fresh session)
386            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            // Skip if session has no lines
396            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            // Check inactivity threshold
406            if now - last_activity < cutoff_ms {
407                continue;
408            }
409
410            // Archive it
411            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        // Prune oldest archives if over the storage cap
437        stats.pruned_count = self.prune_archives(&mut stats.bytes_freed)?;
438
439        Ok(stats)
440    }
441
442    /// Delete oldest `.jsonl.gz` files until total size is under `max_total_bytes`.
443    /// Returns number of files deleted.
444    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        // Collect (mtime, path, size) for all .jsonl.gz files
450        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        // Sort oldest-first
467        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// ---------------------------------------------------------------------------
500// Tests
501// ---------------------------------------------------------------------------
502
503#[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    /// Verify that a block with old last_activity gets archived by the sweeper.
511    /// Uses in-memory stores — no disk I/O needed for this unit test.
512    #[tokio::test]
513    async fn test_sweep_archives_inactive_session() {
514        // Build a temp dir for archives
515        let tmp_dir = tempfile::tempdir().expect("tempdir");
516        let archive_dir = tmp_dir.path().to_path_buf();
517
518        // In-memory FileStore with output data
519        let filestore = Arc::new(
520            FileStore::open_in_memory().expect("filestore"),
521        );
522        // Write a fake "output" file for the block. The ID must be a
523        // valid UUID — `archive_session_output` calls
524        // `update_object_meta`, which parses the ORef
525        // `format!("block:{}", block_id)` via `ORef::parse` and
526        // rejects non-UUID oids. The earlier "blk-sweep-test" string
527        // failed silently inside the sweep's logged-but-suppressed
528        // error path.
529        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        // In-memory Store
539        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        // Insert a fake Block object with required meta
546        use crate::backend::obj::{Block, MetaMapType};
547        let mut meta = MetaMapType::new();
548        meta.insert("view".to_string(), serde_json::json!("agent"));
549        // last_activity 10 days ago (well past the 7-day threshold)
550        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        // Run the archiver (1-day inactive threshold to keep test fast)
569        let archiver = SessionArchiver::new(
570            mstore.clone(),
571            filestore.clone(),
572            1, // 1 day inactive threshold
573            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        // Verify the FileStore entry was deleted
582        let remaining = filestore.stat(block_id, OUTPUT_FILENAME).unwrap();
583        assert!(remaining.is_none(), "filestore entry should be deleted after archive");
584
585        // Verify the .gz file was created
586        let gz_path = archive_dir.join(format!("{}.jsonl.gz", block_id));
587        assert!(gz_path.exists(), ".gz file should exist");
588
589        // Verify the block meta was updated
590        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); // blanks excluded
606    }
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}