1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
3use serde_json::{Value, json};
4use std::{
5 collections::{BTreeMap, HashSet},
6 fs,
7 path::{Path, PathBuf},
8 time::{SystemTime, UNIX_EPOCH},
9};
10use walkdir::WalkDir;
11
12mod model;
13mod output;
14mod storage;
15
16use model::{
17 Assignment, IndexDiagnostics, RolloutDiagnostics, RolloutRecovery, SessionMetaRepair,
18 SessionRecord, SidebarThread,
19};
20pub use model::{DoctorReport, Paths, Report};
21pub use output::{print_doctor_report, print_report};
22use storage::{atomic_copy, atomic_write, copy_database, open_database};
23
24const MAX_RECOVERY_FILE_SIZE: u64 = 1024 * 1024 * 1024;
25
26impl Paths {
27 pub fn resolve(codex_home: Option<PathBuf>) -> Result<Self> {
28 let home = dirs::home_dir().context("无法确定用户主目录")?;
29 let codex_home = codex_home.unwrap_or_else(|| home.join(".codex"));
30 let paths = Self {
31 config: codex_home.join("config.toml"),
32 database: codex_home.join("state_5.sqlite"),
33 session_index: codex_home.join("session_index.jsonl"),
34 backup_root: codex_home.join("codex-sync-backups"),
35 codex_home,
36 };
37 paths.validate()?;
38 Ok(paths)
39 }
40
41 fn validate(&self) -> Result<()> {
42 if !self.codex_home.is_dir() {
43 bail!("Codex 数据目录不存在:{}", self.codex_home.display());
44 }
45 if !self.config.is_file() {
46 bail!("Codex 配置不存在:{}", self.config.display());
47 }
48 if !self.database.is_file() {
49 bail!("Codex 历史数据库不存在:{}", self.database.display());
50 }
51 Ok(())
52 }
53}
54
55pub fn inspect(paths: &Paths) -> Result<Report> {
56 let target = active_assignment(paths)?;
57 let records = session_records(paths)?;
58 let mut rollout_counts = BTreeMap::new();
59 let mut rollout_model_counts = BTreeMap::new();
60 let mut rollout_files_to_change = 0;
61 let mut duplicate_session_meta_lines = 0;
62 for record in &records {
63 *rollout_counts
64 .entry(display_value(&record.provider))
65 .or_insert(0) += 1;
66 *rollout_model_counts
67 .entry(display_value(record.model.as_deref().unwrap_or("")))
68 .or_insert(0) += 1;
69 if assignment_differs(&record.provider, record.model.as_deref(), &target) {
70 rollout_files_to_change += 1;
71 }
72 duplicate_session_meta_lines += record.session_meta_lines.saturating_sub(1);
73 }
74
75 let conn = open_database(&paths.database, true)?;
76 let columns = thread_columns(&conn)?;
77 if !columns.contains("model_provider") {
78 bail!("threads 表缺少 model_provider 字段,无法安全归并历史");
79 }
80 let database_counts = grouped_counts(&conn, "model_provider")?;
81 let model_counts = if columns.contains("model") {
82 grouped_counts(&conn, "model")?
83 } else {
84 BTreeMap::new()
85 };
86 let total_threads = conn.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?;
87 let database_rows_to_change = count_database_changes(&conn, &columns, &target)?;
88 let sidebar_threads = sidebar_threads(&conn, &columns)?;
89 let missing_rollout_files = sidebar_threads
90 .iter()
91 .filter(|thread| !rollout_exists(&paths.codex_home, thread.rollout_path.as_deref()))
92 .count();
93 let database_ids = indexable_ids(&paths.codex_home, &sidebar_threads);
94 let index_ids = read_session_index(&paths.session_index)?
95 .into_keys()
96 .collect::<HashSet<_>>();
97
98 Ok(Report {
99 current_provider: target.provider,
100 current_model: target.model,
101 total_threads,
102 database_counts,
103 model_counts,
104 database_rows_to_change,
105 rollout_counts,
106 rollout_model_counts,
107 rollout_files: records.len(),
108 rollout_files_to_change,
109 duplicate_session_meta_lines,
110 missing_session_index_entries: database_ids.difference(&index_ids).count(),
111 stale_session_index_entries: index_ids.difference(&database_ids).count(),
112 missing_rollout_files,
113 backup: None,
114 })
115}
116
117pub fn doctor(
118 paths: &Paths,
119 apply: bool,
120 restore_missing: &[PathBuf],
121 dedupe_session_meta: Option<&Path>,
122) -> Result<DoctorReport> {
123 let report = inspect_doctor(paths)?;
124 if !apply {
125 if !restore_missing.is_empty() || dedupe_session_meta.is_some() {
126 bail!("恢复或去重参数必须与 --apply 一起使用");
127 }
128 return Ok(report);
129 }
130 if !report.database_integrity_ok {
131 bail!("数据库完整性检查失败;doctor 未修改任何数据");
132 }
133 if report.invalid_rollout_files != 0
134 || report.missing_session_meta_files != 0
135 || report.invalid_session_index_lines != 0
136 {
137 bail!("存在无法安全自动修复的 JSONL;doctor 未修改任何数据");
138 }
139 let recoveries = prepare_rollout_recoveries(paths, restore_missing)?;
140 let session_repair = dedupe_session_meta
141 .map(|path| prepare_session_meta_repair(paths, path))
142 .transpose()?;
143 let needs_index_repair = report.missing_session_index_entries != 0
144 || report.stale_session_index_entries != 0
145 || report.duplicate_session_index_ids != 0;
146 if !needs_index_repair && recoveries.is_empty() && session_repair.is_none() {
147 return Ok(report);
148 }
149
150 let original = fs::read(&paths.session_index).ok();
151 let backup = backup_doctor_state(paths, &report, original.as_deref(), session_repair.as_ref())?;
152 let mut installed = Vec::new();
153 let mut session_replaced = false;
154 let index_touched = needs_index_repair || !recoveries.is_empty();
155 let repair = (|| -> Result<DoctorReport> {
156 let mut connection = open_database(&paths.database, false)?;
157 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
158 for recovery in &recoveries {
159 if recovery.target.exists() {
160 bail!(
161 "待恢复的 rollout 已被其他进程创建:{}",
162 recovery.target.display()
163 );
164 }
165 atomic_write(&recovery.target, &recovery.content)?;
166 installed.push(recovery.target.clone());
167 }
168 if let Some(repair) = &session_repair {
169 if fs::read(&repair.target)? != repair.original {
170 bail!(
171 "rollout 在诊断后发生变化,拒绝覆盖:{}",
172 repair.target.display()
173 );
174 }
175 atomic_write(&repair.target, &repair.repaired)?;
176 session_replaced = true;
177 validate_recovery_rollout(&repair.target, &repair.repaired)?;
178 }
179 if index_touched {
180 let content = render_session_index(paths, &transaction)?;
181 atomic_write(&paths.session_index, &content)?;
182 }
183 let verified = inspect_doctor(paths)?;
184 if (index_touched
185 && (verified.invalid_session_index_lines != 0
186 || verified.duplicate_session_index_ids != 0
187 || verified.missing_session_index_entries != 0
188 || verified.stale_session_index_entries != 0))
189 || verified.missing_rollout_files + recoveries.len() != report.missing_rollout_files
190 {
191 bail!("rollout/侧栏索引原子替换后的复检失败");
192 }
193 transaction.commit()?;
194 Ok(verified)
195 })();
196
197 match repair {
198 Ok(mut verified) => {
199 verified.repaired = true;
200 verified.repaired_session_index = index_touched;
201 verified.restored_rollout_files = recoveries.len();
202 verified.deduplicated_session_meta_files = usize::from(session_repair.is_some());
203 verified.backup = Some(backup);
204 Ok(verified)
205 }
206 Err(error) => {
207 for target in installed.iter().rev() {
208 let _ = fs::remove_file(target);
209 }
210 if session_replaced && let Some(repair) = &session_repair {
211 atomic_write(&repair.target, &repair.original)
212 .context("修复失败,且恢复原 rollout 失败")?;
213 }
214 if index_touched {
215 restore_original_index(paths, original.as_deref())
216 .context("修复失败,且恢复原侧栏索引失败")?;
217 }
218 Err(error.context("修复失败;已原子恢复所有已替换文件"))
219 }
220 }
221}
222
223fn inspect_doctor(paths: &Paths) -> Result<DoctorReport> {
224 paths.validate()?;
225 let connection = open_database(&paths.database, true)?;
226 let database_integrity_messages = database_integrity(&connection)?;
227 let database_integrity_ok = database_integrity_messages.as_slice() == ["ok"];
228 let columns = thread_columns(&connection)?;
229 let total_threads =
230 connection.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?;
231 let sidebar = sidebar_threads(&connection, &columns)?;
232 let database_ids = all_database_ids(&connection)?;
233 let indexable = indexable_ids(&paths.codex_home, &sidebar);
234 let missing_rollout_files = sidebar
235 .iter()
236 .filter(|thread| !rollout_exists(&paths.codex_home, thread.rollout_path.as_deref()))
237 .count();
238 let index = inspect_session_index(&paths.session_index)?;
239 let index_ids = index.entries.keys().cloned().collect::<HashSet<_>>();
240 let rollouts = inspect_rollouts(paths)?;
241 let orphan_rollout_files = rollouts
242 .thread_ids
243 .iter()
244 .filter(|id| !database_ids.contains(*id))
245 .count();
246
247 let mut report = DoctorReport {
248 database_integrity_ok,
249 database_integrity_messages,
250 total_threads,
251 sidebar_threads: sidebar.len(),
252 rollout_files: rollouts.files,
253 invalid_rollout_files: rollouts.invalid_files,
254 missing_session_meta_files: rollouts.missing_session_meta_files,
255 duplicate_session_meta_lines: rollouts.duplicate_session_meta_lines,
256 orphan_rollout_files,
257 missing_rollout_files,
258 invalid_session_index_lines: index.invalid_lines,
259 duplicate_session_index_ids: index.duplicate_ids,
260 missing_session_index_entries: indexable.difference(&index_ids).count(),
261 stale_session_index_entries: index_ids.difference(&indexable).count(),
262 clean: false,
263 repaired: false,
264 repaired_session_index: false,
265 restored_rollout_files: 0,
266 deduplicated_session_meta_files: 0,
267 backup: None,
268 };
269 report.clean = doctor_is_clean(&report);
270 Ok(report)
271}
272
273fn doctor_is_clean(report: &DoctorReport) -> bool {
274 report.database_integrity_ok
275 && report.invalid_rollout_files == 0
276 && report.missing_session_meta_files == 0
277 && report.duplicate_session_meta_lines == 0
278 && report.orphan_rollout_files == 0
279 && report.missing_rollout_files == 0
280 && report.invalid_session_index_lines == 0
281 && report.duplicate_session_index_ids == 0
282 && report.missing_session_index_entries == 0
283 && report.stale_session_index_entries == 0
284}
285
286pub fn merge(paths: &Paths, apply: bool) -> Result<Report> {
287 let mut report = inspect(paths)?;
288 let needs_change = report.database_rows_to_change != 0
289 || report.rollout_files_to_change != 0
290 || report.duplicate_session_meta_lines != 0
291 || report.missing_session_index_entries != 0
292 || report.stale_session_index_entries != 0;
293 if !apply || !needs_change {
294 return Ok(report);
295 }
296
297 let target = active_assignment(paths)?;
298 let backup_path = backup(paths, "history-merge")?;
299 update_database(paths, &target)?;
300 rewrite_sessions(paths, &target)?;
301 rebuild_session_index(paths)?;
302
303 let verified = inspect(paths)?;
304 if verified.database_rows_to_change != 0
305 || verified.rollout_files_to_change != 0
306 || verified.duplicate_session_meta_lines != 0
307 || verified.missing_session_index_entries != 0
308 || verified.stale_session_index_entries != 0
309 {
310 bail!(
311 "历史归并后验证失败;修改前备份位于 {},请先恢复备份",
312 backup_path.display()
313 );
314 }
315 report.backup = Some(backup_path);
316 Ok(report)
317}
318
319pub fn backup(paths: &Paths, label: &str) -> Result<PathBuf> {
320 paths.validate()?;
321 fs::create_dir_all(&paths.backup_root)?;
322 let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
323 let destination = paths.backup_root.join(format!("{label}-{stamp}"));
324 fs::create_dir_all(&destination)?;
325
326 copy_database(&paths.database, &destination.join("state_5.sqlite"))?;
327 if paths.session_index.is_file() {
328 fs::copy(
329 &paths.session_index,
330 destination.join("session_index.jsonl"),
331 )?;
332 }
333 for source in rollout_paths(paths) {
334 let relative = source.strip_prefix(&paths.codex_home)?;
335 let target = destination.join(relative);
336 if let Some(parent) = target.parent() {
337 fs::create_dir_all(parent)?;
338 }
339 fs::copy(source, target)?;
340 }
341 Ok(destination)
342}
343
344pub fn validate_backup(path: &Path) -> Result<()> {
345 if !path.is_dir() {
346 bail!("历史备份目录不存在:{}", path.display());
347 }
348 if !path.join("state_5.sqlite").is_file() {
349 bail!("备份缺少 state_5.sqlite:{}", path.display());
350 }
351 Ok(())
352}
353
354pub fn restore(paths: &Paths, source: &Path) -> Result<PathBuf> {
355 validate_backup(source)?;
356 let safety = backup(paths, "pre-history-restore")?;
357 copy_database(&source.join("state_5.sqlite"), &paths.database)?;
358
359 let index = source.join("session_index.jsonl");
360 if index.is_file() {
361 atomic_copy(&index, &paths.session_index)?;
362 }
363 for directory in ["sessions", "archived_sessions"] {
364 let root = source.join(directory);
365 if !root.is_dir() {
366 continue;
367 }
368 for entry in WalkDir::new(&root).follow_links(false) {
369 let entry = entry?;
370 if !entry.file_type().is_file() {
371 continue;
372 }
373 let relative = entry.path().strip_prefix(source)?;
374 atomic_copy(entry.path(), &paths.codex_home.join(relative))?;
375 }
376 }
377 Ok(safety)
378}
379
380pub fn reconcile_session_index(codex_home: &Path) -> Result<usize> {
381 let paths = Paths {
382 config: codex_home.join("config.toml"),
383 database: codex_home.join("state_5.sqlite"),
384 session_index: codex_home.join("session_index.jsonl"),
385 backup_root: codex_home.join("codex-sync-backups"),
386 codex_home: codex_home.to_path_buf(),
387 };
388 if !paths.database.is_file() {
389 return Ok(0);
390 }
391 let conn = open_database(&paths.database, true)?;
392 let columns = thread_columns(&conn)?;
393 let database_ids = indexable_ids(&paths.codex_home, &sidebar_threads(&conn, &columns)?);
394 let index_ids = read_session_index(&paths.session_index)?
395 .into_keys()
396 .collect::<HashSet<_>>();
397 let missing = database_ids.difference(&index_ids).count();
398 drop(conn);
399 rebuild_session_index(&paths)?;
400 Ok(missing)
401}
402
403fn active_assignment(paths: &Paths) -> Result<Assignment> {
404 let text = fs::read_to_string(&paths.config).context("无法读取 Codex config.toml")?;
405 let value: toml::Value = toml::from_str(&text).context("Codex config.toml 格式错误")?;
406 let provider = value
407 .get("model_provider")
408 .and_then(toml::Value::as_str)
409 .map(str::to_owned)
410 .unwrap_or_else(|| "openai".to_owned());
411 let model = value
412 .get("model")
413 .and_then(toml::Value::as_str)
414 .map(str::to_owned);
415 Ok(Assignment { provider, model })
416}
417
418fn assignment_differs(provider: &str, model: Option<&str>, target: &Assignment) -> bool {
419 provider != target.provider
420 || target
421 .model
422 .as_deref()
423 .is_some_and(|expected| model != Some(expected))
424}
425
426fn display_value(value: &str) -> String {
427 if value.is_empty() {
428 "(empty)".into()
429 } else {
430 value.into()
431 }
432}
433
434fn database_integrity(connection: &Connection) -> Result<Vec<String>> {
435 let mut statement = connection.prepare("PRAGMA quick_check")?;
436 Ok(statement
437 .query_map([], |row| row.get(0))?
438 .collect::<rusqlite::Result<Vec<_>>>()?)
439}
440
441fn all_database_ids(connection: &Connection) -> Result<HashSet<String>> {
442 let mut statement = connection.prepare("SELECT id FROM threads")?;
443 Ok(statement
444 .query_map([], |row| row.get(0))?
445 .collect::<rusqlite::Result<HashSet<_>>>()?)
446}
447
448fn thread_columns(conn: &Connection) -> Result<HashSet<String>> {
449 let mut statement = conn.prepare("PRAGMA table_info(threads)")?;
450 let names = statement
451 .query_map([], |row| row.get::<_, String>(1))?
452 .collect::<rusqlite::Result<HashSet<_>>>()?;
453 Ok(names)
454}
455
456fn grouped_counts(conn: &Connection, column: &str) -> Result<BTreeMap<String, usize>> {
457 let sql = format!(
458 "SELECT COALESCE({column}, ''), COUNT(*) FROM threads GROUP BY {column} ORDER BY {column}"
459 );
460 let mut statement = conn.prepare(&sql)?;
461 let rows = statement.query_map([], |row| {
462 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
463 })?;
464 let mut counts = BTreeMap::new();
465 for row in rows {
466 let (value, count) = row?;
467 counts.insert(display_value(&value), count);
468 }
469 Ok(counts)
470}
471
472fn count_database_changes(
473 conn: &Connection,
474 columns: &HashSet<String>,
475 target: &Assignment,
476) -> Result<usize> {
477 if columns.contains("model")
478 && let Some(model) = &target.model
479 {
480 return Ok(conn.query_row(
481 "SELECT COUNT(*) FROM threads WHERE model_provider IS NULL OR model_provider<>?1 OR model IS NULL OR model<>?2",
482 params![target.provider, model],
483 |row| row.get(0),
484 )?);
485 }
486 Ok(conn.query_row(
487 "SELECT COUNT(*) FROM threads WHERE model_provider IS NULL OR model_provider<>?1",
488 [&target.provider],
489 |row| row.get(0),
490 )?)
491}
492
493fn sidebar_threads(conn: &Connection, columns: &HashSet<String>) -> Result<Vec<SidebarThread>> {
494 let title = if columns.contains("title") {
495 "COALESCE(title, id)"
496 } else {
497 "id"
498 };
499 let updated = if columns.contains("updated_at") {
500 "COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', CASE WHEN updated_at>100000000000 THEN updated_at/1000 ELSE updated_at END, 'unixepoch'), '')"
501 } else {
502 "''"
503 };
504 let rollout_path = if columns.contains("rollout_path") {
505 "rollout_path"
506 } else {
507 "NULL"
508 };
509 let source = if columns.contains("source") {
510 "COALESCE(source, '')"
511 } else {
512 "''"
513 };
514 let thread_source = if columns.contains("thread_source") {
515 "COALESCE(thread_source, '')"
516 } else {
517 "''"
518 };
519 let model = if columns.contains("model") {
520 "COALESCE(model, '')"
521 } else {
522 "''"
523 };
524 let archived = if columns.contains("archived") {
525 " WHERE archived=0"
526 } else {
527 ""
528 };
529 let sql = format!(
530 "SELECT id, {title}, {updated}, {rollout_path}, {source}, {thread_source}, {model} FROM threads{archived}"
531 );
532 let mut statement = conn.prepare(&sql)?;
533 let rows = statement.query_map([], |row| {
534 Ok((
535 row.get::<_, String>(0)?,
536 row.get::<_, String>(1)?,
537 row.get::<_, String>(2)?,
538 row.get::<_, Option<String>>(3)?,
539 row.get::<_, String>(4)?,
540 row.get::<_, String>(5)?,
541 row.get::<_, String>(6)?,
542 ))
543 })?;
544 let mut threads = Vec::new();
545 for row in rows {
546 let (id, title, updated_at, rollout_path, source, thread_source, model) = row?;
547 if is_technical_thread(&source, &thread_source, &model, &title) {
548 continue;
549 }
550 threads.push(SidebarThread {
551 id,
552 title,
553 updated_at,
554 rollout_path: rollout_path.map(PathBuf::from),
555 });
556 }
557 Ok(threads)
558}
559
560fn is_subagent_source(source: &str) -> bool {
561 serde_json::from_str::<Value>(source)
562 .ok()
563 .is_some_and(|value| value.get("subagent").is_some())
564}
565
566fn is_technical_thread(source: &str, thread_source: &str, model: &str, title: &str) -> bool {
567 is_subagent_source(source)
568 || thread_source == "subagent"
569 || (model == "codex-auto-review" && title.trim().is_empty())
570}
571
572fn rollout_exists(codex_home: &Path, rollout_path: Option<&Path>) -> bool {
573 let Some(path) = rollout_path else {
574 return true;
575 };
576 if path.is_absolute() {
577 path.is_file()
578 } else {
579 codex_home.join(path).is_file()
580 }
581}
582
583fn indexable_ids(codex_home: &Path, threads: &[SidebarThread]) -> HashSet<String> {
584 threads
585 .iter()
586 .filter(|thread| rollout_exists(codex_home, thread.rollout_path.as_deref()))
587 .map(|thread| thread.id.clone())
588 .collect()
589}
590
591fn rollout_paths(paths: &Paths) -> Vec<PathBuf> {
592 ["sessions", "archived_sessions"]
593 .into_iter()
594 .flat_map(|directory| {
595 WalkDir::new(paths.codex_home.join(directory))
596 .follow_links(false)
597 .into_iter()
598 .filter_map(Result::ok)
599 .filter(|entry| {
600 entry.file_type().is_file()
601 && entry
602 .path()
603 .extension()
604 .is_some_and(|value| value == "jsonl")
605 })
606 .map(|entry| entry.into_path())
607 })
608 .collect()
609}
610
611fn inspect_rollouts(paths: &Paths) -> Result<RolloutDiagnostics> {
612 let mut diagnostics = RolloutDiagnostics::default();
613 for path in rollout_paths(paths) {
614 diagnostics.files += 1;
615 let text = fs::read_to_string(&path)
616 .with_context(|| format!("无法读取会话文件:{}", path.display()))?;
617 let mut invalid = false;
618 let mut session_meta_lines = 0usize;
619 let mut first_id = None;
620 for line in text.lines().filter(|line| !line.trim().is_empty()) {
621 let value: Value = match serde_json::from_str(line) {
622 Ok(value) => value,
623 Err(_) => {
624 invalid = true;
625 continue;
626 }
627 };
628 if value.get("type").and_then(Value::as_str) != Some("session_meta") {
629 continue;
630 }
631 session_meta_lines += 1;
632 if first_id.is_none() {
633 first_id = value
634 .get("payload")
635 .and_then(|payload| payload.get("id"))
636 .and_then(Value::as_str)
637 .map(str::to_owned);
638 }
639 }
640 diagnostics.invalid_files += usize::from(invalid);
641 diagnostics.missing_session_meta_files += usize::from(session_meta_lines == 0);
642 diagnostics.duplicate_session_meta_lines += session_meta_lines.saturating_sub(1);
643 if let Some(id) = first_id {
644 diagnostics.thread_ids.push(id);
645 }
646 }
647 Ok(diagnostics)
648}
649
650fn prepare_rollout_recoveries(paths: &Paths, sources: &[PathBuf]) -> Result<Vec<RolloutRecovery>> {
651 if sources.is_empty() {
652 return Ok(Vec::new());
653 }
654 let connection = open_database(&paths.database, true)?;
655 let columns = thread_columns(&connection)?;
656 let missing = sidebar_threads(&connection, &columns)?
657 .into_iter()
658 .filter(|thread| !rollout_exists(&paths.codex_home, thread.rollout_path.as_deref()))
659 .collect::<Vec<_>>();
660 let mut targets = HashSet::new();
661 let mut recoveries = Vec::new();
662
663 for source in sources {
664 let metadata = fs::symlink_metadata(source)
665 .with_context(|| format!("恢复来源不存在:{}", source.display()))?;
666 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
667 bail!("恢复来源不是普通文件:{}", source.display());
668 }
669 if metadata.len() > MAX_RECOVERY_FILE_SIZE {
670 bail!("恢复来源超过大小上限:{}", source.display());
671 }
672 let content = fs::read(source)?;
673 let thread_id = validate_recovery_rollout(source, &content)?;
674 let thread = missing
675 .iter()
676 .find(|thread| thread.id == thread_id)
677 .with_context(|| format!("恢复来源不对应任何缺失线程:{}", source.display()))?;
678 let relative = thread
679 .rollout_path
680 .as_deref()
681 .context("缺失线程没有 rollout_path,无法自动恢复")?;
682 let target = if relative.is_absolute() {
683 relative.to_path_buf()
684 } else {
685 paths.codex_home.join(relative)
686 };
687 ensure_safe_recovery_target(&paths.codex_home, &target)?;
688 if source.file_name() != target.file_name() {
689 bail!("恢复来源文件名与数据库 rollout_path 不一致");
690 }
691 if !targets.insert(target.clone()) {
692 bail!("同一缺失 rollout 被重复指定:{}", target.display());
693 }
694 recoveries.push(RolloutRecovery { target, content });
695 }
696 Ok(recoveries)
697}
698
699fn prepare_session_meta_repair(paths: &Paths, target: &Path) -> Result<SessionMetaRepair> {
700 let target = if target.is_absolute() {
701 target.to_path_buf()
702 } else {
703 paths.codex_home.join(target)
704 };
705 ensure_safe_recovery_target(&paths.codex_home, &target)?;
706 let relative = target.strip_prefix(&paths.codex_home)?;
707 if !relative.starts_with("sessions") && !relative.starts_with("archived_sessions") {
708 bail!("只能去重 sessions 或 archived_sessions 中的 rollout");
709 }
710 let metadata = fs::symlink_metadata(&target)?;
711 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
712 bail!("rollout 不是普通文件:{}", target.display());
713 }
714 if metadata.len() > MAX_RECOVERY_FILE_SIZE {
715 bail!("rollout 超过大小上限:{}", target.display());
716 }
717 let connection = open_database(&paths.database, true)?;
718 let database_id = connection
719 .query_row(
720 "SELECT id FROM threads WHERE rollout_path=?1",
721 [target.to_string_lossy().as_ref()],
722 |row| row.get::<_, String>(0),
723 )
724 .optional()?
725 .with_context(|| format!("数据库中找不到 rollout_path:{}", target.display()))?;
726 let original = fs::read(&target)?;
727 let repaired = deduplicate_session_meta(&target, &original, &database_id)?;
728 Ok(SessionMetaRepair {
729 target,
730 original,
731 repaired,
732 })
733}
734
735fn deduplicate_session_meta(path: &Path, content: &[u8], expected_id: &str) -> Result<Vec<u8>> {
736 let text = std::str::from_utf8(content)
737 .with_context(|| format!("rollout 不是 UTF-8 JSONL:{}", path.display()))?;
738 let mut output = Vec::with_capacity(content.len());
739 let mut session_meta_lines = 0usize;
740 let mut kept_expected = false;
741 for segment in text.split_inclusive('\n') {
742 let line = segment.strip_suffix('\n').unwrap_or(segment);
743 let line = line.strip_suffix('\r').unwrap_or(line);
744 let value: Value = serde_json::from_str(line)
745 .with_context(|| format!("rollout 包含非法 JSONL:{}", path.display()))?;
746 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
747 session_meta_lines += 1;
748 let id = value
749 .get("payload")
750 .and_then(|payload| payload.get("id"))
751 .and_then(Value::as_str)
752 .context("session_meta 缺少线程 ID")?;
753 if id != expected_id || kept_expected {
754 continue;
755 }
756 kept_expected = true;
757 }
758 output.extend_from_slice(segment.as_bytes());
759 }
760 if session_meta_lines < 2 {
761 bail!("rollout 没有重复 session_meta,无需修改");
762 }
763 if !kept_expected {
764 bail!("rollout 中没有与数据库线程 ID 一致的 session_meta");
765 }
766 Ok(output)
767}
768
769fn validate_recovery_rollout(source: &Path, content: &[u8]) -> Result<String> {
770 let text = std::str::from_utf8(content)
771 .with_context(|| format!("恢复来源不是 UTF-8 JSONL:{}", source.display()))?;
772 let mut thread_id = None;
773 let mut session_meta_lines = 0usize;
774 for line in text.lines().filter(|line| !line.trim().is_empty()) {
775 let value: Value = serde_json::from_str(line)
776 .with_context(|| format!("恢复来源包含非法 JSONL:{}", source.display()))?;
777 if value.get("type").and_then(Value::as_str) != Some("session_meta") {
778 continue;
779 }
780 session_meta_lines += 1;
781 let id = value
782 .get("payload")
783 .and_then(|payload| payload.get("id"))
784 .and_then(Value::as_str)
785 .context("恢复来源的 session_meta 缺少线程 ID")?;
786 if let Some(existing) = &thread_id
787 && existing != id
788 {
789 bail!("恢复来源包含不同线程的 session_meta");
790 }
791 thread_id = Some(id.to_owned());
792 }
793 if session_meta_lines != 1 {
794 bail!("恢复来源必须恰好包含一条 session_meta");
795 }
796 thread_id.context("恢复来源缺少 session_meta")
797}
798
799fn ensure_safe_recovery_target(codex_home: &Path, target: &Path) -> Result<()> {
800 if !target.starts_with(codex_home)
801 || target
802 .strip_prefix(codex_home)?
803 .components()
804 .any(|component| !matches!(component, std::path::Component::Normal(_)))
805 {
806 bail!(
807 "数据库 rollout_path 超出 Codex 数据目录:{}",
808 target.display()
809 );
810 }
811 let mut current = codex_home.to_path_buf();
812 for component in target.strip_prefix(codex_home)?.components() {
813 current.push(component);
814 if current
815 .symlink_metadata()
816 .is_ok_and(|metadata| metadata.file_type().is_symlink())
817 {
818 bail!("拒绝通过符号链接恢复 rollout:{}", current.display());
819 }
820 }
821 Ok(())
822}
823
824fn session_records(paths: &Paths) -> Result<Vec<SessionRecord>> {
825 let mut records = Vec::new();
826 for path in rollout_paths(paths) {
827 let text = fs::read_to_string(&path)
828 .with_context(|| format!("无法读取会话文件:{}", path.display()))?;
829 let mut record = None;
830 let mut session_meta_lines = 0;
831 for line in text.lines() {
832 let value: Value = serde_json::from_str(line)
833 .with_context(|| format!("会话 JSONL 格式错误:{}", path.display()))?;
834 if value.get("type").and_then(Value::as_str) != Some("session_meta") {
835 continue;
836 }
837 session_meta_lines += 1;
838 if record.is_some() {
839 continue;
840 }
841 let payload = value
842 .get("payload")
843 .and_then(Value::as_object)
844 .with_context(|| format!("session_meta 缺少 payload:{}", path.display()))?;
845 let provider = payload
846 .get("model_provider")
847 .and_then(Value::as_str)
848 .unwrap_or_default()
849 .to_owned();
850 let model = payload
851 .get("model")
852 .and_then(Value::as_str)
853 .map(str::to_owned);
854 record = Some(SessionRecord {
855 path: path.clone(),
856 provider,
857 model,
858 session_meta_lines: 0,
859 });
860 }
861 if let Some(mut record) = record {
862 record.session_meta_lines = session_meta_lines;
863 records.push(record);
864 }
865 }
866 Ok(records)
867}
868
869fn read_session_index(path: &Path) -> Result<BTreeMap<String, Value>> {
870 let diagnostics = inspect_session_index(path)?;
871 if diagnostics.invalid_lines != 0 {
872 bail!("session_index.jsonl 包含非法行");
873 }
874 Ok(diagnostics.entries)
875}
876
877fn inspect_session_index(path: &Path) -> Result<IndexDiagnostics> {
878 let mut diagnostics = IndexDiagnostics::default();
879 if !path.is_file() {
880 return Ok(diagnostics);
881 }
882 for line in fs::read_to_string(path)?
883 .lines()
884 .filter(|line| !line.trim().is_empty())
885 {
886 let Ok(value) = serde_json::from_str::<Value>(line) else {
887 diagnostics.invalid_lines += 1;
888 continue;
889 };
890 let Some(id) = value.get("id").and_then(Value::as_str) else {
891 diagnostics.invalid_lines += 1;
892 continue;
893 };
894 if diagnostics.entries.insert(id.to_owned(), value).is_some() {
895 diagnostics.duplicate_ids += 1;
896 }
897 }
898 Ok(diagnostics)
899}
900
901fn update_database(paths: &Paths, target: &Assignment) -> Result<()> {
902 let mut conn = open_database(&paths.database, false)?;
903 let columns = thread_columns(&conn)?;
904 let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
905 if columns.contains("model")
906 && let Some(model) = &target.model
907 {
908 transaction.execute(
909 "UPDATE threads SET model_provider=?1, model=?2 WHERE model_provider IS NULL OR model_provider<>?1 OR model IS NULL OR model<>?2",
910 params![target.provider, model],
911 )?;
912 } else {
913 transaction.execute(
914 "UPDATE threads SET model_provider=?1 WHERE model_provider IS NULL OR model_provider<>?1",
915 [&target.provider],
916 )?;
917 }
918 transaction.commit()?;
919 conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)")?;
920 Ok(())
921}
922
923fn rewrite_sessions(paths: &Paths, target: &Assignment) -> Result<usize> {
924 let records = session_records(paths)?;
925 let mut updated = 0;
926 for record in records {
927 if !assignment_differs(&record.provider, record.model.as_deref(), target)
928 && record.session_meta_lines == 1
929 {
930 continue;
931 }
932 rewrite_session_meta(&record.path, target)?;
933 updated += 1;
934 }
935 Ok(updated)
936}
937
938fn rewrite_session_meta(path: &Path, target: &Assignment) -> Result<()> {
939 let text = fs::read_to_string(path)?;
940 let mut output = String::with_capacity(text.len());
941 let mut changed = false;
942 for segment in text.split_inclusive('\n') {
943 let (line, ending) = if let Some(line) = segment.strip_suffix("\r\n") {
944 (line, "\r\n")
945 } else if let Some(line) = segment.strip_suffix('\n') {
946 (line, "\n")
947 } else {
948 (segment, "")
949 };
950 let mut value: Value = serde_json::from_str(line)?;
951 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
952 if changed {
953 continue;
954 }
955 if let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) {
956 payload.insert(
957 "model_provider".into(),
958 Value::String(target.provider.clone()),
959 );
960 if let Some(model) = &target.model {
961 payload.insert("model".into(), Value::String(model.clone()));
962 }
963 output.push_str(&serde_json::to_string(&value)?);
964 output.push_str(ending);
965 changed = true;
966 continue;
967 }
968 }
969 output.push_str(segment);
970 }
971 if !changed {
972 bail!("会话文件缺少 session_meta:{}", path.display());
973 }
974 atomic_write(path, output.as_bytes())
975}
976
977fn rebuild_session_index(paths: &Paths) -> Result<()> {
978 let conn = open_database(&paths.database, true)?;
979 atomic_write(&paths.session_index, &render_session_index(paths, &conn)?)
980}
981
982fn render_session_index(paths: &Paths, connection: &Connection) -> Result<Vec<u8>> {
983 let columns = thread_columns(connection)?;
984 let rows = sidebar_threads(connection, &columns)?;
985 let mut existing = read_session_index(&paths.session_index)?;
986 let mut merged = Vec::new();
987 for thread in rows
988 .into_iter()
989 .filter(|thread| rollout_exists(&paths.codex_home, thread.rollout_path.as_deref()))
990 {
991 let entry = existing.remove(&thread.id).unwrap_or_else(|| {
992 json!({
993 "id": thread.id,
994 "thread_name": thread.title,
995 "updated_at": thread.updated_at
996 })
997 });
998 merged.push(entry);
999 }
1000 merged.sort_by_key(index_sort_key);
1001
1002 let mut output = String::new();
1003 for entry in merged {
1004 output.push_str(&serde_json::to_string(&entry)?);
1005 output.push('\n');
1006 }
1007 Ok(output.into_bytes())
1008}
1009
1010fn backup_doctor_state(
1011 paths: &Paths,
1012 report: &DoctorReport,
1013 original: Option<&[u8]>,
1014 session_repair: Option<&SessionMetaRepair>,
1015) -> Result<PathBuf> {
1016 fs::create_dir_all(&paths.backup_root)?;
1017 let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
1018 let destination = paths.backup_root.join(format!("history-doctor-{stamp}"));
1019 fs::create_dir(&destination)?;
1020 let result = (|| -> Result<()> {
1021 atomic_write(
1022 &destination.join("doctor-report.json"),
1023 &serde_json::to_vec_pretty(report)?,
1024 )?;
1025 if let Some(content) = original {
1026 atomic_write(&destination.join("session_index.jsonl"), content)?;
1027 } else {
1028 atomic_write(&destination.join("session-index-was-absent"), b"")?;
1029 }
1030 if let Some(repair) = session_repair {
1031 let relative = repair.target.strip_prefix(&paths.codex_home)?;
1032 atomic_write(
1033 &destination.join("rollouts").join(relative),
1034 &repair.original,
1035 )?;
1036 }
1037 Ok(())
1038 })();
1039 if let Err(error) = result {
1040 let _ = fs::remove_dir_all(&destination);
1041 return Err(error);
1042 }
1043 Ok(destination)
1044}
1045
1046fn restore_original_index(paths: &Paths, original: Option<&[u8]>) -> Result<()> {
1047 if let Some(content) = original {
1048 atomic_write(&paths.session_index, content)
1049 } else if paths.session_index.exists() {
1050 fs::remove_file(&paths.session_index)?;
1051 Ok(())
1052 } else {
1053 Ok(())
1054 }
1055}
1056
1057fn index_sort_key(value: &Value) -> (String, String) {
1058 (
1059 value
1060 .get("updated_at")
1061 .and_then(Value::as_str)
1062 .unwrap_or_default()
1063 .to_owned(),
1064 value
1065 .get("id")
1066 .and_then(Value::as_str)
1067 .unwrap_or_default()
1068 .to_owned(),
1069 )
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 fn fixture() -> Result<(PathBuf, Paths)> {
1077 let root = std::env::temp_dir().join(format!(
1078 "codex-sync-history-{}-{}",
1079 std::process::id(),
1080 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1081 ));
1082 let codex = root.join(".codex");
1083 fs::create_dir_all(codex.join("sessions/2026/07/19"))?;
1084 fs::write(
1085 codex.join("config.toml"),
1086 "model_provider = \"current\"\nmodel = \"gpt-current\"\n",
1087 )?;
1088 let conn = Connection::open(codex.join("state_5.sqlite"))?;
1089 conn.execute_batch(
1090 "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, model_provider TEXT, model TEXT, archived INTEGER, updated_at INTEGER, source TEXT);
1091 INSERT INTO threads VALUES ('old', 'Old chat', 'old-provider', 'gpt-old', 0, 1700000000, 'vscode');
1092 INSERT INTO threads VALUES ('current', 'Current chat', 'current', 'gpt-current', 0, 1700000010, 'vscode');
1093 INSERT INTO threads VALUES ('archived', 'Archived chat', 'current', 'gpt-current', 1, 1700000020, 'vscode');
1094 INSERT INTO threads VALUES ('guardian', 'Guardian', 'current', 'gpt-current', 0, 1700000030, '{\"subagent\":{\"other\":\"guardian\"}}');",
1095 )?;
1096 drop(conn);
1097 fs::write(
1098 codex.join("sessions/2026/07/19/rollout-old.jsonl"),
1099 "{\"type\":\"session_meta\",\"payload\":{\"id\":\"old\",\"model_provider\":\"old-provider\",\"model\":\"gpt-old\"}}\n{\"type\":\"message\",\"payload\":{\"text\":\"keep me\"}}\n{\"type\":\"session_meta\",\"payload\":{\"id\":\"old\",\"model_provider\":\"older-provider\",\"model\":\"gpt-older\"}}\n",
1100 )?;
1101 fs::write(
1102 codex.join("session_index.jsonl"),
1103 "{\"id\":\"current\",\"thread_name\":\"Current chat\",\"updated_at\":\"2023-11-14T22:13:30Z\"}\n{\"id\":\"archived\",\"thread_name\":\"Archived chat\",\"updated_at\":\"2023-11-14T22:13:40Z\"}\n{\"id\":\"guardian\",\"thread_name\":\"Guardian\",\"updated_at\":\"2023-11-14T22:13:50Z\"}\n",
1104 )?;
1105 let paths = Paths::resolve(Some(codex))?;
1106 Ok((root, paths))
1107 }
1108
1109 #[test]
1110 fn merge_updates_database_session_meta_and_index_with_backup() -> Result<()> {
1111 let (root, paths) = fixture()?;
1112 let preview = merge(&paths, false)?;
1113 assert_eq!(preview.database_rows_to_change, 1);
1114 assert_eq!(preview.rollout_files_to_change, 1);
1115 assert_eq!(preview.duplicate_session_meta_lines, 1);
1116 assert_eq!(preview.missing_session_index_entries, 1);
1117 assert_eq!(preview.stale_session_index_entries, 2);
1118
1119 let applied = merge(&paths, true)?;
1120 assert!(applied.backup.as_ref().is_some_and(|path| path.is_dir()));
1121 let verified = inspect(&paths)?;
1122 assert_eq!(verified.database_rows_to_change, 0);
1123 assert_eq!(verified.rollout_files_to_change, 0);
1124 assert_eq!(verified.duplicate_session_meta_lines, 0);
1125 assert_eq!(verified.missing_session_index_entries, 0);
1126 assert_eq!(verified.stale_session_index_entries, 0);
1127 let conn = open_database(&paths.database, true)?;
1128 let assignment: (String, String) = conn.query_row(
1129 "SELECT model_provider, model FROM threads WHERE id='old'",
1130 [],
1131 |row| Ok((row.get(0)?, row.get(1)?)),
1132 )?;
1133 assert_eq!(assignment, ("current".into(), "gpt-current".into()));
1134 let rollout = fs::read_to_string(
1135 paths
1136 .codex_home
1137 .join("sessions/2026/07/19/rollout-old.jsonl"),
1138 )?;
1139 assert!(rollout.contains("\"model_provider\":\"current\""));
1140 assert!(rollout.contains("keep me"));
1141 assert_eq!(rollout.matches("session_meta").count(), 1);
1142 let index = fs::read_to_string(&paths.session_index)?;
1143 assert!(index.contains("\"id\":\"old\""));
1144 assert!(!index.contains("\"id\":\"archived\""));
1145 assert!(!index.contains("\"id\":\"guardian\""));
1146 fs::remove_dir_all(root)?;
1147 Ok(())
1148 }
1149
1150 #[test]
1151 fn doctor_repairs_only_the_index_and_keeps_a_rollback_copy() -> Result<()> {
1152 let (root, paths) = fixture()?;
1153 let preview = doctor(&paths, false, &[], None)?;
1154 assert!(preview.database_integrity_ok);
1155 assert_eq!(preview.invalid_rollout_files, 0);
1156 assert_eq!(preview.duplicate_session_meta_lines, 1);
1157 assert_eq!(preview.missing_session_index_entries, 1);
1158 assert_eq!(preview.stale_session_index_entries, 2);
1159 let original = fs::read(&paths.session_index)?;
1160
1161 let repaired = doctor(&paths, true, &[], None)?;
1162 assert!(repaired.repaired);
1163 assert!(repaired.repaired_session_index);
1164 assert_eq!(repaired.missing_session_index_entries, 0);
1165 assert_eq!(repaired.stale_session_index_entries, 0);
1166 assert!(!repaired.clean);
1167 let backup = repaired.backup.context("doctor backup missing")?;
1168 assert_eq!(fs::read(backup.join("session_index.jsonl"))?, original);
1169 let index = fs::read_to_string(&paths.session_index)?;
1170 assert!(index.contains("\"id\":\"old\""));
1171 assert!(index.contains("\"id\":\"current\""));
1172 assert!(!index.contains("\"id\":\"archived\""));
1173 assert!(!index.contains("\"id\":\"guardian\""));
1174 fs::remove_dir_all(root)?;
1175 Ok(())
1176 }
1177
1178 #[test]
1179 fn doctor_refuses_invalid_input_without_modifying_it() -> Result<()> {
1180 let (root, paths) = fixture()?;
1181 let invalid = b"{not-json}\n";
1182 fs::write(&paths.session_index, invalid)?;
1183 assert!(doctor(&paths, true, &[], None).is_err());
1184 assert_eq!(fs::read(&paths.session_index)?, invalid);
1185 fs::remove_dir_all(root)?;
1186 Ok(())
1187 }
1188
1189 #[test]
1190 fn doctor_atomically_restores_a_valid_missing_rollout() -> Result<()> {
1191 let root = std::env::temp_dir().join(format!(
1192 "codex-sync-doctor-recovery-{}-{}",
1193 std::process::id(),
1194 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1195 ));
1196 let codex = root.join(".codex");
1197 let target = codex.join("sessions/2026/07/19/rollout-missing.jsonl");
1198 let source = root.join("backup/rollout-missing.jsonl");
1199 fs::create_dir_all(source.parent().context("backup parent")?)?;
1200 fs::create_dir_all(&codex)?;
1201 fs::write(codex.join("config.toml"), "model_provider = \"current\"\n")?;
1202 let content = b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"missing\",\"model_provider\":\"current\"}}\n{\"type\":\"message\",\"payload\":{\"text\":\"preserved\"}}\n";
1203 fs::write(&source, content)?;
1204 let connection = Connection::open(codex.join("state_5.sqlite"))?;
1205 connection.execute(
1206 "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, archived INTEGER, updated_at INTEGER, source TEXT, rollout_path TEXT)",
1207 [],
1208 )?;
1209 connection.execute(
1210 "INSERT INTO threads VALUES ('missing', 'Missing', 0, 1700000000, 'vscode', ?1)",
1211 [target.to_string_lossy().as_ref()],
1212 )?;
1213 drop(connection);
1214 let paths = Paths::resolve(Some(codex))?;
1215
1216 let preview = doctor(&paths, false, &[], None)?;
1217 assert_eq!(preview.missing_rollout_files, 1);
1218 let repaired = doctor(&paths, true, std::slice::from_ref(&source), None)?;
1219 assert_eq!(repaired.restored_rollout_files, 1);
1220 assert_eq!(repaired.missing_rollout_files, 0);
1221 assert_eq!(repaired.missing_session_index_entries, 0);
1222 assert_eq!(fs::read(&target)?, content);
1223
1224 let mut duplicated = content.to_vec();
1225 duplicated.extend_from_slice(
1226 b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"other\",\"model_provider\":\"current\"}}\n",
1227 );
1228 fs::write(&target, &duplicated)?;
1229 let deduplicated = doctor(&paths, true, &[], Some(&target))?;
1230 assert_eq!(deduplicated.deduplicated_session_meta_files, 1);
1231 assert!(!deduplicated.repaired_session_index);
1232 assert_eq!(deduplicated.duplicate_session_meta_lines, 0);
1233 assert_eq!(fs::read(&target)?, content);
1234 let backup = deduplicated.backup.context("dedupe backup missing")?;
1235 assert_eq!(
1236 fs::read(backup.join("rollouts/sessions/2026/07/19/rollout-missing.jsonl"))?,
1237 duplicated
1238 );
1239 fs::remove_dir_all(root)?;
1240 Ok(())
1241 }
1242
1243 #[test]
1244 fn restore_recovers_previous_assignments() -> Result<()> {
1245 let (root, paths) = fixture()?;
1246 let applied = merge(&paths, true)?;
1247 let backup_path = applied.backup.context("missing backup")?;
1248 let safety = restore(&paths, &backup_path)?;
1249 assert!(safety.is_dir());
1250 let restored = inspect(&paths)?;
1251 assert_eq!(restored.database_rows_to_change, 1);
1252 assert_eq!(restored.rollout_files_to_change, 1);
1253 assert_eq!(restored.missing_session_index_entries, 1);
1254 assert_eq!(restored.stale_session_index_entries, 2);
1255 fs::remove_dir_all(root)?;
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn missing_environment_is_rejected() {
1261 assert!(Paths::resolve(Some(PathBuf::from("/missing-codex-history"))).is_err());
1262 }
1263
1264 #[test]
1265 fn technical_threads_are_excluded_from_the_user_sidebar() {
1266 assert!(is_technical_thread(
1267 r#"{"subagent":{"other":"guardian"}}"#,
1268 "",
1269 "gpt-current",
1270 "Guardian"
1271 ));
1272 assert!(is_technical_thread(
1273 "vscode",
1274 "subagent",
1275 "gpt-current",
1276 "Worker"
1277 ));
1278 assert!(is_technical_thread("unknown", "", "codex-auto-review", ""));
1279 assert!(!is_technical_thread(
1280 "vscode",
1281 "",
1282 "codex-auto-review",
1283 "用户主动创建的审核任务"
1284 ));
1285 }
1286
1287 #[test]
1288 fn missing_provider_uses_codex_builtin_openai_provider() -> Result<()> {
1289 let (root, paths) = fixture()?;
1290 fs::write(&paths.config, "model = \"gpt-current\"\n")?;
1291 let report = inspect(&paths)?;
1292 assert_eq!(report.current_provider, "openai");
1293 assert_eq!(report.database_rows_to_change, 4);
1294 fs::remove_dir_all(root)?;
1295 Ok(())
1296 }
1297}