use base64::Engine; use chrono::Utc; use rusqlite::types::ValueRef; use rusqlite::{Connection, OpenFlags, ToSql}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use std::sync::Mutex; use std::time::{Duration, SystemTime}; use tauri::{Emitter, Manager}; use crate::{GuiConfigState, MANAGED_AGENT_PROVIDER_ID}; const DEFAULT_PAGE_SIZE: usize = 50; const MAX_PAGE_SIZE: usize = 100; const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); const REPAIR_BACKUP_KEEP_COUNT: usize = 5; const REPAIR_PROGRESS_EVENT: &str = "codex-session-repair-progress"; const SESSION_DIRS: [&str; 2] = ["sessions", "archived_sessions"]; const REFERENCE_TABLE_COLUMNS: [(&str, &str); 12] = [ ("threads", "id"), ("local_thread_catalog", "thread_id"), ("automation_runs", "thread_id"), ("inbox_items", "thread_id"), ("sessions", "id"), ("messages", "session_id"), ("thread_dynamic_tools", "thread_id"), ("thread_goals", "thread_id"), ("thread_spawn_edges", "parent_thread_id"), ("thread_spawn_edges", "child_thread_id"), ("stage1_outputs", "thread_id"), ("agent_job_items", "assigned_thread_id"), ]; static CODEX_SESSION_WRITE_LOCK: Mutex<()> = Mutex::new(()); static BACKUP_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ListCodexSessionsRequest { #[serde(default)] offset: usize, #[serde(default = "default_page_size")] limit: usize, } fn default_page_size() -> usize { DEFAULT_PAGE_SIZE } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionSummary { id: String, title: String, cwd: String, model_provider: String, archived: bool, updated_at_ms: Option, database_path: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionPage { codex_home: String, database_paths: Vec, sessions: Vec, offset: usize, limit: usize, has_more: bool, repair_on_launch: bool, warnings: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DeleteCodexSessionsRequest { session_ids: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionDeleteResult { session_id: String, status: String, message: String, backup_path: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionDeleteBatchResult { results: Vec, deleted_count: usize, failed_count: usize, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionRepairProgress { phase: String, percent: u8, processed: usize, total: usize, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CodexSessionRepairResult { target_provider: String, changed_rollout_files: usize, sqlite_rows_updated: usize, skipped_locked_files: Vec, backup_path: Option, encrypted_content_warning: Option, warnings: Vec, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct SessionIndexCleanupCandidate { id: String, thread_name: String, updated_at: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SessionIndexCleanupPreview { snapshot_sha256: String, candidates: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ApplySessionIndexCleanupRequest { snapshot_sha256: String, thread_ids: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SessionIndexCleanupResult { pruned_entries: usize, backup_path: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ChatGptCloseResult { was_running: bool, closed_processes: usize, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SetRepairOnLaunchRequest { enabled: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RepairOnLaunchSetting { enabled: bool, } #[derive(Debug)] struct DatabaseDeletePlan { path: PathBuf, tables: Map, rollout_paths: Vec, has_thread_row: bool, has_automation_rows: bool, } #[derive(Debug, Clone)] struct RolloutRepair { path: PathBuf, original: Vec, next: Vec, original_mtime: Option, thread_id: Option, cwd: Option, has_user_event: bool, providers: Vec, encrypted_content: bool, changed: bool, } #[derive(Debug)] struct SessionIndexPlan { path: PathBuf, original: Vec, original_text: String, snapshot_sha256: String, candidates: Vec, } #[tauri::command] pub(crate) async fn list_codex_sessions( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, request: Option, ) -> Result { let user_home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let codex_home = resolve_codex_home(&user_home); let repair_on_launch = gui_config_state.snapshot()?.codex_session_repair_on_launch; let request = request.unwrap_or(ListCodexSessionsRequest { offset: 0, limit: DEFAULT_PAGE_SIZE, }); tauri::async_runtime::spawn_blocking(move || { list_codex_sessions_from_home(&codex_home, request.offset, request.limit, repair_on_launch) }) .await .map_err(|error| format!("读取 Codex 会话任务失败: {error}"))? } #[tauri::command] pub(crate) async fn delete_codex_sessions( app: tauri::AppHandle, request: DeleteCodexSessionsRequest, ) -> Result { let user_home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let codex_home = resolve_codex_home(&user_home); tauri::async_runtime::spawn_blocking(move || { delete_codex_sessions_from_home(&codex_home, request.session_ids) }) .await .map_err(|error| format!("删除 Codex 会话任务失败: {error}"))? } #[tauri::command] pub(crate) async fn repair_codex_session_metadata( app: tauri::AppHandle, ) -> Result { let user_home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let codex_home = resolve_codex_home(&user_home); let progress_app = app.clone(); tauri::async_runtime::spawn_blocking(move || { validate_managed_codex_provider(&codex_home)?; repair_codex_session_metadata_from_home(&codex_home, |progress| { let _ = progress_app.emit(REPAIR_PROGRESS_EVENT, progress); }) }) .await .map_err(|error| format!("恢复 Codex 历史会话任务失败: {error}"))? } #[tauri::command] pub(crate) async fn preview_codex_session_index_cleanup( app: tauri::AppHandle, ) -> Result { let user_home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let codex_home = resolve_codex_home(&user_home); tauri::async_runtime::spawn_blocking(move || { preview_session_index_cleanup_from_home(&codex_home) }) .await .map_err(|error| format!("预览 Codex 会话索引任务失败: {error}"))? } #[tauri::command] pub(crate) async fn apply_codex_session_index_cleanup( app: tauri::AppHandle, request: ApplySessionIndexCleanupRequest, ) -> Result { let user_home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let codex_home = resolve_codex_home(&user_home); tauri::async_runtime::spawn_blocking(move || { apply_session_index_cleanup_from_home( &codex_home, &request.snapshot_sha256, &request.thread_ids, true, ) }) .await .map_err(|error| format!("清理 Codex 会话索引任务失败: {error}"))? } #[tauri::command] pub(crate) fn get_codex_session_repair_on_launch( gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { let config = gui_config_state.snapshot()?; Ok(RepairOnLaunchSetting { enabled: config.codex_session_repair_on_launch, }) } #[tauri::command] pub(crate) fn set_codex_session_repair_on_launch( gui_config_state: tauri::State<'_, GuiConfigState>, request: SetRepairOnLaunchRequest, ) -> Result { let config = gui_config_state.set_codex_session_repair_on_launch(request.enabled)?; Ok(RepairOnLaunchSetting { enabled: config.codex_session_repair_on_launch, }) } #[tauri::command] pub(crate) async fn close_chatgpt_app() -> Result { tauri::async_runtime::spawn_blocking(close_chatgpt_app_processes) .await .map_err(|error| format!("关闭 ChatGPT App 任务失败: {error}"))? } pub(crate) fn repair_before_codex_launch( user_home: &Path, ) -> Result { let codex_home = resolve_codex_home(user_home); repair_before_codex_launch_from_home(&codex_home) } fn repair_before_codex_launch_from_home( codex_home: &Path, ) -> Result { validate_managed_codex_provider(&codex_home)?; match repair_codex_session_metadata_from_home(&codex_home, |_| {}) { Ok(result) => Ok(result), Err(error) if is_locked_error_message(&error) => Ok(CodexSessionRepairResult { target_provider: MANAGED_AGENT_PROVIDER_ID.to_string(), changed_rollout_files: 0, sqlite_rows_updated: 0, skipped_locked_files: Vec::new(), backup_path: None, encrypted_content_warning: None, warnings: vec![format!( "启动前恢复历史会话时遇到占用文件,已跳过且继续启动:{error}" )], }), Err(error) => Err(error), } } fn resolve_codex_home(user_home: &Path) -> PathBuf { existing_env_directory("CODEX_HOME").unwrap_or_else(|| user_home.join(".codex")) } fn resolve_sqlite_home(codex_home: &Path) -> PathBuf { existing_env_directory("CODEX_SQLITE_HOME").unwrap_or_else(|| codex_home.to_path_buf()) } fn existing_env_directory(name: &str) -> Option { let path = std::env::var_os(name).map(PathBuf::from)?; (!path.as_os_str().is_empty() && path.is_dir()).then_some(path) } fn open_read_only(path: &Path) -> Result { let connection = Connection::open_with_flags( path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(|error| format!("打开数据库失败 {}: {error}", path.display()))?; connection .busy_timeout(SQLITE_BUSY_TIMEOUT) .map_err(|error| format!("设置数据库等待时间失败 {}: {error}", path.display()))?; Ok(connection) } fn open_read_write(path: &Path) -> Result { let connection = Connection::open_with_flags( path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(|error| format!("打开数据库失败 {}: {error}", path.display()))?; connection .busy_timeout(SQLITE_BUSY_TIMEOUT) .map_err(|error| format!("设置数据库等待时间失败 {}: {error}", path.display()))?; Ok(connection) } fn sqlite_candidate(path: &Path) -> bool { path.extension() .and_then(OsStr::to_str) .is_some_and(|extension| { ["db", "sqlite", "sqlite3"] .iter() .any(|candidate| extension.eq_ignore_ascii_case(candidate)) }) } fn discover_database_paths(codex_home: &Path, reference_only: bool) -> (Vec, Vec) { let sqlite_home = resolve_sqlite_home(codex_home); let mut candidates = Vec::new(); let mut warnings = Vec::new(); let sqlite_dir = sqlite_home.join("sqlite"); if let Ok(entries) = fs::read_dir(&sqlite_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() && sqlite_candidate(&path) { candidates.push(path); } } } let legacy = sqlite_home.join("state_5.sqlite"); if legacy.is_file() && !candidates.contains(&legacy) { candidates.push(legacy.clone()); } candidates.sort_by_key(|path| { let legacy_rank = usize::from(path == &legacy); let codex_dev_rank = usize::from(path.file_name().and_then(OsStr::to_str) != Some("codex-dev.db")); ( legacy_rank, codex_dev_rank, path.file_name().map(OsStr::to_os_string), ) }); let wanted = if reference_only { REFERENCE_TABLE_COLUMNS .iter() .map(|(table, _)| *table) .collect::>() } else { ["threads", "automation_runs", "inbox_items"] .into_iter() .collect::>() }; let mut discovered = Vec::new(); for path in candidates { let connection = match open_read_only(&path) { Ok(connection) => connection, Err(error) => { warnings.push(error); continue; } }; let mut has_supported_table = false; for table in &wanted { match table_exists(&connection, table) { Ok(true) => has_supported_table = true, Ok(false) => {} Err(error) => { warnings.push(format!("检查数据库结构失败 {}: {error}", path.display())) } } } if has_supported_table { discovered.push(path); } } (discovered, warnings) } fn table_exists(connection: &Connection, table: &str) -> Result { match connection.query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1", [table], |_| Ok(()), ) { Ok(()) => Ok(true), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), Err(error) => Err(format!("读取 SQLite 表清单失败: {error}")), } } fn table_columns(connection: &Connection, table: &str) -> Result, String> { if !table_exists(connection, table)? { return Ok(HashSet::new()); } let mut statement = connection .prepare(&format!( "PRAGMA table_info(\"{}\")", table.replace('"', "\"\"") )) .map_err(|error| format!("读取 {table} 表结构失败: {error}"))?; let rows = statement .query_map([], |row| row.get::<_, String>(1)) .map_err(|error| format!("读取 {table} 表结构失败: {error}"))?; rows.collect::>>() .map_err(|error| format!("读取 {table} 表结构失败: {error}")) } fn optional_column<'a>(columns: &HashSet, column: &'a str, fallback: &'a str) -> &'a str { if columns.contains(column) { column } else { fallback } } fn normalized_millis_expression(column: &str) -> String { format!( "CASE WHEN {column} IS NULL THEN NULL WHEN ABS({column}) < 100000000000 THEN CAST({column} * 1000 AS INTEGER) ELSE CAST({column} AS INTEGER) END" ) } fn list_codex_sessions_from_home( codex_home: &Path, offset: usize, requested_limit: usize, repair_on_launch: bool, ) -> Result { let limit = requested_limit.clamp(1, MAX_PAGE_SIZE); let fetch_limit = offset.saturating_add(limit).saturating_add(1); let (database_paths, mut warnings) = discover_database_paths(codex_home, false); let mut sessions = Vec::new(); for path in &database_paths { match list_sessions_from_database(path, fetch_limit) { Ok(mut items) => sessions.append(&mut items), Err(error) => warnings.push(error), } } sessions.sort_by(|left, right| { right .updated_at_ms .cmp(&left.updated_at_ms) .then_with(|| right.id.cmp(&left.id)) .then_with(|| left.database_path.cmp(&right.database_path)) }); let mut seen = HashSet::new(); sessions.retain(|session| seen.insert(session.id.clone())); let has_more = sessions.len() > offset.saturating_add(limit); let sessions = sessions.into_iter().skip(offset).take(limit).collect(); Ok(CodexSessionPage { codex_home: codex_home.to_string_lossy().to_string(), database_paths: database_paths .iter() .map(|path| path.to_string_lossy().to_string()) .collect(), sessions, offset, limit, has_more, repair_on_launch, warnings, }) } fn list_sessions_from_database( path: &Path, limit: usize, ) -> Result, String> { let connection = open_read_only(path)?; let mut sessions = Vec::new(); if table_exists(&connection, "threads")? { sessions.extend(list_thread_rows(&connection, path, limit)?); } if table_exists(&connection, "automation_runs")? { sessions.extend(list_automation_rows(&connection, path, limit)?); } Ok(sessions) } fn list_thread_rows( connection: &Connection, path: &Path, limit: usize, ) -> Result, String> { let columns = table_columns(connection, "threads")?; if !columns.contains("id") { return Ok(Vec::new()); } let title = if columns.contains("title") && columns.contains("name") { "COALESCE(NULLIF(title, ''), NULLIF(name, ''), '')" } else { optional_column(&columns, "title", optional_column(&columns, "name", "''")) }; let cwd = optional_column(&columns, "cwd", "''"); let provider = optional_column(&columns, "model_provider", "''"); let archived = optional_column(&columns, "archived", "0"); let updated = if columns.contains("updated_at_ms") { "updated_at_ms".to_string() } else if columns.contains("updated_at") { normalized_millis_expression("updated_at") } else if columns.contains("created_at_ms") { "created_at_ms".to_string() } else if columns.contains("created_at") { normalized_millis_expression("created_at") } else { "NULL".to_string() }; let sql = format!( "SELECT id, {title}, {cwd}, {provider}, {archived}, {updated} FROM threads ORDER BY COALESCE({updated}, 0) DESC, id DESC LIMIT ?1" ); let mut statement = connection .prepare(&sql) .map_err(|error| format!("读取会话数据库失败 {}: {error}", path.display()))?; let rows = statement .query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { Ok(CodexSessionSummary { id: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), cwd: row.get::<_, Option>(2)?.unwrap_or_default(), model_provider: row.get::<_, Option>(3)?.unwrap_or_default(), archived: row.get::<_, Option>(4)?.unwrap_or_default() != 0, updated_at_ms: row.get(5)?, database_path: path.to_string_lossy().to_string(), }) }) .map_err(|error| format!("读取会话数据库失败 {}: {error}", path.display()))?; rows.collect::>>() .map_err(|error| format!("读取会话数据库失败 {}: {error}", path.display())) } fn list_automation_rows( connection: &Connection, path: &Path, limit: usize, ) -> Result, String> { let columns = table_columns(connection, "automation_runs")?; if !columns.contains("thread_id") { return Ok(Vec::new()); } let title = optional_column(&columns, "thread_title", "''"); let cwd = optional_column(&columns, "source_cwd", "''"); let status = optional_column(&columns, "status", "''"); let updated = if columns.contains("updated_at") { normalized_millis_expression("updated_at") } else if columns.contains("created_at") { normalized_millis_expression("created_at") } else { "NULL".to_string() }; let sql = format!( "SELECT thread_id, {title}, {cwd}, {status}, {updated} FROM automation_runs WHERE COALESCE(thread_id, '') <> '' ORDER BY COALESCE({updated}, 0) DESC, thread_id DESC LIMIT ?1" ); let mut statement = connection .prepare(&sql) .map_err(|error| format!("读取自动化会话数据库失败 {}: {error}", path.display()))?; let rows = statement .query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { let status = row.get::<_, Option>(3)?.unwrap_or_default(); Ok(CodexSessionSummary { id: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), cwd: row.get::<_, Option>(2)?.unwrap_or_default(), model_provider: String::new(), archived: status.eq_ignore_ascii_case("archived"), updated_at_ms: row.get(4)?, database_path: path.to_string_lossy().to_string(), }) }) .map_err(|error| format!("读取自动化会话数据库失败 {}: {error}", path.display()))?; rows.collect::>>() .map_err(|error| format!("读取自动化会话数据库失败 {}: {error}", path.display())) } fn normalize_thread_id(value: &str) -> String { value .trim() .strip_prefix("local:") .unwrap_or(value.trim()) .trim() .to_string() } fn delete_codex_sessions_from_home( codex_home: &Path, session_ids: Vec, ) -> Result { let _guard = CODEX_SESSION_WRITE_LOCK .lock() .map_err(|_| "Codex 会话写入锁已损坏".to_string())?; let mut unique = Vec::new(); let mut seen = HashSet::new(); for value in session_ids { let id = normalize_thread_id(&value); if !id.is_empty() && seen.insert(id.clone()) { unique.push(id); } } if unique.is_empty() { return Err("请至少选择一个有效的会话 ID".to_string()); } if unique.len() > MAX_PAGE_SIZE { return Err(format!("一次最多删除 {MAX_PAGE_SIZE} 个会话")); } let (database_paths, database_warnings) = discover_database_paths(codex_home, false); if !database_warnings.is_empty() { return Err(format!( "无法安全检查全部 Codex 会话数据库,删除已中止:{}", database_warnings.join(";") )); } let mut results = Vec::new(); for id in unique { results.push(delete_one_session(codex_home, &database_paths, &id)); } let deleted_count = results .iter() .filter(|result| matches!(result.status.as_str(), "deleted" | "partial")) .count(); let failed_count = results.len().saturating_sub(deleted_count); Ok(CodexSessionDeleteBatchResult { results, deleted_count, failed_count, }) } fn delete_one_session( codex_home: &Path, database_paths: &[PathBuf], session_id: &str, ) -> CodexSessionDeleteResult { let outcome = (|| -> Result { let mut plans = Vec::new(); for path in database_paths { if let Some(plan) = build_database_delete_plan(path, session_id)? { plans.push(plan); } } if plans.is_empty() { return Ok(CodexSessionDeleteResult { session_id: session_id.to_string(), status: "notFound".to_string(), message: "未在本地数据库中找到该会话".to_string(), backup_path: None, }); } let backup_dir = create_delete_backup(codex_home, session_id, &plans)?; let mut committed_databases = 0usize; for plan in &plans { if let Err(error) = apply_database_delete_plan(plan, session_id) { let status = if committed_databases > 0 { "partial" } else { "failed" }; return Ok(CodexSessionDeleteResult { session_id: session_id.to_string(), status: status.to_string(), message: format!( "已提交 {committed_databases} 个数据库事务,其余数据库删除失败:{error}" ), backup_path: Some(backup_dir.to_string_lossy().to_string()), }); } committed_databases += 1; } let mut rollout_errors = Vec::new(); let mut rollout_paths = plans .iter() .flat_map(|plan| plan.rollout_paths.iter().cloned()) .collect::>(); rollout_paths.sort(); rollout_paths.dedup(); for path in rollout_paths { if !rollout_candidate(codex_home, &path).is_file() { continue; } match validated_rollout_path(codex_home, &path) { Ok(Some(path)) => { if let Err(error) = fs::remove_file(&path) { if error.kind() != std::io::ErrorKind::NotFound { rollout_errors.push(format!("{}: {error}", path.display())); } } } Ok(None) => rollout_errors.push(format!( "已保留不在 Codex 会话目录中的 rollout:{}", path.display() )), Err(error) => rollout_errors.push(error), } } let (status, message) = if rollout_errors.is_empty() { ("deleted", "已删除本地数据库记录和 rollout 文件".to_string()) } else { ( "partial", format!( "数据库记录已删除,但部分 rollout 未删除:{}", rollout_errors.join(";") ), ) }; Ok(CodexSessionDeleteResult { session_id: session_id.to_string(), status: status.to_string(), message, backup_path: Some(backup_dir.to_string_lossy().to_string()), }) })(); outcome.unwrap_or_else(|message| CodexSessionDeleteResult { session_id: session_id.to_string(), status: "failed".to_string(), message, backup_path: None, }) } fn build_database_delete_plan( path: &Path, session_id: &str, ) -> Result, String> { let connection = open_read_only(path)?; let mut tables = Map::new(); let mut rollout_paths = Vec::new(); let thread_rows = select_rows_if_supported(&connection, "threads", &["id"], "id = ?1", &[&session_id])?; let has_thread_row = !thread_rows.is_empty(); if has_thread_row { for row in &thread_rows { if let Some(path) = row.get("rollout_path").and_then(Value::as_str) { if !path.trim().is_empty() { rollout_paths.push(PathBuf::from(path)); } } } tables.insert("threads".to_string(), Value::Array(thread_rows)); for (table, column, clause) in [ ("thread_dynamic_tools", "thread_id", "thread_id = ?1"), ("thread_goals", "thread_id", "thread_id = ?1"), ("stage1_outputs", "thread_id", "thread_id = ?1"), ( "agent_job_items", "assigned_thread_id", "assigned_thread_id = ?1", ), ] { let rows = select_rows_if_supported(&connection, table, &[column], clause, &[&session_id])?; if !rows.is_empty() { tables.insert(table.to_string(), Value::Array(rows)); } } if let Some(clause) = thread_spawn_edge_clause(&connection)? { let rows = select_rows_if_supported( &connection, "thread_spawn_edges", &[], clause, &[&session_id], )?; if !rows.is_empty() { tables.insert("thread_spawn_edges".to_string(), Value::Array(rows)); } } } let automation_rows = select_rows_if_supported( &connection, "automation_runs", &["thread_id"], "thread_id = ?1", &[&session_id], )?; let inbox_rows = select_rows_if_supported( &connection, "inbox_items", &["thread_id"], "thread_id = ?1", &[&session_id], )?; let has_automation_rows = !automation_rows.is_empty() || !inbox_rows.is_empty(); if !automation_rows.is_empty() { tables.insert("automation_runs".to_string(), Value::Array(automation_rows)); } if !inbox_rows.is_empty() { tables.insert("inbox_items".to_string(), Value::Array(inbox_rows)); } if !has_thread_row && !has_automation_rows { return Ok(None); } for (table, column, clause) in [ ("local_thread_catalog", "thread_id", "thread_id = ?1"), ("thread_timeline_ledger", "thread_id", "thread_id = ?1"), ] { let rows = select_rows_if_supported(&connection, table, &[column], clause, &[&session_id])?; if !rows.is_empty() { tables.insert(table.to_string(), Value::Array(rows)); } } Ok(Some(DatabaseDeletePlan { path: path.to_path_buf(), tables, rollout_paths, has_thread_row, has_automation_rows, })) } fn select_rows_if_supported( connection: &Connection, table: &str, required_columns: &[&str], where_clause: &str, params: &[&dyn ToSql], ) -> Result, String> { let columns = table_columns(connection, table)?; if columns.is_empty() || required_columns .iter() .any(|column| !columns.contains(*column)) { return Ok(Vec::new()); } select_json_rows( connection, &format!( "SELECT * FROM \"{}\" WHERE {where_clause}", table.replace('"', "\"\"") ), params, ) } fn thread_spawn_edge_clause(connection: &Connection) -> Result, String> { let columns = table_columns(connection, "thread_spawn_edges")?; Ok( match ( columns.contains("parent_thread_id"), columns.contains("child_thread_id"), ) { (true, true) => Some("parent_thread_id = ?1 OR child_thread_id = ?1"), (true, false) => Some("parent_thread_id = ?1"), (false, true) => Some("child_thread_id = ?1"), (false, false) => None, }, ) } fn select_json_rows( connection: &Connection, sql: &str, params: &[&dyn ToSql], ) -> Result, String> { let mut statement = connection .prepare(sql) .map_err(|error| format!("准备会话备份查询失败: {error}"))?; let columns = statement .column_names() .iter() .map(|name| name.to_string()) .collect::>(); let rows = statement .query_map(params, |row| { let mut value = Map::new(); for (index, column) in columns.iter().enumerate() { value.insert(column.clone(), sql_value_to_json(row.get_ref(index)?)); } Ok(Value::Object(value)) }) .map_err(|error| format!("读取会话备份数据失败: {error}"))?; rows.collect::>>() .map_err(|error| format!("读取会话备份数据失败: {error}")) } fn sql_value_to_json(value: ValueRef<'_>) -> Value { match value { ValueRef::Null => Value::Null, ValueRef::Integer(value) => json!(value), ValueRef::Real(value) => json!(value), ValueRef::Text(value) => Value::String(String::from_utf8_lossy(value).to_string()), ValueRef::Blob(value) => json!({ "$base64": base64::engine::general_purpose::STANDARD.encode(value) }), } } fn create_operation_directory( codex_home: &Path, category: &str, label: &str, ) -> Result { let safe_label = label .chars() .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) .take(64) .collect::(); let sequence = BACKUP_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed); let name = format!( "{}-{}-{sequence:020}-{}", Utc::now().format("%Y%m%d-%H%M%S%.3f"), std::process::id(), if safe_label.is_empty() { "operation" } else { &safe_label } ); let directory = codex_home .join("backups_state") .join("easy-cli-proxy-api") .join(category) .join(name); fs::create_dir_all(&directory) .map_err(|error| format!("创建会话备份目录失败 {}: {error}", directory.display()))?; Ok(directory) } fn create_delete_backup( codex_home: &Path, session_id: &str, plans: &[DatabaseDeletePlan], ) -> Result { let directory = create_operation_directory(codex_home, "deletions", session_id)?; let mut sources = Vec::new(); for (index, plan) in plans.iter().enumerate() { let file_name = format!("database-{index}-rows.json"); let payload = json!({ "databasePath": plan.path.to_string_lossy(), "tables": plan.tables, }); write_json_atomically(&directory.join(&file_name), &payload)?; sources.push(json!({ "databasePath": plan.path.to_string_lossy(), "rowsFile": file_name, })); } let rollout_directory = directory.join("rollouts"); let mut rollout_files = Vec::new(); let mut paths = plans .iter() .flat_map(|plan| plan.rollout_paths.iter().cloned()) .collect::>(); paths.sort(); paths.dedup(); for (index, path) in paths.iter().enumerate() { if !rollout_candidate(codex_home, path).is_file() { continue; } let Some(validated) = validated_rollout_path(codex_home, path)? else { rollout_files.push(json!({ "originalPath": path.to_string_lossy(), "backupPath": Value::Null, "skipped": "outside Codex session directories" })); continue; }; if !validated.is_file() { continue; } fs::create_dir_all(&rollout_directory).map_err(|error| { format!( "创建 rollout 备份目录失败 {}: {error}", rollout_directory.display() ) })?; let file_name = format!( "{index}-{}", validated .file_name() .and_then(OsStr::to_str) .unwrap_or("rollout.jsonl") ); let target = rollout_directory.join(&file_name); fs::copy(&validated, &target) .map_err(|error| format!("备份 rollout 失败 {}: {error}", validated.display()))?; rollout_files.push(json!({ "originalPath": validated.to_string_lossy(), "backupPath": format!("rollouts/{file_name}") })); } write_json_atomically( &directory.join("metadata.json"), &json!({ "schemaVersion": 1, "kind": "codex-session-deletion", "createdAt": Utc::now().to_rfc3339(), "sessionId": session_id, "codexHome": codex_home.to_string_lossy(), "sources": sources, "rolloutFiles": rollout_files, "managedBy": "EasyCLIProxyAPI" }), )?; Ok(directory) } fn rollout_candidate(codex_home: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { codex_home.join(path) } } fn validated_rollout_path(codex_home: &Path, path: &Path) -> Result, String> { let candidate = rollout_candidate(codex_home, path); if !candidate.is_file() { return Ok(None); } let canonical = fs::canonicalize(&candidate) .map_err(|error| format!("解析 rollout 路径失败 {}: {error}", candidate.display()))?; for directory in SESSION_DIRS { let root = codex_home.join(directory); if let Ok(root) = fs::canonicalize(root) { if canonical.starts_with(root) && canonical.extension().and_then(OsStr::to_str) == Some("jsonl") { return Ok(Some(canonical)); } } } Ok(None) } fn apply_database_delete_plan(plan: &DatabaseDeletePlan, session_id: &str) -> Result<(), String> { let mut connection = open_read_write(&plan.path)?; let transaction = connection .transaction() .map_err(|error| format!("开始数据库事务失败 {}: {error}", plan.path.display()))?; if plan.has_thread_row { delete_where_if_supported( &transaction, "thread_dynamic_tools", &["thread_id"], "thread_id = ?1", &[&session_id], )?; delete_where_if_supported( &transaction, "thread_goals", &["thread_id"], "thread_id = ?1", &[&session_id], )?; if let Some(clause) = thread_spawn_edge_clause(&transaction)? { delete_where_if_supported( &transaction, "thread_spawn_edges", &[], clause, &[&session_id], )?; } delete_where_if_supported( &transaction, "stage1_outputs", &["thread_id"], "thread_id = ?1", &[&session_id], )?; let agent_columns = table_columns(&transaction, "agent_job_items")?; if agent_columns.contains("assigned_thread_id") { transaction .execute( "UPDATE agent_job_items SET assigned_thread_id = NULL WHERE assigned_thread_id = ?1", [session_id], ) .map_err(|error| format!("更新 agent_job_items 失败: {error}"))?; } delete_where_if_supported(&transaction, "threads", &["id"], "id = ?1", &[&session_id])?; } if plan.has_automation_rows { delete_where_if_supported( &transaction, "automation_runs", &["thread_id"], "thread_id = ?1", &[&session_id], )?; delete_where_if_supported( &transaction, "inbox_items", &["thread_id"], "thread_id = ?1", &[&session_id], )?; } delete_where_if_supported( &transaction, "local_thread_catalog", &["thread_id"], "thread_id = ?1", &[&session_id], )?; delete_where_if_supported( &transaction, "thread_timeline_ledger", &["thread_id"], "thread_id = ?1", &[&session_id], )?; transaction .commit() .map_err(|error| format!("提交数据库删除失败 {}: {error}", plan.path.display())) } fn delete_where_if_supported( connection: &Connection, table: &str, required_columns: &[&str], where_clause: &str, params: &[&dyn ToSql], ) -> Result<(), String> { let columns = table_columns(connection, table)?; if columns.is_empty() || required_columns .iter() .any(|column| !columns.contains(*column)) { return Ok(()); } connection .execute( &format!( "DELETE FROM \"{}\" WHERE {where_clause}", table.replace('"', "\"\"") ), params, ) .map(|_| ()) .map_err(|error| format!("删除 {table} 关联记录失败: {error}")) } fn write_json_atomically(path: &Path, value: &Value) -> Result<(), String> { let bytes = serde_json::to_vec_pretty(value).map_err(|error| format!("序列化会话备份失败: {error}"))?; crate::write_bytes_atomically(path, &bytes) } fn validate_managed_codex_provider(codex_home: &Path) -> Result<(), String> { let path = codex_home.join("config.toml"); let text = fs::read_to_string(&path) .map_err(|error| format!("读取 Codex 配置失败 {}: {error}", path.display()))?; let document = text .parse::() .map_err(|error| format!("Codex 配置无法解析: {error}"))?; if document.get("model_provider").and_then(toml::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) { return Err("请先为 Codex 应用 EasyCLIProxyAPI 托管配置,再恢复历史会话".to_string()); } Ok(()) } fn repair_codex_session_metadata_from_home( codex_home: &Path, mut progress: F, ) -> Result where F: FnMut(CodexSessionRepairProgress), { let _guard = CODEX_SESSION_WRITE_LOCK .lock() .map_err(|_| "Codex 会话写入锁已损坏".to_string())?; progress(CodexSessionRepairProgress { phase: "scanning".to_string(), percent: 10, processed: 0, total: 0, }); let rollout_paths = collect_rollout_files(codex_home)?; let total = rollout_paths.len(); let mut repairs = Vec::new(); let mut skipped_locked_files = Vec::new(); let mut warnings = Vec::new(); for (index, path) in rollout_paths.iter().enumerate() { match build_rollout_repair(path, MANAGED_AGENT_PROVIDER_ID) { Ok(Some(repair)) => repairs.push(repair), Ok(None) => {} Err(error) if is_locked_error_message(&error) => { skipped_locked_files.push(path.to_string_lossy().to_string()); } Err(error) => warnings.push(error), } if index % 25 == 0 || index + 1 == total { progress(CodexSessionRepairProgress { phase: "scanning".to_string(), percent: 10 + (((index + 1) * 25 / total.max(1)) as u8), processed: index + 1, total, }); } } let projectless = load_projectless_thread_ids(codex_home)?; let (database_paths, database_warnings) = discover_database_paths(codex_home, false); warnings.extend(database_warnings); let (sqlite_updates_needed, database_count_warnings) = count_sqlite_repairs(&database_paths, &repairs, &projectless)?; let sqlite_scan_incomplete = !database_count_warnings.is_empty(); warnings.extend(database_count_warnings); let changed_repairs = repairs .iter() .filter(|repair| repair.changed) .cloned() .collect::>(); let backup_path = if changed_repairs.is_empty() && sqlite_updates_needed == 0 && !sqlite_scan_incomplete { None } else { progress(CodexSessionRepairProgress { phase: "backingUp".to_string(), percent: 45, processed: 0, total: changed_repairs.len(), }); Some(create_repair_backup( codex_home, &database_paths, &changed_repairs, "provider-repair", )?) }; progress(CodexSessionRepairProgress { phase: "rewriting".to_string(), percent: 60, processed: 0, total: changed_repairs.len(), }); let mut written = Vec::new(); for (index, repair) in changed_repairs.iter().enumerate() { match crate::write_bytes_atomically(&repair.path, &repair.next) { Ok(()) => { restore_modified_time(&repair.path, repair.original_mtime); written.push(repair.clone()); } Err(error) if is_locked_error_message(&error) => { let path = repair.path.to_string_lossy().to_string(); if !skipped_locked_files.contains(&path) { skipped_locked_files.push(path); } } Err(error) => { for previous in written.iter().rev() { let _ = crate::write_bytes_atomically(&previous.path, &previous.original); restore_modified_time(&previous.path, previous.original_mtime); } return Err(format!("写入历史会话失败;已尝试回滚:{error}")); } } progress(CodexSessionRepairProgress { phase: "rewriting".to_string(), percent: 60 + (((index + 1) * 20 / changed_repairs.len().max(1)) as u8), processed: index + 1, total: changed_repairs.len(), }); } progress(CodexSessionRepairProgress { phase: "updatingDatabase".to_string(), percent: 85, processed: 0, total: database_paths.len(), }); let sqlite_rows_updated = match apply_sqlite_repairs(&database_paths, &repairs, &projectless) { Ok((updated, database_lock_warnings)) => { warnings.extend(database_lock_warnings); updated } Err(error) => { for previous in written.iter().rev() { let _ = crate::write_bytes_atomically(&previous.path, &previous.original); restore_modified_time(&previous.path, previous.original_mtime); } return Err(format!( "更新会话数据库失败;rollout 已尝试回滚,备份仍保留:{error}" )); } }; if backup_path.is_some() { prune_repair_backups(codex_home)?; } let encrypted_content_warning = encrypted_content_warning(&repairs); progress(CodexSessionRepairProgress { phase: "complete".to_string(), percent: 100, processed: total, total, }); Ok(CodexSessionRepairResult { target_provider: MANAGED_AGENT_PROVIDER_ID.to_string(), changed_rollout_files: written.len(), sqlite_rows_updated, skipped_locked_files, backup_path: backup_path.map(|path| path.to_string_lossy().to_string()), encrypted_content_warning, warnings, }) } fn collect_rollout_files(codex_home: &Path) -> Result, String> { let mut files = Vec::new(); for directory in SESSION_DIRS { collect_rollout_files_recursive(&codex_home.join(directory), &mut files)?; } files.sort(); Ok(files) } fn collect_rollout_files_recursive(root: &Path, files: &mut Vec) -> Result<(), String> { if !root.exists() { return Ok(()); } let entries = fs::read_dir(root) .map_err(|error| format!("扫描会话目录失败 {}: {error}", root.display()))?; for entry in entries { let entry = entry.map_err(|error| format!("读取会话目录项失败: {error}"))?; let file_type = entry .file_type() .map_err(|error| format!("读取会话目录项类型失败: {error}"))?; if file_type.is_symlink() { continue; } let path = entry.path(); if file_type.is_dir() { collect_rollout_files_recursive(&path, files)?; } else if file_type.is_file() && path.extension().and_then(OsStr::to_str) == Some("jsonl") { files.push(path); } } Ok(()) } fn build_rollout_repair( path: &Path, target_provider: &str, ) -> Result, String> { let original = fs::read(path).map_err(|error| format!("读取 rollout 失败 {}: {error}", path.display()))?; let text = String::from_utf8(original.clone()) .map_err(|error| format!("rollout 不是 UTF-8 {}: {error}", path.display()))?; let mut next = String::with_capacity(text.len()); let mut changed = false; let mut session_meta_count = 0usize; let mut thread_id = None; let mut cwd = None; let mut providers = Vec::new(); for segment in text.split_inclusive('\n') { let (body_with_cr, newline) = segment .strip_suffix('\n') .map_or((segment, ""), |body| (body, "\n")); let (body, carriage) = body_with_cr .strip_suffix('\r') .map_or((body_with_cr, ""), |body| (body, "\r")); let mut output = body.to_string(); if let Ok(mut record) = serde_json::from_str::(body) { if record.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) { session_meta_count += 1; if thread_id.is_none() { thread_id = payload .get("id") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(ToString::to_string); } if cwd.is_none() { cwd = payload .get("cwd") .and_then(Value::as_str) .and_then(normalize_workspace_path); } if let Some(provider) = payload.get("model_provider").and_then(Value::as_str) { providers.push(provider.to_string()); } if payload.get("model_provider").and_then(Value::as_str) != Some(target_provider) { payload.insert("model_provider".to_string(), json!(target_provider)); output = serde_json::to_string(&record) .map_err(|error| format!("序列化 session_meta 失败: {error}"))?; changed = true; } } } } next.push_str(&output); next.push_str(carriage); next.push_str(newline); } if session_meta_count == 0 { return Ok(None); } let has_user_event = text.contains("\"user_message\"") || text.contains("\"user_input\""); Ok(Some(RolloutRepair { path: path.to_path_buf(), original, next: next.into_bytes(), original_mtime: fs::metadata(path) .and_then(|metadata| metadata.modified()) .ok(), thread_id, cwd, has_user_event, providers, encrypted_content: text.contains("encrypted_content"), changed, })) } fn normalize_workspace_path(value: &str) -> Option { let value = value.trim(); if value.is_empty() { return None; } let lower = value.to_ascii_lowercase(); if lower.starts_with(r"\\?\unc\") { return Some(format!(r"\\{}", value[8..].replace('/', r"\"))); } if value.starts_with(r"\\?\") { return Some(value[4..].replace('\\', "/")); } Some(value.to_string()) } fn is_locked_error_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("os error 32") || lower.contains("os error 33") || lower.contains("database is locked") || lower.contains("database is busy") || lower.contains("sharing violation") || lower.contains("being used by another process") || lower.contains("permission denied") || lower.contains("access is denied") } fn load_projectless_thread_ids(codex_home: &Path) -> Result, String> { let path = codex_home.join(".codex-global-state.json"); if !path.is_file() { return Ok(HashSet::new()); } let value = serde_json::from_str::( &fs::read_to_string(&path).map_err(|error| format!("读取 Codex 全局状态失败: {error}"))?, ) .map_err(|error| format!("解析 Codex 全局状态失败: {error}"))?; Ok(value .get("projectless-thread-ids") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .filter(|id| !id.trim().is_empty()) .map(ToString::to_string) .collect()) } fn repair_maps( repairs: &[RolloutRepair], projectless: &HashSet, ) -> (HashSet, HashMap) { let user_event_ids = repairs .iter() .filter(|repair| repair.has_user_event) .filter_map(|repair| repair.thread_id.clone()) .collect::>(); let cwd_by_id = repairs .iter() .filter_map(|repair| Some((repair.thread_id.clone()?, repair.cwd.clone()?))) .filter(|(id, _)| !projectless.contains(id)) .collect::>(); (user_event_ids, cwd_by_id) } fn count_sqlite_repairs( paths: &[PathBuf], repairs: &[RolloutRepair], projectless: &HashSet, ) -> Result<(usize, Vec), String> { let (user_event_ids, cwd_by_id) = repair_maps(repairs, projectless); let mut total = 0usize; let mut warnings = Vec::new(); for path in paths { let count_result = (|| -> Result { let connection = open_read_only(path)?; let columns = table_columns(&connection, "threads")?; let mut count = 0usize; if columns.contains("model_provider") { count += connection .query_row( "SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider, '') <> ?1", [MANAGED_AGENT_PROVIDER_ID], |row| row.get::<_, i64>(0), ) .map_err(|error| format!("统计 Provider 迁移行失败: {error}"))? as usize; } if columns.contains("has_user_event") { for id in &user_event_ids { count += connection .query_row( "SELECT COUNT(*) FROM threads WHERE id = ?1 AND COALESCE(has_user_event, 0) <> 1", [id], |row| row.get::<_, i64>(0), ) .map_err(|error| format!("统计会话可见性更新行失败: {error}"))? as usize; } } if columns.contains("cwd") { for (id, cwd) in &cwd_by_id { count += connection .query_row( "SELECT COUNT(*) FROM threads WHERE id = ?1 AND COALESCE(cwd, '') <> ?2", (id, cwd), |row| row.get::<_, i64>(0), ) .map_err(|error| format!("统计项目路径更新行失败: {error}"))? as usize; } } Ok(count) })(); match count_result { Ok(count) => total += count, Err(error) if is_locked_error_message(&error) => warnings.push(format!( "数据库正在使用,无法预统计同步行 {}: {error}", path.display() )), Err(error) => return Err(error), } } Ok((total, warnings)) } fn apply_sqlite_repairs( paths: &[PathBuf], repairs: &[RolloutRepair], projectless: &HashSet, ) -> Result<(usize, Vec), String> { let (user_event_ids, cwd_by_id) = repair_maps(repairs, projectless); let mut total = 0usize; let mut warnings = Vec::new(); for path in paths { let update_result = (|| -> Result { let mut connection = open_read_write(path)?; let columns = table_columns(&connection, "threads")?; if columns.is_empty() { return Ok(0); } let transaction = connection .transaction() .map_err(|error| format!("开始历史会话恢复事务失败 {}: {error}", path.display()))?; let mut updated = 0usize; if columns.contains("model_provider") { updated += transaction .execute( "UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1", [MANAGED_AGENT_PROVIDER_ID], ) .map_err(|error| format!("更新会话 provider 失败: {error}"))?; } if columns.contains("has_user_event") { for id in &user_event_ids { updated += transaction .execute( "UPDATE threads SET has_user_event = 1 WHERE id = ?1 AND COALESCE(has_user_event, 0) <> 1", [id], ) .map_err(|error| format!("更新会话用户事件标记失败: {error}"))?; } } if columns.contains("cwd") { for (id, cwd) in &cwd_by_id { updated += transaction .execute( "UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1", (cwd, id), ) .map_err(|error| format!("更新会话项目路径失败: {error}"))?; } } transaction .commit() .map_err(|error| format!("提交历史会话恢复事务失败 {}: {error}", path.display()))?; Ok(updated) })(); match update_result { Ok(updated) => total += updated, Err(error) if is_locked_error_message(&error) => warnings.push(format!( "数据库正在使用,已跳过本次同步 {}: {error}", path.display() )), Err(error) => return Err(error), } } Ok((total, warnings)) } fn create_repair_backup( codex_home: &Path, database_paths: &[PathBuf], repairs: &[RolloutRepair], label: &str, ) -> Result { let directory = create_operation_directory(codex_home, "session-repair", label)?; for name in [ "config.toml", ".codex-global-state.json", ".codex-global-state.json.bak", ] { let source = codex_home.join(name); if source.is_file() { fs::copy(&source, directory.join(name)) .map_err(|error| format!("备份 {name} 失败: {error}"))?; } } let sqlite_home = resolve_sqlite_home(codex_home); let mut database_files = Vec::new(); for database_path in database_paths { for source in sqlite_sidecar_paths(database_path) { if !source.is_file() { continue; } let relative = source .strip_prefix(&sqlite_home) .unwrap_or(source.as_path()) .to_path_buf(); let target = directory.join("databases").join(&relative); if let Some(parent) = target.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建数据库备份目录失败: {error}"))?; } fs::copy(&source, &target) .map_err(|error| format!("备份数据库失败 {}: {error}", source.display()))?; database_files.push(relative.to_string_lossy().replace('\\', "/")); } } let mut rollout_files = Vec::new(); for (index, repair) in repairs.iter().enumerate() { let file_name = format!( "{index}-{}", repair .path .file_name() .and_then(OsStr::to_str) .unwrap_or("rollout.jsonl") ); let target = directory.join("rollouts").join(&file_name); if let Some(parent) = target.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 rollout 备份目录失败: {error}"))?; } fs::write(&target, &repair.original) .map_err(|error| format!("备份 rollout 失败 {}: {error}", repair.path.display()))?; rollout_files.push(json!({ "originalPath": repair.path.to_string_lossy(), "backupPath": format!("rollouts/{file_name}") })); } write_json_atomically( &directory.join("metadata.json"), &json!({ "schemaVersion": 1, "kind": label, "createdAt": Utc::now().to_rfc3339(), "targetProvider": MANAGED_AGENT_PROVIDER_ID, "databaseFiles": database_files, "rolloutFiles": rollout_files, "managedBy": "EasyCLIProxyAPI" }), )?; Ok(directory) } fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { [ path.to_path_buf(), PathBuf::from(format!("{}-wal", path.to_string_lossy())), PathBuf::from(format!("{}-shm", path.to_string_lossy())), ] } fn restore_modified_time(path: &Path, modified: Option) { let Some(modified) = modified else { return; }; if let Ok(file) = fs::File::options().write(true).open(path) { let _ = file.set_times(fs::FileTimes::new().set_modified(modified)); } } fn encrypted_content_warning(repairs: &[RolloutRepair]) -> Option { let mut providers = repairs .iter() .filter(|repair| repair.encrypted_content) .flat_map(|repair| repair.providers.iter().cloned()) .filter(|provider| provider != MANAGED_AGENT_PROVIDER_ID) .collect::>(); providers.sort(); providers.dedup(); if providers.is_empty() { return None; } Some(format!( "部分历史会话包含来自 {} 的 encrypted_content;元数据已同步到 {},但继续或压缩这些会话可能失败。", providers.join("、"), MANAGED_AGENT_PROVIDER_ID )) } fn prune_repair_backups(codex_home: &Path) -> Result<(), String> { let root = codex_home .join("backups_state") .join("easy-cli-proxy-api") .join("session-repair"); if !root.is_dir() { return Ok(()); } let mut directories = fs::read_dir(&root) .map_err(|error| format!("读取历史会话恢复备份失败: {error}"))? .filter_map(Result::ok) .filter(|entry| entry.path().is_dir()) .collect::>(); directories.sort_by_key(|entry| entry.file_name()); let remove_count = directories.len().saturating_sub(REPAIR_BACKUP_KEEP_COUNT); for entry in directories.into_iter().take(remove_count) { let path = entry.path(); if path.starts_with(&root) { fs::remove_dir_all(&path).map_err(|error| { format!("清理旧的历史会话恢复备份失败 {}: {error}", path.display()) })?; } } Ok(()) } fn preview_session_index_cleanup_from_home( codex_home: &Path, ) -> Result { let live_ids = collect_live_thread_ids(codex_home)?; let path = codex_home.join("session_index.jsonl"); let Some(plan) = build_session_index_plan(&path, &live_ids)? else { return Ok(SessionIndexCleanupPreview { snapshot_sha256: sha256_hex(&[]), candidates: Vec::new(), }); }; Ok(SessionIndexCleanupPreview { snapshot_sha256: plan.snapshot_sha256, candidates: plan.candidates, }) } fn collect_live_thread_ids(codex_home: &Path) -> Result, String> { let mut ids = HashSet::new(); for path in collect_rollout_files(codex_home)? { if let Some(id) = rollout_thread_id_from_file_name(&path) { ids.insert(id); } let text = match fs::read_to_string(&path) { Ok(text) => text, Err(error) if is_locked_io_error(&error) => continue, Err(error) => return Err(format!("读取 rollout 失败 {}: {error}", path.display())), }; for line in text.lines() { let Ok(record) = serde_json::from_str::(line) else { continue; }; if record.get("type").and_then(Value::as_str) != Some("session_meta") { continue; } if let Some(id) = record .get("payload") .and_then(Value::as_object) .and_then(|payload| payload.get("id")) .and_then(Value::as_str) .filter(|id| !id.trim().is_empty()) { ids.insert(id.to_string()); ids.insert(normalize_thread_id(id)); } } } let (database_paths, database_warnings) = discover_database_paths(codex_home, true); if !database_warnings.is_empty() { return Err(format!( "无法完整核对全部 Codex 数据库,无效会话记录检查已中止:{}", database_warnings.join(";") )); } for path in database_paths { let connection = open_read_only(&path)?; for (table, column) in REFERENCE_TABLE_COLUMNS { let columns = table_columns(&connection, table)?; if !columns.contains(column) { continue; } let sql = format!( "SELECT DISTINCT \"{}\" FROM \"{}\" WHERE COALESCE(\"{}\", '') <> ''", column, table, column ); let mut statement = connection .prepare(&sql) .map_err(|error| format!("读取会话引用失败 {}: {error}", path.display()))?; let rows = statement .query_map([], |row| row.get::<_, String>(0)) .map_err(|error| format!("读取会话引用失败 {}: {error}", path.display()))?; for id in rows .collect::>>() .map_err(|error| format!("读取会话引用失败 {}: {error}", path.display()))? { ids.insert(normalize_thread_id(&id)); ids.insert(id); } } } Ok(ids) } fn rollout_thread_id_from_file_name(path: &Path) -> Option { let name = path.file_name()?.to_str()?; let stem = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; if stem.len() < 36 { return None; } let candidate = &stem[stem.len() - 36..]; candidate .chars() .enumerate() .all(|(index, character)| match index { 8 | 13 | 18 | 23 => character == '-', _ => character.is_ascii_hexdigit(), }) .then(|| candidate.to_string()) } fn build_session_index_plan( path: &Path, live_ids: &HashSet, ) -> Result, String> { if !path.is_file() { return Ok(None); } let original = fs::read(path).map_err(|error| format!("读取 session_index.jsonl 失败: {error}"))?; let original_text = String::from_utf8(original.clone()) .map_err(|error| format!("session_index.jsonl 不是 UTF-8: {error}"))?; let mut seen_candidates = HashSet::new(); let candidates = original_text .lines() .filter_map(known_session_index_candidate) .filter(|candidate| { !live_ids.contains(&candidate.id) && !live_ids.contains(&normalize_thread_id(&candidate.id)) }) .filter(|candidate| seen_candidates.insert(candidate.id.clone())) .collect(); Ok(Some(SessionIndexPlan { path: path.to_path_buf(), snapshot_sha256: sha256_hex(&original), original, original_text, candidates, })) } fn known_session_index_candidate(line: &str) -> Option { let record = serde_json::from_str::(line).ok()?; let object = record.as_object()?; if object.len() != 3 || !["id", "thread_name", "updated_at"] .iter() .all(|key| object.contains_key(*key)) { return None; } let id = object.get("id")?.as_str()?.trim(); let thread_name = object.get("thread_name")?.as_str()?; let updated_at = object.get("updated_at")?.as_str()?; if id.is_empty() || updated_at.trim().is_empty() { return None; } Some(SessionIndexCleanupCandidate { id: id.to_string(), thread_name: thread_name.to_string(), updated_at: updated_at.to_string(), }) } fn apply_session_index_cleanup_from_home( codex_home: &Path, expected_snapshot: &str, confirmed_ids: &[String], require_stopped_app: bool, ) -> Result { let _guard = CODEX_SESSION_WRITE_LOCK .lock() .map_err(|_| "Codex 会话写入锁已损坏".to_string())?; if require_stopped_app { ensure_codex_apps_stopped()?; } let live_ids = collect_live_thread_ids(codex_home)?; let path = codex_home.join("session_index.jsonl"); let plan = build_session_index_plan(&path, &live_ids)? .ok_or_else(|| "session_index.jsonl 不存在,无法清理".to_string())?; if plan.snapshot_sha256 != expected_snapshot { return Err("session_index.jsonl 已在预览后发生变化,请重新预览".to_string()); } let candidate_ids = plan .candidates .iter() .map(|candidate| candidate.id.as_str()) .collect::>(); let selected = confirmed_ids .iter() .map(|id| id.trim()) .filter(|id| !id.is_empty()) .map(ToString::to_string) .collect::>(); if selected .iter() .any(|id| !candidate_ids.contains(id.as_str())) { return Err("确认列表已经过期或包含非候选会话,请重新预览".to_string()); } if selected.is_empty() { return Ok(SessionIndexCleanupResult { pruned_entries: 0, backup_path: None, }); } let (next, removed) = filter_session_index(&plan.original_text, &selected); if removed == 0 { return Ok(SessionIndexCleanupResult { pruned_entries: 0, backup_path: None, }); } let backup_dir = create_operation_directory(codex_home, "session-index-cleanup", "index-cleanup")?; fs::write(backup_dir.join("session_index.jsonl"), &plan.original) .map_err(|error| format!("备份 session_index.jsonl 失败: {error}"))?; write_json_atomically( &backup_dir.join("metadata.json"), &json!({ "schemaVersion": 1, "kind": "session-index-cleanup", "createdAt": Utc::now().to_rfc3339(), "snapshotSha256": plan.snapshot_sha256, "removedEntries": removed, "managedBy": "EasyCLIProxyAPI" }), )?; let current = fs::read(&plan.path) .map_err(|error| format!("写入前重新读取 session_index.jsonl 失败: {error}"))?; if current != plan.original { return Err(format!( "session_index.jsonl 在写入前再次发生变化;未覆盖新内容,备份位于 {}", backup_dir.display() )); } if require_stopped_app { ensure_codex_apps_stopped()?; } let expected_after_sha256 = sha256_hex(next.as_bytes()); crate::write_bytes_atomically(&plan.path, next.as_bytes())?; let written = fs::read(&plan.path) .map_err(|error| format!("写入后校验 session_index.jsonl 失败: {error}"))?; if sha256_hex(&written) != expected_after_sha256 { return Err(format!( "session_index.jsonl 写入后 SHA-256 校验失败;原文件备份位于 {}", backup_dir.display() )); } Ok(SessionIndexCleanupResult { pruned_entries: removed, backup_path: Some(backup_dir.to_string_lossy().to_string()), }) } fn filter_session_index(text: &str, selected: &HashSet) -> (String, usize) { let mut next = String::with_capacity(text.len()); let mut removed = 0usize; for segment in text.split_inclusive('\n') { let (body_with_cr, newline) = segment .strip_suffix('\n') .map_or((segment, ""), |body| (body, "\n")); let body = body_with_cr.strip_suffix('\r').unwrap_or(body_with_cr); let remove = known_session_index_candidate(body) .is_some_and(|candidate| selected.contains(&candidate.id)); if remove { removed += 1; } else { next.push_str(body_with_cr); next.push_str(newline); } } (next, removed) } fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn is_locked_io_error(error: &std::io::Error) -> bool { error.kind() == std::io::ErrorKind::PermissionDenied || matches!(error.raw_os_error(), Some(32 | 33)) } fn ensure_codex_apps_stopped() -> Result<(), String> { let running = codex_app_process_ids()?; if running.is_empty() { return Ok(()); } Err(format!( "Codex App / ChatGPT 仍在运行(进程:{}),请完全退出后重新预览并清理", running .iter() .map(u32::to_string) .collect::>() .join("、") )) } #[cfg(target_os = "windows")] fn codex_app_process_ids() -> Result, String> { windows_app_process_ids(&["ChatGPT.exe", "Codex.exe"]) } #[cfg(target_os = "windows")] fn chatgpt_app_process_ids() -> Result, String> { windows_app_process_ids(&["ChatGPT.exe"]) } #[cfg(target_os = "windows")] fn windows_app_process_ids(names: &[&str]) -> Result, String> { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; let mut ids = Vec::new(); for name in names { let filter = format!("IMAGENAME eq {name}"); let output = Command::new("tasklist") .args(["/FI", &filter, "/FO", "CSV", "/NH"]) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|error| format!("无法检查 {name} 进程状态: {error}"))?; if !output.status.success() { return Err(format!( "检查 {name} 进程状态失败: {}", String::from_utf8_lossy(&output.stderr).trim() )); } ids.extend(parse_windows_tasklist_process_ids(&output.stdout)); } ids.sort_unstable(); ids.dedup(); Ok(ids) } #[cfg(target_os = "windows")] fn parse_windows_tasklist_process_ids(output: &[u8]) -> Vec { String::from_utf8_lossy(output) .lines() .filter_map(|line| { let columns = line .trim() .trim_matches('"') .split("\",\"") .collect::>(); columns.get(1)?.parse::().ok() }) .collect() } #[cfg(not(target_os = "windows"))] fn codex_app_process_ids() -> Result, String> { unix_app_process_ids(&["ChatGPT", "Codex"]) } #[cfg(not(target_os = "windows"))] fn chatgpt_app_process_ids() -> Result, String> { unix_app_process_ids(&["ChatGPT"]) } #[cfg(not(target_os = "windows"))] fn unix_app_process_ids(names: &[&str]) -> Result, String> { let mut ids = Vec::new(); for name in names { let output = Command::new("pgrep") .args(["-x", name]) .output() .map_err(|error| format!("无法检查 {name} 进程状态: {error}"))?; match output.status.code() { Some(0) => ids.extend( String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| line.trim().parse::().ok()), ), Some(1) => {} _ => { return Err(format!( "检查 {name} 进程状态失败: {}", String::from_utf8_lossy(&output.stderr).trim() )); } } } ids.sort_unstable(); ids.dedup(); Ok(ids) } #[cfg(target_os = "windows")] fn close_chatgpt_app_processes() -> Result { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; let process_ids = chatgpt_app_process_ids()?; if process_ids.is_empty() { return Ok(ChatGptCloseResult { was_running: false, closed_processes: 0, }); } let output = Command::new("taskkill") .args(["/IM", "ChatGPT.exe", "/T", "/F"]) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|error| format!("无法关闭 ChatGPT.exe: {error}"))?; let mut remaining = chatgpt_app_process_ids()?; for _ in 0..20 { if remaining.is_empty() { break; } std::thread::sleep(Duration::from_millis(100)); remaining = chatgpt_app_process_ids()?; } if !remaining.is_empty() { let detail = String::from_utf8_lossy(if output.stderr.is_empty() { &output.stdout } else { &output.stderr }); return Err(format!( "ChatGPT App 未能完全关闭(剩余进程:{}){}{}", remaining .iter() .map(u32::to_string) .collect::>() .join("、"), if detail.trim().is_empty() { "" } else { ":" }, detail.trim() )); } Ok(ChatGptCloseResult { was_running: true, closed_processes: process_ids.len(), }) } #[cfg(not(target_os = "windows"))] fn close_chatgpt_app_processes() -> Result { let process_ids = chatgpt_app_process_ids()?; if process_ids.is_empty() { return Ok(ChatGptCloseResult { was_running: false, closed_processes: 0, }); } let mut terminate = Command::new("kill"); terminate.arg("-TERM"); for process_id in &process_ids { terminate.arg(process_id.to_string()); } let _ = terminate.status(); for _ in 0..20 { if chatgpt_app_process_ids()?.is_empty() { return Ok(ChatGptCloseResult { was_running: true, closed_processes: process_ids.len(), }); } std::thread::sleep(Duration::from_millis(100)); } let remaining = chatgpt_app_process_ids()?; let mut force = Command::new("kill"); force.arg("-KILL"); for process_id in &remaining { force.arg(process_id.to_string()); } let _ = force.status(); std::thread::sleep(Duration::from_millis(100)); let remaining = chatgpt_app_process_ids()?; if !remaining.is_empty() { return Err(format!( "ChatGPT App 未能完全关闭(剩余进程:{})", remaining .iter() .map(u32::to_string) .collect::>() .join("、") )); } Ok(ChatGptCloseResult { was_running: true, closed_processes: process_ids.len(), }) } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[cfg(target_os = "windows")] #[test] fn windows_tasklist_parser_accepts_csv_rows_and_ignores_status_messages() { let output = b"\"ChatGPT.exe\",\"1234\",\"Console\",\"1\",\"10,000 K\"\r\nINFO: No tasks are running which match the specified criteria.\r\n\"ChatGPT.exe\",\"5678\",\"Console\",\"1\",\"20,000 K\"\r\n"; assert_eq!(parse_windows_tasklist_process_ids(output), vec![1234, 5678]); } fn test_root(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "easy-cli-proxy-codex-sessions-{name}-{}-{}", std::process::id(), TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&path).unwrap(); path } fn create_threads_db(path: &Path, rollout: &Path) { let connection = Connection::open(path).unwrap(); connection .execute_batch( "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, title TEXT, cwd TEXT, model_provider TEXT, archived INTEGER, has_user_event INTEGER, updated_at_ms INTEGER);\n\ CREATE TABLE thread_dynamic_tools (thread_id TEXT, name TEXT);\n\ CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, child_thread_id TEXT);", ) .unwrap(); connection .execute( "INSERT INTO threads VALUES ('thread-1', ?1, 'First', 'C:/old', 'openai', 0, 0, 200)", [rollout.to_string_lossy().as_ref()], ) .unwrap(); connection .execute( "INSERT INTO thread_dynamic_tools VALUES ('thread-1', 'tool')", [], ) .unwrap(); } #[test] fn list_merges_threads_and_automation_rows_in_update_order() { let root = test_root("list"); let sqlite = root.join("sqlite"); fs::create_dir_all(&sqlite).unwrap(); let rollout = root.join("sessions/rollout.jsonl"); fs::create_dir_all(rollout.parent().unwrap()).unwrap(); fs::write(&rollout, "{}\n").unwrap(); create_threads_db(&root.join("state_5.sqlite"), &rollout); let automation = Connection::open(sqlite.join("codex-dev.db")).unwrap(); automation .execute_batch( "CREATE TABLE automation_runs (thread_id TEXT PRIMARY KEY, status TEXT, thread_title TEXT, source_cwd TEXT, updated_at INTEGER);\n\ INSERT INTO automation_runs VALUES ('thread-1', 'running', 'First new', 'C:/newer', 4);\n\ INSERT INTO automation_runs VALUES ('thread-2', 'archived', 'Second', 'C:/new', 3);", ) .unwrap(); drop(automation); let page = list_codex_sessions_from_home(&root, 0, 50, false).unwrap(); assert_eq!(page.sessions.len(), 2); assert_eq!(page.sessions[0].id, "thread-1"); assert_eq!(page.sessions[0].title, "First new"); assert_eq!(page.sessions[0].updated_at_ms, Some(4000)); assert_eq!(page.sessions[1].id, "thread-2"); assert!(page.sessions[1].archived); let second_page = list_codex_sessions_from_home(&root, 1, 1, false).unwrap(); assert_eq!(second_page.sessions.len(), 1); assert_eq!(second_page.sessions[0].id, "thread-2"); assert!(!second_page.has_more); fs::remove_dir_all(root).unwrap(); } #[test] fn list_accepts_optional_thread_columns_and_warns_about_a_broken_database() { let root = test_root("list-optional"); let sqlite = root.join("sqlite"); fs::create_dir_all(&sqlite).unwrap(); let connection = Connection::open(sqlite.join("optional.sqlite3")).unwrap(); connection .execute_batch( "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT);\n\ CREATE TABLE inbox_items (id TEXT PRIMARY KEY);\n\ INSERT INTO threads VALUES ('optional-thread', 'Optional');", ) .unwrap(); drop(connection); fs::write(sqlite.join("broken.db"), b"not a sqlite database").unwrap(); let page = list_codex_sessions_from_home(&root, 0, 0, true).unwrap(); assert_eq!(page.limit, 1); assert_eq!(page.sessions.len(), 1); assert_eq!(page.sessions[0].id, "optional-thread"); assert_eq!(page.sessions[0].cwd, ""); assert_eq!(page.sessions[0].updated_at_ms, None); assert!(page .warnings .iter() .any(|warning| warning.contains("broken.db"))); assert!(page.repair_on_launch); assert_eq!( list_codex_sessions_from_home(&root, 0, usize::MAX, false) .unwrap() .limit, MAX_PAGE_SIZE ); fs::remove_dir_all(root).unwrap(); } #[test] fn deletion_backs_up_rows_and_removes_only_in_home_rollout() { let root = test_root("delete"); let rollout = root.join("sessions/2026/rollout.jsonl"); fs::create_dir_all(rollout.parent().unwrap()).unwrap(); fs::write(&rollout, "session\n").unwrap(); create_threads_db(&root.join("state_5.sqlite"), &rollout); let result = delete_codex_sessions_from_home(&root, vec!["thread-1".to_string()]).unwrap(); assert_eq!(result.deleted_count, 1); assert_eq!(result.results[0].status, "deleted"); assert!(!rollout.exists()); let backup = PathBuf::from(result.results[0].backup_path.as_ref().unwrap()); assert!(backup.join("metadata.json").is_file()); assert!(backup.join("database-0-rows.json").is_file()); let connection = Connection::open(root.join("state_5.sqlite")).unwrap(); assert_eq!( connection .query_row("SELECT COUNT(*) FROM threads", [], |row| row .get::<_, i64>(0)) .unwrap(), 0 ); drop(connection); fs::remove_dir_all(root).unwrap(); } #[test] fn deletion_cleans_known_relations_and_preserves_outside_rollouts() { let root = test_root("delete-relations"); let outside_rollout = root.with_extension("outside.jsonl"); fs::write(&outside_rollout, "outside\n").unwrap(); let database = root.join("state_5.sqlite"); create_threads_db(&database, &outside_rollout); let connection = Connection::open(&database).unwrap(); connection .execute_batch( "CREATE TABLE thread_goals (thread_id TEXT, goal TEXT);\n\ CREATE TABLE stage1_outputs (thread_id TEXT, output TEXT);\n\ CREATE TABLE local_thread_catalog (thread_id TEXT, value TEXT);\n\ CREATE TABLE thread_timeline_ledger (thread_id TEXT, value TEXT);\n\ CREATE TABLE automation_runs (thread_id TEXT, title TEXT);\n\ CREATE TABLE inbox_items (id TEXT, thread_id TEXT);\n\ CREATE TABLE agent_job_items (id TEXT, assigned_thread_id TEXT);\n\ INSERT INTO thread_goals VALUES ('thread-1', 'goal');\n\ INSERT INTO thread_spawn_edges VALUES ('thread-1', 'child');\n\ INSERT INTO stage1_outputs VALUES ('thread-1', 'output');\n\ INSERT INTO local_thread_catalog VALUES ('thread-1', 'catalog');\n\ INSERT INTO thread_timeline_ledger VALUES ('thread-1', 'timeline');\n\ INSERT INTO automation_runs VALUES ('thread-1', 'automation');\n\ INSERT INTO inbox_items VALUES ('inbox-1', 'thread-1');\n\ INSERT INTO agent_job_items VALUES ('job-1', 'thread-1');", ) .unwrap(); drop(connection); let result = delete_codex_sessions_from_home(&root, vec!["thread-1".to_string()]).unwrap(); assert_eq!(result.results[0].status, "partial"); assert!(outside_rollout.is_file()); let backup = PathBuf::from(result.results[0].backup_path.as_ref().unwrap()); let metadata = fs::read_to_string(backup.join("metadata.json")).unwrap(); assert!(metadata.contains("outside Codex session directories")); let connection = Connection::open(&database).unwrap(); for table in [ "threads", "thread_dynamic_tools", "thread_goals", "thread_spawn_edges", "stage1_outputs", "local_thread_catalog", "thread_timeline_ledger", "automation_runs", "inbox_items", ] { let count = connection .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { row.get::<_, i64>(0) }) .unwrap(); assert_eq!(count, 0, "{table} should be cleaned"); } assert_eq!( connection .query_row( "SELECT COUNT(*) FROM agent_job_items WHERE assigned_thread_id IS NULL", [], |row| row.get::<_, i64>(0), ) .unwrap(), 1 ); drop(connection); fs::remove_dir_all(root).unwrap(); fs::remove_file(outside_rollout).unwrap(); } #[test] fn deletion_skips_related_tables_that_lack_optional_thread_columns() { let root = test_root("delete-optional"); let rollout = root.join("sessions/rollout.jsonl"); fs::create_dir_all(rollout.parent().unwrap()).unwrap(); fs::write(&rollout, "session\n").unwrap(); let database = root.join("state_5.sqlite"); create_threads_db(&database, &rollout); let connection = Connection::open(&database).unwrap(); connection .execute_batch( "CREATE TABLE thread_goals (id TEXT PRIMARY KEY);\n\ CREATE TABLE inbox_items (id TEXT PRIMARY KEY);\n\ INSERT INTO thread_goals VALUES ('goal-1');\n\ INSERT INTO inbox_items VALUES ('inbox-1');", ) .unwrap(); drop(connection); let result = delete_codex_sessions_from_home(&root, vec!["thread-1".to_string()]).unwrap(); assert_eq!(result.results[0].status, "deleted"); let connection = Connection::open(&database).unwrap(); assert_eq!( connection .query_row("SELECT COUNT(*) FROM thread_goals", [], |row| row .get::<_, i64>(0)) .unwrap(), 1 ); assert_eq!( connection .query_row("SELECT COUNT(*) FROM inbox_items", [], |row| row .get::<_, i64>(0)) .unwrap(), 1 ); drop(connection); fs::remove_dir_all(root).unwrap(); } #[test] fn deletion_failure_keeps_transaction_rows_and_returns_the_backup() { let root = test_root("delete-failure"); let rollout = root.join("sessions/rollout.jsonl"); fs::create_dir_all(rollout.parent().unwrap()).unwrap(); fs::write(&rollout, "session\n").unwrap(); let database = root.join("state_5.sqlite"); create_threads_db(&database, &rollout); let connection = Connection::open(&database).unwrap(); connection .execute_batch( "CREATE TRIGGER block_thread_delete BEFORE DELETE ON threads\n\ BEGIN SELECT RAISE(ABORT, 'blocked by test'); END;", ) .unwrap(); drop(connection); let result = delete_codex_sessions_from_home(&root, vec!["thread-1".to_string()]).unwrap(); assert_eq!(result.results[0].status, "failed"); let backup = PathBuf::from(result.results[0].backup_path.as_ref().unwrap()); assert!(backup.join("database-0-rows.json").is_file()); assert!(rollout.is_file()); let connection = Connection::open(&database).unwrap(); assert_eq!( connection .query_row("SELECT COUNT(*) FROM threads", [], |row| row .get::<_, i64>(0)) .unwrap(), 1 ); assert_eq!( connection .query_row("SELECT COUNT(*) FROM thread_dynamic_tools", [], |row| { row.get::<_, i64>(0) }) .unwrap(), 1 ); drop(connection); fs::remove_dir_all(root).unwrap(); } #[test] fn deletion_reports_partial_when_an_earlier_database_transaction_committed() { let root = test_root("delete-partial-database"); let rollout = root.join("sessions/rollout.jsonl"); fs::create_dir_all(rollout.parent().unwrap()).unwrap(); fs::write(&rollout, "session\n").unwrap(); let legacy_path = root.join("state_5.sqlite"); create_threads_db(&legacy_path, &rollout); let legacy = Connection::open(&legacy_path).unwrap(); legacy .execute_batch( "CREATE TRIGGER block_thread_delete BEFORE DELETE ON threads\n\ BEGIN SELECT RAISE(ABORT, 'blocked by test'); END;", ) .unwrap(); drop(legacy); let sqlite = root.join("sqlite"); fs::create_dir_all(&sqlite).unwrap(); let automation_path = sqlite.join("codex-dev.db"); let automation = Connection::open(&automation_path).unwrap(); automation .execute_batch( "CREATE TABLE automation_runs (thread_id TEXT PRIMARY KEY);\n\ CREATE TABLE inbox_items (id TEXT PRIMARY KEY, thread_id TEXT);\n\ INSERT INTO automation_runs VALUES ('thread-1');\n\ INSERT INTO inbox_items VALUES ('inbox-1', 'thread-1');", ) .unwrap(); drop(automation); let result = delete_codex_sessions_from_home(&root, vec!["thread-1".to_string()]).unwrap(); assert_eq!(result.results[0].status, "partial"); assert_eq!(result.deleted_count, 1); assert!(result.results[0].backup_path.is_some()); let automation = Connection::open(&automation_path).unwrap(); assert_eq!( automation .query_row("SELECT COUNT(*) FROM automation_runs", [], |row| { row.get::<_, i64>(0) }) .unwrap(), 0 ); let legacy = Connection::open(&legacy_path).unwrap(); assert_eq!( legacy .query_row("SELECT COUNT(*) FROM threads", [], |row| row .get::<_, i64>(0)) .unwrap(), 1 ); drop(automation); drop(legacy); fs::remove_dir_all(root).unwrap(); } #[test] fn provider_repair_updates_rollout_and_sqlite_and_preserves_projectless_cwd() { let root = test_root("repair"); fs::create_dir_all(root.join("sessions")).unwrap(); fs::write(root.join("config.toml"), "model_provider = \"cpa-gui\"\n").unwrap(); fs::write( root.join(".codex-global-state.json"), r#"{"projectless-thread-ids":["thread-1"]}"#, ) .unwrap(); let rollout = root.join("sessions/rollout.jsonl"); fs::write( &rollout, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"cwd\":\"C:/new\",\"model_provider\":\"openai\"}}\n{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\"}}\n", ) .unwrap(); create_threads_db(&root.join("state_5.sqlite"), &rollout); let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap(); assert_eq!(result.changed_rollout_files, 1); assert_eq!(result.sqlite_rows_updated, 2); let backup = PathBuf::from(result.backup_path.as_ref().unwrap()); assert!(backup.join("databases/state_5.sqlite").is_file()); assert!(backup.join(".codex-global-state.json").is_file()); assert!(fs::read_to_string(&rollout).unwrap().contains("cpa-gui")); let connection = Connection::open(root.join("state_5.sqlite")).unwrap(); let row = connection .query_row( "SELECT model_provider, has_user_event, cwd FROM threads WHERE id = 'thread-1'", [], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?, )) }, ) .unwrap(); assert_eq!(row, ("cpa-gui".to_string(), 1, "C:/old".to_string())); drop(connection); fs::remove_dir_all(root).unwrap(); } #[test] fn provider_repair_no_change_does_not_create_a_backup() { let root = test_root("repair-no-change"); fs::create_dir_all(root.join("sessions")).unwrap(); let rollout = root.join("sessions/rollout.jsonl"); fs::write( &rollout, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"cpa-gui\"}}\n", ) .unwrap(); let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap(); assert_eq!(result.changed_rollout_files, 0); assert_eq!(result.sqlite_rows_updated, 0); assert_eq!(result.backup_path, None); assert!(!root.join("backups_state").exists()); fs::remove_dir_all(root).unwrap(); } #[test] fn provider_repair_updates_all_metadata_lines_and_preserves_mtime_and_crlf() { let root = test_root("repair-multiple-meta"); fs::create_dir_all(root.join("archived_sessions/2026/07")).unwrap(); let rollout = root.join("archived_sessions/2026/07/rollout.jsonl"); let original = concat!( "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"openai\"}}\r\n", "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"legacy\"}}\r\n", "{\"type\":\"response_item\",\"payload\":{\"encrypted_content\":\"ciphertext\"}}\r\n" ); fs::write(&rollout, original).unwrap(); let expected_mtime = std::time::UNIX_EPOCH + Duration::from_secs(1_700_000_000); fs::File::options() .write(true) .open(&rollout) .unwrap() .set_times(fs::FileTimes::new().set_modified(expected_mtime)) .unwrap(); let before_mtime = fs::metadata(&rollout).unwrap().modified().unwrap(); let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap(); assert_eq!(result.changed_rollout_files, 1); assert!(result.encrypted_content_warning.is_some()); let next = fs::read(&rollout).unwrap(); let next_text = String::from_utf8(next.clone()).unwrap(); assert_eq!(next_text.matches("cpa-gui").count(), 2); assert!(next .iter() .enumerate() .filter(|(_, byte)| **byte == b'\n') .all(|(index, _)| index > 0 && next[index - 1] == b'\r')); assert_eq!( fs::metadata(&rollout).unwrap().modified().unwrap(), before_mtime ); fs::remove_dir_all(root).unwrap(); } #[test] fn repair_backup_copies_sqlite_sidecars_and_global_state() { let root = test_root("repair-sidecars"); let database = root.join("state_5.sqlite"); fs::write(&database, b"database").unwrap(); fs::write(format!("{}-wal", database.to_string_lossy()), b"wal").unwrap(); fs::write(format!("{}-shm", database.to_string_lossy()), b"shm").unwrap(); fs::write(root.join(".codex-global-state.json"), b"{}").unwrap(); let backup = create_repair_backup( &root, std::slice::from_ref(&database), &[], "provider-repair", ) .unwrap(); for name in ["state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm"] { assert!(backup.join("databases").join(name).is_file()); } assert!(backup.join(".codex-global-state.json").is_file()); fs::remove_dir_all(root).unwrap(); } #[test] fn repair_backup_rotation_keeps_only_the_latest_five_directories() { let root = test_root("repair-rotation"); for index in 0..7 { let directory = create_operation_directory(&root, "session-repair", &format!("repair-{index}")) .unwrap(); fs::write(directory.join("marker"), index.to_string()).unwrap(); } prune_repair_backups(&root).unwrap(); let directories = fs::read_dir(root.join("backups_state/easy-cli-proxy-api/session-repair")) .unwrap() .filter_map(Result::ok) .filter(|entry| entry.path().is_dir()) .count(); assert_eq!(directories, REPAIR_BACKUP_KEEP_COUNT); fs::remove_dir_all(root).unwrap(); } #[test] fn lock_error_detection_covers_rollout_and_sqlite_lock_messages() { assert!(is_locked_error_message("os error 32")); assert!(is_locked_error_message("database is locked")); assert!(is_locked_error_message( "The process cannot access the file because it is being used by another process" )); assert!(!is_locked_error_message("database disk image is malformed")); } #[test] fn launch_hook_requires_managed_config_and_repairs_before_returning() { let root = test_root("repair-launch-hook"); fs::create_dir_all(root.join("sessions")).unwrap(); let rollout = root.join("sessions/rollout.jsonl"); fs::write( &rollout, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"openai\"}}\n", ) .unwrap(); fs::write(root.join("config.toml"), "model_provider = \"openai\"\n").unwrap(); assert!(repair_before_codex_launch_from_home(&root).is_err()); fs::write(root.join("config.toml"), "model_provider = \"cpa-gui\"\n").unwrap(); let result = repair_before_codex_launch_from_home(&root).unwrap(); assert_eq!(result.changed_rollout_files, 1); assert!(fs::read_to_string(&rollout).unwrap().contains("cpa-gui")); fs::remove_dir_all(root).unwrap(); } #[test] fn session_index_cleanup_preserves_live_and_unknown_records_and_checks_snapshot() { let root = test_root("index"); fs::create_dir_all(root.join("sessions")).unwrap(); let live = "019f480d-bbc6-7b62-8a46-99597db8bde7"; let database_live = "019f480d-bbc6-7b62-8a46-99597db8bde8"; let stale = "019f4e36-490e-7ae0-8e78-a8b3ab33a428"; fs::write( root.join("sessions") .join(format!("rollout-2026-{live}.jsonl")), "{}\n", ) .unwrap(); let database = Connection::open(root.join("state_5.sqlite")).unwrap(); database .execute_batch(&format!( "CREATE TABLE threads (id TEXT PRIMARY KEY);\n\ INSERT INTO threads VALUES ('{database_live}');" )) .unwrap(); drop(database); let line = |id: &str, title: &str| { json!({"id": id, "thread_name": title, "updated_at": "2026-07-25T00:00:00Z"}) .to_string() }; let original = format!( "{}\n{}\n{}\n{{\"id\":\"future-id\",\"thread_name\":\"Future\",\"updated_at\":\"now\",\"future\":true}}\n{{\"kind\":\"future\"}}\n", line(live, "Live"), line(database_live, "Database live"), line(stale, "Stale") ); fs::write(root.join("session_index.jsonl"), &original).unwrap(); let preview = preview_session_index_cleanup_from_home(&root).unwrap(); assert_eq!(preview.candidates.len(), 1); assert_eq!(preview.candidates[0].id, stale); assert!(apply_session_index_cleanup_from_home( &root, "bad-snapshot", &[stale.to_string()], false ) .is_err()); let result = apply_session_index_cleanup_from_home( &root, &preview.snapshot_sha256, &[stale.to_string()], false, ) .unwrap(); assert_eq!(result.pruned_entries, 1); assert!(PathBuf::from(result.backup_path.as_ref().unwrap()) .join("session_index.jsonl") .is_file()); let next = fs::read_to_string(root.join("session_index.jsonl")).unwrap(); assert!(next.contains(live)); assert!(next.contains(database_live)); assert!(!next.contains(stale)); assert!(next.contains("future")); fs::remove_dir_all(root).unwrap(); } #[test] fn invalid_index_preview_refuses_to_guess_when_a_database_is_corrupt() { let root = test_root("index-corrupt-database"); fs::create_dir_all(root.join("sqlite")).unwrap(); fs::write(root.join("sqlite/broken.db"), b"not sqlite").unwrap(); fs::write( root.join("session_index.jsonl"), "{\"id\":\"thread-1\",\"thread_name\":\"Maybe live\",\"updated_at\":\"now\"}\n", ) .unwrap(); let error = preview_session_index_cleanup_from_home(&root).unwrap_err(); assert!(error.contains("无效会话记录检查已中止")); assert!(root.join("session_index.jsonl").is_file()); fs::remove_dir_all(root).unwrap(); } }