Skip to content

Commit 06b0fcb

Browse files
committed
fix(codex): 修复 provider 同步功能,解决审查意见
根据审查意见实现 Codex provider 同步功能: sync_codex_rollout_model_provider: 只修复缺失值(空字符串),不覆盖已有值 sync_codex_threads_model_provider: 只修复 NULL 值,不覆盖已有 provider 记录 不添加 rebuild_codex_session_index: 基于错误前提(threads 表不是全量来源) 只在 switch_normal 中执行,不在热切换路径执行(避免重 I/O 操作增加切换耗时) fixes #1694
1 parent 602c571 commit 06b0fcb

File tree

7 files changed

+210
-63
lines changed

7 files changed

+210
-63
lines changed

src-tauri/src/codex_config.rs

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// unused imports removed
2+
use std::io::{BufRead, BufReader};
23
use std::path::PathBuf;
34

45
use crate::config::{
56
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
67
write_text_file,
78
};
89
use crate::error::AppError;
10+
use rusqlite::Connection;
911
use serde_json::Value;
1012
use std::fs;
1113
use std::path::Path;
@@ -250,6 +252,115 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
250252
doc.to_string()
251253
}
252254

255+
/// 获取 Codex session_index.jsonl 路径
256+
#[allow(dead_code)]
257+
pub fn get_codex_session_index_path() -> PathBuf {
258+
get_codex_config_dir().join("session_index.jsonl")
259+
}
260+
261+
/// 获取 Codex state_5.sqlite 路径
262+
pub fn get_codex_state_db_path() -> PathBuf {
263+
get_codex_config_dir().join("state_5.sqlite")
264+
}
265+
266+
fn collect_rollout_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), AppError> {
267+
if !root.exists() {
268+
return Ok(());
269+
}
270+
271+
let entries = std::fs::read_dir(root).map_err(|e| AppError::io(root, e))?;
272+
for entry in entries {
273+
let entry = entry.map_err(|e| AppError::io(root, e))?;
274+
let path = entry.path();
275+
if path.is_dir() {
276+
collect_rollout_files(&path, files)?;
277+
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
278+
files.push(path);
279+
}
280+
}
281+
282+
Ok(())
283+
}
284+
285+
/// 修复 Codex 历史 rollout 首行里缺失的 model_provider
286+
///
287+
/// 只填充缺失值(空字符串或不存在),不覆盖已有值,避免污染跨 provider 的历史数据。
288+
///
289+
/// 注意:此函数在 Codex 运行时执行可能存在竞态条件(读-改-写整文件),
290+
/// 建议仅在 Codex 未运行时调用,或配合文件锁使用。
291+
pub fn sync_codex_rollout_model_provider(provider_id: &str) -> Result<usize, AppError> {
292+
let root = get_codex_config_dir();
293+
let mut files = Vec::new();
294+
collect_rollout_files(&root.join("sessions"), &mut files)?;
295+
collect_rollout_files(&root.join("archived_sessions"), &mut files)?;
296+
297+
let mut changed = 0usize;
298+
299+
for path in files {
300+
let file = std::fs::File::open(&path).map_err(|e| AppError::io(&path, e))?;
301+
let mut lines: Vec<String> = BufReader::new(file)
302+
.lines()
303+
.collect::<Result<Vec<_>, _>>()
304+
.map_err(|e| AppError::io(&path, e))?;
305+
306+
if lines.is_empty() {
307+
continue;
308+
}
309+
310+
let mut first: Value = match serde_json::from_str(&lines[0]) {
311+
Ok(value) => value,
312+
Err(_) => continue,
313+
};
314+
315+
if first.get("type").and_then(Value::as_str) != Some("session_meta") {
316+
continue;
317+
}
318+
319+
let Some(payload) = first.get_mut("payload").and_then(Value::as_object_mut) else {
320+
continue;
321+
};
322+
323+
// 只修复缺失值(空字符串或不存在),不覆盖已有 provider
324+
let current = payload
325+
.get("model_provider")
326+
.and_then(Value::as_str)
327+
.unwrap_or("");
328+
if !current.is_empty() {
329+
continue; // 已有 provider,跳过
330+
}
331+
332+
payload.insert(
333+
"model_provider".to_string(),
334+
Value::String(provider_id.to_string()),
335+
);
336+
lines[0] =
337+
serde_json::to_string(&first).map_err(|e| AppError::JsonSerialize { source: e })?;
338+
write_text_file(&path, &(lines.join("\n") + "\n"))?;
339+
changed += 1;
340+
}
341+
342+
Ok(changed)
343+
}
344+
345+
/// 将 Codex state_5.sqlite 中的 threads.model_provider 修复为当前活动 provider
346+
/// 只修复缺失值(NULL),不覆盖已有值,避免污染跨 provider 的历史数据
347+
pub fn sync_codex_threads_model_provider(provider_id: &str) -> Result<usize, AppError> {
348+
let path = get_codex_state_db_path();
349+
if !path.exists() {
350+
return Ok(0);
351+
}
352+
353+
let conn = Connection::open(&path).map_err(|e| AppError::Database(e.to_string()))?;
354+
// 只修复缺失值,不覆盖已有 provider 的历史记录
355+
let changed = conn
356+
.execute(
357+
"UPDATE threads SET model_provider = ?1 WHERE model_provider IS NULL",
358+
[provider_id],
359+
)
360+
.map_err(|e| AppError::Database(e.to_string()))?;
361+
Ok(changed)
362+
}
363+
253364
#[cfg(test)]
254365
mod tests {
255366
use super::*;
@@ -263,7 +374,6 @@ model = "gpt-5.1-codex"
263374
name = "any"
264375
wire_api = "responses"
265376
"#;
266-
267377
let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap();
268378
let parsed: toml::Value = toml::from_str(&result).unwrap();
269379

src-tauri/src/commands/misc.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,7 +1415,7 @@ mod tests {
14151415

14161416
let count = paths
14171417
.iter()
1418-
.filter(|path| **path == PathBuf::from("/same/path"))
1418+
.filter(|path| **path == std::path::Path::new("/same/path"))
14191419
.count();
14201420
assert_eq!(count, 1);
14211421
}
@@ -1427,7 +1427,7 @@ mod tests {
14271427

14281428
let count = paths
14291429
.iter()
1430-
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
1430+
.filter(|path| **path == std::path::Path::new("/home/tester/.bun/bin"))
14311431
.count();
14321432
assert_eq!(count, 1);
14331433
}

src-tauri/src/commands/settings.rs

Lines changed: 65 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,15 @@ mod tests {
8585

8686
#[test]
8787
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
88-
let mut existing = AppSettings::default();
89-
existing.webdav_sync = Some(WebDavSyncSettings {
90-
base_url: "https://dav.example.com".to_string(),
91-
username: "alice".to_string(),
92-
password: "secret".to_string(),
93-
..WebDavSyncSettings::default()
94-
});
88+
let existing = AppSettings {
89+
webdav_sync: Some(WebDavSyncSettings {
90+
base_url: "https://dav.example.com".to_string(),
91+
username: "alice".to_string(),
92+
password: "secret".to_string(),
93+
..WebDavSyncSettings::default()
94+
}),
95+
..Default::default()
96+
};
9597

9698
let incoming = AppSettings::default();
9799
let merged = merge_settings_for_save(incoming, &existing);
@@ -105,21 +107,25 @@ mod tests {
105107

106108
#[test]
107109
fn save_settings_should_keep_incoming_webdav_when_present() {
108-
let mut existing = AppSettings::default();
109-
existing.webdav_sync = Some(WebDavSyncSettings {
110-
base_url: "https://dav.old.example.com".to_string(),
111-
username: "old".to_string(),
112-
password: "old-pass".to_string(),
113-
..WebDavSyncSettings::default()
114-
});
115-
116-
let mut incoming = AppSettings::default();
117-
incoming.webdav_sync = Some(WebDavSyncSettings {
118-
base_url: "https://dav.new.example.com".to_string(),
119-
username: "new".to_string(),
120-
password: "new-pass".to_string(),
121-
..WebDavSyncSettings::default()
122-
});
110+
let existing = AppSettings {
111+
webdav_sync: Some(WebDavSyncSettings {
112+
base_url: "https://dav.old.example.com".to_string(),
113+
username: "old".to_string(),
114+
password: "old-pass".to_string(),
115+
..WebDavSyncSettings::default()
116+
}),
117+
..Default::default()
118+
};
119+
120+
let incoming = AppSettings {
121+
webdav_sync: Some(WebDavSyncSettings {
122+
base_url: "https://dav.new.example.com".to_string(),
123+
username: "new".to_string(),
124+
password: "new-pass".to_string(),
125+
..WebDavSyncSettings::default()
126+
}),
127+
..Default::default()
128+
};
123129

124130
let merged = merge_settings_for_save(incoming, &existing);
125131

@@ -135,22 +141,26 @@ mod tests {
135141
/// must NOT overwrite the existing one.
136142
#[test]
137143
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
138-
let mut existing = AppSettings::default();
139-
existing.webdav_sync = Some(WebDavSyncSettings {
140-
base_url: "https://dav.example.com".to_string(),
141-
username: "alice".to_string(),
142-
password: "secret".to_string(),
143-
..WebDavSyncSettings::default()
144-
});
144+
let existing = AppSettings {
145+
webdav_sync: Some(WebDavSyncSettings {
146+
base_url: "https://dav.example.com".to_string(),
147+
username: "alice".to_string(),
148+
password: "secret".to_string(),
149+
..WebDavSyncSettings::default()
150+
}),
151+
..Default::default()
152+
};
145153

146154
// Simulate frontend sending settings with cleared password
147-
let mut incoming = AppSettings::default();
148-
incoming.webdav_sync = Some(WebDavSyncSettings {
149-
base_url: "https://dav.example.com".to_string(),
150-
username: "alice".to_string(),
151-
password: "".to_string(),
152-
..WebDavSyncSettings::default()
153-
});
155+
let incoming = AppSettings {
156+
webdav_sync: Some(WebDavSyncSettings {
157+
base_url: "https://dav.example.com".to_string(),
158+
username: "alice".to_string(),
159+
password: "".to_string(),
160+
..WebDavSyncSettings::default()
161+
}),
162+
..Default::default()
163+
};
154164

155165
let merged = merge_settings_for_save(incoming, &existing);
156166

@@ -165,21 +175,25 @@ mod tests {
165175
/// work without panicking and keep the empty state.
166176
#[test]
167177
fn save_settings_should_handle_both_empty_passwords() {
168-
let mut existing = AppSettings::default();
169-
existing.webdav_sync = Some(WebDavSyncSettings {
170-
base_url: "https://dav.example.com".to_string(),
171-
username: "alice".to_string(),
172-
password: "".to_string(),
173-
..WebDavSyncSettings::default()
174-
});
175-
176-
let mut incoming = AppSettings::default();
177-
incoming.webdav_sync = Some(WebDavSyncSettings {
178-
base_url: "https://dav.example.com".to_string(),
179-
username: "alice".to_string(),
180-
password: "".to_string(),
181-
..WebDavSyncSettings::default()
182-
});
178+
let existing = AppSettings {
179+
webdav_sync: Some(WebDavSyncSettings {
180+
base_url: "https://dav.example.com".to_string(),
181+
username: "alice".to_string(),
182+
password: "".to_string(),
183+
..WebDavSyncSettings::default()
184+
}),
185+
..Default::default()
186+
};
187+
188+
let incoming = AppSettings {
189+
webdav_sync: Some(WebDavSyncSettings {
190+
base_url: "https://dav.example.com".to_string(),
191+
username: "alice".to_string(),
192+
password: "".to_string(),
193+
..WebDavSyncSettings::default()
194+
}),
195+
..Default::default()
196+
};
183197

184198
let merged = merge_settings_for_save(incoming, &existing);
185199

src-tauri/src/database/backup.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,8 +791,10 @@ mod tests {
791791
std::fs::create_dir_all(&test_home).expect("create test home");
792792
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
793793

794-
let mut settings = AppSettings::default();
795-
settings.backup_interval_hours = Some(0);
794+
let settings = AppSettings {
795+
backup_interval_hours: Some(0),
796+
..Default::default()
797+
};
796798
update_settings(settings).expect("disable auto backup");
797799

798800
let db = Database::memory()?;

src-tauri/src/provider.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,10 @@ mod tests {
725725

726726
#[test]
727727
fn provider_meta_serializes_pricing_model_source() {
728-
let mut meta = ProviderMeta::default();
729-
meta.pricing_model_source = Some("response".to_string());
728+
let meta = ProviderMeta {
729+
pricing_model_source: Some("response".to_string()),
730+
..Default::default()
731+
};
730732

731733
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
732734

src-tauri/src/proxy/response_processor.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -772,9 +772,11 @@ mod tests {
772772
db.set_pricing_model_source(app_type, "response").await?;
773773
seed_pricing(&db)?;
774774

775-
let mut meta = ProviderMeta::default();
776-
meta.cost_multiplier = Some("2".to_string());
777-
meta.pricing_model_source = Some("request".to_string());
775+
let meta = ProviderMeta {
776+
cost_multiplier: Some("2".to_string()),
777+
pricing_model_source: Some("request".to_string()),
778+
..Default::default()
779+
};
778780
insert_provider(&db, "provider-1", app_type, meta)?;
779781

780782
let state = build_state(db.clone());

src-tauri/src/services/provider/mod.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ use serde::Deserialize;
1313
use serde_json::Value;
1414

1515
use crate::app_config::AppType;
16+
use crate::codex_config::{sync_codex_rollout_model_provider, sync_codex_threads_model_provider};
1617
use crate::error::AppError;
1718
use crate::provider::{Provider, UsageResult};
1819
use crate::services::mcp::McpService;
1920
use crate::settings::CustomEndpoint;
2021
use crate::store::AppState;
21-
2222
// Re-export sub-module functions for external access
2323
pub use live::{
2424
import_default_config, import_openclaw_providers_from_live,
@@ -1542,13 +1542,30 @@ impl ProviderService {
15421542
}
15431543
}
15441544

1545+
// Codex 特殊处理:切换 live 配置后,同步历史 rollout 首行中的 model_provider,
1546+
// 避免 thread/list 因历史元数据与当前 provider 不一致而过滤空历史。
1547+
// 注意:此操作只在正常切换时执行,不在热切换时执行(避免重 I/O 操作增加切换耗时)
1548+
if matches!(app_type, AppType::Codex) {
1549+
if let Err(e) = sync_codex_rollout_model_provider(id) {
1550+
log::warn!("同步 Codex rollout model_provider 失败: {e}");
1551+
result
1552+
.warnings
1553+
.push("codex_rollout_sync_failed".to_string());
1554+
}
1555+
if let Err(e) = sync_codex_threads_model_provider(id) {
1556+
log::warn!("同步 Codex threads.model_provider 失败: {e}");
1557+
result
1558+
.warnings
1559+
.push("codex_threads_sync_failed".to_string());
1560+
}
1561+
}
1562+
15451563
// Sync MCP
15461564
McpService::sync_all_enabled(state)?;
15471565

15481566
Ok(result)
15491567
}
15501568

1551-
/// Sync current provider to live configuration (re-export)
15521569
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
15531570
sync_current_to_live(state)
15541571
}

0 commit comments

Comments
 (0)