Skip to main content

agentmux_srv/backend/agent_session/
archive.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Archive lifecycle, browsing, and previews for agent session zones.
5
6use crate::backend::storage::filestore::FileStore;
7
8use super::global_store::global_transcript_store;
9use super::helpers::{now_ms, write_zone_file};
10use super::session_io::{OUTPUT_FILE, SNAPSHOT_FILE, TSIDX_FILE};
11use super::zone_naming::{agent_archive_zone, is_valid_definition_id, validate_and_current};
12
13/// Archive `agent:<defId>:current` to `agent:<defId>:archive:<now_ms>`.
14///
15/// Atomicity contract:
16/// - We write the archive zone first, then clear the current zone.
17/// - A crash between those two steps leaves both zones populated;
18///   replay-time behaviour is "current wins" so this is safe.
19/// - We never clear `:current` before the archive write has been
20///   acked by FileStore, so the archive-missing case can't happen
21///   without a FileStore I/O failure on the write itself (which is
22///   surfaced as `Err` and aborts the clear step).
23///
24/// Returns:
25/// - `Ok(Some((archive_zoneid, archived_at_ms)))` on successful
26///   archive (current zone had content).
27/// - `Ok(None)` when there was nothing to archive (no
28///   `output.state.json` in :current, OR it was zero-byte). The
29///   caller should treat this as "session was empty, nothing to do"
30///   — we explicitly do NOT create an empty archive zone.
31pub fn archive_session(
32    filestore: &FileStore,
33    definition_id: &str,
34) -> Result<Option<(String, i64)>, String> {
35    let current_zone = validate_and_current(definition_id)?;
36
37    // Prefer the GLOBAL current zone as the archive source. It is the complete
38    // cross-channel accumulation — and (via the hot-path mirror) the place the
39    // `output` NDJSON is fully gathered — so the per-channel `:current` is at
40    // most a subset (often just this channel's snapshot). Archiving from the
41    // global store therefore preserves the full history in *every* case:
42    //   - cross-channel viewer (empty local), AND
43    //   - cross-channel session that also ran locally (non-empty local) —
44    // both previously risked discarding global-only history.
45    // We then clear BOTH currents so the read fallback can't resurrect the
46    // just-archived conversation. Falls through to the per-channel path below
47    // only when there's no global store or it holds nothing for this agent
48    // (pure pre-global / same-channel-only data). (codex + reagent P1/P2 #1399.)
49    if let Some(archived) = archive_global_current(filestore, definition_id)? {
50        clear_local_current_zone(filestore, &current_zone);
51        clear_global_current_zone(definition_id);
52        return Ok(Some(archived));
53    }
54
55    // Determine whether there's anything worth archiving.
56    let state_stat = filestore
57        .stat(&current_zone, SNAPSHOT_FILE)
58        .map_err(|e| format!("stat current: {e}"))?;
59    let has_state = match &state_stat {
60        Some(f) => f.size > 0,
61        None => false,
62    };
63    let output_stat = filestore
64        .stat(&current_zone, OUTPUT_FILE)
65        .map_err(|e| format!("stat current output: {e}"))?;
66    let has_output = match &output_stat {
67        Some(f) => f.size > 0,
68        None => false,
69    };
70    if !has_state && !has_output {
71        return Ok(None);
72    }
73
74    let ts = now_ms();
75    let archive_zone = agent_archive_zone(definition_id, ts);
76
77    // Copy snapshot first (the canonical "history" file).
78    if has_state {
79        let snapshot_bytes = filestore
80            .read_file(&current_zone, SNAPSHOT_FILE)
81            .map_err(|e| format!("read current snapshot: {e}"))?
82            .unwrap_or_default();
83        write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, &snapshot_bytes)?;
84    }
85    // Copy NDJSON output if present.
86    if has_output {
87        let output_bytes = filestore
88            .read_file(&current_zone, OUTPUT_FILE)
89            .map_err(|e| format!("read current output: {e}"))?
90            .unwrap_or_default();
91        if !output_bytes.is_empty() {
92            write_zone_file(filestore, &archive_zone, OUTPUT_FILE, &output_bytes)?;
93        }
94        // The tsidx sidecar travels with output (codex P2 on PR #2508):
95        // without it, archived history loses its receive-time stamps and —
96        // worse — a stale sidecar left in :current would mis-time the NEXT
97        // session (fresh output restarts at offset 0 under old entries).
98        // Best-effort: the sidecar is auxiliary and must never abort an
99        // archive.
100        copy_tsidx_best_effort(filestore, &current_zone, filestore, &archive_zone);
101    }
102
103    // Archive write succeeded. Now safe to clear the current zone: the files
104    // that were archived, and both sidecars with `output` — a stale tsidx
105    // under a fresh (offset-0) output would mis-time the next session's lines
106    // (codex P2 on PR #2508), and a stale output.idx could pass
107    // blockfile:read_range's covered_size check by coincidence (reagent P1 on
108    // #2701). One transaction (5a-2b): a crash can't leave a sidecar
109    // describing an `output` that is gone.
110    let mut clear: Vec<&str> = Vec::with_capacity(4);
111    if state_stat.is_some() {
112        clear.push(SNAPSHOT_FILE);
113    }
114    if output_stat.is_some() {
115        clear.push(OUTPUT_FILE);
116    }
117    clear.extend([TSIDX_FILE, "output.idx"]);
118    if let Err(e) = filestore.delete_files(&current_zone, &clear) {
119        tracing::warn!(
120            definition_id = %definition_id,
121            error = %e,
122            "agent_session: failed to clear current zone after archive (archive already persisted)"
123        );
124    }
125
126    // Clear the GLOBAL current zone in the same lifecycle. Without this, the
127    // cross-channel read fallback (`read_session_state` / `blockfile:read_range`
128    // / `blockfile:line_count`) would treat the intentionally-cleared local
129    // `:current` as a cross-channel miss and resurrect the just-archived
130    // conversation on the next open — so a "new session" for this definition
131    // would inherit stale history. Best-effort, same as the per-channel clear.
132    // (codex P1 on PR #1399.)
133    clear_global_current_zone(definition_id);
134
135    tracing::info!(
136        definition_id = %definition_id,
137        archive_zoneid = %archive_zone,
138        archived_at_ms = ts,
139        "agent_session: archived current session"
140    );
141
142    Ok(Some((archive_zone, ts as i64)))
143}
144
145/// Archive the agent's GLOBAL `agent:<defId>:current` content into a *local*
146/// (per-`filestore`) archive zone, so a cross-channel viewer's conversation is
147/// preserved + browsable in this channel before the global current is cleared.
148///
149/// Returns `Ok(Some((archive_zoneid, ts)))` when the global current held
150/// content (snapshot or output), `Ok(None)` when there was nothing to archive
151/// (no global store, or both files empty/absent). The archive lands in the
152/// per-channel store because archive browsing (`list_archives`) is per-channel.
153pub fn archive_global_current(
154    filestore: &FileStore,
155    definition_id: &str,
156) -> Result<Option<(String, i64)>, String> {
157    let Some(gfs) = global_transcript_store() else {
158        return Ok(None);
159    };
160    let current_zone = validate_and_current(definition_id)?;
161
162    let snap = read_snapshot_bytes(gfs, &current_zone, SNAPSHOT_FILE)?;
163    let out = read_snapshot_bytes(gfs, &current_zone, OUTPUT_FILE)?;
164    let has_snap = snap.as_ref().is_some_and(|b| !b.is_empty());
165    let has_out = out.as_ref().is_some_and(|b| !b.is_empty());
166    if !has_snap && !has_out {
167        return Ok(None);
168    }
169
170    let ts = now_ms();
171    let archive_zone = agent_archive_zone(definition_id, ts);
172    if has_snap {
173        write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, snap.as_ref().unwrap())?;
174    }
175    if has_out {
176        write_zone_file(filestore, &archive_zone, OUTPUT_FILE, out.as_ref().unwrap())?;
177        // Sidecar travels with output — same rationale as the per-channel
178        // path (codex P2 on PR #2508).
179        copy_tsidx_best_effort(gfs, &current_zone, filestore, &archive_zone);
180    }
181    tracing::info!(
182        definition_id = %definition_id,
183        archive_zoneid = %archive_zone,
184        archived_at_ms = ts,
185        "agent_session: archived cross-channel (global) session into local archive"
186    );
187    Ok(Some((archive_zone, ts as i64)))
188}
189
190/// Read a zone file's full bytes, mapping absence to `Ok(None)`.
191fn read_snapshot_bytes(store: &FileStore, zone: &str, name: &str) -> Result<Option<Vec<u8>>, String> {
192    match store.stat(zone, name).map_err(|e| format!("stat: {e}"))? {
193        Some(_) => store
194            .read_file(zone, name)
195            .map_err(|e| format!("read_file: {e}")),
196        None => Ok(None),
197    }
198}
199
200/// Copy the `output.tsidx` sidecar from `src` zone to `dst` zone,
201/// best-effort: absence is normal (pre-sidecar history), and no failure
202/// here may abort the surrounding archive — the sidecar is auxiliary
203/// timing data, not conversation content.
204fn copy_tsidx_best_effort(src_store: &FileStore, src_zone: &str, dst_store: &FileStore, dst_zone: &str) {
205    let bytes = match src_store.stat(src_zone, TSIDX_FILE) {
206        Ok(Some(f)) if f.size > 0 => match src_store.read_file(src_zone, TSIDX_FILE) {
207            Ok(Some(b)) if !b.is_empty() => b,
208            _ => return,
209        },
210        _ => return,
211    };
212    if let Err(e) = write_zone_file(dst_store, dst_zone, TSIDX_FILE, &bytes) {
213        tracing::warn!(src = %src_zone, dst = %dst_zone, error = %e, "agent_session: tsidx sidecar copy failed (archive content unaffected)");
214    }
215}
216
217/// Delete `output.state.json` + `output` from a per-channel `:current` zone,
218/// only for files that are present (so absence isn't logged as an error).
219/// Best-effort — used after the global-preferred archive has persisted the
220/// content, to retire this channel's (subset) copy.
221///
222/// Also clears `output.idx` (#2701): an index left behind after `output` is
223/// deleted and rewritten could otherwise be accepted for a new output of the
224/// same byte size, serving the old session's line count/offsets. Since #3634
225/// the index also carries the output generation it was built for, which
226/// catches that too; deleting it here keeps the zone clean regardless.
227pub fn clear_local_current_zone(filestore: &FileStore, zone: &str) {
228    // One transaction (5a-2b); absent files are not an error.
229    if let Err(e) = filestore.delete_files(zone, &[SNAPSHOT_FILE, OUTPUT_FILE, TSIDX_FILE, "output.idx"]) {
230        tracing::warn!(zone = %zone, error = %e, "agent_session: failed to clear local current after global archive");
231    }
232}
233
234/// Delete `output.state.json` + `output` from the agent's GLOBAL
235/// `agent:<defId>:current` zone, if a global store is installed. Best-effort:
236/// a missing file is the expected "agent never mirrored" case (silent), other
237/// errors are logged but never propagated. Keeps the global zone in lockstep
238/// with the per-channel `:current` clear in [`archive_session`].
239///
240/// Also clears `output.idx` (#2701): an index left behind after `output` is
241/// deleted and rewritten could otherwise be accepted for a new output of the
242/// same byte size. Since #3634 the index also carries the output generation it
243/// was built for, which catches that too; deleting it here forces a rebuild
244/// against the new content regardless.
245pub fn clear_global_current_zone(definition_id: &str) {
246    let Some(gfs) = global_transcript_store() else {
247        return;
248    };
249    let Ok(zone) = validate_and_current(definition_id) else {
250        return;
251    };
252    // One transaction (5a-2b); absent files are not an error.
253    if let Err(e) = gfs.delete_files(
254        &zone,
255        &[
256            SNAPSHOT_FILE,
257            OUTPUT_FILE,
258            TSIDX_FILE,
259            "output.idx",
260            // The running summary describes the conversation being archived.
261            crate::backend::continuity_state::STATE_FILE,
262        ],
263    ) {
264        tracing::warn!(
265            zone = %zone, error = %e,
266            "global transcripts: failed to clear current zone on archive"
267        );
268    }
269}
270
271/// List archive zones for `definition_id`, newest first.
272///
273/// Returns up to `limit` rows. `limit = 0` means "default 20"; caller
274/// must clamp upper bounds. Each row carries a small preview lifted
275/// from the archive's `output.state.json`.
276pub fn list_archives(
277    filestore: &FileStore,
278    definition_id: &str,
279    limit: usize,
280) -> Result<Vec<ArchiveSummary>, String> {
281    if !is_valid_definition_id(definition_id) {
282        return Err(format!(
283            "INVALID_DEFINITION_ID: must match [A-Za-z0-9_-]+, got {:?}",
284            definition_id
285        ));
286    }
287    let prefix = format!("agent:{}:archive:", definition_id);
288    let limit = if limit == 0 { 20 } else { limit.min(100) };
289
290    let all_zones = filestore
291        .get_all_zone_ids()
292        .map_err(|e| format!("get_all_zone_ids: {e}"))?;
293
294    let mut matches: Vec<(u64, String)> = Vec::new();
295    for zone in all_zones {
296        if let Some(suffix) = zone.strip_prefix(&prefix) {
297            if let Ok(ts) = suffix.parse::<u64>() {
298                matches.push((ts, zone));
299            }
300        }
301    }
302    // Newest first.
303    matches.sort_by(|a, b| b.0.cmp(&a.0));
304    matches.truncate(limit);
305
306    let mut rows = Vec::with_capacity(matches.len());
307    for (ts, zone) in matches {
308        let (preview, node_count) = read_archive_preview(filestore, &zone);
309        rows.push(ArchiveSummary {
310            archive_zoneid: zone,
311            archived_at_ms: ts as i64,
312            preview,
313            node_count,
314        });
315    }
316    Ok(rows)
317}
318
319/// A single archive row. Mirrors the shape of `RecentSessionRow`'s
320/// preview fields so the frontend can reuse the same row component.
321#[derive(Debug, Clone)]
322pub struct ArchiveSummary {
323    pub archive_zoneid: String,
324    pub archived_at_ms: i64,
325    pub preview: String,
326    pub node_count: usize,
327}
328
329/// Pull a small preview + node_count out of an archive's
330/// `output.state.json`. Returns `("", 0)` on any error.
331fn read_archive_preview(filestore: &FileStore, zone: &str) -> (String, usize) {
332    let bytes = match filestore.read_file(zone, SNAPSHOT_FILE) {
333        Ok(Some(b)) => b,
334        _ => return (String::new(), 0),
335    };
336    if bytes.len() > MAX_PREVIEW_SNAPSHOT_BYTES {
337        return (String::new(), 0);
338    }
339    snapshot_preview(&bytes)
340}
341
342/// Snapshots larger than this are not parsed for a preview, so a corrupted one
343/// can't stall a listing.
344pub(crate) const MAX_PREVIEW_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024;
345
346/// `(preview, node_count)` from an `output.state.json` snapshot's bytes, or
347/// `("", 0)` when they don't parse or have no `nodes` array. The preview is
348/// the first `user_message`, skipping a leading "# Session Context" bootstrap
349/// message when a later one exists, collapsed to one line of at most 240 chars.
350pub(crate) fn snapshot_preview(bytes: &[u8]) -> (String, usize) {
351    let json: serde_json::Value = match serde_json::from_slice(bytes) {
352        Ok(v) => v,
353        Err(_) => return (String::new(), 0),
354    };
355    let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
356        Some(a) => a,
357        None => return (String::new(), 0),
358    };
359    let node_count = nodes.len();
360    let mut preview = String::new();
361    for node in nodes {
362        let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
363        if ty != "user_message" {
364            continue;
365        }
366        let msg = node
367            .get("message")
368            .and_then(|v| v.as_str())
369            .unwrap_or("")
370            .trim();
371        if msg.is_empty() {
372            continue;
373        }
374        if preview.is_empty() && msg.starts_with("# Session Context") {
375            // Stash as fallback in case there's no later user_message.
376            preview = collapse_preview(msg);
377            continue;
378        }
379        preview = collapse_preview(msg);
380        break;
381    }
382    (preview, node_count)
383}
384
385/// Collapse newlines + extra whitespace, cap at 240 chars. Output is
386/// safe to render inline in a single-line preview row.
387pub(crate) fn collapse_preview(s: &str) -> String {
388    const MAX_CHARS: usize = 240;
389    let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
390    let mut prev_space = false;
391    for ch in s.chars() {
392        if buf.chars().count() >= MAX_CHARS {
393            buf.push('\u{2026}'); // "…"
394            return buf;
395        }
396        if ch.is_whitespace() {
397            if !prev_space && !buf.is_empty() {
398                buf.push(' ');
399                prev_space = true;
400            }
401        } else {
402            buf.push(ch);
403            prev_space = false;
404        }
405    }
406    buf
407}
408
409#[cfg(test)]
410mod preview_tests {
411    use super::snapshot_preview;
412
413    #[test]
414    fn snapshot_preview_skips_a_leading_session_context_and_counts_nodes() {
415        let snap = serde_json::json!({ "nodes": [
416            { "type": "user_message", "message": "# Session Context\nboilerplate" },
417            { "type": "markdown", "content": "ack" },
418            { "type": "user_message", "message": "  fix the\n\nbug  " },
419        ]});
420        let bytes = serde_json::to_vec(&snap).unwrap();
421        assert_eq!(snapshot_preview(&bytes), ("fix the bug".to_string(), 3));
422        assert_eq!(snapshot_preview(b"not json"), (String::new(), 0));
423        assert_eq!(snapshot_preview(br#"{"nodes":{}}"#), (String::new(), 0));
424    }
425}