Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
154 changes: 154 additions & 0 deletions src/services/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,107 @@ pub fn delete_chat_history(workspace_id: &str) {
let _ = fs::remove_file(path);
}

// --- CLI session transcript backup ---
//
// `claude --resume <id>` reads the CLI's *own* transcript at
// ~/.claude/projects/<encoded-cwd>/<id>.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<PathBuf> {
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 `<session_id>.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 {
Expand Down Expand Up @@ -301,3 +402,56 @@ pub fn dedup_labels(configs: &[WorkspaceConfig]) -> Vec<String> {

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");
}
}
2 changes: 2 additions & 0 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down