Skip to main content

codex_sync/
history.rs

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}