diff --git a/core-version.txt b/core-version.txt index ef63c15..f09a003 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.99 +7.2.100 diff --git a/src-tauri/src/codex_sessions.rs b/src-tauri/src/codex_sessions.rs new file mode 100644 index 0000000..e505f58 --- /dev/null +++ b/src-tauri/src/codex_sessions.rs @@ -0,0 +1,2760 @@ +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, 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, + }) +} + +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> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut ids = Vec::new(); + for name in ["ChatGPT.exe", "Codex.exe"] { + 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( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let columns = line + .trim() + .trim_matches('"') + .split("\",\"") + .collect::>(); + columns.get(1)?.parse::().ok() + }), + ); + } + ids.sort_unstable(); + ids.dedup(); + Ok(ids) +} + +#[cfg(not(target_os = "windows"))] +fn codex_app_process_ids() -> Result, String> { + let mut ids = Vec::new(); + for name in ["ChatGPT", "Codex"] { + 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(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + 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(); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1c1079f..3bd2669 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod codex_sessions; mod usage; use flate2::read::GzDecoder; @@ -439,6 +440,7 @@ struct GuiConfigFile { usage_statistics_enabled: bool, plugins_enabled: bool, routing_strategy: String, + codex_session_repair_on_launch: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -501,6 +503,7 @@ impl Default for GuiConfigFile { usage_statistics_enabled: true, plugins_enabled: false, routing_strategy: "round-robin".to_string(), + codex_session_repair_on_launch: false, } } } @@ -515,6 +518,7 @@ struct GuiConfigPresence { auth_dir: Option, api_keys: Option>, management_secret_key: Option, + codex_session_repair_on_launch: Option, usage_statistics_enabled: Option, plugins_enabled: Option, routing_strategy: Option, @@ -1154,6 +1158,13 @@ impl GuiConfigState { }) } + fn set_codex_session_repair_on_launch(&self, enabled: bool) -> Result { + self.update(|config| { + config.codex_session_repair_on_launch = enabled; + Ok(()) + }) + } + fn set_locale(&self, locale: String) -> Result { self.update(|config| { config.locale = normalize_app_locale(&locale).to_string(); @@ -1728,7 +1739,7 @@ async fn update_agent_config( } #[tauri::command] -fn launch_agent( +async fn launch_agent( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, client: String, @@ -1750,6 +1761,14 @@ fn launch_agent( status.modification_enabled, &status.modification_state, )?; + if client == AgentClient::Codex && config.codex_session_repair_on_launch { + let repair_home = home.clone(); + tauri::async_runtime::spawn_blocking(move || { + codex_sessions::repair_before_codex_launch(&repair_home) + }) + .await + .map_err(|error| format!("启动前会话修复任务失败: {error}"))??; + } let requested_target = target .as_deref() .map(str::trim) @@ -10214,6 +10233,9 @@ fn load_or_create_gui_config() -> Result { if presence.locale.is_none() { changed = true; } + if presence.codex_session_repair_on_launch.is_none() { + changed = true; + } changed |= sanitize_gui_config(&mut config)?; validate_gui_config(&config)?; if changed { @@ -10521,6 +10543,10 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul ), ("plugins-enabled", value(config.plugins_enabled)), ("routing-strategy", value(config.routing_strategy.as_str())), + ( + "codex-session-repair-on-launch", + value(config.codex_session_repair_on_launch), + ), ] { set_codex_table_item(root, key, item); } @@ -12792,7 +12818,14 @@ fn main() { usage::get_usage_events, start_core_process, stop_core_process, - restart_core_process + restart_core_process, + codex_sessions::list_codex_sessions, + codex_sessions::delete_codex_sessions, + codex_sessions::repair_codex_session_metadata, + codex_sessions::preview_codex_session_index_cleanup, + codex_sessions::apply_codex_session_index_cleanup, + codex_sessions::get_codex_session_repair_on_launch, + codex_sessions::set_codex_session_repair_on_launch ]) .build(tauri::generate_context!()) .expect("failed to build app"); @@ -13092,6 +13125,7 @@ mod tests { auth_dir: path_to_string(&home.join("custom-auth")), management_secret_key: "custom-secret".to_string(), usage_statistics_enabled: false, + codex_session_repair_on_launch: true, ..GuiConfigFile::default() }; @@ -13104,6 +13138,7 @@ mod tests { assert!(content.contains("host = \"0.0.0.0\"")); assert!(content.contains("management-secret-key = \"custom-secret\"")); assert!(content.contains("usage-statistics-enabled = false")); + assert!(content.contains("codex-session-repair-on-launch = true")); fs::remove_dir_all(home).unwrap(); } @@ -13169,6 +13204,7 @@ mod tests { assert!(content.contains("management-secret-key = \"123456\"")); assert!(content.contains("plugins-enabled = false")); assert!(content.contains("routing-strategy = \"round-robin\"")); + assert!(content.contains("codex-session-repair-on-launch = false")); } #[test] @@ -14960,6 +14996,7 @@ custom_option = "keep-original" usage_statistics_enabled: false, plugins_enabled: true, routing_strategy: "fill-first".to_string(), + codex_session_repair_on_launch: false, }; let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index d320f8f..0937334 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1385,6 +1385,7 @@ mod tests { usage_statistics_enabled: true, plugins_enabled: false, routing_strategy: "round-robin".to_string(), + codex_session_repair_on_launch: false, }; let record = normalize_usage_record( serde_json::json!({ diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 0fa478a..76538f3 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -377,7 +377,74 @@ export const jaOverrides = { 'agents.tabs.core': 'コア設定', 'agents.tabs.sessions': 'セッション管理', 'agents.sessions.title': 'Codex セッション管理', - 'agents.sessions.description': 'セッションの一覧と管理機能はここに表示されます。', + 'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。', + 'agents.sessions.refresh': '更新', + 'agents.sessions.currentPage': '現在のページ', + 'agents.sessions.active': '未アーカイブ', + 'agents.sessions.archived': 'アーカイブ済み', + 'agents.sessions.database': 'データベース', + 'agents.sessions.databaseMissing': 'セッションデータベースが見つかりません', + 'agents.sessions.repairTitle': '過去のセッションを復元', + 'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。', + 'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元', + 'agents.sessions.autoRestoreDescription': 'このアプリから Codex を起動するときにセッションを自動同期します。', + 'agents.sessions.autoRepairEnabled': '起動前の自動復元を有効にしました。', + 'agents.sessions.autoRepairDisabled': '起動前の自動復元を無効にしました。', + 'agents.sessions.settingLoading': '読み込み中', + 'agents.sessions.settingOn': 'オン', + 'agents.sessions.settingOff': 'オフ', + 'agents.sessions.settingLoadFailed': '自動復元設定の読み込みに失敗しました:{error}', + 'agents.sessions.applyManagedFirst': '先に Codex の CPA 管理設定を適用してください', + 'agents.sessions.repairNow': '今すぐ復元', + 'agents.sessions.repairing': '復元中', + 'agents.sessions.loadFailed': 'Codex セッションの読み込みに失敗しました:{error}', + 'agents.sessions.localSessions': 'ローカルセッション', + 'agents.sessions.pageDescription': '{page} ページ目・1 ページ最大 {size} 件', + 'agents.sessions.selected': '{count} 件選択中', + 'agents.sessions.selectAll': 'このページをすべて選択', + 'agents.sessions.clearSelection': '選択を解除', + 'agents.sessions.deleteSelected': '選択項目を削除', + 'agents.sessions.multiSelect': '複数選択', + 'agents.sessions.loading': 'ローカルセッションをスキャンしています…', + 'agents.sessions.empty': 'Codex セッションが見つかりません。', + 'agents.sessions.selectSession': 'セッション {title} を選択', + 'agents.sessions.untitled': '無題のセッション', + 'agents.sessions.noProject': 'プロジェクトパスなし', + 'agents.sessions.noProvider': '不明な Provider', + 'agents.sessions.noTime': '更新時刻不明', + 'agents.sessions.activeBadge': '未アーカイブ', + 'agents.sessions.archivedBadge': 'アーカイブ済み', + 'agents.sessions.previousPage': '前のページ', + 'agents.sessions.nextPage': '次のページ', + 'agents.sessions.pageNumber': '{page} ページ目', + 'agents.sessions.deleteOneTitle': 'このセッションを削除しますか?', + 'agents.sessions.deleteManyTitle': '{count} 件のセッションを削除しますか?', + 'agents.sessions.deleteOneDescription': 'バックアップを作成してから「{title}」と既知の関連記録を削除します。アプリ内で元に戻すことはできません。', + 'agents.sessions.deleteManyDescription': 'バックアップを作成してから、次の {count} 件と既知の関連記録を削除します:{preview}{extra}', + 'agents.sessions.deleteMore': '、ほか {count} 件', + 'agents.sessions.confirmDelete': 'バックアップして削除', + 'agents.sessions.deleteResult': '{deleted} 件を削除、{failed} 件失敗。{details}{backup}', + 'agents.sessions.deleteFailed': 'セッションの削除に失敗しました:{error}', + 'agents.sessions.backupAt': 'バックアップ先:{path}', + 'agents.sessions.settingFailed': '自動復元設定の保存に失敗しました:{error}', + 'agents.sessions.repairResult': '過去のセッションを {provider} に復元しました:rollout {files} ファイルを移行、データベース {rows} 行を更新、使用中の {skipped} ファイルをスキップしました。', + 'agents.sessions.repairFailed': '過去のセッションの復元に失敗しました:{error}', + 'agents.sessions.progressCount': '{processed}/{total}', + 'agents.sessions.progress.scanning': 'セッションをスキャン中', + 'agents.sessions.progress.backingUp': 'バックアップを作成中', + 'agents.sessions.progress.rewriting': '過去のセッションを移行中', + 'agents.sessions.progress.updatingDatabase': 'データベースを同期中', + 'agents.sessions.progress.complete': '復元完了', + 'agents.sessions.scanIndex': '無効なセッション記録を確認', + 'agents.sessions.scanningIndex': '無効な記録を確認中', + 'agents.sessions.cleanupNoCandidates': '安全に削除できる無効なセッション記録はありません。', + 'agents.sessions.cleanupPreviewFailed': '無効なセッション記録の確認に失敗しました:{error}', + 'agents.sessions.cleanupTitle': '削除可能な無効な索引', + 'agents.sessions.cleanupDescription': 'session_index.jsonl のみに存在し、実際のセッション元がなくなった記録が {count} 件見つかりました。初期状態では未選択です。Codex と ChatGPT を終了し、項目ごとに確認して削除してください。不明な形式の記録は保持されます。', + 'agents.sessions.cleanupSelectAll': '候補をすべて選択', + 'agents.sessions.cleanupConfirm': '選択した {count} 件を削除', + 'agents.sessions.cleanupResult': '無効なセッション索引を {count} 件削除しました。{backup}', + 'agents.sessions.cleanupFailed': '無効なセッション索引の削除に失敗しました:{error}', 'agents.localClients': 'ローカルクライアント', 'agents.selectClient': '管理するエージェントを選択', 'agents.installStatus': 'インストール状態', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 97a9fd7..4b97b44 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -380,7 +380,74 @@ export const zhCN = { 'agents.tabs.core': '核心配置', 'agents.tabs.sessions': '会话管理', 'agents.sessions.title': 'Codex 会话管理', - 'agents.sessions.description': '会话列表与管理功能将在这里显示。', + 'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。', + 'agents.sessions.refresh': '刷新', + 'agents.sessions.currentPage': '当前页', + 'agents.sessions.active': '未归档', + 'agents.sessions.archived': '已归档', + 'agents.sessions.database': '数据库来源', + 'agents.sessions.databaseMissing': '未发现会话数据库', + 'agents.sessions.repairTitle': '恢复历史会话', + 'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话', + 'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话', + 'agents.sessions.autoRestoreDescription': '从本软件启动 Codex 时自动同步会话', + 'agents.sessions.autoRepairEnabled': '已开启启动前自动恢复历史会话。', + 'agents.sessions.autoRepairDisabled': '已关闭启动前自动恢复历史会话。', + 'agents.sessions.settingLoading': '读取中', + 'agents.sessions.settingOn': '已开启', + 'agents.sessions.settingOff': '已关闭', + 'agents.sessions.settingLoadFailed': '读取自动恢复设置失败:{error}', + 'agents.sessions.applyManagedFirst': '请先应用 Codex 的 CPA 托管配置', + 'agents.sessions.repairNow': '立即恢复', + 'agents.sessions.repairing': '正在恢复', + 'agents.sessions.loadFailed': '读取 Codex 会话失败:{error}', + 'agents.sessions.localSessions': '本地会话', + 'agents.sessions.pageDescription': '第 {page} 页 · 每页最多 {size} 条', + 'agents.sessions.selected': '已选 {count} 条', + 'agents.sessions.selectAll': '全选本页', + 'agents.sessions.clearSelection': '清空选择', + 'agents.sessions.deleteSelected': '删除所选', + 'agents.sessions.multiSelect': '多选', + 'agents.sessions.loading': '正在扫描本地会话…', + 'agents.sessions.empty': '没有找到可显示的 Codex 会话。', + 'agents.sessions.selectSession': '选择会话 {title}', + 'agents.sessions.untitled': '未命名会话', + 'agents.sessions.noProject': '无项目路径', + 'agents.sessions.noProvider': '未知 Provider', + 'agents.sessions.noTime': '更新时间未知', + 'agents.sessions.activeBadge': '未归档', + 'agents.sessions.archivedBadge': '已归档', + 'agents.sessions.previousPage': '上一页', + 'agents.sessions.nextPage': '下一页', + 'agents.sessions.pageNumber': '第 {page} 页', + 'agents.sessions.deleteOneTitle': '删除这个会话?', + 'agents.sessions.deleteManyTitle': '删除 {count} 个会话?', + 'agents.sessions.deleteOneDescription': '将先创建备份,再删除“{title}”及其已知关联记录。此操作不提供界面内撤销。', + 'agents.sessions.deleteManyDescription': '将先创建备份,再删除以下 {count} 个会话及其已知关联记录:{preview}{extra}', + 'agents.sessions.deleteMore': ',另有 {count} 个', + 'agents.sessions.confirmDelete': '备份并删除', + 'agents.sessions.deleteResult': '已删除 {deleted} 个,失败 {failed} 个。{details}{backup}', + 'agents.sessions.deleteFailed': '删除会话失败:{error}', + 'agents.sessions.backupAt': '备份位置:{path}', + 'agents.sessions.settingFailed': '保存自动恢复设置失败:{error}', + 'agents.sessions.repairResult': '历史会话已恢复到 {provider}:迁移 {files} 个 rollout 文件、更新 {rows} 条数据库记录、跳过 {skipped} 个占用文件。', + 'agents.sessions.repairFailed': '恢复历史会话失败:{error}', + 'agents.sessions.progressCount': '{processed}/{total}', + 'agents.sessions.progress.scanning': '正在扫描会话', + 'agents.sessions.progress.backingUp': '正在创建备份', + 'agents.sessions.progress.rewriting': '正在迁移历史会话', + 'agents.sessions.progress.updatingDatabase': '正在同步数据库', + 'agents.sessions.progress.complete': '恢复完成', + 'agents.sessions.scanIndex': '检查无效会话记录', + 'agents.sessions.scanningIndex': '正在检查无效记录', + 'agents.sessions.cleanupNoCandidates': '未发现可安全清理的无效会话记录。', + 'agents.sessions.cleanupPreviewFailed': '检查无效会话记录失败:{error}', + 'agents.sessions.cleanupTitle': '可清理的无效索引', + 'agents.sessions.cleanupDescription': '发现 {count} 条只存在于 session_index.jsonl、但已没有真实会话来源的记录。默认不选择;确认 Codex 和 ChatGPT 已退出后,再逐项清理。未知格式记录会保留。', + 'agents.sessions.cleanupSelectAll': '选择全部候选', + 'agents.sessions.cleanupConfirm': '清理所选 {count} 条', + 'agents.sessions.cleanupResult': '已清理 {count} 条无效会话索引。{backup}', + 'agents.sessions.cleanupFailed': '清理无效会话索引失败:{error}', 'agents.localClients': '本机客户端', 'agents.selectClient': '选择需要管理的智能体', 'agents.installStatus': '安装状态', @@ -1092,7 +1159,74 @@ export const en: Record = { 'agents.tabs.core': 'Core Configuration', 'agents.tabs.sessions': 'Session Management', 'agents.sessions.title': 'Codex Session Management', - 'agents.sessions.description': 'Session browsing and management will appear here.', + 'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.', + 'agents.sessions.refresh': 'Refresh', + 'agents.sessions.currentPage': 'Current page', + 'agents.sessions.active': 'Active', + 'agents.sessions.archived': 'Archived', + 'agents.sessions.database': 'Database source', + 'agents.sessions.databaseMissing': 'No session database found', + 'agents.sessions.repairTitle': 'Restore Historical Sessions', + 'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.', + 'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex', + 'agents.sessions.autoRestoreDescription': 'Automatically sync sessions when Codex is launched from this app.', + 'agents.sessions.autoRepairEnabled': 'Automatic restoration before launch is enabled.', + 'agents.sessions.autoRepairDisabled': 'Automatic restoration before launch is disabled.', + 'agents.sessions.settingLoading': 'Loading', + 'agents.sessions.settingOn': 'On', + 'agents.sessions.settingOff': 'Off', + 'agents.sessions.settingLoadFailed': 'Failed to load the automatic-restoration setting: {error}', + 'agents.sessions.applyManagedFirst': 'Apply the managed CPA configuration for Codex first', + 'agents.sessions.repairNow': 'Restore now', + 'agents.sessions.repairing': 'Restoring', + 'agents.sessions.loadFailed': 'Failed to load Codex sessions: {error}', + 'agents.sessions.localSessions': 'Local sessions', + 'agents.sessions.pageDescription': 'Page {page} · up to {size} items per page', + 'agents.sessions.selected': '{count} selected', + 'agents.sessions.selectAll': 'Select this page', + 'agents.sessions.clearSelection': 'Clear selection', + 'agents.sessions.deleteSelected': 'Delete selected', + 'agents.sessions.multiSelect': 'Select multiple', + 'agents.sessions.loading': 'Scanning local sessions…', + 'agents.sessions.empty': 'No Codex sessions were found.', + 'agents.sessions.selectSession': 'Select session {title}', + 'agents.sessions.untitled': 'Untitled session', + 'agents.sessions.noProject': 'No project path', + 'agents.sessions.noProvider': 'Unknown provider', + 'agents.sessions.noTime': 'Unknown update time', + 'agents.sessions.activeBadge': 'Active', + 'agents.sessions.archivedBadge': 'Archived', + 'agents.sessions.previousPage': 'Previous page', + 'agents.sessions.nextPage': 'Next page', + 'agents.sessions.pageNumber': 'Page {page}', + 'agents.sessions.deleteOneTitle': 'Delete this session?', + 'agents.sessions.deleteManyTitle': 'Delete {count} sessions?', + 'agents.sessions.deleteOneDescription': 'A backup will be created before “{title}” and its known related records are deleted. There is no in-app undo.', + 'agents.sessions.deleteManyDescription': 'A backup will be created before these {count} sessions and their known related records are deleted: {preview}{extra}', + 'agents.sessions.deleteMore': ', plus {count} more', + 'agents.sessions.confirmDelete': 'Back up and delete', + 'agents.sessions.deleteResult': 'Deleted {deleted}; failed {failed}. {details}{backup}', + 'agents.sessions.deleteFailed': 'Failed to delete sessions: {error}', + 'agents.sessions.backupAt': 'Backup: {path}', + 'agents.sessions.settingFailed': 'Failed to save the automatic-restoration setting: {error}', + 'agents.sessions.repairResult': 'Historical sessions restored to {provider}: migrated {files} rollout files, updated {rows} database rows, and skipped {skipped} locked files.', + 'agents.sessions.repairFailed': 'Failed to restore historical sessions: {error}', + 'agents.sessions.progressCount': '{processed}/{total}', + 'agents.sessions.progress.scanning': 'Scanning sessions', + 'agents.sessions.progress.backingUp': 'Creating backup', + 'agents.sessions.progress.rewriting': 'Migrating historical sessions', + 'agents.sessions.progress.updatingDatabase': 'Synchronizing databases', + 'agents.sessions.progress.complete': 'Restoration complete', + 'agents.sessions.scanIndex': 'Check invalid session records', + 'agents.sessions.scanningIndex': 'Checking invalid records', + 'agents.sessions.cleanupNoCandidates': 'No safely removable invalid session records were found.', + 'agents.sessions.cleanupPreviewFailed': 'Failed to check invalid session records: {error}', + 'agents.sessions.cleanupTitle': 'Removable invalid index entries', + 'agents.sessions.cleanupDescription': 'Found {count} records that exist only in session_index.jsonl and no longer have a real session source. Nothing is selected by default. Exit Codex and ChatGPT, then confirm each item to remove. Unknown record formats are preserved.', + 'agents.sessions.cleanupSelectAll': 'Select all candidates', + 'agents.sessions.cleanupConfirm': 'Remove {count} selected', + 'agents.sessions.cleanupResult': 'Removed {count} invalid session index entries. {backup}', + 'agents.sessions.cleanupFailed': 'Failed to remove invalid session index entries: {error}', 'agents.localClients': 'Local Clients', 'agents.selectClient': 'Select an agent to manage', 'agents.installStatus': 'Installation Status', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 3797cda..a99ae05 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -17,7 +17,6 @@ import { Check, ChevronDown, LoaderCircle, - MessageSquare, Play, RefreshCw, Search, @@ -37,6 +36,8 @@ import { } from '../services/agentModelPicker'; import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; +import { CodexSessionAutoRestoreCard } from './CodexSessionAutoRestoreCard'; +import { CodexSessionsPanel } from './CodexSessionsPanel'; type AgentClientId = | 'claude-code' @@ -828,7 +829,7 @@ export function AgentsPage() { ) : null} -
+
{t('agents.useModel')}{t('agents.useModelDescription')}
{draftChanged ? {t('agents.pending')} : null} @@ -859,16 +860,18 @@ export function AgentsPage() {
-
-
- {t('agents.modify.title')} - {activeStatus?.modificationState === 'external-changed' - ? t('agents.modify.externalChanged') - : activeStatus?.modificationState === 'invalid' - ? t('agents.modify.invalid') - : activeStatus?.modificationState === 'applied' - ? t('agents.modify.applied') - : t('agents.modify.unconfigured')} +
+
+
+ {t('agents.modify.title')} + {activeStatus?.modificationState === 'external-changed' + ? t('agents.modify.externalChanged') + : activeStatus?.modificationState === 'invalid' + ? t('agents.modify.invalid') + : activeStatus?.modificationState === 'applied' + ? t('agents.modify.applied') + : t('agents.modify.unconfigured')} +
@@ -902,6 +905,8 @@ export function AgentsPage() {
+ {selected === 'codex' ? : null} +
{activeStatus?.modificationState === 'applied' ? : } @@ -978,15 +983,7 @@ export function AgentsPage() { role="tabpanel" aria-labelledby="agent-subpage-tab-sessions" > -
- -
-

{t('agents.sessions.title')}

-

{t('agents.sessions.description')}

-
-
+
) : null}
diff --git a/src/pages/CodexSessionAutoRestoreCard.tsx b/src/pages/CodexSessionAutoRestoreCard.tsx new file mode 100644 index 0000000..2bb85be --- /dev/null +++ b/src/pages/CodexSessionAutoRestoreCard.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { LoaderCircle } from 'lucide-react'; +import { useI18n } from '../i18n'; + +type RepairOnLaunchSetting = { + enabled: boolean; +}; + +type StatusMessage = { + kind: 'success' | 'error'; + text: string; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function CodexSessionAutoRestoreCard() { + const { t } = useI18n(); + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(null); + + useEffect(() => { + let disposed = false; + void invoke('get_codex_session_repair_on_launch') + .then((result) => { + if (!disposed) setEnabled(result.enabled); + }) + .catch((error) => { + if (!disposed) { + setStatus({ + kind: 'error', + text: t('agents.sessions.settingLoadFailed', { error: errorMessage(error) }), + }); + } + }) + .finally(() => { + if (!disposed) setLoading(false); + }); + return () => { + disposed = true; + }; + }, [t]); + + const updateSetting = async (nextEnabled: boolean) => { + setSaving(true); + setStatus(null); + try { + const result = await invoke('set_codex_session_repair_on_launch', { + request: { enabled: nextEnabled }, + }); + setEnabled(result.enabled); + setStatus({ + kind: 'success', + text: result.enabled + ? t('agents.sessions.autoRepairEnabled') + : t('agents.sessions.autoRepairDisabled'), + }); + } catch (error) { + setStatus({ + kind: 'error', + text: t('agents.sessions.settingFailed', { error: errorMessage(error) }), + }); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+ {t('agents.sessions.autoRepair')} + {t('agents.sessions.autoRestoreDescription')} +
+ +
+ {status ? ( + + {status.text} + + ) : null} +
+ ); +} diff --git a/src/pages/CodexSessionsPanel.tsx b/src/pages/CodexSessionsPanel.tsx new file mode 100644 index 0000000..ca0855c --- /dev/null +++ b/src/pages/CodexSessionsPanel.tsx @@ -0,0 +1,517 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { + ArrowLeft, + ArrowRight, + Archive, + Database, + Info, + LoaderCircle, + RefreshCw, + ScanSearch, + ShieldCheck, + Trash2, + TriangleAlert, + X, +} from 'lucide-react'; +import { useI18n } from '../i18n'; +import { + codexSessionPageCounts, + retainVisibleCodexSessionIds, + type CodexSessionDeleteBatchResult, + type CodexSessionPage, + type CodexSessionRepairProgress, + type CodexSessionRepairResult, + type CodexSessionSummary, + type SessionIndexCleanupPreview, + type SessionIndexCleanupResult, +} from '../services/codexSessionState'; + +type Notice = { + kind: 'success' | 'warning' | 'error'; + message: string; +}; + +type DeleteConfirmation = { + ids: string[]; + title: string; + description: string; +}; + +const PAGE_SIZE = 50; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function CodexSessionsPanel({ managedProviderActive }: { managedProviderActive: boolean }) { + const { formatDate, t } = useI18n(); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [operation, setOperation] = useState<'delete' | 'repair' | 'preview' | 'cleanup' | null>(null); + const [notice, setNotice] = useState(null); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [deleteConfirmation, setDeleteConfirmation] = useState(null); + const [cleanupPreview, setCleanupPreview] = useState(null); + const [cleanupSelectedIds, setCleanupSelectedIds] = useState>(() => new Set()); + const [repairProgress, setRepairProgress] = useState({ + phase: 'scanning', + percent: 0, + processed: 0, + total: 0, + }); + + const loadPage = useCallback(async (offset = 0, silent = false) => { + if (!silent) setLoading(true); + try { + const result = await invoke('list_codex_sessions', { + request: { offset, limit: PAGE_SIZE }, + }); + if (result.sessions.length === 0 && result.offset > 0) { + return await loadPage(Math.max(0, result.offset - result.limit), silent); + } + setPage(result); + setSelectedIds((current) => retainVisibleCodexSessionIds(current, result.sessions)); + if (!silent && result.warnings.length > 0) { + setNotice({ kind: 'warning', message: result.warnings.join(';') }); + } + return result; + } catch (error) { + setNotice({ kind: 'error', message: t('agents.sessions.loadFailed', { error: errorMessage(error) }) }); + return null; + } finally { + if (!silent) setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadPage(); + }, [loadPage]); + + useEffect(() => { + let disposed = false; + let stop: (() => void) | undefined; + void listen('codex-session-repair-progress', (event) => { + if (!disposed) setRepairProgress(event.payload); + }).then((unlisten) => { + if (disposed) unlisten(); + else stop = unlisten; + }).catch(() => undefined); + return () => { + disposed = true; + stop?.(); + }; + }, []); + + const sessions = page?.sessions ?? []; + const pageCounts = codexSessionPageCounts(sessions); + const currentPage = Math.floor((page?.offset ?? 0) / (page?.limit ?? PAGE_SIZE)) + 1; + const selectedSessions = useMemo( + () => sessions.filter((session) => selectedIds.has(session.id)), + [selectedIds, sessions], + ); + const allSelected = sessions.length > 0 && selectedSessions.length === sessions.length; + const busy = operation !== null; + + const toggleSelected = (id: string, selected: boolean) => { + setSelectedIds((current) => { + const next = new Set(current); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + }; + + const requestDelete = (targets: CodexSessionSummary[]) => { + if (targets.length === 0) return; + const preview = targets + .slice(0, 6) + .map((session) => session.title || session.id) + .join('、'); + const extra = Math.max(0, targets.length - 6); + setDeleteConfirmation({ + ids: targets.map((session) => session.id), + title: targets.length === 1 + ? t('agents.sessions.deleteOneTitle') + : t('agents.sessions.deleteManyTitle', { count: targets.length }), + description: targets.length === 1 + ? t('agents.sessions.deleteOneDescription', { title: preview }) + : t('agents.sessions.deleteManyDescription', { + count: targets.length, + preview, + extra: extra > 0 ? t('agents.sessions.deleteMore', { count: extra }) : '', + }), + }); + }; + + const confirmDelete = async () => { + if (!deleteConfirmation) return; + const request = deleteConfirmation; + setDeleteConfirmation(null); + setOperation('delete'); + setNotice(null); + try { + const result = await invoke('delete_codex_sessions', { + request: { sessionIds: request.ids }, + }); + const failed = result.results.filter((item) => !['deleted', 'partial'].includes(item.status)); + const partial = result.results.filter((item) => item.status === 'partial'); + const backupPaths = result.results + .map((item) => item.backupPath) + .filter((path): path is string => Boolean(path)); + const details = [...partial, ...failed].map((item) => `${item.sessionId}: ${item.message}`).join(';'); + setNotice({ + kind: failed.length > 0 ? (result.deletedCount > 0 ? 'warning' : 'error') : partial.length > 0 ? 'warning' : 'success', + message: t('agents.sessions.deleteResult', { + deleted: result.deletedCount, + failed: result.failedCount, + details: details ? ` ${details}` : '', + backup: backupPaths[0] ? t('agents.sessions.backupAt', { path: backupPaths[0] }) : '', + }), + }); + setSelectedIds(new Set()); + await loadPage(page?.offset ?? 0, true); + } catch (error) { + setNotice({ kind: 'error', message: t('agents.sessions.deleteFailed', { error: errorMessage(error) }) }); + } finally { + setOperation(null); + } + }; + + const repairSessions = async () => { + setOperation('repair'); + setNotice(null); + setRepairProgress({ phase: 'scanning', percent: 4, processed: 0, total: 0 }); + try { + const result = await invoke('repair_codex_session_metadata'); + const messages = [ + t('agents.sessions.repairResult', { + provider: result.targetProvider, + files: result.changedRolloutFiles, + rows: result.sqliteRowsUpdated, + skipped: result.skippedLockedFiles.length, + }), + ]; + if (result.encryptedContentWarning) messages.push(result.encryptedContentWarning); + if (result.warnings.length > 0) messages.push(result.warnings.join(';')); + if (result.backupPath) messages.push(t('agents.sessions.backupAt', { path: result.backupPath })); + setNotice({ + kind: result.encryptedContentWarning || result.warnings.length > 0 ? 'warning' : 'success', + message: messages.join(' '), + }); + await loadPage(page?.offset ?? 0, true); + } catch (error) { + setNotice({ kind: 'error', message: t('agents.sessions.repairFailed', { error: errorMessage(error) }) }); + } finally { + setOperation(null); + } + }; + + const previewCleanup = async () => { + setOperation('preview'); + setNotice(null); + try { + const preview = await invoke('preview_codex_session_index_cleanup'); + setCleanupSelectedIds(new Set()); + if (preview.candidates.length === 0) { + setCleanupPreview(null); + setNotice({ kind: 'success', message: t('agents.sessions.cleanupNoCandidates') }); + } else { + setCleanupPreview(preview); + } + } catch (error) { + setNotice({ + kind: 'error', + message: t('agents.sessions.cleanupPreviewFailed', { error: errorMessage(error) }), + }); + } finally { + setOperation(null); + } + }; + + const applyCleanup = async () => { + if (!cleanupPreview || cleanupSelectedIds.size === 0) return; + setOperation('cleanup'); + setNotice(null); + try { + const result = await invoke('apply_codex_session_index_cleanup', { + request: { + snapshotSha256: cleanupPreview.snapshotSha256, + threadIds: Array.from(cleanupSelectedIds), + }, + }); + setCleanupPreview(null); + setCleanupSelectedIds(new Set()); + setNotice({ + kind: 'success', + message: t('agents.sessions.cleanupResult', { + count: result.prunedEntries, + backup: result.backupPath ? t('agents.sessions.backupAt', { path: result.backupPath }) : '', + }), + }); + } catch (error) { + setCleanupPreview(null); + setCleanupSelectedIds(new Set()); + setNotice({ kind: 'error', message: t('agents.sessions.cleanupFailed', { error: errorMessage(error) }) }); + } finally { + setOperation(null); + } + }; + + const repairPhaseText = t(`agents.sessions.progress.${repairProgress.phase}` as + | 'agents.sessions.progress.scanning' + | 'agents.sessions.progress.backingUp' + | 'agents.sessions.progress.rewriting' + | 'agents.sessions.progress.updatingDatabase' + | 'agents.sessions.progress.complete'); + + return ( +
+
+
+
+ {t('agents.sessions.title')} + {t('agents.sessions.description')} +
+ +
+ +
+
{t('agents.sessions.currentPage')}{sessions.length}
+
{t('agents.sessions.active')}{pageCounts.active}
+
{t('agents.sessions.archived')}{pageCounts.archived}
+
+ {t('agents.sessions.database')} + {page?.databasePaths[0] || t('agents.sessions.databaseMissing')} +
+
+ +
+
+ +
+ {t('agents.sessions.repairTitle')} + {t('agents.sessions.repairDescription')} +
+
+
+ + +
+ {operation === 'repair' ? ( +
+
+ + {repairPhaseText} + {repairProgress.total > 0 + ? ` · ${t('agents.sessions.progressCount', { processed: repairProgress.processed, total: repairProgress.total })}` + : ''} + + {repairProgress.percent}% +
+
+ +
+
+ ) : null} +
+ +
+ + {notice ? ( +
+ {notice.kind === 'error' ? : notice.kind === 'warning' ? : } + {notice.message} + +
+ ) : null} + +
+
+
+ {t('agents.sessions.localSessions')} + {t('agents.sessions.pageDescription', { page: currentPage, size: page?.limit ?? PAGE_SIZE })} +
+
+ {selectionMode ? {t('agents.sessions.selected', { count: selectedSessions.length })} : null} + {selectionMode ? ( + <> + + + + + ) : null} + +
+
+ + {loading ? ( +
{t('agents.sessions.loading')}
+ ) : sessions.length === 0 ? ( +
{t('agents.sessions.empty')}
+ ) : ( +
+ {sessions.map((session) => ( +
+ {selectionMode ? ( + + ) : null} +
+ {session.title || t('agents.sessions.untitled')} + {session.id} + {session.cwd || t('agents.sessions.noProject')} +
+
+ {session.archived ? t('agents.sessions.archivedBadge') : t('agents.sessions.activeBadge')} + {session.modelProvider || t('agents.sessions.noProvider')} + +
+ +
+ ))} +
+ )} + +
+ + {t('agents.sessions.pageNumber', { page: currentPage })} + +
+
+ + {deleteConfirmation ? ( +
+
+

{deleteConfirmation.title}

+

{deleteConfirmation.description}

+
+ + +
+
+
+ ) : null} + + {cleanupPreview ? ( +
+
+
+

{t('agents.sessions.cleanupTitle')}

+ +
+

{t('agents.sessions.cleanupDescription', { count: cleanupPreview.candidates.length })}

+ +
+ {cleanupPreview.candidates.map((candidate) => ( + + ))} +
+
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/src/services/codexSessionState.ts b/src/services/codexSessionState.ts new file mode 100644 index 0000000..20c99ce --- /dev/null +++ b/src/services/codexSessionState.ts @@ -0,0 +1,89 @@ +export type CodexSessionSummary = { + id: string; + title: string; + cwd: string; + modelProvider: string; + archived: boolean; + updatedAtMs: number | null; + databasePath: string; +}; + +export type CodexSessionPage = { + codexHome: string; + databasePaths: string[]; + sessions: CodexSessionSummary[]; + offset: number; + limit: number; + hasMore: boolean; + repairOnLaunch: boolean; + warnings: string[]; +}; + +export type CodexSessionDeleteResult = { + sessionId: string; + status: 'deleted' | 'partial' | 'notFound' | 'failed'; + message: string; + backupPath: string | null; +}; + +export type CodexSessionDeleteBatchResult = { + results: CodexSessionDeleteResult[]; + deletedCount: number; + failedCount: number; +}; + +export type CodexSessionRepairProgress = { + phase: 'scanning' | 'backingUp' | 'rewriting' | 'updatingDatabase' | 'complete'; + percent: number; + processed: number; + total: number; +}; + +export type CodexSessionRepairResult = { + targetProvider: string; + changedRolloutFiles: number; + sqliteRowsUpdated: number; + skippedLockedFiles: string[]; + backupPath: string | null; + encryptedContentWarning: string | null; + warnings: string[]; +}; + +export type SessionIndexCleanupCandidate = { + id: string; + threadName: string; + updatedAt: string; +}; + +export type SessionIndexCleanupPreview = { + snapshotSha256: string; + candidates: SessionIndexCleanupCandidate[]; +}; + +export type SessionIndexCleanupResult = { + prunedEntries: number; + backupPath: string | null; +}; + +export type CodexSessionPageCounts = { + active: number; + archived: number; +}; + +export function codexSessionPageCounts( + sessions: readonly Pick[], +): CodexSessionPageCounts { + const archived = sessions.reduce( + (count, session) => count + Number(session.archived), + 0, + ); + return { active: sessions.length - archived, archived }; +} + +export function retainVisibleCodexSessionIds( + selectedIds: ReadonlySet, + sessions: readonly Pick[], +): Set { + const visibleIds = new Set(sessions.map((session) => session.id)); + return new Set(Array.from(selectedIds).filter((id) => visibleIds.has(id))); +} diff --git a/src/styles.css b/src/styles.css index 484d404..3689a25 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3926,43 +3926,507 @@ input:focus-visible { display: grid; min-width: 0; min-height: 360px; - place-items: center; - padding: 32px; - border: 1px dashed #d5d2cb; + align-content: start; +} + +.codex-sessions-page { + display: grid; + min-width: 0; + align-content: start; + gap: 12px; +} + +.codex-session-overview, +.codex-session-list-section { + display: grid; + min-width: 0; + gap: 12px; + padding: 15px; + border: 1px solid #e3e1db; border-radius: 8px; background: #faf9f5; } -.agent-sessions-empty { - display: grid; - grid-template-columns: 44px minmax(0, 1fr); +.codex-session-overview-head, +.codex-session-list-head { + display: flex; + min-width: 0; align-items: center; - gap: 12px; - width: min(100%, 420px); + justify-content: space-between; + gap: 14px; } -.agent-sessions-icon { +.codex-session-overview-head > div, +.codex-session-list-head > div:first-child { display: grid; - width: 44px; - height: 44px; - place-items: center; + min-width: 0; + gap: 4px; +} + +.codex-session-overview-head strong, +.codex-session-list-head strong { + color: #2d2a26; + font-size: 13px; +} + +.codex-session-overview-head span, +.codex-session-list-head > div:first-child > span { + color: #8a847c; + font-size: 11px; + line-height: 1.45; +} + +.codex-session-overview-head .compact-button, +.codex-session-selection-actions .compact-button, +.codex-session-repair-actions .compact-button, +.codex-session-delete-button { + min-height: 32px; + margin-top: 0; + padding: 0 10px; + font-size: 11px; +} + +.codex-session-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(74px, 0.55fr)) minmax(150px, 2fr); + gap: 8px; +} + +.codex-session-metrics > div { + display: grid; + min-width: 0; + gap: 5px; + padding: 10px 11px; border: 1px solid #e3e1db; - border-radius: 9px; + border-radius: 7px; background: #fffdf8; +} + +.codex-session-metrics span { + display: flex; + min-width: 0; + align-items: center; + gap: 5px; + color: #8a847c; + font-size: 9px; +} + +.codex-session-metrics strong { + min-width: 0; + overflow: hidden; + color: #2d2a26; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.codex-session-database-metric strong { + direction: rtl; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + text-align: left; +} + +.codex-session-repair-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px 18px; + padding: 12px; + border: 1px solid #e3e1db; + border-radius: 8px; + background: #fffdf8; +} + +.codex-session-repair-copy, +.codex-session-repair-actions, +.codex-session-selection-actions, +.codex-session-notice, +.codex-session-pagination { + display: flex; + align-items: center; +} + +.codex-session-repair-copy { + min-width: 0; + gap: 9px; color: #5f5a53; } -.agent-sessions-empty h3 { - margin: 0 0 5px; - color: #2d2a26; - font-size: 14px; +.codex-session-repair-copy > div { + display: grid; + min-width: 0; + gap: 3px; } -.agent-sessions-empty p { - margin: 0; +.codex-session-repair-copy strong { + color: #2d2a26; + font-size: 12px; +} + +.codex-session-repair-copy span { + color: #8a847c; + font-size: 10px; + line-height: 1.45; +} + +.codex-session-repair-actions { + flex: none; + gap: 10px; +} + +.codex-session-repair-progress { + display: grid; + grid-column: 1 / -1; + gap: 6px; +} + +.codex-session-repair-progress > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; color: #7a746c; + font-size: 10px; +} + +.codex-session-progress-track { + height: 5px; + overflow: hidden; + border-radius: 999px; + background: #dce8df; +} + +.codex-session-progress-track i { + display: block; + height: 100%; + border-radius: inherit; + background: #4f825c; + transition: width 0.2s ease; +} + +.codex-session-notice { + min-width: 0; + align-items: flex-start; + gap: 8px; + padding: 10px 11px; + border: 1px solid #b6d3bd; + border-radius: 7px; + background: #eef7f0; + color: #2f6b3f; + font-size: 10px; + line-height: 1.5; +} + +.codex-session-notice.warning { + border-color: #dfbf91; + background: #fff7e9; + color: #8b5b21; +} + +.codex-session-notice.error { + border-color: #dfb7b0; + background: #fff1ee; + color: #9f2d20; +} + +.codex-session-notice > svg { + flex: none; + margin-top: 1px; +} + +.codex-session-notice > span { + min-width: 0; + flex: 1; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.codex-session-notice > button, +.codex-session-dialog-close { + display: grid; + width: 24px; + height: 24px; + flex: none; + place-items: center; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: currentColor; +} + +.codex-session-notice > button:hover, +.codex-session-dialog-close:hover { + background: rgba(45, 42, 38, 0.08); +} + +.codex-session-list-section { + background: #fffdf8; +} + +.codex-session-selection-actions { + min-width: 0; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} + +.codex-session-selection-actions > span { + color: #8a847c; + font-size: 10px; + white-space: nowrap; +} + +.codex-session-list { + display: grid; + min-width: 0; + overflow: hidden; + border: 1px solid #e3e1db; + border-radius: 7px; + background: #fff; +} + +.codex-session-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(150px, auto) auto; + min-width: 0; + align-items: center; + gap: 12px; + min-height: 76px; + padding: 10px 11px; + border-bottom: 1px solid #eeeae2; + background: #fff; +} + +.codex-session-row:has(.codex-session-checkbox) { + grid-template-columns: 20px minmax(0, 1fr) minmax(150px, auto) auto; +} + +.codex-session-row:last-child { + border-bottom: 0; +} + +.codex-session-row:hover, +.codex-session-row.selected { + background: #faf9f5; +} + +.codex-session-row.selected { + box-shadow: inset 3px 0 #4f825c; +} + +.codex-session-checkbox { + display: grid; + align-self: stretch; + place-items: center; +} + +.codex-session-checkbox input, +.codex-session-cleanup-dialog input { + width: 15px; + height: 15px; + margin: 0; + accent-color: #4f825c; +} + +.codex-session-main, +.codex-session-meta { + display: grid; + min-width: 0; +} + +.codex-session-main { + gap: 4px; +} + +.codex-session-main strong, +.codex-session-main code, +.codex-session-main span, +.codex-session-meta small, +.codex-session-meta time { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.codex-session-main strong { + color: #2d2a26; + font-size: 12px; +} + +.codex-session-main code { + color: #8a847c; + font-size: 9px; +} + +.codex-session-main span { + color: #5f5a53; + font-size: 10px; +} + +.codex-session-meta { + justify-items: end; + gap: 4px; + text-align: right; +} + +.codex-session-meta > span { + padding: 3px 7px; + border: 1px solid #b6d3bd; + border-radius: 999px; + background: #eef7f0; + color: #2f6b3f; + font-size: 8px; + font-weight: 800; +} + +.codex-session-meta > span.archived { + border-color: #d5d2cb; + background: #f4f1ea; + color: #5f5a53; +} + +.codex-session-meta small, +.codex-session-meta time { + max-width: 190px; + color: #8a847c; + font-size: 9px; +} + +.codex-session-delete-button { + align-self: center; +} + +.codex-session-empty { + display: grid; + min-height: 150px; + place-content: center; + justify-items: center; + gap: 9px; + padding: 18px; + border: 1px dashed #d5d2cb; + border-radius: 7px; + background: #faf9f5; + color: #8a847c; font-size: 11px; - line-height: 1.6; + text-align: center; +} + +.codex-session-pagination { + justify-content: center; + gap: 10px; +} + +.codex-session-pagination button { + width: 34px; + min-width: 34px; + min-height: 32px; + padding: 0; +} + +.codex-session-pagination span { + min-width: 72px; + color: #5f5a53; + font-size: 10px; + font-weight: 700; + text-align: center; +} + +.codex-session-dialog, +.codex-session-cleanup-dialog { + grid-template-rows: auto; + height: auto; +} + +.codex-session-dialog { + min-height: 210px; +} + +.codex-session-dialog .config-dialog-heading > div, +.codex-session-cleanup-dialog .config-dialog-heading > div { + display: flex; + align-items: center; + color: #9f2d20; +} + +.codex-session-dialog > p, +.codex-session-cleanup-dialog > p { + margin: 0; + color: #5f5a53; + font-size: 11px; + line-height: 1.65; + overflow-wrap: anywhere; +} + +.codex-session-cleanup-dialog { + width: min(580px, 100%); + max-height: min(720px, calc(100vh - 40px)); + min-height: 320px; + overflow: hidden; +} + +.codex-session-cleanup-select-all { + display: flex; + align-items: center; + gap: 8px; + color: #5f5a53; + font-size: 11px; + font-weight: 700; +} + +.codex-session-cleanup-list { + display: grid; + min-height: 120px; + max-height: 360px; + overflow-y: auto; + border: 1px solid #e3e1db; + border-radius: 7px; + background: #fff; +} + +.codex-session-cleanup-list > label { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 62px; + padding: 8px 10px; + border-bottom: 1px solid #eeeae2; +} + +.codex-session-cleanup-list > label:last-child { + border-bottom: 0; +} + +.codex-session-cleanup-list > label:hover { + background: #faf9f5; +} + +.codex-session-cleanup-list > label > span { + display: grid; + min-width: 0; + gap: 3px; +} + +.codex-session-cleanup-list strong, +.codex-session-cleanup-list code, +.codex-session-cleanup-list small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.codex-session-cleanup-list strong { + color: #2d2a26; + font-size: 11px; +} + +.codex-session-cleanup-list code, +.codex-session-cleanup-list small { + color: #8a847c; + font-size: 9px; } .agent-status-messages { @@ -4051,17 +4515,21 @@ input:focus-visible { white-space: nowrap; } -.agent-model-section { +.agent-core-setting-section { display: grid; - gap: 9px; min-width: 0; - min-height: 138px; + align-content: start; + gap: 9px; padding: 15px; border: 1px solid #e3e1db; border-radius: 8px; background: #faf9f5; } +.agent-model-section { + min-height: 138px; +} + .agent-section-heading { display: flex; align-items: center; @@ -4302,37 +4770,68 @@ input:focus-visible { } .agent-modification-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 0.85fr); + min-height: 104px; +} + +.codex-auto-restore-card { + min-height: 82px; +} + +.codex-auto-restore-toggle { + display: inline-flex; + flex: none; align-items: center; - gap: 18px; - min-height: 72px; - padding: 13px 15px; - border: 1px solid #e3e1db; - border-radius: 8px; - background: #fffdf8; + gap: 10px; + cursor: pointer; + user-select: none; } -.agent-modification-actions.enabled { - border-color: #b6d3bd; - background: #f3f8f4; +.codex-auto-restore-switch { + width: 54px; + height: 30px; } -.agent-modification-copy { - display: grid; - min-width: 0; - gap: 4px; +.codex-auto-restore-switch .switch-track::after { + width: 22px; + height: 22px; + margin: 3px; } -.agent-modification-copy strong { - color: #2d2a26; - font-size: 13px; +.codex-auto-restore-switch input:checked + .switch-track::after { + transform: translateX(24px); } -.agent-modification-copy > span { - color: #7a746c; +.codex-auto-restore-switch input:focus-visible + .switch-track { + outline: 3px solid rgba(45, 42, 38, 0.18); + outline-offset: 2px; +} + +.codex-auto-restore-toggle:has(input:disabled) { + cursor: not-allowed; + opacity: 0.55; +} + +.codex-auto-restore-toggle > strong { + min-width: 42px; + color: #5f5a53; font-size: 11px; - line-height: 1.45; + text-align: right; +} + +.codex-auto-restore-card.enabled .codex-auto-restore-toggle > strong { + color: #2f6b3f; +} + +.codex-auto-restore-message { + max-width: 100%; + color: #2f6b3f; + font-size: 10px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.codex-auto-restore-message.error { + color: #9f2d20; } .agent-modification-control { @@ -4342,7 +4841,7 @@ input:focus-visible { } .agent-modification-control > .agent-inline-message { - text-align: right; + text-align: left; } .agent-modification-buttons { @@ -5439,11 +5938,6 @@ input:focus-visible { grid-template-columns: 200px minmax(0, 1fr); } - .agent-modification-actions { - grid-template-columns: minmax(0, 1fr); - gap: 12px; - } - .usage-filter-panel { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -5611,6 +6105,52 @@ input:focus-visible { min-height: 0; } + .codex-session-metrics { + grid-template-columns: repeat(3, minmax(70px, 1fr)); + } + + .codex-session-database-metric { + grid-column: 1 / -1; + } + + .codex-session-repair-panel { + grid-template-columns: 1fr; + } + + .codex-session-repair-actions { + justify-content: space-between; + } + + .codex-session-row, + .codex-session-row:has(.codex-session-checkbox) { + grid-template-columns: minmax(0, 1fr) auto; + } + + .codex-session-row:has(.codex-session-checkbox) .codex-session-main { + grid-column: 2; + } + + .codex-session-row:has(.codex-session-checkbox) .codex-session-meta { + grid-column: 1 / -1; + } + + .codex-session-meta { + grid-column: 1 / -1; + grid-template-columns: auto minmax(80px, 1fr) auto; + align-items: center; + justify-items: start; + text-align: left; + } + + .codex-session-meta small, + .codex-session-meta time { + max-width: none; + } + + .codex-session-meta time { + justify-self: end; + } + .agents-page, .agent-config-panel { grid-template-rows: auto; @@ -5701,6 +6241,74 @@ input:focus-visible { grid-auto-columns: minmax(104px, 1fr); } + .codex-session-overview, + .codex-session-list-section { + padding: 12px; + } + + .codex-session-overview-head, + .codex-session-list-head { + align-items: stretch; + flex-direction: column; + } + + .codex-session-overview-head .compact-button { + width: 100%; + } + + .codex-session-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .codex-session-database-metric { + grid-column: 1 / -1; + } + + .codex-session-repair-actions, + .codex-session-selection-actions { + width: 100%; + align-items: stretch; + flex-direction: column; + } + + .codex-session-row, + .codex-session-row:has(.codex-session-checkbox) { + grid-template-columns: 20px minmax(0, 1fr); + gap: 9px; + } + + .codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-main, + .codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-meta, + .codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-delete-button { + grid-column: 1 / -1; + } + + .codex-session-main, + .codex-session-row:has(.codex-session-checkbox) .codex-session-main { + grid-column: 2; + } + + .codex-session-meta, + .codex-session-row:has(.codex-session-checkbox) .codex-session-meta { + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px; + } + + .codex-session-meta time { + grid-column: 1 / -1; + justify-self: start; + } + + .codex-session-delete-button { + grid-column: 1 / -1; + width: 100%; + } + + .codex-session-cleanup-dialog { + max-height: calc(100vh - 28px); + } + .usage-tabs { width: 100%; grid-auto-columns: 1fr; @@ -5756,6 +6364,16 @@ input:focus-visible { flex-direction: column; } + .codex-auto-restore-toggle { + width: 100%; + min-width: 0; + justify-content: space-between; + } + + .codex-auto-restore-toggle > strong { + text-align: left; + } + .agent-config-footer .primary-button { width: 100%; } diff --git a/tests/codexSessionState.test.ts b/tests/codexSessionState.test.ts new file mode 100644 index 0000000..670e52c --- /dev/null +++ b/tests/codexSessionState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'bun:test'; +import { + codexSessionPageCounts, + retainVisibleCodexSessionIds, +} from '../src/services/codexSessionState'; + +describe('Codex session page state', () => { + it('counts active and archived sessions on the current page', () => { + expect(codexSessionPageCounts([ + { archived: false }, + { archived: true }, + { archived: false }, + ])).toEqual({ active: 2, archived: 1 }); + }); + + it('drops selections that are no longer on the visible page', () => { + expect(Array.from(retainVisibleCodexSessionIds( + new Set(['thread-a', 'thread-c']), + [{ id: 'thread-a' }, { id: 'thread-b' }], + ))).toEqual(['thread-a']); + }); +});