From fb918386e8e13cd53182713dcd1aae09816c0f61 Mon Sep 17 00:00:00 2001 From: Sergii Kamenskyi Date: Fri, 19 Jun 2026 18:14:15 +0200 Subject: [PATCH] feat(session): back up CLI --resume transcripts to survive cleanup `claude --resume ` reads the CLI's own transcript at ~/.claude/projects//.jsonl, which the CLI prunes by age (cleanupPeriodDays, default 30). A workspace left idle past that window loses its backing file and can no longer resume ("No conversation found with session ID"), even though we still hold the full chat history. Keep a rolling copy under ~/.config/flycrys/sessions/transcripts/. The locate + copy run on a worker thread during autosave (sync only on shutdown), gated on chat-history growth so there is zero idle churn and the GTK main loop never touches the disk. Atomic temp+rename copy; a stat-only up-to-date check skips redundant writes. Tests cover the project-dir encoding, the up-to-date check, and the atomic copy. --- src/main.rs | 20 +++++- src/services/storage.rs | 154 ++++++++++++++++++++++++++++++++++++++++ src/session.rs | 2 + 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 1ebe881..21a1ba5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,7 +287,25 @@ impl TabSlot { // avoids re-serializing a multi-MB blob to disk every autosave tick. let chat_len = ws.chat_history.borrow().len(); if chat_len != ws.last_saved_chat_len.get() { - let id = ws.config.borrow().id.clone(); + let (id, session_id, cwd) = { + let cfg = ws.config.borrow(); + ( + cfg.id.clone(), + cfg.agent_1_session_id.clone(), + cfg.working_directory.clone(), + ) + }; + // Back up the CLI's --resume transcript whenever the chat + // advances, so a long-idle tab can still resume after Claude's + // own transcript cleanup. Off-thread during autosave; sync on + // shutdown so the final turn is captured before we exit. + if let Some(session_id) = session_id { + if background { + session::backup_session_transcript_async(session_id, cwd); + } else { + session::backup_session_transcript(&session_id, &cwd); + } + } let history = ws.chat_history.borrow(); if background { session::save_chat_history_async(&id, &history); diff --git a/src/services/storage.rs b/src/services/storage.rs index 3ec771f..45c22df 100644 --- a/src/services/storage.rs +++ b/src/services/storage.rs @@ -137,6 +137,107 @@ pub fn delete_chat_history(workspace_id: &str) { let _ = fs::remove_file(path); } +// --- CLI session transcript backup --- +// +// `claude --resume ` reads the CLI's *own* transcript at +// ~/.claude/projects//.jsonl — not our chat history. The CLI +// prunes those transcripts by age (`cleanupPeriodDays`, default 30 days), so a +// workspace left idle for weeks loses its backing file and can no longer be +// resumed ("No conversation found with session ID"). We keep a rolling copy +// under our own config dir so the transcript survives that cleanup. All +// filesystem work runs off the GTK main thread (see the `_async` variant). + +fn transcripts_dir() -> PathBuf { + sessions_dir().join("transcripts") +} + +/// Path of our backup copy for a given CLI session id. +pub fn transcript_backup_path(session_id: &str) -> PathBuf { + transcripts_dir().join(format!("{session_id}.jsonl")) +} + +/// Map an absolute working directory to the CLI's project-folder name: the cwd +/// with every non-alphanumeric character replaced by '-' (e.g. `/home/u/p` -> +/// `-home-u-p`). Only a fast-path guess — [`locate_cli_transcript`] scans if it +/// misses. +fn encode_project_dir(cwd: &str) -> String { + cwd.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +/// Locate the CLI's transcript for `session_id`. Tries the deterministic path +/// derived from `cwd`, then scans every project folder (session ids are UUIDs, +/// so any match is unambiguous). `None` means the CLI has no such transcript +/// (already pruned, or the session never wrote one yet). +fn locate_cli_transcript(session_id: &str, cwd: &str) -> Option { + let projects = dirs::home_dir()?.join(".claude").join("projects"); + let file = format!("{session_id}.jsonl"); + + let guess = projects.join(encode_project_dir(cwd)).join(&file); + if guess.is_file() { + return Some(guess); + } + for entry in fs::read_dir(&projects).ok()?.flatten() { + let candidate = entry.path().join(&file); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// True when `dst` already mirrors `src` (same byte length, not older) so the +/// copy can be skipped. Stat-only — never reads file contents. +fn backup_up_to_date(src: &std::path::Path, dst: &std::path::Path) -> bool { + let (Ok(s), Ok(d)) = (fs::metadata(src), fs::metadata(dst)) else { + return false; + }; + s.len() == d.len() && matches!((s.modified(), d.modified()), (Ok(sm), Ok(dm)) if sm <= dm) +} + +/// Atomic copy of `src` into `dir` as `.jsonl`: copy to a temp file +/// then rename, so a reader never sees a half-written transcript. +fn copy_transcript_atomic( + src: &std::path::Path, + dir: &std::path::Path, + session_id: &str, +) -> std::io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering}; + /// Disambiguates temp files if two copies for one session ever overlap. + static SEQ: AtomicU64 = AtomicU64::new(0); + + fs::create_dir_all(dir)?; + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let tmp = dir.join(format!(".{session_id}.jsonl.tmp.{seq}")); + match fs::copy(src, &tmp) { + Ok(_) => fs::rename(&tmp, dir.join(format!("{session_id}.jsonl"))), + Err(e) => { + let _ = fs::remove_file(&tmp); + Err(e) + } + } +} + +/// Back up the CLI transcript for `session_id` (best effort, synchronous). +/// No-op if the transcript can't be located or our copy is already current. +pub fn backup_session_transcript(session_id: &str, cwd: &str) { + let Some(src) = locate_cli_transcript(session_id, cwd) else { + return; + }; + let dst = transcript_backup_path(session_id); + if backup_up_to_date(&src, &dst) { + return; + } + let _ = copy_transcript_atomic(&src, &transcripts_dir(), session_id); +} + +/// Like [`backup_session_transcript`] but runs the locate + copy on a worker +/// thread, so periodic autosave never touches the disk on the GTK main loop. +pub fn backup_session_transcript_async(session_id: String, cwd: String) { + std::thread::spawn(move || backup_session_transcript(&session_id, &cwd)); +} + // --- Agent config persistence --- fn agent_config_path(name: &str) -> PathBuf { @@ -301,3 +402,56 @@ pub fn dedup_labels(configs: &[WorkspaceConfig]) -> Vec { result } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn encode_project_dir_matches_cli_scheme() { + assert_eq!(encode_project_dir("/home/u/work/p"), "-home-u-work-p"); + // Dots and existing dashes both collapse to '-'. + assert_eq!(encode_project_dir("/a.b/c-d"), "-a-b-c-d"); + assert_eq!(encode_project_dir("/srv/2solar"), "-srv-2solar"); + } + + #[test] + fn up_to_date_detects_changes() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src.jsonl"); + let dst = dir.path().join("dst.jsonl"); + + // Missing destination -> not up to date. + fs::write(&src, b"hello").unwrap(); + assert!(!backup_up_to_date(&src, &dst)); + + // Identical copy -> up to date. + copy_transcript_atomic(&src, dir.path(), "dst").unwrap(); + assert!(backup_up_to_date(&src, &dst)); + + // Source grows -> stale again (length mismatch). + let mut f = fs::OpenOptions::new().append(true).open(&src).unwrap(); + f.write_all(b" world").unwrap(); + f.sync_all().unwrap(); + assert!(!backup_up_to_date(&src, &dst)); + } + + #[test] + fn copy_transcript_atomic_writes_named_file_and_leaves_no_temp() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("s.jsonl"); + fs::write(&src, b"{\"x\":1}\n").unwrap(); + let out = dir.path().join("backups"); + + copy_transcript_atomic(&src, &out, "abc-123").unwrap(); + + assert_eq!(fs::read(out.join("abc-123.jsonl")).unwrap(), b"{\"x\":1}\n"); + let temps = fs::read_dir(&out) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().contains(".tmp")) + .count(); + assert_eq!(temps, 0, "temp files must be renamed away"); + } +} diff --git a/src/session.rs b/src/session.rs index 73d93a1..d88db91 100644 --- a/src/session.rs +++ b/src/session.rs @@ -8,6 +8,8 @@ pub use crate::models::ChatMessage; pub use crate::models::{RunTabConfig, RunTabType, WorkspaceConfig}; // Re-export all storage functions so existing `session::func()` calls still compile. +pub use crate::services::storage::backup_session_transcript; +pub use crate::services::storage::backup_session_transcript_async; pub use crate::services::storage::dedup_labels; pub use crate::services::storage::delete_agent_config; pub use crate::services::storage::delete_chat_history;