agentmux_srv/backend/agent_session/
archive.rs1use 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
13pub 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 if let Some(archived) = archive_global_current(filestore, definition_id)? {
50 clear_local_current_zone(filestore, ¤t_zone);
51 clear_global_current_zone(definition_id);
52 return Ok(Some(archived));
53 }
54
55 let state_stat = filestore
57 .stat(¤t_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(¤t_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 if has_state {
79 let snapshot_bytes = filestore
80 .read_file(¤t_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 if has_output {
87 let output_bytes = filestore
88 .read_file(¤t_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 copy_tsidx_best_effort(filestore, ¤t_zone, filestore, &archive_zone);
101 }
102
103 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(¤t_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_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
145pub 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, ¤t_zone, SNAPSHOT_FILE)?;
163 let out = read_snapshot_bytes(gfs, ¤t_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 copy_tsidx_best_effort(gfs, ¤t_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
190fn 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
200fn 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
217pub fn clear_local_current_zone(filestore: &FileStore, zone: &str) {
228 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
234pub 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 if let Err(e) = gfs.delete_files(
254 &zone,
255 &[
256 SNAPSHOT_FILE,
257 OUTPUT_FILE,
258 TSIDX_FILE,
259 "output.idx",
260 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
271pub 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 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#[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
329fn 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
342pub(crate) const MAX_PREVIEW_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024;
345
346pub(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 preview = collapse_preview(msg);
377 continue;
378 }
379 preview = collapse_preview(msg);
380 break;
381 }
382 (preview, node_count)
383}
384
385pub(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}'); 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}