Files
EasyCLIProxyAPI/src-tauri/src/codex_sessions.rs
T

3197 lines
114 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::io::{BufRead, BufReader};
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, UNIX_EPOCH};
use tauri::{Emitter, Manager};
use crate::GuiConfigState;
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<i64>,
database_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexSessionPage {
codex_home: String,
database_paths: Vec<String>,
sessions: Vec<CodexSessionSummary>,
offset: usize,
limit: usize,
has_more: bool,
repair_on_launch: bool,
warnings: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteCodexSessionsRequest {
session_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexSessionDeleteResult {
session_id: String,
status: String,
message: String,
backup_path: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexSessionDeleteBatchResult {
results: Vec<CodexSessionDeleteResult>,
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<String>,
backup_path: Option<String>,
encrypted_content_warning: Option<String>,
warnings: Vec<String>,
}
#[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<SessionIndexCleanupCandidate>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApplySessionIndexCleanupRequest {
snapshot_sha256: String,
thread_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionIndexCleanupResult {
pruned_entries: usize,
backup_path: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChatGptCloseResult {
was_running: bool,
closed_processes: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SetRepairOnLaunchRequest {
enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RepairOnLaunchSetting {
enabled: bool,
}
#[derive(Debug)]
struct DatabaseDeletePlan {
path: PathBuf,
tables: Map<String, Value>,
rollout_paths: Vec<PathBuf>,
has_thread_row: bool,
has_automation_rows: bool,
}
#[derive(Debug, Clone)]
struct RolloutRepair {
path: PathBuf,
original: Vec<u8>,
next: Vec<u8>,
original_mtime: Option<SystemTime>,
thread_id: Option<String>,
cwd: Option<String>,
has_user_event: bool,
providers: Vec<String>,
encrypted_content: bool,
changed: bool,
}
#[derive(Debug)]
struct SessionIndexPlan {
path: PathBuf,
original: Vec<u8>,
original_text: String,
snapshot_sha256: String,
candidates: Vec<SessionIndexCleanupCandidate>,
}
#[derive(Debug, Clone)]
struct SessionIndexMetadata {
title: String,
updated_at_ms: Option<i64>,
}
#[tauri::command]
pub(crate) async fn list_codex_sessions(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
request: Option<ListCodexSessionsRequest>,
) -> Result<CodexSessionPage, String> {
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<CodexSessionDeleteBatchResult, String> {
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<CodexSessionRepairResult, String> {
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 || {
let target_provider = resolve_current_codex_provider(&codex_home)?;
repair_codex_session_metadata_from_home(&codex_home, &target_provider, |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<SessionIndexCleanupPreview, String> {
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<SessionIndexCleanupResult, String> {
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<RepairOnLaunchSetting, String> {
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<RepairOnLaunchSetting, String> {
let config = gui_config_state.set_codex_session_repair_on_launch(request.enabled)?;
Ok(RepairOnLaunchSetting {
enabled: config.codex_session_repair_on_launch,
})
}
#[tauri::command]
pub(crate) async fn close_chatgpt_app() -> Result<ChatGptCloseResult, String> {
tauri::async_runtime::spawn_blocking(close_chatgpt_app_processes)
.await
.map_err(|error| format!("关闭 ChatGPT App 任务失败: {error}"))?
}
pub(crate) fn repair_before_codex_launch(
user_home: &Path,
) -> Result<CodexSessionRepairResult, String> {
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<CodexSessionRepairResult, String> {
let target_provider = resolve_current_codex_provider(codex_home)?;
match repair_codex_session_metadata_from_home(codex_home, &target_provider, |_| {}) {
Ok(result) => Ok(result),
Err(error) if is_locked_error_message(&error) => Ok(CodexSessionRepairResult {
target_provider,
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<PathBuf> {
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<Connection, String> {
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<Connection, String> {
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<PathBuf>, Vec<String>) {
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::<HashSet<_>>()
} else {
["threads", "automation_runs", "inbox_items"]
.into_iter()
.collect::<HashSet<_>>()
};
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<bool, String> {
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<HashSet<String>, 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::<rusqlite::Result<HashSet<_>>>()
.map_err(|error| format!("读取 {table} 表结构失败: {error}"))
}
fn optional_column<'a>(columns: &HashSet<String>, 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<CodexSessionPage, String> {
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),
}
}
let (mut rollout_sessions, rollout_warnings) = list_sessions_from_rollouts(codex_home);
sessions.append(&mut rollout_sessions);
warnings.extend(rollout_warnings);
let mut sessions = merge_session_summaries(sessions);
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 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 merge_session_summaries(sessions: Vec<CodexSessionSummary>) -> Vec<CodexSessionSummary> {
let mut merged = Vec::<CodexSessionSummary>::new();
let mut positions = HashMap::<String, usize>::new();
for session in sessions {
let key = normalize_thread_id(&session.id);
let key = if key.is_empty() {
session.id.clone()
} else {
key
};
let Some(index) = positions.get(&key).copied() else {
positions.insert(key, merged.len());
merged.push(session);
continue;
};
let existing = &mut merged[index];
let candidate_is_newer = session.updated_at_ms > existing.updated_at_ms;
if candidate_is_newer {
let mut next = session;
fill_missing_session_fields(&mut next, existing);
*existing = next;
} else {
fill_missing_session_fields(existing, &session);
}
}
merged
}
fn fill_missing_session_fields(target: &mut CodexSessionSummary, fallback: &CodexSessionSummary) {
if target.title.trim().is_empty() {
target.title = fallback.title.clone();
}
if target.cwd.trim().is_empty() {
target.cwd = fallback.cwd.clone();
}
if target.model_provider.trim().is_empty() {
target.model_provider = fallback.model_provider.clone();
}
if target.database_path.trim().is_empty() {
target.database_path = fallback.database_path.clone();
}
if target.updated_at_ms.is_none() {
target.updated_at_ms = fallback.updated_at_ms;
}
}
fn list_sessions_from_rollouts(codex_home: &Path) -> (Vec<CodexSessionSummary>, Vec<String>) {
let mut warnings = Vec::new();
let metadata = match read_session_index_metadata(codex_home) {
Ok(metadata) => metadata,
Err(error) => {
warnings.push(error);
HashMap::new()
}
};
let paths = match collect_rollout_files(codex_home) {
Ok(paths) => paths,
Err(error) => {
warnings.push(error);
return (Vec::new(), warnings);
}
};
let mut sessions = Vec::new();
for path in paths {
match summarize_rollout_session(codex_home, &path, &metadata) {
Ok(Some(session)) => sessions.push(session),
Ok(None) => {}
Err(error) if is_locked_error_message(&error) => {
warnings.push(format!(
"会话文件正在使用,已跳过 {}: {error}",
path.display()
));
}
Err(error) => warnings.push(error),
}
}
(sessions, warnings)
}
fn read_session_index_metadata(
codex_home: &Path,
) -> Result<HashMap<String, SessionIndexMetadata>, String> {
let path = codex_home.join("session_index.jsonl");
if !path.is_file() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取 session_index.jsonl 失败: {error}"))?;
let mut metadata = HashMap::new();
for line in content.lines() {
let Ok(record) = serde_json::from_str::<Value>(line) else {
continue;
};
let Some(object) = record.as_object() else {
continue;
};
let Some(id) = object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
let item = SessionIndexMetadata {
title: object
.get("thread_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
updated_at_ms: object
.get("updated_at")
.and_then(Value::as_str)
.and_then(parse_session_timestamp_millis),
};
metadata.insert(id.to_string(), item.clone());
let normalized = normalize_thread_id(id);
if !normalized.is_empty() {
metadata.entry(normalized).or_insert(item);
}
}
Ok(metadata)
}
fn summarize_rollout_session(
codex_home: &Path,
path: &Path,
metadata: &HashMap<String, SessionIndexMetadata>,
) -> Result<Option<CodexSessionSummary>, String> {
let file = fs::File::open(path)
.map_err(|error| format!("读取 rollout 失败 {}: {error}", path.display()))?;
let mut id = None;
let mut title = String::new();
let mut cwd = String::new();
let mut model_provider = String::new();
let mut updated_at_ms = None;
for line in BufReader::new(file).lines() {
let line =
line.map_err(|error| format!("读取 rollout 失败 {}: {error}", path.display()))?;
let Ok(record) = serde_json::from_str::<Value>(&line) else {
continue;
};
if record.get("type").and_then(Value::as_str) != Some("session_meta") {
continue;
}
let Some(payload) = record.get("payload").and_then(Value::as_object) else {
continue;
};
id = payload
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
title = payload
.get("title")
.or_else(|| payload.get("name"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
cwd = payload
.get("cwd")
.and_then(Value::as_str)
.and_then(normalize_workspace_path)
.unwrap_or_default();
model_provider = payload
.get("model_provider")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
updated_at_ms = record
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_session_timestamp_millis);
break;
}
let id = id.or_else(|| rollout_thread_id_from_file_name(path));
let Some(id) = id else {
return Ok(None);
};
let normalized_id = normalize_thread_id(&id);
let index_metadata = metadata.get(&id).or_else(|| metadata.get(&normalized_id));
if title.trim().is_empty() {
title = index_metadata
.map(|item| item.title.clone())
.unwrap_or_default();
}
updated_at_ms = index_metadata
.and_then(|item| item.updated_at_ms)
.or(updated_at_ms)
.or_else(|| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.and_then(|duration| i64::try_from(duration.as_millis()).ok())
});
Ok(Some(CodexSessionSummary {
id,
title,
cwd,
model_provider,
archived: path.starts_with(codex_home.join("archived_sessions")),
updated_at_ms,
database_path: String::new(),
}))
}
fn parse_session_timestamp_millis(value: &str) -> Option<i64> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Ok(value) = value.parse::<i64>() {
return Some(if value.abs() < 100_000_000_000 {
value.saturating_mul(1_000)
} else {
value
});
}
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|timestamp| timestamp.timestamp_millis())
}
fn list_sessions_from_database(
path: &Path,
limit: usize,
) -> Result<Vec<CodexSessionSummary>, 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<Vec<CodexSessionSummary>, 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<String>>(1)?.unwrap_or_default(),
cwd: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
model_provider: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
archived: row.get::<_, Option<i64>>(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::<rusqlite::Result<Vec<_>>>()
.map_err(|error| format!("读取会话数据库失败 {}: {error}", path.display()))
}
fn list_automation_rows(
connection: &Connection,
path: &Path,
limit: usize,
) -> Result<Vec<CodexSessionSummary>, 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<String>>(3)?.unwrap_or_default();
Ok(CodexSessionSummary {
id: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
cwd: row.get::<_, Option<String>>(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::<rusqlite::Result<Vec<_>>>()
.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<String>,
) -> Result<CodexSessionDeleteBatchResult, String> {
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<CodexSessionDeleteResult, String> {
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)?;
for (committed_databases, plan) in plans.iter().enumerate() {
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()),
});
}
}
let mut rollout_errors = Vec::new();
let mut rollout_paths = plans
.iter()
.flat_map(|plan| plan.rollout_paths.iter().cloned())
.collect::<Vec<_>>();
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<Option<DatabaseDeletePlan>, 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<Vec<Value>, 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<Option<&'static str>, 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<Vec<Value>, String> {
let mut statement = connection
.prepare(sql)
.map_err(|error| format!("准备会话备份查询失败: {error}"))?;
let columns = statement
.column_names()
.iter()
.map(|name| name.to_string())
.collect::<Vec<_>>();
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::<rusqlite::Result<Vec<_>>>()
.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<PathBuf, String> {
let safe_label = label
.chars()
.filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
.take(64)
.collect::<String>();
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<PathBuf, String> {
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::<Vec<_>>();
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<Option<PathBuf>, 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 resolve_current_codex_provider(codex_home: &Path) -> Result<String, String> {
let path = codex_home.join("config.toml");
if !path.is_file() {
return Ok("openai".to_string());
}
let text = fs::read_to_string(&path)
.map_err(|error| format!("读取 Codex 配置失败 {}: {error}", path.display()))?;
let document = text
.parse::<toml::Value>()
.map_err(|error| format!("Codex 配置无法解析: {error}"))?;
Ok(document
.get("model_provider")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|provider| !provider.is_empty())
.unwrap_or("openai")
.to_string())
}
fn repair_codex_session_metadata_from_home<F>(
codex_home: &Path,
target_provider: &str,
mut progress: F,
) -> Result<CodexSessionRepairResult, String>
where
F: FnMut(CodexSessionRepairProgress),
{
let target_provider = target_provider.trim();
if target_provider.is_empty() {
return Err("当前 Codex provider 不能为空".to_string());
}
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, target_provider) {
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, target_provider)?;
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::<Vec<_>>();
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",
target_provider,
)?)
};
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, target_provider) {
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, target_provider);
progress(CodexSessionRepairProgress {
phase: "complete".to_string(),
percent: 100,
processed: total,
total,
});
Ok(CodexSessionRepairResult {
target_provider: target_provider.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<Vec<PathBuf>, 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<PathBuf>) -> 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<Option<RolloutRepair>, 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::<Value>(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<String> {
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 let Some(stripped) = value.strip_prefix(r"\\?\") {
return Some(stripped.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<HashSet<String>, String> {
let path = codex_home.join(".codex-global-state.json");
if !path.is_file() {
return Ok(HashSet::new());
}
let value = serde_json::from_str::<Value>(
&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<String>,
) -> (HashSet<String>, HashMap<String, String>) {
let user_event_ids = repairs
.iter()
.filter(|repair| repair.has_user_event)
.filter_map(|repair| repair.thread_id.clone())
.collect::<HashSet<_>>();
let cwd_by_id = repairs
.iter()
.filter_map(|repair| Some((repair.thread_id.clone()?, repair.cwd.clone()?)))
.filter(|(id, _)| !projectless.contains(id))
.collect::<HashMap<_, _>>();
(user_event_ids, cwd_by_id)
}
fn count_sqlite_repairs(
paths: &[PathBuf],
repairs: &[RolloutRepair],
projectless: &HashSet<String>,
target_provider: &str,
) -> Result<(usize, Vec<String>), 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<usize, String> {
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",
[target_provider],
|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<String>,
target_provider: &str,
) -> Result<(usize, Vec<String>), 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<usize, String> {
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",
[target_provider],
)
.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,
target_provider: &str,
) -> Result<PathBuf, String> {
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": target_provider,
"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<SystemTime>) {
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], target_provider: &str) -> Option<String> {
let mut providers = repairs
.iter()
.filter(|repair| repair.encrypted_content)
.flat_map(|repair| repair.providers.iter().cloned())
.filter(|provider| provider != target_provider)
.collect::<Vec<_>>();
providers.sort();
providers.dedup();
if providers.is_empty() {
return None;
}
Some(format!(
"部分历史会话包含来自 {} 的 encrypted_content;元数据已同步到 {},但继续或压缩这些会话可能失败。",
providers.join("、"),
target_provider
))
}
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::<Vec<_>>();
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<SessionIndexCleanupPreview, String> {
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<HashSet<String>, 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::<Value>(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::<rusqlite::Result<HashSet<_>>>()
.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<String> {
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<String>,
) -> Result<Option<SessionIndexPlan>, 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<SessionIndexCleanupCandidate> {
let record = serde_json::from_str::<Value>(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<SessionIndexCleanupResult, String> {
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::<HashSet<_>>();
let selected = confirmed_ids
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect::<HashSet<_>>();
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>) -> (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::<Vec<_>>()
.join("、")
))
}
#[cfg(target_os = "windows")]
fn codex_app_process_ids() -> Result<Vec<u32>, String> {
windows_app_process_ids(&["ChatGPT.exe", "Codex.exe"])
}
#[cfg(target_os = "windows")]
fn chatgpt_app_process_ids() -> Result<Vec<u32>, String> {
windows_app_process_ids(&["ChatGPT.exe"])
}
#[cfg(target_os = "windows")]
fn windows_app_process_ids(names: &[&str]) -> Result<Vec<u32>, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut ids = Vec::new();
for name in names {
let filter = format!("IMAGENAME eq {name}");
let output = Command::new("tasklist")
.args(["/FI", &filter, "/FO", "CSV", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|error| format!("无法检查 {name} 进程状态: {error}"))?;
if !output.status.success() {
return Err(format!(
"检查 {name} 进程状态失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
ids.extend(parse_windows_tasklist_process_ids(&output.stdout));
}
ids.sort_unstable();
ids.dedup();
Ok(ids)
}
#[cfg(target_os = "windows")]
fn parse_windows_tasklist_process_ids(output: &[u8]) -> Vec<u32> {
String::from_utf8_lossy(output)
.lines()
.filter_map(|line| {
let columns = line
.trim()
.trim_matches('"')
.split("\",\"")
.collect::<Vec<_>>();
columns.get(1)?.parse::<u32>().ok()
})
.collect()
}
#[cfg(not(target_os = "windows"))]
fn codex_app_process_ids() -> Result<Vec<u32>, String> {
unix_app_process_ids(&["ChatGPT", "Codex"])
}
#[cfg(not(target_os = "windows"))]
fn chatgpt_app_process_ids() -> Result<Vec<u32>, String> {
unix_app_process_ids(&["ChatGPT"])
}
#[cfg(not(target_os = "windows"))]
fn unix_app_process_ids(names: &[&str]) -> Result<Vec<u32>, String> {
let mut ids = Vec::new();
for name in names {
let output = Command::new("pgrep")
.args(["-x", name])
.output()
.map_err(|error| format!("无法检查 {name} 进程状态: {error}"))?;
match output.status.code() {
Some(0) => ids.extend(
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok()),
),
Some(1) => {}
_ => {
return Err(format!(
"检查 {name} 进程状态失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
}
}
ids.sort_unstable();
ids.dedup();
Ok(ids)
}
#[cfg(target_os = "windows")]
fn close_chatgpt_app_processes() -> Result<ChatGptCloseResult, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let process_ids = chatgpt_app_process_ids()?;
if process_ids.is_empty() {
return Ok(ChatGptCloseResult {
was_running: false,
closed_processes: 0,
});
}
let output = Command::new("taskkill")
.args(["/IM", "ChatGPT.exe", "/T", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|error| format!("无法关闭 ChatGPT.exe: {error}"))?;
let mut remaining = chatgpt_app_process_ids()?;
for _ in 0..20 {
if remaining.is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(100));
remaining = chatgpt_app_process_ids()?;
}
if !remaining.is_empty() {
let detail = String::from_utf8_lossy(if output.stderr.is_empty() {
&output.stdout
} else {
&output.stderr
});
return Err(format!(
"ChatGPT App 未能完全关闭(剩余进程:{}){}{}",
remaining
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("、"),
if detail.trim().is_empty() { "" } else { ":" },
detail.trim()
));
}
Ok(ChatGptCloseResult {
was_running: true,
closed_processes: process_ids.len(),
})
}
#[cfg(not(target_os = "windows"))]
fn close_chatgpt_app_processes() -> Result<ChatGptCloseResult, String> {
let process_ids = chatgpt_app_process_ids()?;
if process_ids.is_empty() {
return Ok(ChatGptCloseResult {
was_running: false,
closed_processes: 0,
});
}
let mut terminate = Command::new("kill");
terminate.arg("-TERM");
for process_id in &process_ids {
terminate.arg(process_id.to_string());
}
let _ = terminate.status();
for _ in 0..20 {
if chatgpt_app_process_ids()?.is_empty() {
return Ok(ChatGptCloseResult {
was_running: true,
closed_processes: process_ids.len(),
});
}
std::thread::sleep(Duration::from_millis(100));
}
let remaining = chatgpt_app_process_ids()?;
let mut force = Command::new("kill");
force.arg("-KILL");
for process_id in &remaining {
force.arg(process_id.to_string());
}
let _ = force.status();
std::thread::sleep(Duration::from_millis(100));
let remaining = chatgpt_app_process_ids()?;
if !remaining.is_empty() {
return Err(format!(
"ChatGPT App 未能完全关闭(剩余进程:{})",
remaining
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("、")
));
}
Ok(ChatGptCloseResult {
was_running: true,
closed_processes: process_ids.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[cfg(target_os = "windows")]
#[test]
fn windows_tasklist_parser_accepts_csv_rows_and_ignores_status_messages() {
let output = b"\"ChatGPT.exe\",\"1234\",\"Console\",\"1\",\"10,000 K\"\r\nINFO: No tasks are running which match the specified criteria.\r\n\"ChatGPT.exe\",\"5678\",\"Console\",\"1\",\"20,000 K\"\r\n";
assert_eq!(parse_windows_tasklist_process_ids(output), vec![1234, 5678]);
}
fn test_root(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"easy-cli-proxy-codex-sessions-{name}-{}-{}",
std::process::id(),
TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
path
}
fn create_threads_db(path: &Path, rollout: &Path) {
let connection = Connection::open(path).unwrap();
connection
.execute_batch(
"CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, title TEXT, cwd TEXT, model_provider TEXT, archived INTEGER, has_user_event INTEGER, updated_at_ms INTEGER);\n\
CREATE TABLE thread_dynamic_tools (thread_id TEXT, name TEXT);\n\
CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, child_thread_id TEXT);",
)
.unwrap();
connection
.execute(
"INSERT INTO threads VALUES ('thread-1', ?1, 'First', 'C:/old', 'openai', 0, 0, 200)",
[rollout.to_string_lossy().as_ref()],
)
.unwrap();
connection
.execute(
"INSERT INTO thread_dynamic_tools VALUES ('thread-1', 'tool')",
[],
)
.unwrap();
}
#[test]
fn list_merges_threads_and_automation_rows_in_update_order() {
let root = test_root("list");
let sqlite = root.join("sqlite");
fs::create_dir_all(&sqlite).unwrap();
let rollout = root.join("sessions/rollout.jsonl");
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
fs::write(&rollout, "{}\n").unwrap();
create_threads_db(&root.join("state_5.sqlite"), &rollout);
let automation = Connection::open(sqlite.join("codex-dev.db")).unwrap();
automation
.execute_batch(
"CREATE TABLE automation_runs (thread_id TEXT PRIMARY KEY, status TEXT, thread_title TEXT, source_cwd TEXT, updated_at INTEGER);\n\
INSERT INTO automation_runs VALUES ('thread-1', 'running', 'First new', 'C:/newer', 4);\n\
INSERT INTO automation_runs VALUES ('thread-2', 'archived', 'Second', 'C:/new', 3);",
)
.unwrap();
drop(automation);
let page = list_codex_sessions_from_home(&root, 0, 50, false).unwrap();
assert_eq!(page.sessions.len(), 2);
assert_eq!(page.sessions[0].id, "thread-1");
assert_eq!(page.sessions[0].title, "First new");
assert_eq!(page.sessions[0].updated_at_ms, Some(4000));
assert_eq!(page.sessions[1].id, "thread-2");
assert!(page.sessions[1].archived);
let second_page = list_codex_sessions_from_home(&root, 1, 1, false).unwrap();
assert_eq!(second_page.sessions.len(), 1);
assert_eq!(second_page.sessions[0].id, "thread-2");
assert!(!second_page.has_more);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn list_includes_rollout_sessions_from_every_provider() {
let root = test_root("list-all-providers");
let api_rollout = root.join("sessions/api.jsonl");
let oauth_rollout = root.join("archived_sessions/oauth.jsonl");
fs::create_dir_all(api_rollout.parent().unwrap()).unwrap();
fs::create_dir_all(oauth_rollout.parent().unwrap()).unwrap();
fs::write(
&api_rollout,
"{\"timestamp\":\"2026-08-04T12:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"api-thread\",\"cwd\":\"C:/api\",\"model_provider\":\"cpa-gui\"}}\n",
)
.unwrap();
fs::write(
&oauth_rollout,
"{\"timestamp\":\"2026-08-03T12:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"oauth-thread\",\"cwd\":\"C:/oauth\",\"model_provider\":\"openai\"}}\n",
)
.unwrap();
fs::write(
root.join("session_index.jsonl"),
concat!(
"{\"id\":\"api-thread\",\"thread_name\":\"API session\",\"updated_at\":\"2026-08-04T12:00:00Z\"}\n",
"{\"id\":\"oauth-thread\",\"thread_name\":\"OAuth session\",\"updated_at\":\"2026-08-03T12:00:00Z\"}\n"
),
)
.unwrap();
let page = list_codex_sessions_from_home(&root, 0, 50, false).unwrap();
assert_eq!(page.sessions.len(), 2);
assert_eq!(page.sessions[0].id, "api-thread");
assert_eq!(page.sessions[0].title, "API session");
assert_eq!(page.sessions[0].model_provider, "cpa-gui");
assert!(!page.sessions[0].archived);
assert_eq!(page.sessions[1].id, "oauth-thread");
assert_eq!(page.sessions[1].title, "OAuth session");
assert_eq!(page.sessions[1].model_provider, "openai");
assert!(page.sessions[1].archived);
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, "cpa-gui", |_| {}).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, "cpa-gui", |_| {}).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, "cpa-gui", |_| {}).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",
"cpa-gui",
)
.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_repairs_sessions_for_the_current_provider() {
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\":\"legacy\"}}\n",
)
.unwrap();
fs::write(root.join("config.toml"), "model_provider = \"openai\"\n").unwrap();
let result = repair_before_codex_launch_from_home(&root).unwrap();
assert_eq!(result.target_provider, "openai");
assert!(fs::read_to_string(&rollout).unwrap().contains("openai"));
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.target_provider, "cpa-gui");
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();
}
}