cli/src/state_db_recovery.rs
159 lines
//! CLI handling for local state database startup failures.//!//! This keeps user-facing backup and lock-contention handling out of the main//! CLI dispatch path while preserving the TUI startup error as the boundary type.use codex_state::RuntimeDbBackup;use codex_tui::LocalStateDbStartupError;use std::io::IsTerminal;use std::path::Path;pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> { err.get_ref() .and_then(|err| err.downcast_ref::<LocalStateDbStartupError>())}pub(crate) fn is_locked(detail: &str) -> bool { codex_state::sqlite_error_detail_is_lock(detail)}pub(crate) fn is_corruption(detail: &str) -> bool { codex_state::sqlite_error_detail_is_corruption(detail)}pub(crate) fn is_auto_backup_recoverable(startup_error: &LocalStateDbStartupError) -> bool { is_corruption(startup_error.detail()) || sqlite_home_is_blocking_file(startup_error)}fn sqlite_home_is_blocking_file(startup_error: &LocalStateDbStartupError) -> bool { startup_error .database_path() .parent() .and_then(|path| std::fs::metadata(path).ok()) .is_some_and(|metadata| metadata.is_file())}pub(crate) fn print_auto_backup_start(startup_error: &LocalStateDbStartupError) { eprintln!("Codex couldn't start because its local database appears to be damaged."); eprintln!("Moving the damaged local database aside so Codex can rebuild it from saved data."); print_technical_details(startup_error);}pub(crate) async fn backup_files_for_fresh_start( startup_error: &LocalStateDbStartupError,) -> std::io::Result<Vec<RuntimeDbBackup>> { codex_state::backup_runtime_db_for_fresh_start(startup_error.database_path()).await}pub(crate) fn confirm_fresh_start_rebuild( startup_error: &LocalStateDbStartupError, backups: &[RuntimeDbBackup],) -> std::io::Result<()> { eprintln!("Codex rebuilt its local database."); eprintln!( "Codex detected a damaged local database, moved it into a backup folder, and will continue startup with a fresh database." ); eprintln!("Database path: {}", startup_error.database_path().display()); if let Some(backup_folder) = backup_folder(backups) { eprintln!("Backup folder: {}", backup_folder.display()); } else { eprintln!("Backup folder: unavailable"); } if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { eprintln!("Press Enter to continue."); let mut input = String::new(); std::io::stdin().read_line(&mut input)?; } else { eprintln!("Continuing startup with a fresh local database..."); } Ok(())}pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) { eprintln!("Codex couldn't start because its local database appears to be damaged."); eprintln!("Run `codex doctor` to check your setup and get next-step guidance."); eprintln!("If this keeps happening, share the technical details below when asking for help."); print_technical_details(startup_error);}pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) { eprintln!("Codex couldn't start because another Codex process is using its local data."); eprintln!("Quit any other copies of Codex that may still be running, then try again."); print_technical_details(startup_error);}fn print_technical_details(startup_error: &LocalStateDbStartupError) { eprintln!("Technical details:"); eprintln!(" Location: {}", startup_error.database_path().display()); eprintln!(" Cause: {}", startup_error.detail());}fn backup_folder(backups: &[RuntimeDbBackup]) -> Option<&Path> { backups.first()?.backup_path.parent()}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; use std::path::PathBuf; use tempfile::TempDir; #[tokio::test] async fn backup_backs_up_only_failed_database_file() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let state_path = codex_state::state_db_path(temp_dir.path()); let failed_db_path = codex_state::logs_db_path(temp_dir.path()); tokio::fs::write(state_path.as_path(), b"state").await?; tokio::fs::write(failed_db_path.as_path(), b"logs").await?; let startup_error = LocalStateDbStartupError::new(failed_db_path.clone(), "corrupt".to_string()); let backups = backup_files_for_fresh_start(&startup_error).await?; assert_eq!( backups .iter() .map(|backup| &backup.original_path) .collect::<Vec<_>>(), vec![&failed_db_path] ); assert!(!tokio::fs::try_exists(failed_db_path.as_path()).await?); assert!(tokio::fs::try_exists(state_path.as_path()).await?); assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?); Ok(()) } #[tokio::test] async fn backup_replaces_blocking_sqlite_home_file() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let sqlite_home = temp_dir.path().join("sqlite-home"); tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?; let startup_error = LocalStateDbStartupError::new( codex_state::state_db_path(sqlite_home.as_path()), "File exists".to_string(), ); assert!(is_auto_backup_recoverable(&startup_error)); let backups = backup_files_for_fresh_start(&startup_error).await?; assert_eq!(backups.len(), 1); assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir()); assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?); Ok(()) } #[test] fn backup_folder_uses_parent_of_first_backup_path() { let backups = vec![RuntimeDbBackup { original_path: PathBuf::from("/tmp/state_5.sqlite"), backup_path: PathBuf::from("/tmp/db-backups/sqlite-1-0/state_5.sqlite"), }]; assert_eq!( backup_folder(&backups), Some(Path::new("/tmp/db-backups/sqlite-1-0")) ); }}