diff --git a/Cargo.lock b/Cargo.lock index 9347097..bafc656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,7 +232,7 @@ dependencies = [ [[package]] name = "cdxs" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "axum", @@ -240,6 +240,7 @@ dependencies = [ "chrono", "clap", "dirs", + "filetime", "hex", "rand 0.10.1", "reqwest", @@ -467,6 +468,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index d076cfd..ef58852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdxs" -version = "0.1.7" +version = "0.1.8" edition = "2021" description = "Codex account switcher CLI" @@ -11,6 +11,7 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive"] } dirs = "6" +filetime = "0.2" hex = "0.4" rand = "0.10.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "system-proxy", "gzip", "brotli", "deflate", "zstd"] } diff --git a/scripts/publish.ps1 b/scripts/build.ps1 similarity index 100% rename from scripts/publish.ps1 rename to scripts/build.ps1 diff --git a/scripts/publish.sh b/scripts/build.sh similarity index 99% rename from scripts/publish.sh rename to scripts/build.sh index bd89c6f..c14fe70 100644 --- a/scripts/publish.sh +++ b/scripts/build.sh @@ -11,7 +11,7 @@ clean=0 usage() { cat <<'EOF' -Usage: scripts/publish.sh [options] +Usage: scripts/build.sh [options] Build release binaries with Docker buildx. diff --git a/scripts/push.ps1 b/scripts/push.ps1 new file mode 100644 index 0000000..a29f044 --- /dev/null +++ b/scripts/push.ps1 @@ -0,0 +1,50 @@ +param( + [string]$Version = "", + [string]$Image = "docker.pchuan.top/cdxs", + [string]$Dockerfile = "Dockerfile", + [string]$Platform = "linux/amd64", + [switch]$NoCache +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $repoRoot + +if (-not $Version) { + $cargoToml = Get-Content -Raw -Path "Cargo.toml" + if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') { + throw "Cannot read package version from Cargo.toml" + } + $Version = $Matches[1] +} + +$versionTag = "${Image}:${Version}" +$latestTag = "${Image}:latest" + +Write-Host "Building and pushing Docker image for $Platform" +Write-Host "Tags:" +Write-Host " $versionTag" +Write-Host " $latestTag" + +$buildArgs = @( + "buildx", "build", + "--platform", $Platform, + "-f", $Dockerfile, + "-t", $versionTag, + "-t", $latestTag, + "--push" +) + +if ($NoCache) { + $buildArgs += "--no-cache" +} + +$buildArgs += "." + +docker @buildArgs +if ($LASTEXITCODE -ne 0) { + throw "Docker buildx push failed" +} + +Write-Host "Pushed $versionTag and $latestTag" diff --git a/src/account.rs b/src/account.rs deleted file mode 100644 index f883eb8..0000000 --- a/src/account.rs +++ /dev/null @@ -1,1088 +0,0 @@ -//! Account and managed `CODEX_HOME` operations. -//! -//! This module owns the high-level workflows that mutate `cdxs.toml` and -//! Codex `auth.json`: importing credentials, switching accounts, creating -//! named homes, and preparing an isolated home before running Codex. - -use std::path::PathBuf; - -use anyhow::{anyhow, Context, Result}; -use chrono::Utc; -use serde::Serialize; -use sha2::{Digest, Sha256}; - -use crate::auth_file; -use crate::config_store::{self, Account, AuthMode, Home, Store, Tokens}; -use crate::{codex_config, jwt, paths, token}; - -const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1"; - -#[derive(Debug, Clone, Default)] -pub struct ApiKeyOptions { - pub alias: Option, - pub model: Option, - pub provider_name: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct SwitchOptions { - pub model: Option, - pub reasoning_effort: Option, - pub provider_name: Option, -} - -#[derive(Debug, Serialize)] -struct AliasRow { - alias: String, - account_id: String, - email: String, - auth_mode: String, -} - -pub fn import_auth(file: Option, codex_home: Option, switch: bool) -> Result<()> { - // Store imported credentials in the main cdxs config, even when the source - // auth file came from another CODEX_HOME. - let config_home = paths::codex_home(None)?; - let source_home = paths::codex_home(codex_home)?; - let auth_path = file.unwrap_or_else(|| paths::auth_path(&source_home)); - let auth = auth_file::read_auth_file(&auth_path)?; - let mut store = Store::load(&config_home)?; - let account = account_from_auth(auth)?; - let id = account.id.clone(); - let email = account.email.clone(); - store.upsert_account(account); - if switch { - store.meta.current_account_id = Some(id.clone()); - let account = store.find_account(&id).expect("just inserted account"); - auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?; - codex_config::apply_account_provider(&source_home, account)?; - } - store.save(&config_home)?; - println!("已导入账号: {email} ({id})"); - Ok(()) -} - -pub fn add_api_key( - key: String, - base_url: Option, - options: ApiKeyOptions, - switch: bool, -) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let account = api_key_account(key, base_url, options)?; - let id = account.id.clone(); - let email = account.email.clone(); - if let Some(alias) = account.alias.as_deref() { - ensure_alias_available(&store, alias, &id)?; - } - store.upsert_account(account); - if switch { - store.meta.current_account_id = Some(id.clone()); - let account = store.find_account(&id).expect("just inserted account"); - auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?; - codex_config::apply_account_provider(&home, account)?; - } - store.save(&home)?; - println!("已保存 API Key 账号: {email} ({id})"); - Ok(()) -} - -pub async fn list_accounts(json: bool, force: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let quota_concurrency = store.settings.quota_concurrency; - let quota_max_age_seconds = if force { 0 } else { 60 }; - let ids = store - .accounts - .iter() - .map(|account| account.id.clone()) - .collect::>(); - let quota_report = crate::quota::refresh_stale_quotas( - &mut store, - &ids, - quota_max_age_seconds, - quota_concurrency, - ) - .await; - if !quota_report.errors.is_empty() { - eprintln!( - "提示: {} 个账号配额刷新失败,已使用本地缓存。", - quota_report.errors.len() - ); - } - if quota_report.changed { - store.save(&home)?; - } - print_accounts(&store, json) -} - -pub fn show_accounts(json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - print_accounts(&store, json) -} - -pub fn list_aliases(json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - let mut rows = store - .accounts - .iter() - .filter_map(|account| { - account.alias.as_ref().map(|alias| AliasRow { - alias: alias.clone(), - account_id: account.id.clone(), - email: alias_target_display(account).to_string(), - auth_mode: auth_file::account_auth_mode_name(account).to_string(), - }) - }) - .collect::>(); - rows.sort_by(|left, right| { - left.alias - .to_ascii_lowercase() - .cmp(&right.alias.to_ascii_lowercase()) - .then_with(|| left.account_id.cmp(&right.account_id)) - }); - - if json { - println!("{}", serde_json::to_string_pretty(&rows)?); - return Ok(()); - } - if rows.is_empty() { - println!("没有设置 alias。"); - return Ok(()); - } - println!("{:<18} {:<22} {:<10} Account", "Alias", "ID", "Mode"); - for row in rows { - println!( - "{:<18} {:<22} {:<10} {}", - shorten(&row.alias, 18), - shorten(&row.account_id, 22), - shorten(&row.auth_mode, 10), - row.email - ); - } - Ok(()) -} - -pub fn set_alias(alias: &str, query: &str) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let alias = normalize_alias(alias)?; - let account_id = find_unique_account_id(&store, query)?; - ensure_alias_available(&store, &alias, &account_id)?; - let account = store - .find_account_mut(&account_id) - .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; - let changed = account.alias.as_deref() != Some(alias.as_str()); - account.alias = Some(alias.clone()); - if changed { - account.updated_at = Utc::now().timestamp(); - } - store.save(&home)?; - println!("已设置 alias: {alias} -> {account_id}"); - Ok(()) -} - -pub fn remove_alias(query: &str) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let account_id = find_unique_account_id(&store, query)?; - let account = store - .find_account_mut(&account_id) - .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; - let Some(alias) = account.alias.take() else { - return Err(anyhow!("账号未设置 alias: {account_id}")); - }; - account.updated_at = Utc::now().timestamp(); - store.save(&home)?; - println!("已删除 alias: {alias} ({account_id})"); - Ok(()) -} - -fn print_accounts(store: &Store, json: bool) -> Result<()> { - if json { - println!("{}", serde_json::to_string_pretty(&store.accounts)?); - return Ok(()); - } - if store.accounts.is_empty() { - println!("没有保存的账号。可使用 `cdxs import auth` 导入。"); - return Ok(()); - } - print_account_table_border(); - print_account_table_row("", "ID", "Email", "Mode", "Plan", "5h", "Week"); - print_account_table_border(); - let current_account_id = store.meta.current_account_id.as_deref(); - let mut accounts = store.accounts.iter().enumerate().collect::>(); - accounts - .sort_by_key(|(index, account)| account_list_sort_key(account, *index, current_account_id)); - for (_, account) in accounts { - let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) { - "*" - } else { - "" - }; - let (primary_quota, secondary_quota) = format_quota_cells(account); - print_account_table_row( - current, - &account_id_display(&account.id), - account_email_display(account), - auth_file::account_auth_mode_name(account), - account_plan_display(account), - &primary_quota, - &secondary_quota, - ); - } - print_account_table_border(); - Ok(()) -} - -fn account_list_sort_key( - account: &Account, - index: usize, - current_account_id: Option<&str>, -) -> (u8, u8, usize) { - let current_rank = if Some(account.id.as_str()) == current_account_id { - 0 - } else { - 1 - }; - let mode_rank = if account.auth_mode == AuthMode::ApiKey { - 1 - } else { - 0 - }; - (current_rank, mode_rank, index) -} - -pub fn current_account(json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - let Some(id) = store.meta.current_account_id.as_deref() else { - println!("当前未设置账号。"); - return Ok(()); - }; - let account = store - .find_account(id) - .ok_or_else(|| anyhow!("current_account_id 指向不存在的账号: {id}"))?; - if json { - println!("{}", serde_json::to_string_pretty(account)?); - } else { - print_account(account); - } - Ok(()) -} - -pub fn show_account(query: &str, json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - let account = store - .find_account(query) - .ok_or_else(|| anyhow!("账号不存在: {query}"))?; - if json { - println!("{}", serde_json::to_string_pretty(account)?); - } else { - print_account(account); - } - Ok(()) -} - -pub fn remove_account(query: &str) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let id = store - .find_account(query) - .ok_or_else(|| anyhow!("账号不存在: {query}"))? - .id - .clone(); - store.accounts.retain(|account| account.id != id); - if store.meta.current_account_id.as_deref() == Some(id.as_str()) { - store.meta.current_account_id = None; - } - for home in &mut store.homes { - if home.bound_account_id.as_deref() == Some(id.as_str()) { - home.bound_account_id = None; - } - } - store.save(&home)?; - println!("已删除账号: {id}"); - Ok(()) -} - -pub async fn switch_account( - query: &str, - codex_home: Option, - apply_fingerprint: bool, - options: SwitchOptions, -) -> Result<()> { - if apply_fingerprint { - eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); - } - // The saved account list is always loaded from the main cdxs config, while - // the target auth.json can be redirected with --codex-home. - let config_home = paths::codex_home(None)?; - let target_home = paths::codex_home(codex_home)?; - let mut store = Store::load(&config_home)?; - let account_id = find_unique_account_id(&store, query)?; - update_account_switch_options(&mut store, &account_id, &options)?; - switch_account_id(&mut store, &config_home, &target_home, &account_id).await -} - -pub async fn switch_auto( - codex_home: Option, - apply_fingerprint: bool, - options: SwitchOptions, -) -> Result<()> { - if apply_fingerprint { - eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); - } - let config_home = paths::codex_home(None)?; - let target_home = paths::codex_home(codex_home)?; - let mut store = Store::load(&config_home)?; - let ids = store - .accounts - .iter() - .map(|account| account.id.clone()) - .collect::>(); - let quota_concurrency = store.settings.quota_concurrency; - let quota_report = - crate::quota::refresh_stale_quotas(&mut store, &ids, 60, quota_concurrency).await; - if !quota_report.errors.is_empty() { - eprintln!( - "提示: {} 个账号配额刷新失败,自动选择将使用本地缓存。", - quota_report.errors.len() - ); - } - let account_id = best_auto_switch_account(&store)?; - update_account_switch_options(&mut store, &account_id, &options)?; - switch_account_id(&mut store, &config_home, &target_home, &account_id).await?; - if let Some(account) = store.find_account(&account_id) { - println!( - "自动选择账号: {} <{}> plan={} quota={}", - account.id, - account.email, - account.plan_type.as_deref().unwrap_or("-"), - format_quota(account) - ); - } - Ok(()) -} - -async fn switch_account_id( - store: &mut Store, - config_home: &std::path::Path, - target_home: &std::path::Path, - account_id: &str, -) -> Result<()> { - token::refresh_account_if_needed(store, account_id).await?; - let account = store - .find_account(account_id) - .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; - auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?; - codex_config::apply_account_provider(target_home, account)?; - if let Some(account) = store.find_account_mut(account_id) { - account.last_used_at = Utc::now().timestamp(); - } - store.meta.current_account_id = Some(account_id.to_string()); - store.save(config_home)?; - println!("已切换 Codex 账号: {account_id}"); - Ok(()) -} - -pub fn list_homes(json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - if json { - println!("{}", serde_json::to_string_pretty(&store.homes)?); - return Ok(()); - } - for item in &store.homes { - let current = if store.meta.current_home.as_deref() == Some(item.name.as_str()) { - "*" - } else { - " " - }; - println!( - "{} {:<18} {:<48} {}", - current, - item.name, - item.path, - item.bound_account_id.as_deref().unwrap_or("-") - ); - } - Ok(()) -} - -pub fn create_home(name: &str, path: PathBuf, account: Option) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - if store.homes.iter().any(|item| item.name == name) { - return Err(anyhow!("home 已存在: {name}")); - } - let bound_account_id = if let Some(query) = account { - Some( - store - .find_account(&query) - .ok_or_else(|| anyhow!("账号不存在: {query}"))? - .id - .clone(), - ) - } else { - None - }; - let path = paths::expand_home(path); - std::fs::create_dir_all(&path) - .with_context(|| format!("创建 CODEX_HOME 目录失败: {}", path.display()))?; - // When an account is bound at creation time, make the new home immediately - // runnable by writing its auth.json now. - if let Some(account_id) = bound_account_id.as_deref() { - let account = store.find_account(account_id).expect("checked account"); - auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?; - codex_config::apply_account_provider(&path, account)?; - } - store.homes.push(Home { - name: name.to_string(), - path: config_store::path_to_string(&path), - bound_account_id, - }); - store.save(&home)?; - println!("已创建 home: {name} -> {}", path.display()); - Ok(()) -} - -pub fn bind_home(name: &str, account: &str) -> Result<()> { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let account_id = store - .find_account(account) - .ok_or_else(|| anyhow!("账号不存在: {account}"))? - .id - .clone(); - let target_home = store - .homes - .iter_mut() - .find(|item| item.name == name) - .ok_or_else(|| anyhow!("home 不存在: {name}"))?; - target_home.bound_account_id = Some(account_id.clone()); - store.save(&home)?; - println!("已绑定 home {name} -> {account_id}"); - Ok(()) -} - -pub fn home_path(name: &str) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - let item = store - .find_home(name) - .ok_or_else(|| anyhow!("home 不存在: {name}"))?; - println!("{}", item.path); - Ok(()) -} - -pub fn remove_home(name: &str) -> Result<()> { - if name == "default" { - return Err(anyhow!("不能删除 default home")); - } - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let before = store.homes.len(); - store.homes.retain(|item| item.name != name); - if store.homes.len() == before { - return Err(anyhow!("home 不存在: {name}")); - } - store.save(&home)?; - println!("已删除 home: {name}"); - Ok(()) -} - -pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result { - // Used by `cdxs run`: refresh the account if needed, write auth.json into - // the selected home, then let the caller execute a child process there. - let main_home = paths::codex_home(None)?; - let mut store = Store::load(&main_home)?; - let account_id = store - .find_account(query) - .ok_or_else(|| anyhow!("账号不存在: {query}"))? - .id - .clone(); - token::refresh_account_if_needed(&mut store, &account_id).await?; - let (account_id, email) = { - let account = store - .find_account(&account_id) - .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; - auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?; - codex_config::apply_account_provider(&codex_home, account)?; - (account.id.clone(), account.email.clone()) - }; - if let Some(account) = store.find_account_mut(&account_id) { - account.last_used_at = Utc::now().timestamp(); - } - store.save(&main_home)?; - Ok(email) -} - -pub fn resolve_home_for_run(name: &str) -> Result<(PathBuf, Option)> { - let main_home = paths::codex_home(None)?; - let store = Store::load(&main_home)?; - let home = store - .find_home(name) - .ok_or_else(|| anyhow!("home 不存在: {name}"))?; - Ok(( - paths::expand_home(PathBuf::from(&home.path)), - home.bound_account_id.clone(), - )) -} - -fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result { - // Codex auth.json can represent either API-key mode or OAuth-token mode. - // Normalize both shapes into one stored Account record. - if auth_file::is_api_key_mode(&auth) { - let key = auth_file::extract_api_key(&auth) - .ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?; - return api_key_account( - key, - auth_file::api_base_url(&auth), - ApiKeyOptions::default(), - ); - } - - let tokens = auth - .tokens - .ok_or_else(|| anyhow!("auth.json 缺少 tokens 或 OPENAI_API_KEY"))?; - let account_id_hint = tokens.account_id.clone(); - let store_tokens = auth_file::auth_tokens_to_store(tokens); - oauth_account(store_tokens, account_id_hint) -} - -pub fn upsert_oauth_tokens( - tokens: Tokens, - account_id_hint: Option, - switch: bool, -) -> Result { - let home = paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let account = oauth_account(tokens, account_id_hint)?; - let saved = account.clone(); - store.upsert_account(account); - if switch { - store.meta.current_account_id = Some(saved.id.clone()); - let account = store - .find_account(&saved.id) - .expect("just inserted account"); - auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?; - } - store.save(&home)?; - Ok(saved) -} - -fn oauth_account(tokens: Tokens, account_id_hint: Option) -> Result { - // The id_token carries email, plan and organization hints. Account ids are - // deterministic so re-importing the same auth updates the existing record. - let payload = jwt::decode_payload(&tokens.id_token)?; - let auth = payload.auth.clone(); - let email = payload - .email - .or_else(|| payload.sub.map(|sub| format!("{sub}@unknown.local"))) - .unwrap_or_else(|| "unknown@unknown.local".to_string()); - let account_id = - account_id_hint.or_else(|| auth.as_ref().and_then(|item| item.account_id.clone())); - let organization_id = auth.as_ref().and_then(|item| item.organization_id.clone()); - let plan_type = auth - .as_ref() - .and_then(|item| item.chatgpt_plan_type.clone()); - let id = stable_id( - "oauth", - &email, - account_id.as_deref(), - organization_id.as_deref().or_else(|| { - if account_id.is_none() { - plan_type.as_deref() - } else { - None - } - }), - ); - let now = Utc::now().timestamp(); - Ok(Account { - id, - email, - auth_mode: AuthMode::Oauth, - alias: None, - model: None, - reasoning_effort: None, - api_provider_name: None, - plan_type, - account_id, - organization_id, - tokens: Some(tokens), - openai_api_key: None, - api_base_url: None, - quota: None, - fingerprint_id: None, - created_at: now, - updated_at: now, - last_used_at: now, - requires_reauth: false, - }) -} - -fn api_key_account( - key: String, - base_url: Option, - options: ApiKeyOptions, -) -> Result { - let key = key.trim(); - if key.is_empty() { - return Err(anyhow!("API Key 不能为空")); - } - let base_url = normalize_api_base_url(base_url); - let alias = normalize_optional_field(options.alias); - let model = normalize_optional_field(options.model); - let provider_name = normalize_optional_field(options.provider_name); - let id = stable_id("apikey", key, base_url.as_deref(), None); - let email = api_base_url_display(base_url.as_deref()).to_string(); - let now = Utc::now().timestamp(); - Ok(Account { - id, - email, - auth_mode: AuthMode::ApiKey, - alias, - model, - reasoning_effort: None, - api_provider_name: provider_name, - plan_type: None, - account_id: None, - organization_id: None, - tokens: None, - openai_api_key: Some(key.to_string()), - api_base_url: base_url, - quota: None, - fingerprint_id: None, - created_at: now, - updated_at: now, - last_used_at: now, - requires_reauth: false, - }) -} - -fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String { - // Avoid storing secrets in ids while still making repeated imports stable. - let mut hasher = Sha256::new(); - hasher.update(kind.as_bytes()); - hasher.update([0]); - hasher.update(a.as_bytes()); - hasher.update([0]); - hasher.update(b.unwrap_or_default().as_bytes()); - hasher.update([0]); - hasher.update(c.unwrap_or_default().as_bytes()); - let hex = hex::encode(hasher.finalize()); - format!("{kind}_{}", &hex[..16]) -} - -fn find_unique_account_id(store: &Store, query: &str) -> Result { - // Exact ids are always unambiguous. Email and prefix queries can match both - // personal and team accounts for the same login email, so reject ambiguity. - if let Some(account) = store - .accounts - .iter() - .find(|account| account.id == query || account.alias.as_deref() == Some(query)) - { - return Ok(account.id.clone()); - } - - let query_lower = query.to_ascii_lowercase(); - let matches = store - .accounts - .iter() - .filter(|account| { - account_id_matches_query(&account.id, query) - || account.email.eq_ignore_ascii_case(query) - || account - .alias - .as_deref() - .map(|alias| alias.eq_ignore_ascii_case(query)) - .unwrap_or(false) - || account - .email - .to_ascii_lowercase() - .starts_with(query_lower.as_str()) - }) - .collect::>(); - - match matches.as_slice() { - [] => Err(anyhow!("账号不存在: {query}")), - [account] => Ok(account.id.clone()), - _ => { - let choices = matches - .iter() - .map(|account| { - format!( - "{} ({}, account_id={})", - account.id, - account.plan_type.as_deref().unwrap_or("-"), - account.account_id.as_deref().unwrap_or("-") - ) - }) - .collect::>() - .join("; "); - Err(anyhow!( - "账号匹配不唯一: {query}。请使用完整账号 ID。匹配项: {choices}" - )) - } - } -} - -fn update_account_switch_options( - store: &mut Store, - account_id: &str, - options: &SwitchOptions, -) -> Result<()> { - let Some(account) = store.find_account_mut(account_id) else { - return Err(anyhow!("账号不存在: {account_id}")); - }; - let mut changed = false; - if let Some(model) = normalize_optional_field(options.model.clone()) { - changed |= account.model.as_deref() != Some(model.as_str()); - account.model = Some(model); - } - if let Some(reasoning_effort) = normalize_optional_field(options.reasoning_effort.clone()) { - changed |= account.reasoning_effort.as_deref() != Some(reasoning_effort.as_str()); - account.reasoning_effort = Some(reasoning_effort); - } - if let Some(provider_name) = normalize_optional_field(options.provider_name.clone()) { - if account.auth_mode != AuthMode::ApiKey { - return Err(anyhow!("--name 仅支持 API Key 账号")); - } - changed |= account.api_provider_name.as_deref() != Some(provider_name.as_str()); - account.api_provider_name = Some(provider_name); - } - if changed { - account.updated_at = Utc::now().timestamp(); - } - Ok(()) -} - -fn account_id_matches_query(id: &str, query: &str) -> bool { - id.starts_with(query) - || id - .split_once('_') - .map(|(_, suffix)| suffix.starts_with(query)) - .unwrap_or(false) -} - -fn account_id_display(id: &str) -> String { - let Some((kind, suffix)) = id.split_once('_') else { - return shorten(id, 12); - }; - let short_suffix = suffix.chars().take(6).collect::(); - format!("{kind}_{short_suffix}") -} - -fn best_auto_switch_account(store: &Store) -> Result { - store - .accounts - .iter() - .enumerate() - .min_by(|(left_index, left), (right_index, right)| { - auto_switch_key(left, *left_index).cmp(&auto_switch_key(right, *right_index)) - }) - .map(|(_, account)| account.id.clone()) - .ok_or_else(|| anyhow!("没有可切换的账号")) -} - -fn auto_switch_key(account: &Account, index: usize) -> (u8, i32, i32, usize) { - let (weekly, primary) = account - .quota - .as_ref() - .map(|quota| { - ( - quota.secondary_remaining_percent, - quota.primary_remaining_percent, - ) - }) - .unwrap_or((0, 0)); - ( - account_auto_rank(account), - -weekly, - -primary, - if account.auth_mode == AuthMode::ApiKey { - index - } else { - 0 - }, - ) -} - -fn account_auto_rank(account: &Account) -> u8 { - if account.auth_mode == AuthMode::ApiKey { - return 2; - } - let plan = account - .plan_type - .as_deref() - .unwrap_or("") - .to_ascii_lowercase(); - if plan.contains("team") { - 0 - } else if plan.contains("free") { - 1 - } else { - 1 - } -} - -fn format_quota(account: &Account) -> String { - account - .quota - .as_ref() - .map(|quota| { - format!( - "5h={}%, weekly={}%", - quota.primary_remaining_percent, quota.secondary_remaining_percent - ) - }) - .unwrap_or_else(|| "-".to_string()) -} - -fn account_plan_display(account: &Account) -> &str { - if account.auth_mode == AuthMode::ApiKey { - "-" - } else if account.requires_reauth { - "reauth" - } else { - account.plan_type.as_deref().unwrap_or("-") - } -} - -fn account_email_display(account: &Account) -> &str { - if let Some(alias) = account.alias.as_deref() { - alias - } else if account.auth_mode == AuthMode::ApiKey { - api_base_url_display(account.api_base_url.as_deref()) - } else { - &account.email - } -} - -fn alias_target_display(account: &Account) -> &str { - if account.auth_mode == AuthMode::ApiKey { - api_base_url_display(account.api_base_url.as_deref()) - } else { - &account.email - } -} - -fn api_base_url_display(base_url: Option<&str>) -> &str { - base_url.unwrap_or(DEFAULT_API_BASE_URL) -} - -fn format_quota_cells(account: &Account) -> (String, String) { - let Some(quota) = account.quota.as_ref() else { - return ("-".to_string(), "-".to_string()); - }; - ( - format_quota_cell( - quota.primary_remaining_percent, - quota.primary_reset_time, - format_reset_time, - ), - format_quota_cell( - quota.secondary_remaining_percent, - quota.secondary_reset_time, - format_week_reset_time, - ), - ) -} - -fn format_quota_cell( - remaining_percent: i32, - reset_time: Option, - format_reset: fn(i64) -> String, -) -> String { - format!( - "{:>3}% / {}", - remaining_percent, - reset_time - .map(format_reset) - .unwrap_or_else(|| "-".to_string()) - ) -} - -fn format_reset_time(reset_time: i64) -> String { - let now = Utc::now().timestamp(); - let seconds = reset_time.saturating_sub(now); - if seconds <= 0 { - return "now".to_string(); - } - - let days = seconds / 86_400; - let hours = (seconds % 86_400) / 3_600; - let minutes = (seconds % 3_600) / 60; - if days > 0 { - format!("{days}d{hours}h") - } else if hours > 0 { - format!("{hours}h{minutes:02}m") - } else { - format!("{minutes}m") - } -} - -fn format_week_reset_time(reset_time: i64) -> String { - let now = Utc::now().timestamp(); - let seconds = reset_time.saturating_sub(now); - if seconds <= 0 { - return "now".to_string(); - } - - let days = seconds / 86_400; - let hours = (seconds % 86_400) / 3_600; - format!("{days}d{hours:02}h") -} - -fn print_account_table_border() { - println!( - "+{}+{}+{}+{}+{}+{}+{}+", - "-".repeat(3), - "-".repeat(24), - "-".repeat(30), - "-".repeat(10), - "-".repeat(8), - "-".repeat(14), - "-".repeat(14) - ); -} - -fn print_account_table_row( - marker: &str, - id: &str, - email: &str, - mode: &str, - plan: &str, - primary_quota: &str, - secondary_quota: &str, -) { - println!( - "| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |", - shorten(marker, 1), - shorten(id, 22), - shorten(email, 28), - shorten(mode, 8), - shorten(plan, 6), - shorten(primary_quota, 12), - shorten(secondary_quota, 12) - ); -} - -fn shorten(value: &str, width: usize) -> String { - if value.chars().count() <= width { - return value.to_string(); - } - if width <= 1 { - return "…".to_string(); - } - let mut out = value.chars().take(width - 1).collect::(); - out.push('…'); - out -} - -fn print_account(account: &Account) { - println!("id: {}", account.id); - println!("alias: {}", account.alias.as_deref().unwrap_or("-")); - println!("email: {}", account_email_display(account)); - println!("auth_mode: {}", auth_file::account_auth_mode_name(account)); - println!("model: {}", account.model.as_deref().unwrap_or("-")); - println!( - "reasoning_effort: {}", - account.reasoning_effort.as_deref().unwrap_or("-") - ); - println!("plan_type: {}", account_plan_display(account)); - println!( - "account_id: {}", - account.account_id.as_deref().unwrap_or("-") - ); - println!( - "organization_id: {}", - account.organization_id.as_deref().unwrap_or("-") - ); - if account.auth_mode == AuthMode::ApiKey { - println!( - "api_base_url: {}", - api_base_url_display(account.api_base_url.as_deref()) - ); - println!( - "api_provider_name: {}", - account.api_provider_name.as_deref().unwrap_or("-") - ); - println!( - "openai_api_key: {}", - account - .openai_api_key - .as_deref() - .map(mask_api_key) - .unwrap_or_else(|| "-".to_string()) - ); - } - println!("requires_reauth: {}", account.requires_reauth); -} - -fn normalize_api_base_url(base_url: Option) -> Option { - base_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.trim_end_matches('/').to_string()) -} - -fn normalize_alias(alias: &str) -> Result { - let alias = alias.trim(); - if alias.is_empty() { - return Err(anyhow!("alias 不能为空")); - } - Ok(alias.to_string()) -} - -fn normalize_optional_field(value: Option) -> Option { - value - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn ensure_alias_available(store: &Store, alias: &str, account_id: &str) -> Result<()> { - for account in &store.accounts { - if account.id == account_id { - continue; - } - if account - .alias - .as_deref() - .map(|existing| existing.eq_ignore_ascii_case(alias)) - .unwrap_or(false) - { - return Err(anyhow!("alias 已被使用: {alias} -> {}", account.id)); - } - if account.id.eq_ignore_ascii_case(alias) || account.email.eq_ignore_ascii_case(alias) { - return Err(anyhow!("alias 与已有账号冲突: {alias} -> {}", account.id)); - } - } - Ok(()) -} - -fn mask_api_key(key: &str) -> String { - let chars = key.chars().collect::>(); - if chars.len() <= 8 { - return "".to_string(); - } - let prefix = chars.iter().take(3).collect::(); - let suffix = chars - .iter() - .rev() - .take(4) - .collect::>() - .into_iter() - .rev() - .collect::(); - format!("{prefix}...{suffix}") -} diff --git a/src/account/homes.rs b/src/account/homes.rs new file mode 100644 index 0000000..fdc15c4 --- /dev/null +++ b/src/account/homes.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; + +use crate::auth_file; +use crate::config_store::{self, Home, Store}; +use crate::{codex_config, paths}; + +pub fn list_homes(json: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + if json { + println!("{}", serde_json::to_string_pretty(&store.homes)?); + return Ok(()); + } + for item in &store.homes { + let current = if store.meta.current_home.as_deref() == Some(item.name.as_str()) { + "*" + } else { + " " + }; + println!( + "{} {:<18} {:<48} {}", + current, + item.name, + item.path, + item.bound_account_id.as_deref().unwrap_or("-") + ); + } + Ok(()) +} + +pub fn create_home(name: &str, path: PathBuf, account: Option) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + if store.homes.iter().any(|item| item.name == name) { + return Err(anyhow!("home 已存在: {name}")); + } + let bound_account_id = if let Some(query) = account { + Some( + store + .find_account(&query) + .ok_or_else(|| anyhow!("账号不存在: {query}"))? + .id + .clone(), + ) + } else { + None + }; + let path = paths::expand_home(path); + std::fs::create_dir_all(&path) + .with_context(|| format!("创建 CODEX_HOME 目录失败: {}", path.display()))?; + // When an account is bound at creation time, make the new home immediately + // runnable by writing its auth.json now. + if let Some(account_id) = bound_account_id.as_deref() { + let account = store.find_account(account_id).expect("checked account"); + auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?; + codex_config::apply_account_provider(&path, account)?; + } + store.homes.push(Home { + name: name.to_string(), + path: config_store::path_to_string(&path), + bound_account_id, + }); + store.save(&home)?; + println!("已创建 home: {name} -> {}", path.display()); + Ok(()) +} + +pub fn bind_home(name: &str, account: &str) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let account_id = store + .find_account(account) + .ok_or_else(|| anyhow!("账号不存在: {account}"))? + .id + .clone(); + let target_home = store + .homes + .iter_mut() + .find(|item| item.name == name) + .ok_or_else(|| anyhow!("home 不存在: {name}"))?; + target_home.bound_account_id = Some(account_id.clone()); + store.save(&home)?; + println!("已绑定 home {name} -> {account_id}"); + Ok(()) +} + +pub fn home_path(name: &str) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + let item = store + .find_home(name) + .ok_or_else(|| anyhow!("home 不存在: {name}"))?; + println!("{}", item.path); + Ok(()) +} + +pub fn remove_home(name: &str) -> Result<()> { + if name == "default" { + return Err(anyhow!("不能删除 default home")); + } + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let before = store.homes.len(); + store.homes.retain(|item| item.name != name); + if store.homes.len() == before { + return Err(anyhow!("home 不存在: {name}")); + } + store.save(&home)?; + println!("已删除 home: {name}"); + Ok(()) +} diff --git a/src/account/listing.rs b/src/account/listing.rs new file mode 100644 index 0000000..d1c2bf5 --- /dev/null +++ b/src/account/listing.rs @@ -0,0 +1,371 @@ +use anyhow::{anyhow, Result}; +use chrono::Utc; + +use crate::auth_file; +use crate::config_store::{Account, AuthMode, Store}; +use crate::paths; + +use super::{ + account_email_display, account_id_display, account_plan_display, alias_target_display, + ensure_alias_available, find_unique_account_id, mask_api_key, normalize_alias, shorten, + touch_updated_at, AliasRow, +}; + +pub async fn list_accounts(json: bool, force: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let quota_concurrency = store.settings.quota_concurrency; + let quota_max_age_seconds = if force { 0 } else { 60 }; + let ids = store + .accounts + .iter() + .map(|account| account.id.clone()) + .collect::>(); + let quota_report = crate::quota::refresh_stale_quotas( + &mut store, + &ids, + quota_max_age_seconds, + quota_concurrency, + ) + .await; + if !quota_report.errors.is_empty() { + eprintln!( + "提示: {} 个账号配额刷新失败,已使用本地缓存。", + quota_report.errors.len() + ); + } + if quota_report.changed { + store.save(&home)?; + } + print_accounts(&store, json) +} + +pub fn show_accounts(json: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + print_accounts(&store, json) +} + +pub fn list_aliases(json: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + let mut rows = store + .accounts + .iter() + .filter_map(|account| { + account.alias.as_ref().map(|alias| AliasRow { + alias: alias.clone(), + account_id: account.id.clone(), + email: alias_target_display(account).to_string(), + auth_mode: auth_file::account_auth_mode_name(account).to_string(), + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + left.alias + .to_ascii_lowercase() + .cmp(&right.alias.to_ascii_lowercase()) + .then_with(|| left.account_id.cmp(&right.account_id)) + }); + + if json { + println!("{}", serde_json::to_string_pretty(&rows)?); + return Ok(()); + } + if rows.is_empty() { + println!("没有设置 alias。"); + return Ok(()); + } + println!("{:<18} {:<22} {:<10} Account", "Alias", "ID", "Mode"); + for row in rows { + println!( + "{:<18} {:<22} {:<10} {}", + shorten(&row.alias, 18), + shorten(&row.account_id, 22), + shorten(&row.auth_mode, 10), + row.email + ); + } + Ok(()) +} + +pub fn set_alias(alias: &str, query: &str) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let alias = normalize_alias(alias)?; + let account_id = find_unique_account_id(&store, query)?; + ensure_alias_available(&store, &alias, &account_id)?; + let account = store + .find_account_mut(&account_id) + .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; + let changed = account.alias.as_deref() != Some(alias.as_str()); + account.alias = Some(alias.clone()); + if changed { + touch_updated_at(account); + } + store.save(&home)?; + println!("已设置 alias: {alias} -> {account_id}"); + Ok(()) +} + +pub fn remove_alias(query: &str) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let account_id = find_unique_account_id(&store, query)?; + let account = store + .find_account_mut(&account_id) + .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; + let Some(alias) = account.alias.take() else { + return Err(anyhow!("账号未设置 alias: {account_id}")); + }; + account.updated_at = Utc::now().timestamp(); + store.save(&home)?; + println!("已删除 alias: {alias} ({account_id})"); + Ok(()) +} + +pub fn current_account(json: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + let Some(id) = store.meta.current_account_id.as_deref() else { + println!("当前未设置账号。"); + return Ok(()); + }; + let account = store + .find_account(id) + .ok_or_else(|| anyhow!("current_account_id 指向不存在的账号: {id}"))?; + if json { + println!("{}", serde_json::to_string_pretty(account)?); + } else { + print_account(account); + } + Ok(()) +} + +pub fn show_account(query: &str, json: bool) -> Result<()> { + let home = paths::codex_home(None)?; + let store = Store::load(&home)?; + let account = store + .find_account(query) + .ok_or_else(|| anyhow!("账号不存在: {query}"))?; + if json { + println!("{}", serde_json::to_string_pretty(account)?); + } else { + print_account(account); + } + Ok(()) +} + +pub fn remove_account(query: &str) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let id = store + .find_account(query) + .ok_or_else(|| anyhow!("账号不存在: {query}"))? + .id + .clone(); + store.accounts.retain(|account| account.id != id); + if store.meta.current_account_id.as_deref() == Some(id.as_str()) { + store.meta.current_account_id = None; + } + for home in &mut store.homes { + if home.bound_account_id.as_deref() == Some(id.as_str()) { + home.bound_account_id = None; + } + } + store.save(&home)?; + println!("已删除账号: {id}"); + Ok(()) +} + +fn print_accounts(store: &Store, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&store.accounts)?); + return Ok(()); + } + if store.accounts.is_empty() { + println!("没有保存的账号。可使用 `cdxs import auth` 导入。"); + return Ok(()); + } + print_account_table_border(); + print_account_table_row("", "ID", "Email", "Mode", "Plan", "5h", "Week"); + print_account_table_border(); + let current_account_id = store.meta.current_account_id.as_deref(); + let mut accounts = store.accounts.iter().enumerate().collect::>(); + accounts + .sort_by_key(|(index, account)| account_list_sort_key(account, *index, current_account_id)); + for (_, account) in accounts { + let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) { + "*" + } else { + "" + }; + let (primary_quota, secondary_quota) = format_quota_cells(account); + print_account_table_row( + current, + &account_id_display(&account.id), + account_email_display(account), + auth_file::account_auth_mode_name(account), + account_plan_display(account), + &primary_quota, + &secondary_quota, + ); + } + print_account_table_border(); + Ok(()) +} + +fn account_list_sort_key( + account: &Account, + index: usize, + current_account_id: Option<&str>, +) -> (u8, u8, usize) { + let current_rank = if Some(account.id.as_str()) == current_account_id { + 0 + } else { + 1 + }; + let mode_rank = if account.auth_mode == AuthMode::ApiKey { + 1 + } else { + 0 + }; + (current_rank, mode_rank, index) +} + +fn print_account_table_border() { + println!( + "+{}+{}+{}+{}+{}+{}+{}+", + "-".repeat(3), + "-".repeat(24), + "-".repeat(30), + "-".repeat(10), + "-".repeat(8), + "-".repeat(14), + "-".repeat(14) + ); +} + +fn print_account_table_row( + marker: &str, + id: &str, + email: &str, + mode: &str, + plan: &str, + primary_quota: &str, + secondary_quota: &str, +) { + println!( + "| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |", + shorten(marker, 1), + shorten(id, 22), + shorten(email, 28), + shorten(mode, 8), + shorten(plan, 6), + shorten(primary_quota, 12), + shorten(secondary_quota, 12) + ); +} + +fn print_account(account: &Account) { + println!("id: {}", account.id); + println!("alias: {}", account.alias.as_deref().unwrap_or("-")); + println!("email: {}", account_email_display(account)); + println!("auth_mode: {}", auth_file::account_auth_mode_name(account)); + println!("model: {}", account.model.as_deref().unwrap_or("-")); + println!( + "reasoning_effort: {}", + account.reasoning_effort.as_deref().unwrap_or("-") + ); + println!("plan_type: {}", account_plan_display(account)); + println!( + "account_id: {}", + account.account_id.as_deref().unwrap_or("-") + ); + println!( + "organization_id: {}", + account.organization_id.as_deref().unwrap_or("-") + ); + if account.auth_mode == AuthMode::ApiKey { + println!( + "api_base_url: {}", + super::api_base_url_display(account.api_base_url.as_deref()) + ); + println!( + "api_provider_name: {}", + account.api_provider_name.as_deref().unwrap_or("-") + ); + println!( + "openai_api_key: {}", + account + .openai_api_key + .as_deref() + .map(mask_api_key) + .unwrap_or_else(|| "-".to_string()) + ); + } + println!("requires_reauth: {}", account.requires_reauth); +} + +pub(super) fn format_quota_cells(account: &Account) -> (String, String) { + let Some(quota) = account.quota.as_ref() else { + return ("-".to_string(), "-".to_string()); + }; + ( + format_quota_cell( + quota.primary_remaining_percent, + quota.primary_reset_time, + format_reset_time, + ), + format_quota_cell( + quota.secondary_remaining_percent, + quota.secondary_reset_time, + format_week_reset_time, + ), + ) +} + +fn format_quota_cell( + remaining_percent: i32, + reset_time: Option, + format_reset: fn(i64) -> String, +) -> String { + format!( + "{:>3}% / {}", + remaining_percent, + reset_time + .map(format_reset) + .unwrap_or_else(|| "-".to_string()) + ) +} + +fn format_reset_time(reset_time: i64) -> String { + let now = Utc::now().timestamp(); + let seconds = reset_time.saturating_sub(now); + if seconds <= 0 { + return "now".to_string(); + } + + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3_600; + let minutes = (seconds % 3_600) / 60; + if days > 0 { + format!("{days}d{hours}h") + } else if hours > 0 { + format!("{hours}h{minutes:02}m") + } else { + format!("{minutes}m") + } +} + +fn format_week_reset_time(reset_time: i64) -> String { + let now = Utc::now().timestamp(); + let seconds = reset_time.saturating_sub(now); + if seconds <= 0 { + return "now".to_string(); + } + + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3_600; + format!("{days}d{hours:02}h") +} diff --git a/src/account/mod.rs b/src/account/mod.rs new file mode 100644 index 0000000..55cbe25 --- /dev/null +++ b/src/account/mod.rs @@ -0,0 +1,254 @@ +//! Account and managed `CODEX_HOME` operations. +//! +//! This module owns the high-level workflows that mutate `cdxs.toml` and +//! Codex `auth.json`: importing credentials, switching accounts, creating +//! named homes, and preparing an isolated home before running Codex. + +mod homes; +mod listing; +mod storage; +mod switching; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::config_store::{Account, AuthMode, Store}; + +pub use homes::{bind_home, create_home, home_path, list_homes, remove_home}; +pub use listing::{ + current_account, list_accounts, list_aliases, remove_account, remove_alias, set_alias, + show_account, show_accounts, +}; +pub use storage::{add_api_key, import_auth, upsert_oauth_tokens}; +pub use switching::{prepare_account_in_home, resolve_home_for_run, switch_account, switch_auto}; + +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1"; + +#[derive(Debug, Clone, Default)] +pub struct ApiKeyOptions { + pub alias: Option, + pub model: Option, + pub provider_name: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct SwitchOptions { + pub model: Option, + pub reasoning_effort: Option, + pub provider_name: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct AliasRow { + alias: String, + account_id: String, + email: String, + auth_mode: String, +} + +pub(super) fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String { + // Avoid storing secrets in ids while still making repeated imports stable. + let mut hasher = Sha256::new(); + hasher.update(kind.as_bytes()); + hasher.update([0]); + hasher.update(a.as_bytes()); + hasher.update([0]); + hasher.update(b.unwrap_or_default().as_bytes()); + hasher.update([0]); + hasher.update(c.unwrap_or_default().as_bytes()); + let hex = hex::encode(hasher.finalize()); + format!("{kind}_{}", &hex[..16]) +} + +pub(super) fn find_unique_account_id(store: &Store, query: &str) -> Result { + // Exact ids are always unambiguous. Email and prefix queries can match both + // personal and team accounts for the same login email, so reject ambiguity. + if let Some(account) = store + .accounts + .iter() + .find(|account| account.id == query || account.alias.as_deref() == Some(query)) + { + return Ok(account.id.clone()); + } + + let query_lower = query.to_ascii_lowercase(); + let matches = store + .accounts + .iter() + .filter(|account| { + account_id_matches_query(&account.id, query) + || account.email.eq_ignore_ascii_case(query) + || account + .alias + .as_deref() + .map(|alias| alias.eq_ignore_ascii_case(query)) + .unwrap_or(false) + || account + .email + .to_ascii_lowercase() + .starts_with(query_lower.as_str()) + }) + .collect::>(); + + match matches.as_slice() { + [] => Err(anyhow!("账号不存在: {query}")), + [account] => Ok(account.id.clone()), + _ => { + let choices = matches + .iter() + .map(|account| { + format!( + "{} ({}, account_id={})", + account.id, + account.plan_type.as_deref().unwrap_or("-"), + account.account_id.as_deref().unwrap_or("-") + ) + }) + .collect::>() + .join("; "); + Err(anyhow!( + "账号匹配不唯一: {query}。请使用完整账号 ID。匹配项: {choices}" + )) + } + } +} + +pub(super) fn account_id_matches_query(id: &str, query: &str) -> bool { + id.starts_with(query) + || id + .split_once('_') + .map(|(_, suffix)| suffix.starts_with(query)) + .unwrap_or(false) +} + +pub(super) fn account_id_display(id: &str) -> String { + let Some((kind, suffix)) = id.split_once('_') else { + return shorten(id, 12); + }; + let short_suffix = suffix.chars().take(6).collect::(); + format!("{kind}_{short_suffix}") +} + +pub(super) fn account_plan_display(account: &Account) -> &str { + if account.auth_mode == AuthMode::ApiKey { + "-" + } else if account.requires_reauth { + "reauth" + } else { + account.plan_type.as_deref().unwrap_or("-") + } +} + +pub(super) fn account_email_display(account: &Account) -> &str { + if let Some(alias) = account.alias.as_deref() { + alias + } else if account.auth_mode == AuthMode::ApiKey { + api_base_url_display(account.api_base_url.as_deref()) + } else { + &account.email + } +} + +pub(super) fn alias_target_display(account: &Account) -> &str { + if account.auth_mode == AuthMode::ApiKey { + api_base_url_display(account.api_base_url.as_deref()) + } else { + &account.email + } +} + +pub(super) fn api_base_url_display(base_url: Option<&str>) -> &str { + base_url.unwrap_or(DEFAULT_API_BASE_URL) +} + +pub(super) fn format_quota(account: &Account) -> String { + account + .quota + .as_ref() + .map(|quota| { + format!( + "5h={}%, weekly={}%", + quota.primary_remaining_percent, quota.secondary_remaining_percent + ) + }) + .unwrap_or_else(|| "-".to_string()) +} + +pub(super) fn normalize_api_base_url(base_url: Option) -> Option { + base_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_end_matches('/').to_string()) +} + +pub(super) fn normalize_alias(alias: &str) -> Result { + let alias = alias.trim(); + if alias.is_empty() { + return Err(anyhow!("alias 不能为空")); + } + Ok(alias.to_string()) +} + +pub(super) fn normalize_optional_field(value: Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub(super) fn ensure_alias_available(store: &Store, alias: &str, account_id: &str) -> Result<()> { + for account in &store.accounts { + if account.id == account_id { + continue; + } + if account + .alias + .as_deref() + .map(|existing| existing.eq_ignore_ascii_case(alias)) + .unwrap_or(false) + { + return Err(anyhow!("alias 已被使用: {alias} -> {}", account.id)); + } + if account.id.eq_ignore_ascii_case(alias) || account.email.eq_ignore_ascii_case(alias) { + return Err(anyhow!("alias 与已有账号冲突: {alias} -> {}", account.id)); + } + } + Ok(()) +} + +pub(super) fn mask_api_key(key: &str) -> String { + let chars = key.chars().collect::>(); + if chars.len() <= 8 { + return "".to_string(); + } + let prefix = chars.iter().take(3).collect::(); + let suffix = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("{prefix}...{suffix}") +} + +pub(super) fn shorten(value: &str, width: usize) -> String { + if value.chars().count() <= width { + return value.to_string(); + } + if width <= 1 { + return "…".to_string(); + } + let mut out = value.chars().take(width - 1).collect::(); + out.push('…'); + out +} + +pub(super) fn touch_updated_at(account: &mut Account) { + account.updated_at = Utc::now().timestamp(); +} diff --git a/src/account/storage.rs b/src/account/storage.rs new file mode 100644 index 0000000..c09858f --- /dev/null +++ b/src/account/storage.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use chrono::Utc; + +use crate::auth_file; +use crate::config_store::{Account, AuthMode, Store, Tokens}; +use crate::{codex_config, jwt, paths}; + +use super::{ + ensure_alias_available, normalize_api_base_url, normalize_optional_field, stable_id, + ApiKeyOptions, +}; + +pub fn import_auth(file: Option, codex_home: Option, switch: bool) -> Result<()> { + // Store imported credentials in the main cdxs config, even when the source + // auth file came from another CODEX_HOME. + let config_home = paths::codex_home(None)?; + let source_home = paths::codex_home(codex_home)?; + let auth_path = file.unwrap_or_else(|| paths::auth_path(&source_home)); + let auth = auth_file::read_auth_file(&auth_path)?; + let mut store = Store::load(&config_home)?; + let account = account_from_auth(auth)?; + let id = account.id.clone(); + let email = account.email.clone(); + store.upsert_account(account); + if switch { + store.meta.current_account_id = Some(id.clone()); + let account = store.find_account(&id).expect("just inserted account"); + auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?; + codex_config::apply_account_provider(&source_home, account)?; + } + store.save(&config_home)?; + println!("已导入账号: {email} ({id})"); + Ok(()) +} + +pub fn add_api_key( + key: String, + base_url: Option, + options: ApiKeyOptions, + switch: bool, +) -> Result<()> { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let account = api_key_account(key, base_url, options)?; + let id = account.id.clone(); + let email = account.email.clone(); + if let Some(alias) = account.alias.as_deref() { + ensure_alias_available(&store, alias, &id)?; + } + store.upsert_account(account); + if switch { + store.meta.current_account_id = Some(id.clone()); + let account = store.find_account(&id).expect("just inserted account"); + auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?; + codex_config::apply_account_provider(&home, account)?; + } + store.save(&home)?; + println!("已保存 API Key 账号: {email} ({id})"); + Ok(()) +} + +pub fn upsert_oauth_tokens( + tokens: Tokens, + account_id_hint: Option, + switch: bool, +) -> Result { + let home = paths::codex_home(None)?; + let mut store = Store::load(&home)?; + let account = oauth_account(tokens, account_id_hint)?; + let saved = account.clone(); + store.upsert_account(account); + if switch { + store.meta.current_account_id = Some(saved.id.clone()); + let account = store + .find_account(&saved.id) + .expect("just inserted account"); + auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?; + } + store.save(&home)?; + Ok(saved) +} + +fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result { + // Codex auth.json can represent either API-key mode or OAuth-token mode. + // Normalize both shapes into one stored Account record. + if auth_file::is_api_key_mode(&auth) { + let key = auth_file::extract_api_key(&auth) + .ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?; + return api_key_account( + key, + auth_file::api_base_url(&auth), + ApiKeyOptions::default(), + ); + } + + let tokens = auth + .tokens + .ok_or_else(|| anyhow!("auth.json 缺少 tokens 或 OPENAI_API_KEY"))?; + let account_id_hint = tokens.account_id.clone(); + let store_tokens = auth_file::auth_tokens_to_store(tokens); + oauth_account(store_tokens, account_id_hint) +} + +fn oauth_account(tokens: Tokens, account_id_hint: Option) -> Result { + // The id_token carries email, plan and organization hints. Account ids are + // deterministic so re-importing the same auth updates the existing record. + let payload = jwt::decode_payload(&tokens.id_token)?; + let auth = payload.auth.clone(); + let email = payload + .email + .or_else(|| payload.sub.map(|sub| format!("{sub}@unknown.local"))) + .unwrap_or_else(|| "unknown@unknown.local".to_string()); + let account_id = + account_id_hint.or_else(|| auth.as_ref().and_then(|item| item.account_id.clone())); + let organization_id = auth.as_ref().and_then(|item| item.organization_id.clone()); + let plan_type = auth + .as_ref() + .and_then(|item| item.chatgpt_plan_type.clone()); + let id = stable_id( + "oauth", + &email, + account_id.as_deref(), + organization_id.as_deref().or_else(|| { + if account_id.is_none() { + plan_type.as_deref() + } else { + None + } + }), + ); + let now = Utc::now().timestamp(); + Ok(Account { + id, + email, + auth_mode: AuthMode::Oauth, + alias: None, + model: None, + reasoning_effort: None, + api_provider_name: None, + plan_type, + account_id, + organization_id, + tokens: Some(tokens), + openai_api_key: None, + api_base_url: None, + quota: None, + fingerprint_id: None, + created_at: now, + updated_at: now, + last_used_at: now, + requires_reauth: false, + }) +} + +fn api_key_account( + key: String, + base_url: Option, + options: ApiKeyOptions, +) -> Result { + let key = key.trim(); + if key.is_empty() { + return Err(anyhow!("API Key 不能为空")); + } + let base_url = normalize_api_base_url(base_url); + let alias = normalize_optional_field(options.alias); + let model = normalize_optional_field(options.model); + let provider_name = normalize_optional_field(options.provider_name); + let id = stable_id("apikey", key, base_url.as_deref(), None); + let email = super::api_base_url_display(base_url.as_deref()).to_string(); + let now = Utc::now().timestamp(); + Ok(Account { + id, + email, + auth_mode: AuthMode::ApiKey, + alias, + model, + reasoning_effort: None, + api_provider_name: provider_name, + plan_type: None, + account_id: None, + organization_id: None, + tokens: None, + openai_api_key: Some(key.to_string()), + api_base_url: base_url, + quota: None, + fingerprint_id: None, + created_at: now, + updated_at: now, + last_used_at: now, + requires_reauth: false, + }) +} diff --git a/src/account/switching.rs b/src/account/switching.rs new file mode 100644 index 0000000..aa1f07a --- /dev/null +++ b/src/account/switching.rs @@ -0,0 +1,212 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; +use chrono::Utc; + +use crate::config_store::{Account, AuthMode, Store}; +use crate::{auth_file, codex_config, paths, session, token}; + +use super::{ + find_unique_account_id, format_quota, normalize_optional_field, touch_updated_at, SwitchOptions, +}; + +pub async fn switch_account( + query: &str, + codex_home: Option, + apply_fingerprint: bool, + options: SwitchOptions, +) -> Result<()> { + if apply_fingerprint { + eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); + } + // The saved account list is always loaded from the main cdxs config, while + // the target auth.json can be redirected with --codex-home. + let config_home = paths::codex_home(None)?; + let target_home = paths::codex_home(codex_home)?; + let mut store = Store::load(&config_home)?; + let account_id = find_unique_account_id(&store, query)?; + update_account_switch_options(&mut store, &account_id, &options)?; + switch_account_id(&mut store, &config_home, &target_home, &account_id).await +} + +pub async fn switch_auto( + codex_home: Option, + apply_fingerprint: bool, + options: SwitchOptions, +) -> Result<()> { + if apply_fingerprint { + eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。"); + } + let config_home = paths::codex_home(None)?; + let target_home = paths::codex_home(codex_home)?; + let mut store = Store::load(&config_home)?; + let ids = store + .accounts + .iter() + .map(|account| account.id.clone()) + .collect::>(); + let quota_concurrency = store.settings.quota_concurrency; + let quota_report = + crate::quota::refresh_stale_quotas(&mut store, &ids, 60, quota_concurrency).await; + if !quota_report.errors.is_empty() { + eprintln!( + "提示: {} 个账号配额刷新失败,自动选择将使用本地缓存。", + quota_report.errors.len() + ); + } + let account_id = best_auto_switch_account(&store)?; + update_account_switch_options(&mut store, &account_id, &options)?; + switch_account_id(&mut store, &config_home, &target_home, &account_id).await?; + if let Some(account) = store.find_account(&account_id) { + println!( + "自动选择账号: {} <{}> plan={} quota={}", + account.id, + account.email, + account.plan_type.as_deref().unwrap_or("-"), + format_quota(account) + ); + } + Ok(()) +} + +pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result { + // Used by `cdxs run`: refresh the account if needed, write auth.json into + // the selected home, then let the caller execute a child process there. + let main_home = paths::codex_home(None)?; + let mut store = Store::load(&main_home)?; + let account_id = store + .find_account(query) + .ok_or_else(|| anyhow!("账号不存在: {query}"))? + .id + .clone(); + token::refresh_account_if_needed(&mut store, &account_id).await?; + let (account_id, email) = { + let account = store + .find_account(&account_id) + .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; + auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?; + codex_config::apply_account_provider(&codex_home, account)?; + (account.id.clone(), account.email.clone()) + }; + session::align_provider_buckets(&codex_home)?; + if let Some(account) = store.find_account_mut(&account_id) { + account.last_used_at = Utc::now().timestamp(); + } + store.save(&main_home)?; + Ok(email) +} + +pub fn resolve_home_for_run(name: &str) -> Result<(PathBuf, Option)> { + let main_home = paths::codex_home(None)?; + let store = Store::load(&main_home)?; + let home = store + .find_home(name) + .ok_or_else(|| anyhow!("home 不存在: {name}"))?; + Ok(( + paths::expand_home(PathBuf::from(&home.path)), + home.bound_account_id.clone(), + )) +} + +async fn switch_account_id( + store: &mut Store, + config_home: &Path, + target_home: &Path, + account_id: &str, +) -> Result<()> { + token::refresh_account_if_needed(store, account_id).await?; + let account = store + .find_account(account_id) + .ok_or_else(|| anyhow!("账号不存在: {account_id}"))?; + auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?; + codex_config::apply_account_provider(target_home, account)?; + session::align_provider_buckets(target_home)?; + if let Some(account) = store.find_account_mut(account_id) { + account.last_used_at = Utc::now().timestamp(); + } + store.meta.current_account_id = Some(account_id.to_string()); + store.save(config_home)?; + println!("已切换 Codex 账号: {account_id}"); + Ok(()) +} + +fn update_account_switch_options( + store: &mut Store, + account_id: &str, + options: &SwitchOptions, +) -> Result<()> { + let Some(account) = store.find_account_mut(account_id) else { + return Err(anyhow!("账号不存在: {account_id}")); + }; + let mut changed = false; + if let Some(model) = normalize_optional_field(options.model.clone()) { + changed |= account.model.as_deref() != Some(model.as_str()); + account.model = Some(model); + } + if let Some(reasoning_effort) = normalize_optional_field(options.reasoning_effort.clone()) { + changed |= account.reasoning_effort.as_deref() != Some(reasoning_effort.as_str()); + account.reasoning_effort = Some(reasoning_effort); + } + if let Some(provider_name) = normalize_optional_field(options.provider_name.clone()) { + if account.auth_mode != AuthMode::ApiKey { + return Err(anyhow!("--name 仅支持 API Key 账号")); + } + changed |= account.api_provider_name.as_deref() != Some(provider_name.as_str()); + account.api_provider_name = Some(provider_name); + } + if changed { + touch_updated_at(account); + } + Ok(()) +} + +fn best_auto_switch_account(store: &Store) -> Result { + store + .accounts + .iter() + .enumerate() + .min_by(|(left_index, left), (right_index, right)| { + auto_switch_key(left, *left_index).cmp(&auto_switch_key(right, *right_index)) + }) + .map(|(_, account)| account.id.clone()) + .ok_or_else(|| anyhow!("没有可切换的账号")) +} + +fn auto_switch_key(account: &Account, index: usize) -> (u8, i32, i32, usize) { + let (weekly, primary) = account + .quota + .as_ref() + .map(|quota| { + ( + quota.secondary_remaining_percent, + quota.primary_remaining_percent, + ) + }) + .unwrap_or((0, 0)); + ( + account_auto_rank(account), + -weekly, + -primary, + if account.auth_mode == AuthMode::ApiKey { + index + } else { + 0 + }, + ) +} + +fn account_auto_rank(account: &Account) -> u8 { + if account.auth_mode == AuthMode::ApiKey { + return 2; + } + let plan = account + .plan_type + .as_deref() + .unwrap_or("") + .to_ascii_lowercase(); + if plan.contains("team") { + 0 + } else { + 1 + } +} diff --git a/src/atomic.rs b/src/atomic.rs index 03f038a..4b122bf 100644 --- a/src/atomic.rs +++ b/src/atomic.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::Utc; +use filetime::{set_file_times, FileTime}; /// Write a file through a sibling temporary file and then rename it into place. /// This keeps auth/config files from being left half-written after failures. @@ -24,6 +25,28 @@ pub fn write_atomic(path: &Path, content: &str) -> Result<()> { Ok(()) } +/// Atomically replace a file while preserving its original access/modify times. +pub fn write_atomic_preserve_times(path: &Path, content: &str) -> Result<()> { + let original_times = if path.exists() { + let metadata = fs::metadata(path) + .with_context(|| format!("读取文件元数据失败: {}", path.display()))?; + Some(( + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )) + } else { + None + }; + + write_atomic(path, content)?; + + if let Some((atime, mtime)) = original_times { + set_file_times(path, atime, mtime) + .with_context(|| format!("恢复文件时间失败: {}", path.display()))?; + } + Ok(()) +} + /// Backup an existing file into `/cdxs-backups/`. pub fn backup_if_exists(path: &Path, codex_home: &Path, label: &str) -> Result> { if !path.exists() { diff --git a/src/codex_config.rs b/src/codex_config.rs index e79d781..53cb002 100644 --- a/src/codex_config.rs +++ b/src/codex_config.rs @@ -1,6 +1,6 @@ //! Codex `config.toml` helpers for account-specific provider settings. -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; @@ -11,6 +11,9 @@ use crate::config_store::{Account, AuthMode}; use crate::{atomic, paths}; const MANAGED_PROVIDER_PREFIX: &str = "cdxs_api_"; +const STATE_DB_FILENAME: &str = "state_5.sqlite"; +const DEFAULT_PROVIDER: &str = "openai"; +const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<()> { match account.auth_mode { @@ -19,6 +22,57 @@ pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<() } } +pub fn active_model_provider(codex_home: &Path) -> Result { + let config = read_effective_config(codex_home)?; + Ok(config + .get("model_provider") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_PROVIDER) + .to_string()) +} + +pub fn state_db_path(codex_home: &Path) -> Result { + let config = read_effective_config(codex_home)?; + let config_sqlite_home = config + .get("sqlite_home") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + if let Some(path) = config_sqlite_home { + let root = if path.is_absolute() { + path + } else { + codex_home.join(path) + }; + return Ok(root.join(STATE_DB_FILENAME)); + } + + let env_sqlite_home = std::env::var(CODEX_SQLITE_HOME_ENV) + .ok() + .map(|value| { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim() + .to_string() + }) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + + let root = match env_sqlite_home { + Some(path) if path.is_absolute() => path, + Some(path) => std::env::current_dir() + .context("解析 CODEX_SQLITE_HOME 相对路径失败")? + .join(path), + None => codex_home.to_path_buf(), + }; + Ok(root.join(STATE_DB_FILENAME)) +} + fn apply_oauth_config(codex_home: &Path, account: &Account) -> Result<()> { let path = paths::codex_config_path(codex_home); if !path.exists() && account.model.is_none() && account.reasoning_effort.is_none() { @@ -88,6 +142,57 @@ fn read_config(path: &Path) -> Result> { Ok(value.as_table().cloned().unwrap_or_default()) } +fn read_effective_config(codex_home: &Path) -> Result> { + let mut config = read_config(&paths::codex_config_path(codex_home))?; + let profile = config + .get("profile") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + if let Some(profile) = profile { + apply_profile_config(codex_home, &mut config, &profile)?; + } + Ok(config) +} + +fn apply_profile_config( + codex_home: &Path, + config: &mut Map, + profile: &str, +) -> Result<()> { + if !is_valid_profile_name(profile) { + return Ok(()); + } + let profile_path = codex_home.join(format!("{profile}.config.toml")); + if profile_path.exists() { + let profile_config = read_config(&profile_path)?; + overlay_config(config, &profile_config); + return Ok(()); + } + if let Some(Value::Table(profiles)) = config.get("profiles") { + if let Some(Value::Table(profile_config)) = profiles.get(profile) { + let profile_config = profile_config.clone(); + overlay_config(config, &profile_config); + } + } + Ok(()) +} + +fn overlay_config(config: &mut Map, overlay: &Map) { + for key in ["model_provider", "sqlite_home"] { + if let Some(value) = overlay.get(key) { + config.insert(key.to_string(), value.clone()); + } + } +} + +fn is_valid_profile_name(profile: &str) -> bool { + profile + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + fn write_config(path: &Path, codex_home: &Path, config: &Map) -> Result<()> { atomic::backup_if_exists(path, codex_home, "config.toml")?; let content = toml::to_string_pretty(config).context("序列化 Codex config.toml 失败")?; @@ -172,3 +277,56 @@ fn normalize_field(value: &str) -> Option { Some(value.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use uuid::Uuid; + + fn temp_home() -> PathBuf { + let path = std::env::temp_dir().join(format!("cdxs-test-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + #[test] + fn active_model_provider_defaults_to_openai() { + let home = temp_home(); + assert_eq!(active_model_provider(&home).unwrap(), "openai"); + let _ = fs::remove_dir_all(home); + } + + #[test] + fn state_db_path_follows_sqlite_home_config() { + let home = temp_home(); + fs::write(home.join("config.toml"), "sqlite_home = \"sqlite-data\"\n").unwrap(); + assert_eq!( + state_db_path(&home).unwrap(), + home.join("sqlite-data").join("state_5.sqlite") + ); + let _ = fs::remove_dir_all(home); + } + + #[test] + fn active_model_provider_reads_selected_profile_config() { + let home = temp_home(); + fs::write( + home.join("config.toml"), + "model_provider = \"openai\"\nprofile = \"work\"\n", + ) + .unwrap(); + fs::write( + home.join("work.config.toml"), + "model_provider = \"bedrock\"\nsqlite_home = \"work-state\"\n", + ) + .unwrap(); + + assert_eq!(active_model_provider(&home).unwrap(), "bedrock"); + assert_eq!( + state_db_path(&home).unwrap(), + home.join("work-state").join("state_5.sqlite") + ); + let _ = fs::remove_dir_all(home); + } +} diff --git a/src/session.rs b/src/session.rs deleted file mode 100644 index fc3c310..0000000 --- a/src/session.rs +++ /dev/null @@ -1,1296 +0,0 @@ -//! Inspect and repair Codex session state. -//! -//! Codex stores session visibility across SQLite (`state_5.sqlite`), -//! `session_index.jsonl`, and rollout JSONL files. This module keeps those -//! pieces consistent, implements a reversible cdxs trash, and can copy missing -//! threads across managed homes. - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection, Row}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::config_store::Store; -use crate::{atomic, paths}; - -#[derive(Debug, Clone, Serialize)] -pub struct SessionSummary { - /// Name of the managed home that owns this session. - pub home_name: String, - pub home_path: String, - pub id: String, - pub title: Option, - pub cwd: Option, - pub updated_at_ms: Option, - pub tokens_used: i64, - pub rollout_path: String, - pub archived: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SessionStats { - pub session: SessionSummary, - pub rollout_bytes: Option, - pub rollout_lines: Option, - pub sqlite_tokens_used: i64, - pub rollout_total_tokens: Option, - pub rollout_input_tokens: Option, - pub rollout_output_tokens: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct VisibilityIssue { - pub home_name: String, - pub home_path: String, - pub session_id: String, - pub issue: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RepairAction { - pub home_name: String, - pub session_id: String, - pub action: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncThreadAction { - pub session_id: String, - pub source_home: String, - pub target_home: String, - pub action: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TrashManifest { - /// Manifest schema version for future migrations. - version: u32, - deleted_at: String, - home_name: String, - home_path: String, - session_id: String, - original_rollout_path: String, - rollout_backup_file: Option, - session_index_entries: Vec, - thread: ThreadRowData, -} - -#[derive(Debug, Clone, Serialize)] -struct TrashEntry { - home_name: String, - home_path: String, - session_id: String, - title: Option, - cwd: Option, - deleted_at: String, - trash_dir: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ThreadRowData { - // Mirrors the Codex `threads` table. Keeping this shape explicit makes - // repair/restore code independent from rusqlite row lifetimes. - id: String, - rollout_path: String, - created_at: Option, - updated_at: Option, - source: Option, - model_provider: Option, - cwd: Option, - title: Option, - sandbox_policy: Option, - approval_mode: Option, - tokens_used: Option, - has_user_event: Option, - archived: Option, - archived_at: Option, - git_sha: Option, - git_branch: Option, - git_origin_url: Option, - cli_version: Option, - first_user_message: Option, - agent_nickname: Option, - agent_role: Option, - memory_mode: Option, - model: Option, - reasoning_effort: Option, - agent_path: Option, - created_at_ms: Option, - updated_at_ms: Option, -} - -#[derive(Debug, Clone)] -struct HomeTarget { - name: String, - path: PathBuf, -} - -#[derive(Debug, Clone)] -struct RolloutMeta { - session_id: String, - relative_path: String, - cwd: Option, - model_provider: Option, - source: Option, - cli_version: Option, - created_at_ms: Option, - updated_at_ms: Option, -} - -pub fn list_sessions(all_homes: bool, json: bool) -> Result<()> { - let mut sessions = Vec::new(); - for home in homes(all_homes)? { - sessions.extend(read_sessions_for_home(&home)?); - } - sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); - - if json { - println!("{}", serde_json::to_string_pretty(&sessions)?); - return Ok(()); - } - if sessions.is_empty() { - println!("没有找到 Codex 会话。"); - return Ok(()); - } - println!( - "{:<22} {:<12} {:<10} {:<20} {}", - "ID", "Home", "Tokens", "Updated", "Title" - ); - for item in sessions { - println!( - "{:<22} {:<12} {:<10} {:<20} {}", - shorten(&item.id, 22), - shorten(&item.home_name, 12), - item.tokens_used, - format_time(item.updated_at_ms), - shorten(item.title.as_deref().unwrap_or("-"), 80) - ); - } - Ok(()) -} - -pub fn session_stats(session_id: &str, all_homes: bool, json: bool) -> Result<()> { - let (home, row) = - find_thread(session_id, all_homes)?.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; - let summary = summary_from_thread(&home, &row); - let rollout = resolve_rollout_path(&home.path, &row.rollout_path); - let (rollout_bytes, rollout_lines, token_usage) = rollout_stats(&rollout)?; - let stats = SessionStats { - sqlite_tokens_used: row.tokens_used.unwrap_or_default(), - session: summary, - rollout_bytes, - rollout_lines, - rollout_total_tokens: token_usage.as_ref().and_then(|v| v.total_tokens), - rollout_input_tokens: token_usage.as_ref().and_then(|v| v.input_tokens), - rollout_output_tokens: token_usage.as_ref().and_then(|v| v.output_tokens), - }; - - if json { - println!("{}", serde_json::to_string_pretty(&stats)?); - return Ok(()); - } - println!("id: {}", stats.session.id); - println!( - "home: {} ({})", - stats.session.home_name, stats.session.home_path - ); - println!("title: {}", stats.session.title.as_deref().unwrap_or("-")); - println!("cwd: {}", stats.session.cwd.as_deref().unwrap_or("-")); - println!("updated: {}", format_time(stats.session.updated_at_ms)); - println!("sqlite_tokens_used: {}", stats.sqlite_tokens_used); - println!( - "rollout_tokens: total={}, input={}, output={}", - opt_i64(stats.rollout_total_tokens), - opt_i64(stats.rollout_input_tokens), - opt_i64(stats.rollout_output_tokens) - ); - println!( - "rollout_file: {} (bytes={}, lines={})", - stats.session.rollout_path, - opt_u64(stats.rollout_bytes), - stats - .rollout_lines - .map(|v| v.to_string()) - .unwrap_or_else(|| "-".to_string()) - ); - Ok(()) -} - -pub fn trash_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { - if session_ids.is_empty() { - return Err(anyhow!("至少需要提供一个 session id")); - } - for session_id in session_ids { - let (home, row) = find_thread(&session_id, all_homes)? - .ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; - trash_one(&home, row)?; - } - Ok(()) -} - -pub fn list_trash(all_homes: bool, json: bool) -> Result<()> { - let mut entries = Vec::new(); - for home in homes(all_homes)? { - entries.extend(read_trash_entries(&home)?); - } - entries.sort_by(|a, b| b.deleted_at.cmp(&a.deleted_at)); - if json { - println!("{}", serde_json::to_string_pretty(&entries)?); - return Ok(()); - } - if entries.is_empty() { - println!("垃圾箱为空。"); - return Ok(()); - } - println!( - "{:<22} {:<12} {:<24} {}", - "ID", "Home", "Deleted At", "Title" - ); - for item in entries { - println!( - "{:<22} {:<12} {:<24} {}", - shorten(&item.session_id, 22), - shorten(&item.home_name, 12), - item.deleted_at, - shorten(item.title.as_deref().unwrap_or("-"), 80) - ); - } - Ok(()) -} - -pub fn restore_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { - if session_ids.is_empty() { - return Err(anyhow!("至少需要提供一个 session id")); - } - let manifests = trash_manifests(all_homes)?; - for session_id in session_ids { - let (manifest_path, manifest) = manifests - .iter() - .find(|(_, item)| item.session_id == session_id) - .cloned() - .ok_or_else(|| anyhow!("垃圾箱中没有找到会话: {session_id}"))?; - restore_one(&manifest_path, &manifest)?; - } - Ok(()) -} - -pub fn visibility_check(all_homes: bool, json: bool) -> Result<()> { - let issues = collect_visibility_issues(all_homes)?; - if json { - println!("{}", serde_json::to_string_pretty(&issues)?); - return Ok(()); - } - if issues.is_empty() { - println!("没有发现会话可见性问题。"); - return Ok(()); - } - println!( - "{:<12} {:<22} {:<18} {}", - "Home", "Session", "Issue", "Detail" - ); - for issue in issues { - println!( - "{:<12} {:<22} {:<18} {}", - shorten(&issue.home_name, 12), - shorten(&issue.session_id, 22), - issue.issue, - issue.detail - ); - } - Ok(()) -} - -pub fn visibility_repair(all_homes: bool, json: bool) -> Result<()> { - let mut actions = Vec::new(); - for home in homes(all_homes)? { - actions.extend(repair_home_visibility(&home)?); - } - if json { - println!("{}", serde_json::to_string_pretty(&actions)?); - return Ok(()); - } - if actions.is_empty() { - println!("没有需要修复的会话可见性问题。"); - return Ok(()); - } - for action in actions { - println!( - "{} {}: {}", - action.home_name, action.session_id, action.detail - ); - } - Ok(()) -} - -pub fn sync_threads(all_homes: bool, dry_run: bool, json: bool) -> Result<()> { - let selected_homes = homes(all_homes)?; - if selected_homes.len() < 2 { - return Err(anyhow!( - "sync-threads 至少需要两个 home,请先用 `cdxs home create` 添加实例" - )); - } - - // Choose the first seen copy of each thread as the source of truth, then - // fill in homes that do not have that thread yet. - let mut rows_by_id: HashMap = HashMap::new(); - let mut indexes: HashMap> = HashMap::new(); - for home in &selected_homes { - let rows = read_thread_rows_for_home(home)?; - indexes.insert(home.name.clone(), read_session_index_ids(&home.path)?); - for row in rows { - rows_by_id - .entry(row.id.clone()) - .or_insert((home.clone(), row)); - } - } - - let mut actions = Vec::new(); - for target in &selected_homes { - let target_rows = read_thread_rows_for_home(target)?; - let target_ids: HashSet = target_rows.into_iter().map(|row| row.id).collect(); - for (session_id, (source, source_row)) in &rows_by_id { - if target_ids.contains(session_id) { - continue; - } - let source_rollout = resolve_rollout_path(&source.path, &source_row.rollout_path); - if !source_rollout.exists() { - actions.push(SyncThreadAction { - session_id: session_id.clone(), - source_home: source.name.clone(), - target_home: target.name.clone(), - action: "skip".to_string(), - detail: "source rollout missing".to_string(), - }); - continue; - } - - let mut target_row = source_row.clone(); - let target_relative = portable_rollout_path(source_row, source, target); - let target_rollout = resolve_rollout_path(&target.path, &target_relative); - target_row.rollout_path = target_relative.clone(); - actions.push(SyncThreadAction { - session_id: session_id.clone(), - source_home: source.name.clone(), - target_home: target.name.clone(), - action: if dry_run { "would_sync" } else { "synced" }.to_string(), - detail: target_relative.clone(), - }); - if dry_run { - continue; - } - - backup_state_files(&target.path)?; - if let Some(parent) = target_rollout.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; - } - fs::copy(&source_rollout, &target_rollout).with_context(|| { - format!( - "复制 rollout 失败: source={}, target={}", - source_rollout.display(), - target_rollout.display() - ) - })?; - let conn = open_state_db(&target.path)?; - insert_thread(&conn, &target_row)?; - if !indexes - .get(&target.name) - .map(|ids| ids.contains(session_id)) - .unwrap_or(false) - { - append_session_index_entry(&target.path, &index_entry_from_thread(&target_row)?)?; - } - } - } - - if json { - println!("{}", serde_json::to_string_pretty(&actions)?); - return Ok(()); - } - if actions.is_empty() { - println!("所有 home 的会话线程已经一致。"); - return Ok(()); - } - for action in actions { - println!( - "{} -> {} {}: {}", - action.source_home, action.target_home, action.session_id, action.action - ); - } - Ok(()) -} - -fn read_sessions_for_home(home: &HomeTarget) -> Result> { - let db = state_db_path(&home.path); - if !db.exists() { - return Ok(Vec::new()); - } - let conn = Connection::open(&db) - .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; - let rows = read_thread_rows(&conn)?; - Ok(rows - .iter() - .map(|row| summary_from_thread(home, row)) - .collect()) -} - -fn read_thread_rows_for_home(home: &HomeTarget) -> Result> { - let db = state_db_path(&home.path); - if !db.exists() { - return Ok(Vec::new()); - } - let conn = open_state_db(&home.path)?; - read_thread_rows(&conn) -} - -fn read_thread_rows(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, - sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, - git_sha, git_branch, git_origin_url, cli_version, first_user_message, - agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, - created_at_ms, updated_at_ms - FROM threads", - )?; - let rows = stmt - .query_map([], thread_from_row)? - .collect::, _>>()?; - Ok(rows) -} - -fn find_thread(session_id: &str, all_homes: bool) -> Result> { - for home in homes(all_homes)? { - let db = state_db_path(&home.path); - if !db.exists() { - continue; - } - let conn = Connection::open(&db) - .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; - let mut stmt = conn.prepare( - "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, - sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, - git_sha, git_branch, git_origin_url, cli_version, first_user_message, - agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, - created_at_ms, updated_at_ms - FROM threads - WHERE id = ?1", - )?; - let mut rows = stmt.query(params![session_id])?; - if let Some(row) = rows.next()? { - return Ok(Some((home, thread_from_row(row)?))); - } - } - Ok(None) -} - -fn collect_visibility_issues(all_homes: bool) -> Result> { - let mut issues = Vec::new(); - for home in homes(all_homes)? { - let threads = read_thread_rows_for_home(&home)?; - let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); - let index_ids = read_session_index_ids(&home.path)?; - let rollouts = scan_rollouts(&home.path)?; - let rollout_ids: HashSet = rollouts.keys().cloned().collect(); - - // A visible Codex thread needs a SQLite row, a rollout file, and a - // session_index.jsonl entry. Report whichever side is missing. - for row in &threads { - if !resolve_rollout_path(&home.path, &row.rollout_path).exists() { - issues.push(issue( - &home, - &row.id, - "missing_rollout", - format!("rollout not found: {}", row.rollout_path), - )); - } - if !index_ids.contains(&row.id) { - issues.push(issue( - &home, - &row.id, - "missing_index", - "session_index.jsonl missing entry".to_string(), - )); - } - } - for id in index_ids.difference(&thread_ids) { - issues.push(issue( - &home, - id, - "orphan_index", - "session_index.jsonl entry has no SQLite thread".to_string(), - )); - } - for id in rollout_ids.difference(&thread_ids) { - issues.push(issue( - &home, - id, - "orphan_rollout", - "rollout file has no SQLite thread".to_string(), - )); - } - } - Ok(issues) -} - -fn repair_home_visibility(home: &HomeTarget) -> Result> { - let mut actions = Vec::new(); - let db = state_db_path(&home.path); - if !db.exists() { - return Ok(actions); - } - let conn = open_state_db(&home.path)?; - let threads = read_thread_rows(&conn)?; - let mut index_ids = read_session_index_ids(&home.path)?; - let rollouts = scan_rollouts(&home.path)?; - let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); - - // Existing SQLite rows are preferred. If their rollout path or index entry - // is missing, repair those pieces in place. - for row in &threads { - let current_rollout = resolve_rollout_path(&home.path, &row.rollout_path); - if !current_rollout.exists() { - if let Some(found) = rollouts.get(&row.id) { - backup_state_files(&home.path)?; - conn.execute( - "UPDATE threads SET rollout_path = ?1 WHERE id = ?2", - params![found.relative_path, row.id], - )?; - actions.push(repair_action( - home, - &row.id, - "repair_rollout_path", - format!("updated rollout_path to {}", found.relative_path), - )); - } - } - if !index_ids.contains(&row.id) { - backup_state_files(&home.path)?; - append_session_index_entry(&home.path, &index_entry_from_thread(row)?)?; - index_ids.insert(row.id.clone()); - actions.push(repair_action( - home, - &row.id, - "append_index", - "added session_index.jsonl entry".to_string(), - )); - } - } - - // Rollout files without SQLite rows can still be made visible by creating a - // minimal thread row from the rollout metadata. - for (session_id, rollout) in &rollouts { - if thread_ids.contains(session_id) { - continue; - } - backup_state_files(&home.path)?; - let row = thread_from_rollout(rollout); - insert_thread(&conn, &row)?; - if !index_ids.contains(session_id) { - append_session_index_entry(&home.path, &index_entry_from_thread(&row)?)?; - index_ids.insert(session_id.clone()); - } - actions.push(repair_action( - home, - session_id, - "insert_thread", - "created minimal SQLite thread from rollout metadata".to_string(), - )); - } - Ok(actions) -} - -fn trash_one(home: &HomeTarget, row: ThreadRowData) -> Result<()> { - let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f").to_string(); - let trash_dir = home.path.join("cdxs-trash").join(format!( - "{}-{}-{}", - stamp, - safe_name(&row.id), - safe_name(&home.name) - )); - fs::create_dir_all(&trash_dir) - .with_context(|| format!("创建垃圾箱目录失败: {}", trash_dir.display()))?; - - // Trash is reversible: save the rollout copy, removed index lines and the - // original SQLite row in a manifest before deleting visibility state. - let rollout = resolve_rollout_path(&home.path, &row.rollout_path); - let rollout_backup_file = if rollout.exists() { - let file_name = rollout - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("rollout.jsonl") - .to_string(); - fs::copy(&rollout, trash_dir.join(&file_name)).with_context(|| { - format!( - "备份 rollout 失败: source={}, trash={}", - rollout.display(), - trash_dir.display() - ) - })?; - Some(file_name) - } else { - None - }; - let index_entries = remove_session_index_entries(&home.path, &row.id)?; - let manifest = TrashManifest { - version: 1, - deleted_at: Utc::now().to_rfc3339(), - home_name: home.name.clone(), - home_path: home.path.to_string_lossy().to_string(), - session_id: row.id.clone(), - original_rollout_path: row.rollout_path.clone(), - rollout_backup_file, - session_index_entries: index_entries, - thread: row.clone(), - }; - let manifest_content = serde_json::to_string_pretty(&manifest)?; - atomic::write_atomic(&trash_dir.join("manifest.json"), &manifest_content)?; - - let db = state_db_path(&home.path); - let conn = Connection::open(&db) - .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; - conn.execute("DELETE FROM threads WHERE id = ?1", params![row.id])?; - if rollout.exists() { - fs::remove_file(&rollout) - .with_context(|| format!("删除 rollout 文件失败: {}", rollout.display()))?; - } - println!( - "已移入垃圾箱: {} ({})", - manifest.session_id, - trash_dir.display() - ); - Ok(()) -} - -fn restore_one(manifest_path: &Path, manifest: &TrashManifest) -> Result<()> { - let home_path = PathBuf::from(&manifest.home_path); - let db = state_db_path(&home_path); - let conn = Connection::open(&db) - .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; - // Restore SQLite first, then rollout and index entries. This makes a failed - // restore easy to retry from the manifest. - insert_thread(&conn, &manifest.thread)?; - - if let Some(file_name) = manifest.rollout_backup_file.as_deref() { - let backup = manifest_path - .parent() - .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))? - .join(file_name); - let target = resolve_rollout_path(&home_path, &manifest.original_rollout_path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; - } - fs::copy(&backup, &target).with_context(|| { - format!( - "恢复 rollout 失败: source={}, target={}", - backup.display(), - target.display() - ) - })?; - } - restore_session_index_entries(&home_path, &manifest.session_index_entries)?; - - let trash_dir = manifest_path - .parent() - .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?; - fs::remove_dir_all(trash_dir) - .with_context(|| format!("清理垃圾箱条目失败: {}", trash_dir.display()))?; - println!("已恢复会话: {}", manifest.session_id); - Ok(()) -} - -fn remove_session_index_entries(home: &Path, session_id: &str) -> Result> { - let path = home.join("session_index.jsonl"); - if !path.exists() { - return Ok(Vec::new()); - } - atomic::backup_if_exists(&path, home, "session_index.jsonl")?; - let content = fs::read_to_string(&path) - .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; - let mut kept = Vec::new(); - let mut removed = Vec::new(); - for line in content.lines() { - if line_session_id(line).as_deref() == Some(session_id) { - removed.push(line.to_string()); - } else { - kept.push(line.to_string()); - } - } - let mut output = kept.join("\n"); - if !output.is_empty() { - output.push('\n'); - } - atomic::write_atomic(&path, &output)?; - Ok(removed) -} - -fn restore_session_index_entries(home: &Path, entries: &[String]) -> Result<()> { - if entries.is_empty() { - return Ok(()); - } - let path = home.join("session_index.jsonl"); - let mut existing = if path.exists() { - fs::read_to_string(&path) - .with_context(|| format!("读取 session_index 失败: {}", path.display()))? - } else { - String::new() - }; - let existing_ids: HashSet = existing.lines().filter_map(line_session_id).collect(); - if !existing.is_empty() && !existing.ends_with('\n') { - existing.push('\n'); - } - for entry in entries { - let should_append = line_session_id(entry) - .map(|id| !existing_ids.contains(&id)) - .unwrap_or(true); - if should_append { - existing.push_str(entry); - existing.push('\n'); - } - } - atomic::backup_if_exists(&path, home, "session_index.jsonl")?; - atomic::write_atomic(&path, &existing)?; - Ok(()) -} - -fn read_session_index_ids(home: &Path) -> Result> { - let path = home.join("session_index.jsonl"); - if !path.exists() { - return Ok(HashSet::new()); - } - let content = fs::read_to_string(&path) - .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; - Ok(content.lines().filter_map(line_session_id).collect()) -} - -fn append_session_index_entry(home: &Path, entry: &str) -> Result<()> { - let path = home.join("session_index.jsonl"); - let mut content = if path.exists() { - fs::read_to_string(&path) - .with_context(|| format!("读取 session_index 失败: {}", path.display()))? - } else { - String::new() - }; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(entry); - content.push('\n'); - atomic::write_atomic(&path, &content)?; - Ok(()) -} - -fn index_entry_from_thread(row: &ThreadRowData) -> Result { - // Codex only needs a compact JSONL index entry for the session picker. - let title = row - .title - .as_deref() - .or(row.first_user_message.as_deref()) - .unwrap_or(&row.id); - let updated_at = row - .updated_at_ms - .or(row.updated_at.map(|value| value * 1000)) - .and_then(DateTime::::from_timestamp_millis) - .unwrap_or_else(Utc::now); - let value = serde_json::json!({ - "id": row.id, - "thread_name": title, - "updated_at": updated_at.to_rfc3339(), - }); - Ok(serde_json::to_string(&value)?) -} - -fn scan_rollouts(home: &Path) -> Result> { - let mut out = HashMap::new(); - for dirname in ["sessions", "archived_sessions"] { - let root = home.join(dirname); - if root.exists() { - scan_rollout_dir(home, &root, &mut out)?; - } - } - Ok(out) -} - -fn scan_rollout_dir(home: &Path, dir: &Path, out: &mut HashMap) -> Result<()> { - for entry in fs::read_dir(dir).with_context(|| format!("读取目录失败: {}", dir.display()))? - { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - scan_rollout_dir(home, &path, out)?; - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { - continue; - } - if let Some(meta) = read_rollout_meta(home, &path)? { - out.entry(meta.session_id.clone()).or_insert(meta); - } - } - Ok(()) -} - -fn read_rollout_meta(home: &Path, path: &Path) -> Result> { - let content = fs::read_to_string(path) - .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; - // session_meta is normally at the top of the rollout. Limit scanning so a - // malformed large transcript cannot make repair unexpectedly expensive. - for line in content.lines().take(25) { - let Ok(value) = serde_json::from_str::(line) else { - continue; - }; - if value.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - let payload = value.get("payload").unwrap_or(&Value::Null); - let Some(session_id) = payload.get("id").and_then(Value::as_str) else { - continue; - }; - let metadata = fs::metadata(path) - .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; - let updated_at_ms = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|value| value.as_millis() as i64); - return Ok(Some(RolloutMeta { - session_id: session_id.to_string(), - relative_path: relative_path(home, path), - cwd: payload - .get("cwd") - .and_then(Value::as_str) - .map(str::to_string), - model_provider: payload - .get("model_provider") - .and_then(Value::as_str) - .map(str::to_string), - source: payload - .get("source") - .and_then(Value::as_str) - .map(str::to_string), - cli_version: payload - .get("cli_version") - .and_then(Value::as_str) - .map(str::to_string), - created_at_ms: payload - .get("timestamp") - .and_then(Value::as_str) - .and_then(parse_rfc3339_ms), - updated_at_ms, - })); - } - Ok(None) -} - -fn thread_from_rollout(meta: &RolloutMeta) -> ThreadRowData { - let now_ms = Utc::now().timestamp_millis(); - let created_at_ms = meta.created_at_ms.or(meta.updated_at_ms).unwrap_or(now_ms); - let updated_at_ms = meta.updated_at_ms.unwrap_or(created_at_ms); - ThreadRowData { - id: meta.session_id.clone(), - rollout_path: meta.relative_path.clone(), - created_at: Some(created_at_ms / 1000), - updated_at: Some(updated_at_ms / 1000), - source: meta.source.clone(), - model_provider: meta - .model_provider - .clone() - .or_else(|| Some("openai".to_string())), - cwd: meta.cwd.clone(), - title: Some(meta.session_id.clone()), - sandbox_policy: None, - approval_mode: None, - tokens_used: Some(0), - has_user_event: Some(1), - archived: Some(0), - archived_at: None, - git_sha: None, - git_branch: None, - git_origin_url: None, - cli_version: meta.cli_version.clone().or_else(|| Some(String::new())), - first_user_message: Some(String::new()), - agent_nickname: None, - agent_role: None, - memory_mode: Some("enabled".to_string()), - model: None, - reasoning_effort: None, - agent_path: None, - created_at_ms: Some(created_at_ms), - updated_at_ms: Some(updated_at_ms), - } -} - -fn backup_state_files(home: &Path) -> Result<()> { - // These are the Codex files session operations may modify. - let db = state_db_path(home); - atomic::backup_if_exists(&db, home, "state_5.sqlite")?; - atomic::backup_if_exists( - &home.join("session_index.jsonl"), - home, - "session_index.jsonl", - )?; - Ok(()) -} - -fn open_state_db(home: &Path) -> Result { - let db = state_db_path(home); - Connection::open(&db).with_context(|| format!("打开 Codex 状态库失败: {}", db.display())) -} - -fn portable_rollout_path(row: &ThreadRowData, source: &HomeTarget, target: &HomeTarget) -> String { - let source_rollout = resolve_rollout_path(&source.path, &row.rollout_path); - let relative = relative_path(&source.path, &source_rollout); - let target_rollout = resolve_rollout_path(&target.path, &relative); - if target_rollout.exists() { - // Avoid overwriting an existing rollout in the target home. - let file_name = source_rollout - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("rollout.jsonl"); - format!( - "sessions/cdxs-sync/{}/{}", - safe_name(&source.name), - file_name - ) - } else { - relative - } -} - -fn relative_path(base: &Path, path: &Path) -> String { - path.strip_prefix(base) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") -} - -fn parse_rfc3339_ms(value: &str) -> Option { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|value| value.timestamp_millis()) -} - -fn issue(home: &HomeTarget, session_id: &str, issue: &str, detail: String) -> VisibilityIssue { - VisibilityIssue { - home_name: home.name.clone(), - home_path: home.path.to_string_lossy().to_string(), - session_id: session_id.to_string(), - issue: issue.to_string(), - detail, - } -} - -fn repair_action( - home: &HomeTarget, - session_id: &str, - action: &str, - detail: String, -) -> RepairAction { - RepairAction { - home_name: home.name.clone(), - session_id: session_id.to_string(), - action: action.to_string(), - detail, - } -} - -fn trash_manifests(all_homes: bool) -> Result> { - let mut manifests = Vec::new(); - for home in homes(all_homes)? { - let root = home.path.join("cdxs-trash"); - if !root.exists() { - continue; - } - for entry in - fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? - { - let entry = entry?; - let manifest_path = entry.path().join("manifest.json"); - if !manifest_path.exists() { - continue; - } - let content = fs::read_to_string(&manifest_path) - .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; - let manifest: TrashManifest = - serde_json::from_str(&content).context("解析 manifest 失败")?; - manifests.push((manifest_path, manifest)); - } - } - Ok(manifests) -} - -fn read_trash_entries(home: &HomeTarget) -> Result> { - let mut out = Vec::new(); - let root = home.path.join("cdxs-trash"); - if !root.exists() { - return Ok(out); - } - for entry in - fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? - { - let entry = entry?; - let manifest_path = entry.path().join("manifest.json"); - if !manifest_path.exists() { - continue; - } - let content = fs::read_to_string(&manifest_path) - .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; - let manifest: TrashManifest = - serde_json::from_str(&content).context("解析 manifest 失败")?; - out.push(TrashEntry { - home_name: manifest.home_name, - home_path: manifest.home_path, - session_id: manifest.session_id, - title: manifest.thread.title, - cwd: manifest.thread.cwd, - deleted_at: manifest.deleted_at, - trash_dir: entry.path().to_string_lossy().to_string(), - }); - } - Ok(out) -} - -fn homes(all_homes: bool) -> Result> { - let default_home = paths::codex_home(None)?; - if !all_homes { - return Ok(vec![HomeTarget { - name: "default".to_string(), - path: default_home, - }]); - } - - let store = Store::load(&default_home)?; - let mut seen = HashSet::new(); - let mut result = Vec::new(); - // De-duplicate paths because multiple names can point to the same home. - for home in &store.homes { - let path = paths::expand_home(PathBuf::from(&home.path)); - let key = path.to_string_lossy().to_string(); - if seen.insert(key) { - result.push(HomeTarget { - name: home.name.clone(), - path, - }); - } - } - Ok(result) -} - -fn thread_from_row(row: &Row<'_>) -> rusqlite::Result { - Ok(ThreadRowData { - id: row.get(0)?, - rollout_path: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - source: row.get(4)?, - model_provider: row.get(5)?, - cwd: row.get(6)?, - title: row.get(7)?, - sandbox_policy: row.get(8)?, - approval_mode: row.get(9)?, - tokens_used: row.get(10)?, - has_user_event: row.get(11)?, - archived: row.get(12)?, - archived_at: row.get(13)?, - git_sha: row.get(14)?, - git_branch: row.get(15)?, - git_origin_url: row.get(16)?, - cli_version: row.get(17)?, - first_user_message: row.get(18)?, - agent_nickname: row.get(19)?, - agent_role: row.get(20)?, - memory_mode: row.get(21)?, - model: row.get(22)?, - reasoning_effort: row.get(23)?, - agent_path: row.get(24)?, - created_at_ms: row.get(25)?, - updated_at_ms: row.get(26)?, - }) -} - -fn insert_thread(conn: &Connection, row: &ThreadRowData) -> Result<()> { - conn.execute( - "INSERT OR REPLACE INTO threads ( - id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, - sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, - git_sha, git_branch, git_origin_url, cli_version, first_user_message, - agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, - created_at_ms, updated_at_ms - ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, - ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27 - )", - params![ - row.id, - row.rollout_path, - row.created_at, - row.updated_at, - row.source, - row.model_provider, - row.cwd, - row.title, - row.sandbox_policy, - row.approval_mode, - row.tokens_used, - row.has_user_event, - row.archived, - row.archived_at, - row.git_sha, - row.git_branch, - row.git_origin_url, - row.cli_version, - row.first_user_message, - row.agent_nickname, - row.agent_role, - row.memory_mode, - row.model, - row.reasoning_effort, - row.agent_path, - row.created_at_ms, - row.updated_at_ms, - ], - )?; - Ok(()) -} - -fn summary_from_thread(home: &HomeTarget, row: &ThreadRowData) -> SessionSummary { - SessionSummary { - home_name: home.name.clone(), - home_path: home.path.to_string_lossy().to_string(), - id: row.id.clone(), - title: row.title.clone(), - cwd: row.cwd.clone(), - updated_at_ms: row - .updated_at_ms - .or(row.updated_at.map(|value| value * 1000)), - tokens_used: row.tokens_used.unwrap_or_default(), - rollout_path: row.rollout_path.clone(), - archived: row.archived.unwrap_or_default() != 0, - } -} - -fn rollout_stats(path: &Path) -> Result<(Option, Option, Option)> { - if !path.exists() { - return Ok((None, None, None)); - } - let metadata = fs::metadata(path) - .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; - let content = fs::read_to_string(path) - .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; - let mut usage = None; - for line in content.lines() { - // Keep the last token usage record, which represents the latest model - // accounting in the rollout. - if let Ok(value) = serde_json::from_str::(line) { - if let Some(next) = find_token_usage(&value) { - usage = Some(next); - } - } - } - Ok((Some(metadata.len()), Some(content.lines().count()), usage)) -} - -#[derive(Debug, Clone)] -struct TokenUsage { - total_tokens: Option, - input_tokens: Option, - output_tokens: Option, -} - -fn find_token_usage(value: &Value) -> Option { - match value { - Value::Object(map) => { - if let Some(total) = map.get("total_token_usage") { - return Some(TokenUsage { - total_tokens: total.get("total_tokens").and_then(Value::as_i64), - input_tokens: total.get("input_tokens").and_then(Value::as_i64), - output_tokens: total.get("output_tokens").and_then(Value::as_i64), - }); - } - for child in map.values() { - if let Some(usage) = find_token_usage(child) { - return Some(usage); - } - } - None - } - Value::Array(items) => items.iter().find_map(find_token_usage), - _ => None, - } -} - -fn line_session_id(line: &str) -> Option { - serde_json::from_str::(line) - .ok() - .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) -} - -fn state_db_path(home: &Path) -> PathBuf { - home.join("state_5.sqlite") -} - -fn resolve_rollout_path(home: &Path, rollout_path: &str) -> PathBuf { - let path = PathBuf::from(rollout_path); - if path.is_absolute() { - path - } else { - home.join(path) - } -} - -fn format_time(ms: Option) -> String { - let Some(ms) = ms else { - return "-".to_string(); - }; - DateTime::::from_timestamp_millis(ms) - .map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| ms.to_string()) -} - -fn opt_i64(value: Option) -> String { - value - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()) -} - -fn opt_u64(value: Option) -> String { - value - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()) -} - -fn safe_name(value: &str) -> String { - value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect() -} - -fn shorten(value: &str, width: usize) -> String { - if value.chars().count() <= width { - return value.to_string(); - } - if width <= 1 { - return "...".to_string(); - } - let mut out = value - .chars() - .take(width.saturating_sub(3)) - .collect::(); - out.push_str("..."); - out -} diff --git a/src/session/inspect.rs b/src/session/inspect.rs new file mode 100644 index 0000000..1f0629d --- /dev/null +++ b/src/session/inspect.rs @@ -0,0 +1,159 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use super::store::{ + find_thread, homes, read_sessions_for_home, resolve_rollout_path, summary_from_thread, +}; +use super::SessionStats; + +pub fn list_sessions(all_homes: bool, json: bool) -> Result<()> { + let mut sessions = Vec::new(); + for home in homes(all_homes)? { + sessions.extend(read_sessions_for_home(&home)?); + } + sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); + + if json { + println!("{}", serde_json::to_string_pretty(&sessions)?); + return Ok(()); + } + if sessions.is_empty() { + println!("没有找到 Codex 会话。"); + return Ok(()); + } + println!( + "{:<22} {:<12} {:<10} {:<20} {}", + "ID", "Home", "Tokens", "Updated", "Title" + ); + for item in sessions { + println!( + "{:<22} {:<12} {:<10} {:<20} {}", + super::shorten(&item.id, 22), + super::shorten(&item.home_name, 12), + item.tokens_used, + format_time(item.updated_at_ms), + super::shorten(item.title.as_deref().unwrap_or("-"), 80) + ); + } + Ok(()) +} + +pub fn session_stats(session_id: &str, all_homes: bool, json: bool) -> Result<()> { + let (home, row) = + find_thread(session_id, all_homes)?.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; + let summary = summary_from_thread(&home, &row); + let rollout = resolve_rollout_path(&home.path, &row.rollout_path); + let (rollout_bytes, rollout_lines, token_usage) = rollout_stats(&rollout)?; + let stats = SessionStats { + sqlite_tokens_used: row.tokens_used.unwrap_or_default(), + session: summary, + rollout_bytes, + rollout_lines, + rollout_total_tokens: token_usage.as_ref().and_then(|v| v.total_tokens), + rollout_input_tokens: token_usage.as_ref().and_then(|v| v.input_tokens), + rollout_output_tokens: token_usage.as_ref().and_then(|v| v.output_tokens), + }; + + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + return Ok(()); + } + println!("id: {}", stats.session.id); + println!( + "home: {} ({})", + stats.session.home_name, stats.session.home_path + ); + println!("title: {}", stats.session.title.as_deref().unwrap_or("-")); + println!("cwd: {}", stats.session.cwd.as_deref().unwrap_or("-")); + println!("updated: {}", format_time(stats.session.updated_at_ms)); + println!("sqlite_tokens_used: {}", stats.sqlite_tokens_used); + println!( + "rollout_tokens: total={}, input={}, output={}", + opt_i64(stats.rollout_total_tokens), + opt_i64(stats.rollout_input_tokens), + opt_i64(stats.rollout_output_tokens) + ); + println!( + "rollout_file: {} (bytes={}, lines={})", + stats.session.rollout_path, + opt_u64(stats.rollout_bytes), + stats + .rollout_lines + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()) + ); + Ok(()) +} + +fn rollout_stats(path: &Path) -> Result<(Option, Option, Option)> { + if !path.exists() { + return Ok((None, None, None)); + } + let metadata = fs::metadata(path) + .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; + let content = fs::read_to_string(path) + .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; + let mut usage = None; + for line in content.lines() { + if let Ok(value) = serde_json::from_str::(line) { + if let Some(next) = find_token_usage(&value) { + usage = Some(next); + } + } + } + Ok((Some(metadata.len()), Some(content.lines().count()), usage)) +} + +fn find_token_usage(value: &Value) -> Option { + match value { + Value::Object(map) => { + if let Some(total) = map.get("total_token_usage") { + return Some(TokenUsage { + total_tokens: total.get("total_tokens").and_then(Value::as_i64), + input_tokens: total.get("input_tokens").and_then(Value::as_i64), + output_tokens: total.get("output_tokens").and_then(Value::as_i64), + }); + } + for child in map.values() { + if let Some(usage) = find_token_usage(child) { + return Some(usage); + } + } + None + } + Value::Array(items) => items.iter().find_map(find_token_usage), + _ => None, + } +} + +fn format_time(ms: Option) -> String { + let Some(ms) = ms else { + return "-".to_string(); + }; + DateTime::::from_timestamp_millis(ms) + .map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| ms.to_string()) +} + +fn opt_i64(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn opt_u64(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +#[derive(Debug, Clone)] +pub(super) struct TokenUsage { + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..ae1eacb --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,180 @@ +//! Inspect and repair Codex session state. +//! +//! Codex stores session visibility across SQLite (`state_5.sqlite`), +//! `session_index.jsonl`, and rollout JSONL files. This module keeps those +//! pieces consistent, implements a reversible cdxs trash, and can copy missing +//! threads across managed homes. + +mod inspect; +mod store; +mod sync_threads; +mod trash; +mod visibility; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +pub use inspect::{list_sessions, session_stats}; +pub use sync_threads::sync_threads; +pub use trash::{list_trash, restore_sessions, trash_sessions}; +pub use visibility::{align_provider_buckets, visibility_check, visibility_repair}; + +#[derive(Debug, Clone, Serialize)] +pub struct SessionSummary { + pub home_name: String, + pub home_path: String, + pub id: String, + pub title: Option, + pub cwd: Option, + pub updated_at_ms: Option, + pub tokens_used: i64, + pub rollout_path: String, + pub archived: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SessionStats { + pub session: SessionSummary, + pub rollout_bytes: Option, + pub rollout_lines: Option, + pub sqlite_tokens_used: i64, + pub rollout_total_tokens: Option, + pub rollout_input_tokens: Option, + pub rollout_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VisibilityIssue { + pub home_name: String, + pub home_path: String, + pub session_id: String, + pub issue: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepairAction { + pub home_name: String, + pub session_id: String, + pub action: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProviderAlignSummary { + pub provider: String, + pub updated_threads: u64, + pub checked_rollouts: u64, + pub updated_rollouts: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SyncThreadAction { + pub session_id: String, + pub source_home: String, + pub target_home: String, + pub action: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct TrashManifest { + version: u32, + deleted_at: String, + home_name: String, + home_path: String, + session_id: String, + original_rollout_path: String, + rollout_backup_file: Option, + session_index_entries: Vec, + thread: ThreadRowData, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct TrashEntry { + home_name: String, + home_path: String, + session_id: String, + title: Option, + cwd: Option, + deleted_at: String, + trash_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct ThreadRowData { + id: String, + rollout_path: String, + created_at: Option, + updated_at: Option, + source: Option, + model_provider: Option, + cwd: Option, + title: Option, + sandbox_policy: Option, + approval_mode: Option, + tokens_used: Option, + has_user_event: Option, + archived: Option, + archived_at: Option, + git_sha: Option, + git_branch: Option, + git_origin_url: Option, + cli_version: Option, + first_user_message: Option, + agent_nickname: Option, + agent_role: Option, + memory_mode: Option, + model: Option, + reasoning_effort: Option, + agent_path: Option, + created_at_ms: Option, + updated_at_ms: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct HomeTarget { + name: String, + path: PathBuf, +} + +#[derive(Debug, Clone)] +pub(super) struct RolloutMeta { + session_id: String, + relative_path: String, + cwd: Option, + model_provider: Option, + source: Option, + cli_version: Option, + created_at_ms: Option, + updated_at_ms: Option, +} + +pub(super) fn shorten(value: &str, width: usize) -> String { + if value.chars().count() <= width { + return value.to_string(); + } + if width <= 1 { + return "...".to_string(); + } + let mut out = value + .chars() + .take(width.saturating_sub(3)) + .collect::(); + out.push_str("..."); + out +} + +pub(super) fn safe_name(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} diff --git a/src/session/store.rs b/src/session/store.rs new file mode 100644 index 0000000..ef1274a --- /dev/null +++ b/src/session/store.rs @@ -0,0 +1,417 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Row}; +use serde_json::Value; + +use crate::config_store::Store; +use crate::{atomic, codex_config, paths}; + +use super::{HomeTarget, RolloutMeta, SessionSummary, ThreadRowData}; + +pub(super) fn read_sessions_for_home(home: &HomeTarget) -> Result> { + let db = state_db_path(&home.path); + if !db.exists() { + return Ok(Vec::new()); + } + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + let rows = read_thread_rows(&conn)?; + Ok(rows + .iter() + .map(|row| summary_from_thread(home, row)) + .collect()) +} + +pub(super) fn read_thread_rows_for_home(home: &HomeTarget) -> Result> { + let db = state_db_path(&home.path); + if !db.exists() { + return Ok(Vec::new()); + } + let conn = open_state_db(&home.path)?; + read_thread_rows(&conn) +} + +pub(super) fn read_thread_rows(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + FROM threads", + )?; + let rows = stmt + .query_map([], thread_from_row)? + .collect::, _>>()?; + Ok(rows) +} + +pub(super) fn read_rollout_targets(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT id, rollout_path FROM threads WHERE rollout_path <> ''")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) +} + +pub(super) fn find_thread( + session_id: &str, + all_homes: bool, +) -> Result> { + for home in homes(all_homes)? { + let db = state_db_path(&home.path); + if !db.exists() { + continue; + } + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + let mut stmt = conn.prepare( + "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + FROM threads + WHERE id = ?1", + )?; + let mut rows = stmt.query(params![session_id])?; + if let Some(row) = rows.next()? { + return Ok(Some((home, thread_from_row(row)?))); + } + } + Ok(None) +} + +pub(super) fn read_session_index_ids(home: &Path) -> Result> { + let path = home.join("session_index.jsonl"); + if !path.exists() { + return Ok(HashSet::new()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; + Ok(content.lines().filter_map(line_session_id).collect()) +} + +pub(super) fn append_session_index_entry(home: &Path, entry: &str) -> Result<()> { + let path = home.join("session_index.jsonl"); + let mut content = if path.exists() { + fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))? + } else { + String::new() + }; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(entry); + content.push('\n'); + atomic::write_atomic(&path, &content)?; + Ok(()) +} + +pub(super) fn index_entry_from_thread(row: &ThreadRowData) -> Result { + let title = row + .title + .as_deref() + .or(row.first_user_message.as_deref()) + .unwrap_or(&row.id); + let updated_at = row + .updated_at_ms + .or(row.updated_at.map(|value| value * 1000)) + .and_then(DateTime::::from_timestamp_millis) + .unwrap_or_else(Utc::now); + let value = serde_json::json!({ + "id": row.id, + "thread_name": title, + "updated_at": updated_at.to_rfc3339(), + }); + Ok(serde_json::to_string(&value)?) +} + +pub(super) fn scan_rollouts(home: &Path) -> Result> { + let mut out = HashMap::new(); + for dirname in ["sessions", "archived_sessions"] { + let root = home.join(dirname); + if root.exists() { + scan_rollout_dir(home, &root, &mut out)?; + } + } + Ok(out) +} + +fn scan_rollout_dir(home: &Path, dir: &Path, out: &mut HashMap) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("读取目录失败: {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + scan_rollout_dir(home, &path, out)?; + continue; + } + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + if let Some(meta) = read_rollout_meta(home, &path)? { + out.entry(meta.session_id.clone()).or_insert(meta); + } + } + Ok(()) +} + +fn read_rollout_meta(home: &Path, path: &Path) -> Result> { + let content = fs::read_to_string(path) + .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; + for line in content.lines().take(25) { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; + } + let payload = value.get("payload").unwrap_or(&Value::Null); + let Some(session_id) = payload.get("id").and_then(Value::as_str) else { + continue; + }; + let metadata = fs::metadata(path) + .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; + let updated_at_ms = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|value| value.as_millis() as i64); + return Ok(Some(RolloutMeta { + session_id: session_id.to_string(), + relative_path: relative_path(home, path), + cwd: payload + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string), + model_provider: payload + .get("model_provider") + .and_then(Value::as_str) + .map(str::to_string), + source: payload + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + cli_version: payload + .get("cli_version") + .and_then(Value::as_str) + .map(str::to_string), + created_at_ms: payload + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_rfc3339_ms), + updated_at_ms, + })); + } + Ok(None) +} + +pub(super) fn thread_from_rollout(meta: &RolloutMeta) -> ThreadRowData { + let now_ms = Utc::now().timestamp_millis(); + let created_at_ms = meta.created_at_ms.or(meta.updated_at_ms).unwrap_or(now_ms); + let updated_at_ms = meta.updated_at_ms.unwrap_or(created_at_ms); + ThreadRowData { + id: meta.session_id.clone(), + rollout_path: meta.relative_path.clone(), + created_at: Some(created_at_ms / 1000), + updated_at: Some(updated_at_ms / 1000), + source: meta.source.clone(), + model_provider: meta + .model_provider + .clone() + .or_else(|| Some("openai".to_string())), + cwd: meta.cwd.clone(), + title: Some(meta.session_id.clone()), + sandbox_policy: None, + approval_mode: None, + tokens_used: Some(0), + has_user_event: Some(1), + archived: Some(0), + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + cli_version: meta.cli_version.clone().or_else(|| Some(String::new())), + first_user_message: Some(String::new()), + agent_nickname: None, + agent_role: None, + memory_mode: Some("enabled".to_string()), + model: None, + reasoning_effort: None, + agent_path: None, + created_at_ms: Some(created_at_ms), + updated_at_ms: Some(updated_at_ms), + } +} + +pub(super) fn backup_state_files(home: &Path) -> Result<()> { + let db = state_db_path(home); + atomic::backup_if_exists(&db, home, "state_5.sqlite")?; + atomic::backup_if_exists( + &home.join("session_index.jsonl"), + home, + "session_index.jsonl", + )?; + Ok(()) +} + +pub(super) fn open_state_db(home: &Path) -> Result { + let db = state_db_path(home); + Connection::open(&db).with_context(|| format!("打开 Codex 状态库失败: {}", db.display())) +} + +pub(super) fn relative_path(base: &Path, path: &Path) -> String { + path.strip_prefix(base) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn parse_rfc3339_ms(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|value| value.timestamp_millis()) +} + +pub(super) fn homes(all_homes: bool) -> Result> { + let default_home = paths::codex_home(None)?; + if !all_homes { + return Ok(vec![HomeTarget { + name: "default".to_string(), + path: default_home, + }]); + } + + let store = Store::load(&default_home)?; + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for home in &store.homes { + let path = paths::expand_home(PathBuf::from(&home.path)); + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + result.push(HomeTarget { + name: home.name.clone(), + path, + }); + } + } + Ok(result) +} + +fn thread_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(ThreadRowData { + id: row.get(0)?, + rollout_path: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + source: row.get(4)?, + model_provider: row.get(5)?, + cwd: row.get(6)?, + title: row.get(7)?, + sandbox_policy: row.get(8)?, + approval_mode: row.get(9)?, + tokens_used: row.get(10)?, + has_user_event: row.get(11)?, + archived: row.get(12)?, + archived_at: row.get(13)?, + git_sha: row.get(14)?, + git_branch: row.get(15)?, + git_origin_url: row.get(16)?, + cli_version: row.get(17)?, + first_user_message: row.get(18)?, + agent_nickname: row.get(19)?, + agent_role: row.get(20)?, + memory_mode: row.get(21)?, + model: row.get(22)?, + reasoning_effort: row.get(23)?, + agent_path: row.get(24)?, + created_at_ms: row.get(25)?, + updated_at_ms: row.get(26)?, + }) +} + +pub(super) fn insert_thread(conn: &Connection, row: &ThreadRowData) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, + ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27 + )", + params![ + row.id, + row.rollout_path, + row.created_at, + row.updated_at, + row.source, + row.model_provider, + row.cwd, + row.title, + row.sandbox_policy, + row.approval_mode, + row.tokens_used, + row.has_user_event, + row.archived, + row.archived_at, + row.git_sha, + row.git_branch, + row.git_origin_url, + row.cli_version, + row.first_user_message, + row.agent_nickname, + row.agent_role, + row.memory_mode, + row.model, + row.reasoning_effort, + row.agent_path, + row.created_at_ms, + row.updated_at_ms, + ], + )?; + Ok(()) +} + +pub(super) fn summary_from_thread(home: &HomeTarget, row: &ThreadRowData) -> SessionSummary { + SessionSummary { + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + id: row.id.clone(), + title: row.title.clone(), + cwd: row.cwd.clone(), + updated_at_ms: row + .updated_at_ms + .or(row.updated_at.map(|value| value * 1000)), + tokens_used: row.tokens_used.unwrap_or_default(), + rollout_path: row.rollout_path.clone(), + archived: row.archived.unwrap_or_default() != 0, + } +} + +pub(super) fn line_session_id(line: &str) -> Option { + serde_json::from_str::(line) + .ok() + .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) +} + +pub(super) fn state_db_path(home: &Path) -> PathBuf { + codex_config::state_db_path(home).unwrap_or_else(|_| home.join("state_5.sqlite")) +} + +pub(super) fn resolve_rollout_path(home: &Path, rollout_path: &str) -> PathBuf { + let path = PathBuf::from(rollout_path); + if path.is_absolute() { + path + } else { + home.join(path) + } +} diff --git a/src/session/sync_threads.rs b/src/session/sync_threads.rs new file mode 100644 index 0000000..73f01f8 --- /dev/null +++ b/src/session/sync_threads.rs @@ -0,0 +1,124 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{anyhow, Context, Result}; + +use super::store::{ + append_session_index_entry, backup_state_files, homes, index_entry_from_thread, insert_thread, + read_session_index_ids, read_thread_rows_for_home, relative_path, resolve_rollout_path, +}; +use super::{HomeTarget, SyncThreadAction, ThreadRowData}; + +pub fn sync_threads(all_homes: bool, dry_run: bool, json: bool) -> Result<()> { + let selected_homes = homes(all_homes)?; + if selected_homes.len() < 2 { + return Err(anyhow!( + "sync-threads 至少需要两个 home,请先用 `cdxs home create` 添加实例" + )); + } + + let mut rows_by_id: HashMap = HashMap::new(); + let mut indexes: HashMap> = HashMap::new(); + for home in &selected_homes { + let rows = read_thread_rows_for_home(home)?; + indexes.insert(home.name.clone(), read_session_index_ids(&home.path)?); + for row in rows { + rows_by_id + .entry(row.id.clone()) + .or_insert((home.clone(), row)); + } + } + + let mut actions = Vec::new(); + for target in &selected_homes { + let target_rows = read_thread_rows_for_home(target)?; + let target_ids: HashSet = target_rows.into_iter().map(|row| row.id).collect(); + for (session_id, (source, source_row)) in &rows_by_id { + if target_ids.contains(session_id) { + continue; + } + let source_rollout = resolve_rollout_path(&source.path, &source_row.rollout_path); + if !source_rollout.exists() { + actions.push(SyncThreadAction { + session_id: session_id.clone(), + source_home: source.name.clone(), + target_home: target.name.clone(), + action: "skip".to_string(), + detail: "source rollout missing".to_string(), + }); + continue; + } + + let mut target_row = source_row.clone(); + let target_relative = portable_rollout_path(source_row, source, target); + let target_rollout = resolve_rollout_path(&target.path, &target_relative); + target_row.rollout_path = target_relative.clone(); + actions.push(SyncThreadAction { + session_id: session_id.clone(), + source_home: source.name.clone(), + target_home: target.name.clone(), + action: if dry_run { "would_sync" } else { "synced" }.to_string(), + detail: target_relative.clone(), + }); + if dry_run { + continue; + } + + backup_state_files(&target.path)?; + if let Some(parent) = target_rollout.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; + } + std::fs::copy(&source_rollout, &target_rollout).with_context(|| { + format!( + "复制 rollout 失败: source={}, target={}", + source_rollout.display(), + target_rollout.display() + ) + })?; + let conn = super::store::open_state_db(&target.path)?; + insert_thread(&conn, &target_row)?; + if !indexes + .get(&target.name) + .map(|ids| ids.contains(session_id)) + .unwrap_or(false) + { + append_session_index_entry(&target.path, &index_entry_from_thread(&target_row)?)?; + } + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&actions)?); + return Ok(()); + } + if actions.is_empty() { + println!("所有 home 的会话线程已经一致。"); + return Ok(()); + } + for action in actions { + println!( + "{} -> {} {}: {}", + action.source_home, action.target_home, action.session_id, action.action + ); + } + Ok(()) +} + +fn portable_rollout_path(row: &ThreadRowData, source: &HomeTarget, target: &HomeTarget) -> String { + let source_rollout = resolve_rollout_path(&source.path, &row.rollout_path); + let relative = relative_path(&source.path, &source_rollout); + let target_rollout = resolve_rollout_path(&target.path, &relative); + if target_rollout.exists() { + let file_name = source_rollout + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("rollout.jsonl"); + format!( + "sessions/cdxs-sync/{}/{}", + super::safe_name(&source.name), + file_name + ) + } else { + relative + } +} diff --git a/src/session/trash.rs b/src/session/trash.rs new file mode 100644 index 0000000..fae140a --- /dev/null +++ b/src/session/trash.rs @@ -0,0 +1,279 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; + +use crate::atomic; + +use super::store::{ + find_thread, homes, insert_thread, line_session_id, open_state_db, resolve_rollout_path, + state_db_path, +}; +use super::{ThreadRowData, TrashEntry, TrashManifest}; + +pub fn trash_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { + if session_ids.is_empty() { + return Err(anyhow!("至少需要提供一个 session id")); + } + for session_id in session_ids { + let (home, row) = find_thread(&session_id, all_homes)? + .ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; + trash_one(&home, row)?; + } + Ok(()) +} + +pub fn list_trash(all_homes: bool, json: bool) -> Result<()> { + let mut entries = Vec::new(); + for home in homes(all_homes)? { + entries.extend(read_trash_entries(&home)?); + } + entries.sort_by(|a, b| b.deleted_at.cmp(&a.deleted_at)); + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + if entries.is_empty() { + println!("垃圾箱为空。"); + return Ok(()); + } + println!( + "{:<22} {:<12} {:<24} {}", + "ID", "Home", "Deleted At", "Title" + ); + for item in entries { + println!( + "{:<22} {:<12} {:<24} {}", + super::shorten(&item.session_id, 22), + super::shorten(&item.home_name, 12), + item.deleted_at, + super::shorten(item.title.as_deref().unwrap_or("-"), 80) + ); + } + Ok(()) +} + +pub fn restore_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { + if session_ids.is_empty() { + return Err(anyhow!("至少需要提供一个 session id")); + } + let manifests = trash_manifests(all_homes)?; + for session_id in session_ids { + let (manifest_path, manifest) = manifests + .iter() + .find(|(_, item)| item.session_id == session_id) + .cloned() + .ok_or_else(|| anyhow!("垃圾箱中没有找到会话: {session_id}"))?; + restore_one(&manifest_path, &manifest)?; + } + Ok(()) +} + +fn trash_one(home: &super::HomeTarget, row: ThreadRowData) -> Result<()> { + let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f").to_string(); + let trash_dir = home.path.join("cdxs-trash").join(format!( + "{}-{}-{}", + stamp, + super::safe_name(&row.id), + super::safe_name(&home.name) + )); + fs::create_dir_all(&trash_dir) + .with_context(|| format!("创建垃圾箱目录失败: {}", trash_dir.display()))?; + + let rollout = resolve_rollout_path(&home.path, &row.rollout_path); + let rollout_backup_file = if rollout.exists() { + let file_name = rollout + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("rollout.jsonl") + .to_string(); + fs::copy(&rollout, trash_dir.join(&file_name)).with_context(|| { + format!( + "备份 rollout 失败: source={}, trash={}", + rollout.display(), + trash_dir.display() + ) + })?; + Some(file_name) + } else { + None + }; + let index_entries = remove_session_index_entries(&home.path, &row.id)?; + let manifest = TrashManifest { + version: 1, + deleted_at: Utc::now().to_rfc3339(), + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + session_id: row.id.clone(), + original_rollout_path: row.rollout_path.clone(), + rollout_backup_file, + session_index_entries: index_entries, + thread: row.clone(), + }; + let manifest_content = serde_json::to_string_pretty(&manifest)?; + atomic::write_atomic(&trash_dir.join("manifest.json"), &manifest_content)?; + + let db = state_db_path(&home.path); + let conn = rusqlite::Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + conn.execute( + "DELETE FROM threads WHERE id = ?1", + rusqlite::params![row.id], + )?; + if rollout.exists() { + fs::remove_file(&rollout) + .with_context(|| format!("删除 rollout 文件失败: {}", rollout.display()))?; + } + println!( + "已移入垃圾箱: {} ({})", + manifest.session_id, + trash_dir.display() + ); + Ok(()) +} + +fn restore_one(manifest_path: &Path, manifest: &TrashManifest) -> Result<()> { + let home_path = PathBuf::from(&manifest.home_path); + let conn = open_state_db(&home_path)?; + insert_thread(&conn, &manifest.thread)?; + + if let Some(file_name) = manifest.rollout_backup_file.as_deref() { + let backup = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))? + .join(file_name); + let target = resolve_rollout_path(&home_path, &manifest.original_rollout_path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; + } + fs::copy(&backup, &target).with_context(|| { + format!( + "恢复 rollout 失败: source={}, target={}", + backup.display(), + target.display() + ) + })?; + } + restore_session_index_entries(&home_path, &manifest.session_index_entries)?; + + let trash_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?; + fs::remove_dir_all(trash_dir) + .with_context(|| format!("清理垃圾箱条目失败: {}", trash_dir.display()))?; + println!("已恢复会话: {}", manifest.session_id); + Ok(()) +} + +fn remove_session_index_entries(home: &Path, session_id: &str) -> Result> { + let path = home.join("session_index.jsonl"); + if !path.exists() { + return Ok(Vec::new()); + } + atomic::backup_if_exists(&path, home, "session_index.jsonl")?; + let content = fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; + let mut kept = Vec::new(); + let mut removed = Vec::new(); + for line in content.lines() { + if line_session_id(line).as_deref() == Some(session_id) { + removed.push(line.to_string()); + } else { + kept.push(line.to_string()); + } + } + let mut output = kept.join("\n"); + if !output.is_empty() { + output.push('\n'); + } + atomic::write_atomic(&path, &output)?; + Ok(removed) +} + +fn restore_session_index_entries(home: &Path, entries: &[String]) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + let path = home.join("session_index.jsonl"); + let mut existing = if path.exists() { + fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))? + } else { + String::new() + }; + let existing_ids: std::collections::HashSet = + existing.lines().filter_map(line_session_id).collect(); + if !existing.is_empty() && !existing.ends_with('\n') { + existing.push('\n'); + } + for entry in entries { + let should_append = line_session_id(entry) + .map(|id| !existing_ids.contains(&id)) + .unwrap_or(true); + if should_append { + existing.push_str(entry); + existing.push('\n'); + } + } + atomic::backup_if_exists(&path, home, "session_index.jsonl")?; + atomic::write_atomic(&path, &existing)?; + Ok(()) +} + +fn trash_manifests(all_homes: bool) -> Result> { + let mut manifests = Vec::new(); + for home in homes(all_homes)? { + let root = home.path.join("cdxs-trash"); + if !root.exists() { + continue; + } + for entry in + fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? + { + let entry = entry?; + let manifest_path = entry.path().join("manifest.json"); + if !manifest_path.exists() { + continue; + } + let content = fs::read_to_string(&manifest_path) + .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; + let manifest: TrashManifest = + serde_json::from_str(&content).context("解析 manifest 失败")?; + manifests.push((manifest_path, manifest)); + } + } + Ok(manifests) +} + +fn read_trash_entries(home: &super::HomeTarget) -> Result> { + let mut out = Vec::new(); + let root = home.path.join("cdxs-trash"); + if !root.exists() { + return Ok(out); + } + for entry in + fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? + { + let entry = entry?; + let manifest_path = entry.path().join("manifest.json"); + if !manifest_path.exists() { + continue; + } + let content = fs::read_to_string(&manifest_path) + .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; + let manifest: TrashManifest = + serde_json::from_str(&content).context("解析 manifest 失败")?; + out.push(TrashEntry { + home_name: manifest.home_name, + home_path: manifest.home_path, + session_id: manifest.session_id, + title: manifest.thread.title, + cwd: manifest.thread.cwd, + deleted_at: manifest.deleted_at, + trash_dir: entry.path().to_string_lossy().to_string(), + }); + } + Ok(out) +} diff --git a/src/session/visibility.rs b/src/session/visibility.rs new file mode 100644 index 0000000..8beff67 --- /dev/null +++ b/src/session/visibility.rs @@ -0,0 +1,342 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use serde_json::Value; + +use crate::atomic; +use crate::codex_config; + +use super::store::{ + append_session_index_entry, backup_state_files, homes, index_entry_from_thread, insert_thread, + open_state_db, read_rollout_targets, read_session_index_ids, read_thread_rows, + read_thread_rows_for_home, resolve_rollout_path, scan_rollouts, thread_from_rollout, +}; +use super::{ProviderAlignSummary, RepairAction, VisibilityIssue}; + +pub fn visibility_check(all_homes: bool, json: bool) -> Result<()> { + let issues = collect_visibility_issues(all_homes)?; + if json { + println!("{}", serde_json::to_string_pretty(&issues)?); + return Ok(()); + } + if issues.is_empty() { + println!("没有发现会话可见性问题。"); + return Ok(()); + } + println!( + "{:<12} {:<22} {:<18} {}", + "Home", "Session", "Issue", "Detail" + ); + for issue in issues { + println!( + "{:<12} {:<22} {:<18} {}", + super::shorten(&issue.home_name, 12), + super::shorten(&issue.session_id, 22), + issue.issue, + issue.detail + ); + } + Ok(()) +} + +pub fn visibility_repair(all_homes: bool, json: bool) -> Result<()> { + let mut actions = Vec::new(); + for home in homes(all_homes)? { + actions.extend(repair_home_visibility(&home)?); + } + if json { + println!("{}", serde_json::to_string_pretty(&actions)?); + return Ok(()); + } + if actions.is_empty() { + println!("没有需要修复的会话可见性问题。"); + return Ok(()); + } + for action in actions { + println!( + "{} {}: {}", + action.home_name, action.session_id, action.detail + ); + } + Ok(()) +} + +pub fn align_provider_buckets(codex_home: &Path) -> Result { + let provider = codex_config::active_model_provider(codex_home)?; + let db = codex_config::state_db_path(codex_home)?; + if !db.exists() { + return Ok(ProviderAlignSummary { + provider, + updated_threads: 0, + checked_rollouts: 0, + updated_rollouts: 0, + }); + } + + let mut conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + let rollout_targets = read_rollout_targets(&conn)?; + + atomic::backup_if_exists(&db, codex_home, "state_5.sqlite")?; + let tx = conn.transaction()?; + let updated_threads = tx.execute( + "UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1", + params![provider.as_str()], + )? as u64; + tx.commit()?; + + let mut checked_rollouts = 0; + let mut updated_rollouts = 0; + for (session_id, rollout_path) in rollout_targets { + let rollout = resolve_rollout_path(codex_home, &rollout_path); + if !rollout.exists() { + continue; + } + checked_rollouts += 1; + if align_rollout_provider(&rollout, &session_id, provider.as_str(), codex_home)? { + updated_rollouts += 1; + } + } + + Ok(ProviderAlignSummary { + provider, + updated_threads, + checked_rollouts, + updated_rollouts, + }) +} + +fn collect_visibility_issues(all_homes: bool) -> Result> { + let mut issues = Vec::new(); + for home in homes(all_homes)? { + let threads = read_thread_rows_for_home(&home)?; + let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); + let index_ids = read_session_index_ids(&home.path)?; + let rollouts = scan_rollouts(&home.path)?; + let rollout_ids: HashSet = rollouts.keys().cloned().collect(); + + for row in &threads { + if !resolve_rollout_path(&home.path, &row.rollout_path).exists() { + issues.push(issue( + &home, + &row.id, + "missing_rollout", + format!("rollout not found: {}", row.rollout_path), + )); + } + if !index_ids.contains(&row.id) { + issues.push(issue( + &home, + &row.id, + "missing_index", + "session_index.jsonl missing entry".to_string(), + )); + } + } + for id in index_ids.difference(&thread_ids) { + issues.push(issue( + &home, + id, + "orphan_index", + "session_index.jsonl entry has no SQLite thread".to_string(), + )); + } + for id in rollout_ids.difference(&thread_ids) { + issues.push(issue( + &home, + id, + "orphan_rollout", + "rollout file has no SQLite thread".to_string(), + )); + } + } + Ok(issues) +} + +fn repair_home_visibility(home: &super::HomeTarget) -> Result> { + let mut actions = Vec::new(); + let db = super::store::state_db_path(&home.path); + if !db.exists() { + return Ok(actions); + } + let conn = open_state_db(&home.path)?; + let threads = read_thread_rows(&conn)?; + let mut index_ids = read_session_index_ids(&home.path)?; + let rollouts = scan_rollouts(&home.path)?; + let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); + + for row in &threads { + let current_rollout = resolve_rollout_path(&home.path, &row.rollout_path); + if !current_rollout.exists() { + if let Some(found) = rollouts.get(&row.id) { + backup_state_files(&home.path)?; + conn.execute( + "UPDATE threads SET rollout_path = ?1 WHERE id = ?2", + params![found.relative_path, row.id], + )?; + actions.push(repair_action( + home, + &row.id, + "repair_rollout_path", + format!("updated rollout_path to {}", found.relative_path), + )); + } + } + if !index_ids.contains(&row.id) { + backup_state_files(&home.path)?; + append_session_index_entry(&home.path, &index_entry_from_thread(row)?)?; + index_ids.insert(row.id.clone()); + actions.push(repair_action( + home, + &row.id, + "append_index", + "added session_index.jsonl entry".to_string(), + )); + } + } + + for (session_id, rollout) in &rollouts { + if thread_ids.contains(session_id) { + continue; + } + backup_state_files(&home.path)?; + let row = thread_from_rollout(rollout); + insert_thread(&conn, &row)?; + if !index_ids.contains(session_id) { + append_session_index_entry(&home.path, &index_entry_from_thread(&row)?)?; + index_ids.insert(session_id.clone()); + } + actions.push(repair_action( + home, + session_id, + "insert_thread", + "created minimal SQLite thread from rollout metadata".to_string(), + )); + } + Ok(actions) +} + +fn issue( + home: &super::HomeTarget, + session_id: &str, + issue: &str, + detail: String, +) -> VisibilityIssue { + VisibilityIssue { + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + session_id: session_id.to_string(), + issue: issue.to_string(), + detail, + } +} + +fn repair_action( + home: &super::HomeTarget, + session_id: &str, + action: &str, + detail: String, +) -> RepairAction { + RepairAction { + home_name: home.name.clone(), + session_id: session_id.to_string(), + action: action.to_string(), + detail, + } +} + +pub(super) fn align_rollout_provider( + path: &Path, + session_id: &str, + provider: &str, + codex_home: &Path, +) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; + let Some(newline_index) = content.find('\n') else { + return Ok(false); + }; + let first_line = &content[..newline_index]; + let mut value: Value = match serde_json::from_str(first_line) { + Ok(value) => value, + Err(_) => return Ok(false), + }; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + return Ok(false); + } + let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) else { + return Ok(false); + }; + if payload.get("id").and_then(Value::as_str) != Some(session_id) { + return Ok(false); + } + if payload + .get("model_provider") + .and_then(Value::as_str) + .is_some_and(|current| current == provider) + { + return Ok(false); + } + payload.insert( + "model_provider".to_string(), + Value::String(provider.to_string()), + ); + let replacement = serde_json::to_string(&value)?; + let mut rewritten = String::with_capacity(content.len() + replacement.len()); + rewritten.push_str(&replacement); + rewritten.push_str(&content[newline_index..]); + atomic::backup_if_exists(path, codex_home, "rollout.jsonl")?; + atomic::write_atomic_preserve_times(path, &rewritten)?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use filetime::{set_file_times, FileTime}; + use uuid::Uuid; + + use super::align_rollout_provider; + + fn temp_home() -> PathBuf { + let path = std::env::temp_dir().join(format!("cdxs-session-test-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + #[test] + fn align_rollout_provider_rewrites_session_meta_first_line() { + let home = temp_home(); + let rollout = home.join("sessions").join("thread.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + fs::write( + &rollout, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"old\"}}\n", + "{\"type\":\"message\",\"payload\":{\"text\":\"hello\"}}\n" + ), + ) + .unwrap(); + let original_time = FileTime::from_unix_time(1_700_000_000, 0); + set_file_times(&rollout, original_time, original_time).unwrap(); + + let changed = align_rollout_provider(&rollout, "thread-1", "new", &home).unwrap(); + let content = fs::read_to_string(&rollout).unwrap(); + let metadata = fs::metadata(&rollout).unwrap(); + + assert!(changed); + assert!(content.contains("\"model_provider\":\"new\"")); + assert!(content.contains("\"text\":\"hello\"")); + assert_eq!( + FileTime::from_last_modification_time(&metadata), + original_time + ); + + let _ = fs::remove_dir_all(home); + } +}