feat(doctor): repair incompatible state migrations

This commit is contained in:
Felipe Coury
2026-06-15 11:20:49 -04:00
parent e5253b97fb
commit 2d49b27f8b
21 changed files with 1043 additions and 13 deletions
+4
View File
File diff suppressed because one or more lines are too long
+46
View File
@@ -2333,6 +2333,7 @@ dependencies = [
"sqlx",
"supports-color 3.0.2",
"sys-locale",
"sysinfo",
"tempfile",
"tokio",
"toml 0.9.11+spec-1.1.0",
@@ -9210,6 +9211,15 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -9474,6 +9484,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -9485,6 +9505,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
dependencies = [
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -13010,6 +13041,21 @@ dependencies = [
"libc",
]
[[package]]
name = "sysinfo"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"objc2-open-directory",
"windows 0.62.2",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
+1
View File
@@ -400,6 +400,7 @@ strum = "0.27.2"
strum_macros = "0.28.0"
supports-color = "3.0.2"
syntect = "5"
sysinfo = "0.39.3"
sys-locale = "0.3.2"
tar = { version = "=0.4.45", default-features = false }
tempfile = "3.23.0"
+1
View File
@@ -68,6 +68,7 @@ regex-lite = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
supports-color = { workspace = true }
sysinfo = { workspace = true }
sys-locale = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = [
+28 -7
View File
@@ -1,14 +1,14 @@
//! Implements the `codex doctor` diagnostic report.
//!
//! Doctor is intentionally read-mostly: checks inspect the current installation,
//! Doctor is read-only by default: checks inspect the current installation,
//! configuration, authentication, terminal, state paths, and bounded reachability
//! probes without attempting repair or starting long-lived services. Each check
//! returns a redacted, serializable row so the same data can back the human
//! summary and `--json` support report.
//!
//! A failing check should describe the problem and remediation, but it should not
//! mutate user state. That keeps the command safe to run before filing a support
//! issue or while diagnosing a broken local installation.
//! A failing check should describe the problem and remediation. The explicit,
//! interactive `--fix` path is reserved for narrowly scoped repairs that create
//! backups before mutating user state.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
@@ -66,16 +66,19 @@ use serde::Serialize;
use supports_color::Stream;
mod background;
mod fix;
mod git;
mod output;
mod progress;
mod runtime;
mod state_migrations;
mod system;
mod thread_inventory;
mod title;
mod updates;
use background::background_server_check;
use fix::run_migration_fix;
use git::git_check;
use output::HumanOutputOptions;
use output::redact_detail;
@@ -84,6 +87,7 @@ use progress::DoctorProgress;
use progress::doctor_progress;
use runtime::runtime_check;
use runtime::search_check;
use state_migrations::migration_check;
use system::system_check;
use thread_inventory::thread_inventory_check;
use title::terminal_title_check;
@@ -152,6 +156,10 @@ pub struct DoctorCommand {
#[arg(long, default_value_t = false)]
json: bool,
/// Repair incompatible state database migration history after confirmation.
#[arg(long, default_value_t = false, conflicts_with = "json")]
fix: bool,
/// Only show grouped check rows and the final count summary.
#[arg(long, default_value_t = false)]
summary: bool,
@@ -300,15 +308,21 @@ impl DoctorCheck {
/// Builds, renders, and exits according to the current doctor report.
///
/// This is the CLI entry point for codex doctor. It does not repair issues;
/// failures are represented in the report and cause a non-zero process exit so
/// scripts can distinguish a clean environment from one that needs attention.
/// This is the CLI entry point for codex doctor. Checks are read-only unless the
/// user explicitly selects and confirms `--fix`.
pub async fn run_doctor(
command: DoctorCommand,
root_config_overrides: CliConfigOverrides,
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
) -> anyhow::Result<()> {
if command.fix {
let config = load_config(root_config_overrides.clone(), interactive, arg0_paths)
.await
.context("failed to load Codex config for database repair")?;
let inspections = codex_state::inspect_runtime_db_migrations(&config.sqlite_home).await;
run_migration_fix(&config, &inspections).await?;
}
let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await;
if command.json {
@@ -364,6 +378,7 @@ async fn build_report(
git_check,
terminal_title_check,
state_check,
migration_check,
thread_inventory_check,
background_server_check,
reachability_check,
@@ -395,6 +410,11 @@ async fn build_report(
})
},
run_async_check("state", progress.clone(), state_check(config)),
run_async_check(
"state migrations",
progress.clone(),
migration_check(config),
),
run_async_check(
"thread inventory",
progress.clone(),
@@ -423,6 +443,7 @@ async fn build_report(
git_check,
terminal_title_check,
state_check,
migration_check,
thread_inventory_check,
background_server_check,
reachability_check,
+279
View File
@@ -0,0 +1,279 @@
//! Interactive repair for incompatible runtime database migration metadata.
use std::io::IsTerminal;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::bail;
use codex_app_server_daemon::LifecycleCommand;
use codex_core::config::Config;
use codex_state::RuntimeDbMigrationInspection;
use codex_state::RuntimeDbMigrationStatus;
use sysinfo::Pid;
use sysinfo::ProcessesToUpdate;
use sysinfo::Signal;
use sysinfo::System;
use super::state_migrations::incompatible_database_paths;
const PROCESS_STOP_TIMEOUT: Duration = Duration::from_secs(5);
const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(100);
const PROCESS_STABLE_WINDOW: Duration = Duration::from_secs(1);
#[derive(Clone, Debug, Eq, PartialEq)]
struct AppServerProcess {
pid: Pid,
executable: PathBuf,
}
pub(super) async fn run_migration_fix(
config: &Config,
inspections: &[RuntimeDbMigrationInspection],
) -> anyhow::Result<bool> {
let database_paths = incompatible_database_paths(inspections);
if database_paths.is_empty() {
return Ok(false);
}
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
bail!(
"cannot repair state databases without an interactive terminal; rerun `codex doctor --fix` in a terminal"
);
}
let processes = app_server_processes();
if !confirm_repair(&database_paths, &processes)? {
eprintln!("No changes made.");
return Ok(false);
}
stop_managed_daemon().await;
stop_app_server_processes().await?;
let mut backup_folders = Vec::new();
for path in &database_paths {
let backups = codex_state::backup_runtime_db_for_fresh_start(path)
.await
.with_context(|| format!("failed to back up {}", path.display()))?;
if let Some(folder) = backups
.first()
.and_then(|backup| backup.backup_path.parent())
.map(Path::to_path_buf)
&& !backup_folders.contains(&folder)
{
backup_folders.push(folder);
}
}
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.context("failed to rebuild Codex runtime databases")?;
runtime.close().await;
let post_repair = codex_state::inspect_runtime_db_migrations(&config.sqlite_home).await;
if let Some(inspection) = post_repair.iter().find(|inspection| {
matches!(
inspection.status,
RuntimeDbMigrationStatus::Incompatible(_) | RuntimeDbMigrationStatus::Unreadable(_)
)
}) {
bail!(
"rebuilt {} still has incompatible migration metadata at {}",
inspection.label,
inspection.path.display()
);
}
eprintln!("Codex rebuilt the affected runtime database.");
for backup_folder in backup_folders {
eprintln!("Backup folder: {}", backup_folder.display());
}
eprintln!("App servers remain stopped.");
eprintln!("Reopen the Codex Desktop App or run `codex app-server daemon start` when ready.");
Ok(true)
}
fn confirm_repair(
database_paths: &[PathBuf],
processes: &[AppServerProcess],
) -> std::io::Result<bool> {
let mut stderr = std::io::stderr().lock();
writeln!(
stderr,
"Codex found incompatible database migration history:"
)?;
for path in database_paths {
writeln!(stderr, " - {}", path.display())?;
}
writeln!(
stderr,
"The affected database and SQLite sidecars will be moved to a backup folder before a fresh database is created."
)?;
if processes.is_empty() {
writeln!(
stderr,
"No running Codex app-server processes were detected."
)?;
} else {
writeln!(
stderr,
"The following Codex app-server processes will be stopped, interrupting active work:"
)?;
for process in processes {
writeln!(
stderr,
" - PID {}: {}",
process.pid,
process.executable.display()
)?;
}
}
write!(stderr, "Continue? [y/N]: ")?;
stderr.flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let answer = input.trim();
Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
}
async fn stop_managed_daemon() {
if let Err(err) = codex_app_server_daemon::run(LifecycleCommand::Stop).await {
eprintln!("Could not stop the managed app-server daemon directly: {err}");
}
}
async fn stop_app_server_processes() -> anyhow::Result<()> {
signal_app_server_processes(/*force*/ false)?;
if wait_for_app_servers_to_stop(PROCESS_STOP_TIMEOUT).await {
return Ok(());
}
signal_app_server_processes(/*force*/ true)?;
if wait_for_app_servers_to_stop(PROCESS_STOP_TIMEOUT).await {
return Ok(());
}
let remaining = app_server_processes();
let remaining_pids = remaining
.iter()
.map(|process| process.pid.to_string())
.collect::<Vec<_>>()
.join(", ");
bail!(
"Codex app-server processes are still running or were restarted (PIDs: {remaining_pids}). Quit the Codex Desktop App and any editor integrations, then rerun `codex doctor --fix`. No database files were changed."
)
}
fn signal_app_server_processes(force: bool) -> anyhow::Result<()> {
let mut system = System::new();
system.refresh_processes(ProcessesToUpdate::All, /*remove_dead_processes*/ true);
let current_pid = Pid::from_u32(std::process::id());
let mut failed = Vec::new();
for (pid, process) in system.processes() {
if *pid == current_pid
|| !is_app_server_process(process.name(), process.exe(), process.cmd())
{
continue;
}
let signaled = if force {
process.kill()
} else {
process
.kill_with(Signal::Term)
.unwrap_or_else(|| process.kill())
};
if !signaled {
failed.push(pid.to_string());
}
}
if failed.is_empty() {
Ok(())
} else {
bail!(
"failed to stop Codex app-server processes with PIDs: {}",
failed.join(", ")
)
}
}
async fn wait_for_app_servers_to_stop(timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
let mut empty_since = None;
loop {
if app_server_processes().is_empty() {
let empty_since = empty_since.get_or_insert_with(Instant::now);
if empty_since.elapsed() >= PROCESS_STABLE_WINDOW {
return true;
}
} else {
empty_since = None;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(PROCESS_POLL_INTERVAL).await;
}
}
fn app_server_processes() -> Vec<AppServerProcess> {
let mut system = System::new();
system.refresh_processes(ProcessesToUpdate::All, /*remove_dead_processes*/ true);
let current_pid = Pid::from_u32(std::process::id());
system
.processes()
.iter()
.filter_map(|(pid, process)| {
if *pid == current_pid
|| !is_app_server_process(process.name(), process.exe(), process.cmd())
{
return None;
}
Some(AppServerProcess {
pid: *pid,
executable: process
.exe()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(process.name())),
})
})
.collect()
}
fn is_app_server_process(
process_name: &std::ffi::OsStr,
executable: Option<&Path>,
command: &[std::ffi::OsString],
) -> bool {
let executable_name = executable
.and_then(Path::file_name)
.unwrap_or(process_name)
.to_string_lossy()
.to_ascii_lowercase();
if executable_name.contains("codex-app-server") {
return true;
}
if !executable_name.starts_with("codex") {
return false;
}
let args = command
.iter()
.map(|arg| arg.to_string_lossy().to_ascii_lowercase())
.collect::<Vec<_>>();
let Some(app_server_index) = args.iter().position(|arg| arg == "app-server") else {
return false;
};
args.get(app_server_index + 1)
.is_none_or(|arg| arg != "daemon")
}
#[cfg(test)]
#[path = "fix_tests.rs"]
mod tests;
+52
View File
@@ -0,0 +1,52 @@
use std::ffi::OsStr;
use std::ffi::OsString;
use super::*;
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn recognizes_cli_and_standalone_app_servers() {
assert!(is_app_server_process(
OsStr::new("codex"),
Some(Path::new("/usr/bin/codex")),
&args(&["codex", "app-server", "--listen", "stdio://"]),
));
assert!(is_app_server_process(
OsStr::new("codex-app-server"),
Some(Path::new("/usr/bin/codex-app-server")),
&args(&["codex-app-server"]),
));
}
#[test]
fn excludes_daemon_commands_and_unrelated_codex_processes() {
assert!(!is_app_server_process(
OsStr::new("codex"),
Some(Path::new("/usr/bin/codex")),
&args(&["codex", "app-server", "daemon", "pid-update-loop"]),
));
assert!(!is_app_server_process(
OsStr::new("codex"),
Some(Path::new("/usr/bin/codex")),
&args(&["codex", "doctor", "--fix"]),
));
}
#[tokio::test]
async fn no_incompatible_database_skips_interactive_repair() {
let temp = tempfile::tempdir().expect("tempdir");
let config = codex_core::config::ConfigBuilder::default()
.codex_home(temp.path().to_path_buf())
.build()
.await
.expect("config");
let repaired = run_migration_fix(&config, &[])
.await
.expect("repair result");
assert!(!repaired);
}
+9 -1
View File
@@ -26,8 +26,16 @@ const GROUPS: &[OutputGroup] = &[
OutputGroup {
title: "Environment",
keys: &[
"system", "runtime", "install", "search", "git", "terminal", "title", "state",
"system",
"runtime",
"install",
"search",
"git",
"terminal",
"title",
"state",
"threads",
"migrations",
],
},
OutputGroup {
@@ -0,0 +1,21 @@
---
source: cli/src/doctor/state_migrations_tests.rs
expression: rendered
---
Codex Doctor v0.0.0-test
Notes
[XX] migrations state DB migration 36 was previously applied but has been modified
-------------------------------------------------------------
Environment
[XX] migrations state DB migration 36 was previously applied but has been modified
state DB migration database /Users/example/.codex/state_5.sqlite
> state DB migrations state DB migration 36 was previously applied but has been modified (expected embedded migration metadata)
-> run codex doctor --fix
-------------------------------------------------------------
0 ok | 1 notes | 0 warn | 1 fail failed
--summary compact output --all expand truncated lists
--json redacted report
+128
View File
@@ -0,0 +1,128 @@
//! Reports runtime database migration compatibility without applying migrations.
use std::path::PathBuf;
use codex_core::config::Config;
use codex_state::RuntimeDbMigrationInspection;
use codex_state::RuntimeDbMigrationIssue;
use codex_state::RuntimeDbMigrationStatus;
use super::CheckStatus;
use super::DoctorCheck;
use super::DoctorIssue;
pub(super) async fn migration_check(config: &Config) -> DoctorCheck {
migration_check_from_inspections(
codex_state::inspect_runtime_db_migrations(&config.sqlite_home).await,
)
}
pub(super) fn incompatible_database_paths(
inspections: &[RuntimeDbMigrationInspection],
) -> Vec<PathBuf> {
inspections
.iter()
.filter(|&inspection| {
matches!(inspection.status, RuntimeDbMigrationStatus::Incompatible(_))
})
.map(|inspection| inspection.path.clone())
.collect()
}
pub(super) fn migration_check_from_inspections(
inspections: Vec<RuntimeDbMigrationInspection>,
) -> DoctorCheck {
let mut details = Vec::new();
let mut issues = Vec::new();
let mut has_incompatible = false;
let mut has_unreadable = false;
for inspection in inspections {
match inspection.status {
RuntimeDbMigrationStatus::Missing => {
details.push(format!(
"{} migrations: skipped (missing)",
inspection.label
));
}
RuntimeDbMigrationStatus::Compatible { applied, pending } => {
details.push(format!(
"{} migrations: compatible ({applied} applied, {pending} pending)",
inspection.label
));
}
RuntimeDbMigrationStatus::Incompatible(db_issues) => {
has_incompatible = true;
details.push(format!(
"{} migration database: {}",
inspection.label,
inspection.path.display()
));
for issue in db_issues {
let cause = migration_issue_cause(inspection.label, &issue);
details.push(format!("{} migrations: {cause}", inspection.label));
issues.push(
DoctorIssue::new(CheckStatus::Fail, cause)
.measured("applied migration metadata")
.expected("embedded migration metadata")
.remedy("run codex doctor --fix")
.field(format!("{} migrations", inspection.label)),
);
}
}
RuntimeDbMigrationStatus::Unreadable(error) => {
has_unreadable = true;
details.push(format!(
"{} migrations: unreadable ({error})",
inspection.label
));
}
}
}
let (status, summary) = if has_incompatible {
(
CheckStatus::Fail,
"state database migration history is incompatible",
)
} else if has_unreadable {
(
CheckStatus::Fail,
"state database migration history could not be inspected",
)
} else {
(
CheckStatus::Ok,
"state database migration history is compatible",
)
};
let mut check =
DoctorCheck::new("state.migrations", "migrations", status, summary).details(details);
for issue in issues {
check = check.issue(issue);
}
if has_incompatible {
check = check.remediation(
"Run `codex doctor --fix` to stop Codex app servers, back up the affected database, and rebuild it.",
);
} else if has_unreadable {
check = check
.remediation("Resolve the reported SQLite access error, then rerun `codex doctor`.");
}
check
}
fn migration_issue_cause(label: &str, issue: &RuntimeDbMigrationIssue) -> String {
match issue {
RuntimeDbMigrationIssue::Dirty { version } => {
format!("{label} migration {version} is partially applied")
}
RuntimeDbMigrationIssue::ChecksumMismatch { version } => {
format!("{label} migration {version} was previously applied but has been modified")
}
}
}
#[cfg(test)]
#[path = "state_migrations_tests.rs"]
mod tests;
@@ -0,0 +1,97 @@
use std::path::PathBuf;
use codex_state::RuntimeDbMigrationInspection;
use codex_state::RuntimeDbMigrationIssue;
use codex_state::RuntimeDbMigrationStatus;
use pretty_assertions::assert_eq;
use super::super::DoctorReport;
use super::super::output::HumanOutputOptions;
use super::super::output::render_human_report;
use super::*;
#[test]
fn checksum_mismatch_is_fixable_failure() {
let path = PathBuf::from("/tmp/state_5.sqlite");
let inspections = vec![RuntimeDbMigrationInspection {
label: "state DB",
path: path.clone(),
status: RuntimeDbMigrationStatus::Incompatible(vec![
RuntimeDbMigrationIssue::ChecksumMismatch { version: 36 },
]),
}];
let check = migration_check_from_inspections(inspections.clone());
assert_eq!(check.id, "state.migrations");
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(
check.summary,
"state database migration history is incompatible"
);
assert_eq!(incompatible_database_paths(&inspections), vec![path]);
assert_eq!(check.issues.len(), 1);
assert_eq!(
check.remediation.as_deref(),
Some(
"Run `codex doctor --fix` to stop Codex app servers, back up the affected database, and rebuild it."
)
);
}
#[test]
fn compatible_and_missing_databases_stay_ok() {
let inspections = vec![
RuntimeDbMigrationInspection {
label: "state DB",
path: PathBuf::from("state_5.sqlite"),
status: RuntimeDbMigrationStatus::Compatible {
applied: 37,
pending: 0,
},
},
RuntimeDbMigrationInspection {
label: "log DB",
path: PathBuf::from("logs_2.sqlite"),
status: RuntimeDbMigrationStatus::Missing,
},
];
let check = migration_check_from_inspections(inspections);
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(
check.summary,
"state database migration history is compatible"
);
}
#[test]
fn migration_conflict_has_human_snapshot_coverage() {
let check = migration_check_from_inspections(vec![RuntimeDbMigrationInspection {
label: "state DB",
path: PathBuf::from("/Users/example/.codex/state_5.sqlite"),
status: RuntimeDbMigrationStatus::Incompatible(vec![
RuntimeDbMigrationIssue::ChecksumMismatch { version: 36 },
]),
}]);
let report = DoctorReport {
schema_version: 1,
generated_at: "2026-06-15T12:00:00Z".to_string(),
overall_status: CheckStatus::Fail,
codex_version: "0.0.0-test".to_string(),
checks: vec![check],
};
let rendered = render_human_report(
&report,
HumanOutputOptions {
show_details: true,
show_all: false,
ascii: true,
color_enabled: false,
},
);
insta::assert_snapshot!("doctor_migration_conflict", rendered);
}
+16
View File
@@ -3475,6 +3475,22 @@ mod tests {
);
}
#[test]
fn doctor_fix_parses() {
let cli = MultitoolCli::try_parse_from(["codex", "doctor", "--fix"])
.expect("doctor --fix should parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Doctor(_))));
}
#[test]
fn doctor_fix_rejects_json_output() {
let error = MultitoolCli::try_parse_from(["codex", "doctor", "--fix", "--json"])
.expect_err("doctor --fix --json should be rejected");
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn root_strict_config_is_supported_for_exec_server() {
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "exec-server"])
+13 -2
View File
@@ -21,6 +21,10 @@ pub(crate) fn is_corruption(detail: &str) -> bool {
codex_state::sqlite_error_detail_is_corruption(detail)
}
pub(crate) fn is_migration_incompatible(detail: &str) -> bool {
codex_state::sqlite_error_detail_is_migration_incompatible(detail)
}
pub(crate) fn is_auto_backup_recoverable(startup_error: &LocalStateDbStartupError) -> bool {
is_corruption(startup_error.detail()) || sqlite_home_is_blocking_file(startup_error)
}
@@ -71,8 +75,15 @@ pub(crate) fn confirm_fresh_start_rebuild(
}
pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) {
eprintln!("Codex couldn't start because its local database appears to be damaged.");
eprintln!("Run `codex doctor` to check your setup and get next-step guidance.");
if is_migration_incompatible(startup_error.detail()) {
eprintln!(
"Codex couldn't start because its local database migration history is incompatible with this version."
);
eprintln!("Run `codex doctor --fix` to back up and rebuild the affected database.");
} else {
eprintln!("Codex couldn't start because its local database appears to be damaged.");
eprintln!("Run `codex doctor` to check your setup and get next-step guidance.");
}
eprintln!("If this keeps happening, share the technical details below when asking for help.");
print_technical_details(startup_error);
}
+7
View File
@@ -12,6 +12,7 @@ const _: () = assert!(
mod audit;
mod extract;
pub mod log_db;
mod migration_diagnostics;
mod migrations;
mod model;
mod paths;
@@ -32,6 +33,10 @@ pub use audit::read_thread_state_audit_rows;
/// Most consumers should prefer [`StateRuntime`].
pub use extract::apply_rollout_item;
pub use extract::rollout_item_affects_thread_metadata;
pub use migration_diagnostics::RuntimeDbMigrationInspection;
pub use migration_diagnostics::RuntimeDbMigrationIssue;
pub use migration_diagnostics::RuntimeDbMigrationStatus;
pub use migration_diagnostics::inspect_runtime_db_migrations;
pub use model::AgentJob;
pub use model::AgentJobCreateParams;
pub use model::AgentJobItem;
@@ -74,9 +79,11 @@ pub use runtime::logs_db_path;
pub use runtime::memories_db_filename;
pub use runtime::memories_db_path;
pub use runtime::runtime_db_path_for_corruption_error;
pub use runtime::runtime_db_path_for_init_error;
pub use runtime::runtime_db_paths;
pub use runtime::sqlite_error_detail_is_corruption;
pub use runtime::sqlite_error_detail_is_lock;
pub use runtime::sqlite_error_detail_is_migration_incompatible;
pub use runtime::sqlite_integrity_check;
pub use runtime::state_db_filename;
pub use runtime::state_db_path;
+175
View File
@@ -0,0 +1,175 @@
//! Read-only inspection of SQLx migration metadata for runtime databases.
use std::path::Path;
use std::path::PathBuf;
use sqlx::Row;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::sqlite::SqlitePoolOptions;
use crate::migrations::runtime_goals_migrator;
use crate::migrations::runtime_logs_migrator;
use crate::migrations::runtime_memories_migrator;
use crate::migrations::runtime_state_migrator;
use crate::runtime::goals_db_path;
use crate::runtime::logs_db_path;
use crate::runtime::memories_db_path;
use crate::runtime::state_db_path;
/// Migration compatibility for one Codex runtime database.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeDbMigrationInspection {
pub label: &'static str,
pub path: PathBuf,
pub status: RuntimeDbMigrationStatus,
}
/// Read-only compatibility status for a runtime database's migration ledger.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeDbMigrationStatus {
Missing,
Compatible { applied: usize, pending: usize },
Incompatible(Vec<RuntimeDbMigrationIssue>),
Unreadable(String),
}
/// An applied migration state that the current runtime cannot safely use.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeDbMigrationIssue {
Dirty { version: i64 },
ChecksumMismatch { version: i64 },
}
struct RuntimeDbMigrationSpec {
label: &'static str,
path: PathBuf,
migrator: Migrator,
}
/// Inspect every runtime database without applying pending migrations.
pub async fn inspect_runtime_db_migrations(
sqlite_home: &Path,
) -> Vec<RuntimeDbMigrationInspection> {
let specs = [
RuntimeDbMigrationSpec {
label: "state DB",
path: state_db_path(sqlite_home),
migrator: runtime_state_migrator(),
},
RuntimeDbMigrationSpec {
label: "log DB",
path: logs_db_path(sqlite_home),
migrator: runtime_logs_migrator(),
},
RuntimeDbMigrationSpec {
label: "goals DB",
path: goals_db_path(sqlite_home),
migrator: runtime_goals_migrator(),
},
RuntimeDbMigrationSpec {
label: "memories DB",
path: memories_db_path(sqlite_home),
migrator: runtime_memories_migrator(),
},
];
let mut inspections = Vec::with_capacity(specs.len());
for spec in specs {
inspections.push(inspect_runtime_db_migration(spec).await);
}
inspections
}
async fn inspect_runtime_db_migration(
spec: RuntimeDbMigrationSpec,
) -> RuntimeDbMigrationInspection {
if !spec.path.is_file() {
return RuntimeDbMigrationInspection {
label: spec.label,
path: spec.path,
status: RuntimeDbMigrationStatus::Missing,
};
}
let status = match inspect_existing_database(&spec.path, &spec.migrator).await {
Ok(status) => status,
Err(err) => RuntimeDbMigrationStatus::Unreadable(err.to_string()),
};
RuntimeDbMigrationInspection {
label: spec.label,
path: spec.path,
status,
}
}
async fn inspect_existing_database(
path: &Path,
migrator: &Migrator,
) -> anyhow::Result<RuntimeDbMigrationStatus> {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(false)
.read_only(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?;
let table_exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'",
)
.fetch_one(&pool)
.await?
> 0;
if !table_exists {
pool.close().await;
return Ok(RuntimeDbMigrationStatus::Compatible {
applied: 0,
pending: migrator.iter().count(),
});
}
let rows = sqlx::query("SELECT version, success, checksum FROM _sqlx_migrations")
.fetch_all(&pool)
.await?;
pool.close().await;
let mut applied_versions = Vec::with_capacity(rows.len());
let mut issues = Vec::new();
for row in &rows {
let version = row.try_get::<i64, _>("version")?;
applied_versions.push(version);
if !row.try_get::<bool, _>("success")? {
issues.push(RuntimeDbMigrationIssue::Dirty { version });
continue;
}
let Some(migration) = migrator
.iter()
.find(|migration| migration.version == version)
else {
// Runtime migrators intentionally tolerate migrations from a newer binary.
continue;
};
let checksum = row.try_get::<Vec<u8>, _>("checksum")?;
if checksum.as_slice() != migration.checksum.as_ref() {
issues.push(RuntimeDbMigrationIssue::ChecksumMismatch { version });
}
}
if !issues.is_empty() {
return Ok(RuntimeDbMigrationStatus::Incompatible(issues));
}
let pending = migrator
.iter()
.filter(|migration| !applied_versions.contains(&migration.version))
.count();
Ok(RuntimeDbMigrationStatus::Compatible {
applied: rows.len(),
pending,
})
}
#[cfg(test)]
#[path = "migration_diagnostics_tests.rs"]
mod tests;
@@ -0,0 +1,143 @@
use std::path::Path;
use pretty_assertions::assert_eq;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
use super::*;
use crate::StateRuntime;
use crate::migrations::STATE_MIGRATOR;
use crate::runtime::test_support::unique_temp_dir;
async fn state_inspection(sqlite_home: &Path) -> RuntimeDbMigrationInspection {
inspect_runtime_db_migrations(sqlite_home)
.await
.into_iter()
.find(|inspection| inspection.label == "state DB")
.expect("state inspection")
}
async fn state_pool(sqlite_home: &Path) -> SqlitePool {
SqlitePool::connect_with(
SqliteConnectOptions::new()
.filename(state_db_path(sqlite_home))
.create_if_missing(false),
)
.await
.expect("state pool")
}
#[tokio::test]
async fn missing_runtime_databases_are_reported_without_creation() {
let sqlite_home = unique_temp_dir();
let inspections = inspect_runtime_db_migrations(&sqlite_home).await;
assert_eq!(inspections.len(), 4);
assert!(
inspections
.iter()
.all(|inspection| inspection.status == RuntimeDbMigrationStatus::Missing)
);
assert!(!sqlite_home.exists());
}
#[tokio::test]
async fn current_runtime_database_is_compatible() {
let sqlite_home = unique_temp_dir();
let runtime = StateRuntime::init(sqlite_home.clone(), "test-provider".to_string())
.await
.expect("runtime");
runtime.close().await;
let inspection = state_inspection(&sqlite_home).await;
assert_eq!(
inspection.status,
RuntimeDbMigrationStatus::Compatible {
applied: STATE_MIGRATOR.iter().count(),
pending: 0,
}
);
}
#[tokio::test]
async fn changed_checksum_is_incompatible() {
let sqlite_home = unique_temp_dir();
let runtime = StateRuntime::init(sqlite_home.clone(), "test-provider".to_string())
.await
.expect("runtime");
runtime.close().await;
let pool = state_pool(&sqlite_home).await;
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 36")
.bind(vec![0_u8; 48])
.execute(&pool)
.await
.expect("change checksum");
pool.close().await;
let inspection = state_inspection(&sqlite_home).await;
assert_eq!(
inspection.status,
RuntimeDbMigrationStatus::Incompatible(vec![RuntimeDbMigrationIssue::ChecksumMismatch {
version: 36
}])
);
}
#[tokio::test]
async fn dirty_migration_is_incompatible() {
let sqlite_home = unique_temp_dir();
let runtime = StateRuntime::init(sqlite_home.clone(), "test-provider".to_string())
.await
.expect("runtime");
runtime.close().await;
let pool = state_pool(&sqlite_home).await;
sqlx::query("UPDATE _sqlx_migrations SET success = FALSE WHERE version = 36")
.execute(&pool)
.await
.expect("mark dirty");
pool.close().await;
let inspection = state_inspection(&sqlite_home).await;
assert_eq!(
inspection.status,
RuntimeDbMigrationStatus::Incompatible(vec![RuntimeDbMigrationIssue::Dirty {
version: 36,
}])
);
}
#[tokio::test]
async fn future_migration_is_tolerated() {
let sqlite_home = unique_temp_dir();
let runtime = StateRuntime::init(sqlite_home.clone(), "test-provider".to_string())
.await
.expect("runtime");
runtime.close().await;
let pool = state_pool(&sqlite_home).await;
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, ?, ?, ?, ?)",
)
.bind(/*value*/ 9_999_i64)
.bind("future migration")
.bind(/*value*/ true)
.bind(vec![1_u8, 2, 3, 4])
.bind(/*value*/ 1_i64)
.execute(&pool)
.await
.expect("insert future migration");
pool.close().await;
let inspection = state_inspection(&sqlite_home).await;
assert_eq!(
inspection.status,
RuntimeDbMigrationStatus::Compatible {
applied: STATE_MIGRATOR.iter().count() + 1,
pending: 0,
}
);
}
+3 -1
View File
@@ -65,7 +65,7 @@ mod memories;
mod recovery;
mod remote_control;
#[cfg(test)]
mod test_support;
pub(crate) mod test_support;
mod threads;
pub use goals::GoalAccountingMode;
@@ -77,8 +77,10 @@ pub use recovery::RuntimeDbBackup;
pub use recovery::backup_runtime_db_for_fresh_start;
pub use recovery::is_sqlite_corruption_error;
pub use recovery::runtime_db_path_for_corruption_error;
pub use recovery::runtime_db_path_for_init_error;
pub use recovery::sqlite_error_detail_is_corruption;
pub use recovery::sqlite_error_detail_is_lock;
pub use recovery::sqlite_error_detail_is_migration_incompatible;
pub use remote_control::RemoteControlEnrollmentRecord;
pub use threads::ThreadFilterOptions;
+12
View File
@@ -95,6 +95,11 @@ pub fn runtime_db_path_for_corruption_error(err: &anyhow::Error) -> Option<PathB
if !is_sqlite_corruption_error(err) {
return None;
}
runtime_db_path_for_init_error(err)
}
/// Return the runtime database path attached to any database initialization error.
pub fn runtime_db_path_for_init_error(err: &anyhow::Error) -> Option<PathBuf> {
err.chain()
.find_map(|source| source.downcast_ref::<RuntimeDbInitError>())
.map(|err| err.path().to_path_buf())
@@ -141,6 +146,13 @@ pub fn sqlite_error_detail_is_lock(detail: &str) -> bool {
detail.contains("database is locked") || detail.contains("database is busy")
}
pub fn sqlite_error_detail_is_migration_incompatible(detail: &str) -> bool {
let detail = detail.to_ascii_lowercase();
(detail.contains("migration ")
&& detail.contains("was previously applied but has been modified"))
|| (detail.contains("migration ") && detail.contains("is partially applied"))
}
async fn backup_runtime_db_files(db_path: &Path) -> std::io::Result<Vec<RuntimeDbBackup>> {
let sqlite_home = db_path.parent().ok_or_else(|| {
std::io::Error::other(format!(
@@ -72,6 +72,12 @@ fn sqlite_error_detail_classifies_corruption_and_lock_errors() {
assert!(!sqlite_error_detail_is_corruption("database is locked"));
assert!(sqlite_error_detail_is_lock("database is locked"));
assert!(sqlite_error_detail_is_lock("database is busy"));
assert!(sqlite_error_detail_is_migration_incompatible(
"migration 36 was previously applied but has been modified"
));
assert!(sqlite_error_detail_is_migration_incompatible(
"migration 12 is partially applied; fix and remove row"
));
}
#[tokio::test]
+1 -1
View File
@@ -25,7 +25,7 @@ use uuid::Uuid;
use crate::ThreadMetadata;
#[cfg(test)]
pub(super) fn unique_temp_dir() -> PathBuf {
pub(crate) fn unique_temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
+1 -1
View File
@@ -284,7 +284,7 @@ async fn init_state_db_for_app_server_target(
) -> std::io::Result<Option<StateDbHandle>> {
match app_server_target {
AppServerTarget::Embedded => state_db::try_init(config).await.map(Some).map_err(|err| {
let database_path = codex_state::runtime_db_path_for_corruption_error(&err)
let database_path = codex_state::runtime_db_path_for_init_error(&err)
.unwrap_or_else(|| codex_state::state_db_path(config.sqlite_home.as_path()));
std::io::Error::other(LocalStateDbStartupError::new(
database_path,