feat(codex): add opt-in migration and ledger-based restore for unified session history

- Enable dialog gains a checkbox (default off) to migrate existing
  official sessions from the built-in "openai" bucket into the shared
  "custom" bucket, with per-generation backups; failed migrations retry
  at startup
- Disable dialog offers a precise restore driven by the backup ledger:
  only sessions recorded as "openai" in backups are flipped back, and
  sessions created while the toggle was on are never touched
- Completion marker and backup generations are bound to the canonical
  Codex config dir; migrate/restore serialize on an op lock and the
  marker is written conditionally inside the settings write lock
- save_settings rolls back the toggle and fails the save when the live
  rewrite fails; migration additionally requires the live config to
  actually route to the shared bucket (skips with live_not_unified so
  refused injection or proxy takeover can't split history)
- Restore refuses to run while the toggle is (re-)enabled and reports
  nothing_to_restore instead of a zero-count success; local migration
  markers are now backend-owned in merge_settings_for_save so stale
  frontend payloads can't resurrect them
- Settings autosave reverts optimistic form state on failure so a
  failed toggle change can't be replayed by an unrelated save
- ConfirmDialog supports an optional checkbox; all four locales updated
This commit is contained in:
Jason
2026-06-12 23:35:01 +08:00
parent 948d762792
commit eab6bfd20c
15 changed files with 1369 additions and 43 deletions
+77 -3
View File
@@ -287,6 +287,10 @@ pub struct LocalMigrations {
Option<CodexThirdPartyHistoryProviderBucketMigration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider_template_v1: Option<CodexProviderTemplateMigration>,
/// 统一会话开关的官方历史迁移标记。开关关闭时会被清除,
/// 这样重新开启能把"关闭期间"落入 openai 桶的官方会话补迁进来。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_official_history_unify_v1: Option<CodexOfficialHistoryUnifyMigration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -312,6 +316,21 @@ pub struct CodexProviderTemplateMigration {
pub migrated_provider_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexOfficialHistoryUnifyMigration {
pub completed_at: String,
pub target_provider_id: String,
#[serde(default)]
pub migrated_jsonl_files: usize,
#[serde(default)]
pub migrated_state_rows: usize,
/// 迁移时的规范化 Codex 目录。标记只对同一目录生效:
/// 切换 codex_config_dir 后旧标记不会挡住新目录的迁移。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -358,11 +377,15 @@ pub struct AppSettings {
#[serde(default)]
pub preserve_codex_official_auth_on_switch: bool,
/// Run official Codex providers under the shared "custom" model_provider id
/// so future official sessions share one resume-history bucket with
/// third-party providers. Forward-only: existing sessions are not migrated.
/// Opt-in: defaults to false.
/// so official sessions share one resume-history bucket with third-party
/// providers. Opt-in: defaults to false.
#[serde(default)]
pub unify_codex_session_history: bool,
/// User opted in (via the enable dialog checkbox) to migrate existing
/// official sessions ("openai" bucket) into the shared bucket. Persisted so
/// a failed migration retries at startup; cleared when the toggle turns off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unify_codex_migrate_existing: Option<bool>,
/// User has confirmed the failover toggle first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failover_confirmed: Option<bool>,
@@ -482,6 +505,7 @@ impl Default for AppSettings {
enable_failover_toggle: false,
preserve_codex_official_auth_on_switch: false,
unify_codex_session_history: false,
unify_codex_migrate_existing: None,
failover_confirmed: None,
first_run_notice_confirmed: None,
common_config_confirmed: None,
@@ -766,6 +790,56 @@ pub fn mark_codex_provider_template_migrated(
})
}
/// 统一会话迁移标记是否覆盖指定目录。标记里没记目录(不应出现的旧格式)
/// 视为不匹配——重跑迁移是幂等的,宁可重迁也不漏迁。
pub fn is_codex_official_history_unify_migrated_for_dir(codex_dir: &str) -> bool {
get_settings()
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.is_some_and(|migration| migration.codex_config_dir.as_deref() == Some(codex_dir))
}
/// 条件写入迁移完成标记:仅当此刻开关仍开启且迁移意愿仍在时才写。
/// 检查与写入在 settings 写锁内原子完成,与关闭开关路径
/// `update_settings` / 清标记)串行,消除"迁移线程复查开关后、写标记前
/// 用户恰好关闭开关"的竞态窗口。返回是否实际写入。
pub fn mark_codex_official_history_unify_migrated_if_enabled(
migration: CodexOfficialHistoryUnifyMigration,
) -> Result<bool, AppError> {
let mut written = false;
mutate_settings(|settings| {
if settings.unify_codex_session_history
&& settings.unify_codex_migrate_existing.unwrap_or(false)
{
settings
.local_migrations
.get_or_insert_with(Default::default)
.codex_official_history_unify_v1 = Some(migration);
written = true;
}
})?;
Ok(written)
}
pub fn clear_codex_official_history_unify_migration() -> Result<(), AppError> {
mutate_settings(|settings| {
if let Some(migrations) = settings.local_migrations.as_mut() {
migrations.codex_official_history_unify_v1 = None;
}
})
}
pub fn unify_codex_migrate_existing_requested() -> bool {
get_settings().unify_codex_migrate_existing.unwrap_or(false)
}
pub fn clear_codex_unify_migrate_existing() -> Result<(), AppError> {
mutate_settings(|settings| {
settings.unify_codex_migrate_existing = None;
})
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {