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;