mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
feat(doctor): repair incompatible state migrations
This commit is contained in:
Generated
+4
File diff suppressed because one or more lines are too long
Generated
+46
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+21
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user