From 9267c8bbaf71403aef1eabbee8730347946a3538 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 15:06:24 -0700 Subject: [PATCH 01/10] feat(codex): add experimental, reversible thread-history provider migration Codex records the provider that produced each thread in `threads.model_provider` and filters its resume picker by the provider that is currently active. Installing the Relay integration switches Codex to the `nemo-relay-openai` provider, so threads recorded under the built-in `openai` provider stop appearing in the picker. They stay on disk and remain resumable by id; only discovery is affected. Add `nemo-relay install codex --migrate-history`, which rewrites the recorded provider so pre-install history stays visible, and reverse it automatically at `nemo-relay uninstall codex`. Reversal is inferred from a migration journal rather than a repeated flag, and moves every thread still recorded under `nemo-relay-openai` back to `openai`, not only the ids captured at migration time: uninstall removes that provider from config.toml, so a thread left pointing at it would be hidden from the picker in the same way the migration exists to prevent. Pass `--skip-history-migration` to opt out. The migration shells out to `sqlite3` rather than adding a SQLite crate to the workspace, takes the write lock up front so a running Codex fails cleanly instead of writing partial state, and copies the database and its WAL sidecars to a timestamped backup first. This is a stopgap. Codex owns the schema and offers no supported API for changing a thread's provider; see openai/codex#27381. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/history.rs | 370 ++++++++++++++++++ crates/cli/src/agents/codex/install.rs | 25 +- crates/cli/src/agents/codex/mod.rs | 1 + crates/cli/src/commands/install.rs | 20 + crates/cli/src/commands/integrations.rs | 3 + crates/cli/src/installation/mod.rs | 4 + .../coverage/agents/codex_history_tests.rs | 355 +++++++++++++++++ .../coverage/agents/plugin_install_tests.rs | 4 + docs/nemo-relay-cli/codex.mdx | 54 +++ 9 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 crates/cli/src/agents/codex/history.rs create mode 100644 crates/cli/tests/coverage/agents/codex_history_tests.rs diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs new file mode 100644 index 000000000..ac2d4a72f --- /dev/null +++ b/crates/cli/src/agents/codex/history.rs @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Experimental Codex thread-history provider migration. +//! +//! Codex records the provider that produced each thread in +//! `threads.model_provider` and filters its resume picker by the provider that +//! is currently active. Installing the Relay integration switches Codex to the +//! `nemo-relay-openai` provider, so threads recorded under the built-in +//! `openai` provider stop appearing in the picker even though they remain +//! resumable by id. +//! +//! `nemo-relay install codex --migrate-history` rewrites the recorded provider +//! so that pre-install history stays visible. Every migration writes a journal +//! next to the other Relay user state; `nemo-relay uninstall codex` reads that +//! journal and reverses the rewrite without needing the flag again. +//! +//! This is experimental. Codex owns the schema, offers no supported API for +//! changing a thread's provider, and may change the storage layout in any +//! release. Until is resolved +//! upstream, editing the database directly is the only available mechanism. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use super::host::codex_home_dir; + +/// Codex's built-in provider id, used before the Relay integration is installed. +pub(crate) const OPENAI_PROVIDER: &str = "openai"; +/// The provider id Relay installs into `config.toml`. +pub(crate) const RELAY_PROVIDER: &str = "nemo-relay-openai"; + +/// Codex's thread index. The numeric suffix is a Codex schema generation. +const STATE_DB_FILE: &str = "state_5.sqlite"; +/// Journal recording migrations so uninstall can infer and reverse them. +const JOURNAL_FILE: &str = "codex-history-migration.json"; +const JOURNAL_VERSION: u64 = 1; + +/// Milliseconds `sqlite3` waits for a competing writer before giving up. +const BUSY_TIMEOUT_MS: u64 = 5_000; + +/// Outcome of a migration or reversal, for reporting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MigrationOutcome { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) thread_ids: Vec, +} + +impl MigrationOutcome { + fn describe(&self) -> String { + format!( + "{} Codex thread(s) from provider `{}` to `{}`", + self.thread_ids.len(), + self.from, + self.to + ) + } +} + +/// Rewrites pre-install `openai` threads to the Relay provider. +/// +/// Returns `None` when there was nothing to migrate. +pub(crate) fn migrate_to_relay(dry_run: bool) -> Result, String> { + let database = state_db_path()?; + if !database.exists() { + return Err(format!( + "no Codex thread database at {}; run Codex at least once before migrating history", + database.display() + )); + } + require_sqlite3()?; + let thread_ids = thread_ids_for_provider(&database, OPENAI_PROVIDER)?; + if thread_ids.is_empty() { + println!( + "no Codex threads are recorded under provider `{OPENAI_PROVIDER}`; nothing to migrate." + ); + return Ok(None); + } + let outcome = MigrationOutcome { + from: OPENAI_PROVIDER.to_string(), + to: RELAY_PROVIDER.to_string(), + thread_ids, + }; + if dry_run { + println!( + "would move {} in {}", + outcome.describe(), + database.display() + ); + return Ok(Some(outcome)); + } + let backup = back_up_database(&database)?; + update_provider(&database, OPENAI_PROVIDER, RELAY_PROVIDER)?; + write_journal(&database, &backup, &outcome)?; + println!("moved {} in {}", outcome.describe(), database.display()); + println!( + "backed up the previous thread database to {}", + backup.display() + ); + println!( + "`nemo-relay uninstall codex` reverses this automatically; no flag is needed at uninstall." + ); + Ok(Some(outcome)) +} + +/// Reverses a recorded migration, returning threads to the built-in provider. +/// +/// Reversal moves every thread still recorded under [`RELAY_PROVIDER`] back to +/// [`OPENAI_PROVIDER`], not only the ids captured at migration time. Threads +/// created while Relay was installed carry the Relay provider legitimately, but +/// uninstall removes that provider from `config.toml`, so leaving them behind +/// would hide them from the picker — the same defect the migration exists to +/// fix, mirrored. +/// +/// Returns `None` when no migration was recorded or there is nothing to move. +pub(crate) fn restore_from_relay(dry_run: bool) -> Result, String> { + let Some(journal) = read_journal()? else { + return Ok(None); + }; + let database = journal_database(&journal).unwrap_or(state_db_path()?); + if !database.exists() { + clear_journal(dry_run)?; + return Err(format!( + "recorded Codex thread database {} no longer exists; discarded the migration journal", + database.display() + )); + } + require_sqlite3()?; + let thread_ids = thread_ids_for_provider(&database, RELAY_PROVIDER)?; + if thread_ids.is_empty() { + clear_journal(dry_run)?; + return Ok(None); + } + let outcome = MigrationOutcome { + from: RELAY_PROVIDER.to_string(), + to: OPENAI_PROVIDER.to_string(), + thread_ids, + }; + if dry_run { + println!( + "would move {} in {}", + outcome.describe(), + database.display() + ); + return Ok(Some(outcome)); + } + let backup = back_up_database(&database)?; + update_provider(&database, RELAY_PROVIDER, OPENAI_PROVIDER)?; + clear_journal(dry_run)?; + println!("moved {} in {}", outcome.describe(), database.display()); + println!( + "backed up the previous thread database to {}", + backup.display() + ); + Ok(Some(outcome)) +} + +/// Reports whether a migration is recorded, so uninstall can infer the flag. +/// +/// Uninstall infers reversal from [`restore_from_relay`] reading the same +/// journal, so this exists for tests and future doctor reporting. +#[cfg(test)] +pub(crate) fn migration_recorded() -> bool { + matches!(read_journal(), Ok(Some(_))) +} + +fn state_db_path() -> Result { + Ok(codex_home_dir()?.join(STATE_DB_FILE)) +} + +fn journal_path() -> Result { + crate::configuration::user_config_dir() + .map(|path| path.join(JOURNAL_FILE)) + .ok_or_else(|| { + "cannot determine the user configuration directory for the Codex history migration \ + journal" + .to_string() + }) +} + +fn require_sqlite3() -> Result<(), String> { + match Command::new("sqlite3").arg("--version").output() { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(format!( + "`sqlite3 --version` failed with status {}; Codex history migration requires a working \ + sqlite3 on PATH", + output.status + )), + Err(error) => Err(format!( + "Codex history migration requires the `sqlite3` command on PATH, but it could not be \ + run: {error}" + )), + } +} + +/// Runs one or more statements against `database` and returns stdout. +/// +/// `BEGIN IMMEDIATE` takes the write lock up front so a running Codex causes a +/// clean `database is locked` failure instead of a partial rewrite. +fn run_sqlite(database: &Path, sql: &str) -> Result { + // `.timeout` rather than `PRAGMA busy_timeout`: the pragma emits its value + // as a result row, which would contaminate the rows callers parse. + let output = Command::new("sqlite3") + .arg("-noheader") + .arg("-batch") + .arg("-cmd") + .arg(format!(".timeout {BUSY_TIMEOUT_MS}")) + .arg(database) + .arg(sql) + .output() + .map_err(|error| { + format!( + "failed to run sqlite3 against {}: {error}", + database.display() + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + let hint = if stderr.contains("locked") || stderr.contains("busy") { + " — quit any running Codex session and retry" + } else { + "" + }; + return Err(format!( + "sqlite3 failed against {}: {stderr}{hint}", + database.display() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("sqlite3 returned non-UTF-8 output: {error}")) +} + +fn thread_ids_for_provider(database: &Path, provider: &str) -> Result, String> { + let sql = format!( + "SELECT id FROM threads WHERE model_provider = {};", + sql_string(provider) + ); + Ok(run_sqlite(database, &sql)? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) +} + +fn update_provider(database: &Path, from: &str, to: &str) -> Result<(), String> { + let sql = format!( + "BEGIN IMMEDIATE;\nUPDATE threads SET model_provider = {} WHERE model_provider = {};\nCOMMIT;", + sql_string(to), + sql_string(from) + ); + run_sqlite(database, &sql).map(|_| ()) +} + +/// Copies the database and its WAL sidecars to a timestamped directory. +/// +/// A checkpoint runs first so the copied main database is self-contained, but +/// the sidecars are copied too: a checkpoint can be declined by a reader. +fn back_up_database(database: &Path) -> Result { + run_sqlite(database, "PRAGMA wal_checkpoint(TRUNCATE);")?; + let parent = database + .parent() + .ok_or_else(|| format!("{} has no parent directory", database.display()))?; + let backup_dir = parent.join(format!("nemo-relay-history-backup-{}", unix_timestamp())); + fs::create_dir_all(&backup_dir) + .map_err(|error| format!("failed to create {}: {error}", backup_dir.display()))?; + for suffix in ["", "-wal", "-shm"] { + let mut source = database.as_os_str().to_os_string(); + source.push(suffix); + let source = PathBuf::from(source); + if !source.exists() { + continue; + } + let file_name = source + .file_name() + .ok_or_else(|| format!("{} has no file name", source.display()))?; + fs::copy(&source, backup_dir.join(file_name)).map_err(|error| { + format!( + "failed to back up {} into {}: {error}", + source.display(), + backup_dir.display() + ) + })?; + } + Ok(backup_dir) +} + +fn write_journal(database: &Path, backup: &Path, outcome: &MigrationOutcome) -> Result<(), String> { + let path = journal_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let mut bytes = serde_json::to_vec_pretty(&json!({ + "version": JOURNAL_VERSION, + "database": database, + "backup": backup, + "migratedAt": unix_timestamp(), + "from": outcome.from, + "to": outcome.to, + "threadIds": outcome.thread_ids, + })) + .map_err(|error| error.to_string())?; + bytes.push(b'\n'); + crate::filesystem::atomic_write_private(&path, &bytes) +} + +fn read_journal() -> Result, String> { + let path = journal_path()?; + match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(Some) + .map_err(|error| { + format!( + "failed to parse the Codex history migration journal {}: {error}", + path.display() + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "failed to read the Codex history migration journal {}: {error}", + path.display() + )), + } +} + +fn journal_database(journal: &Value) -> Option { + journal + .get("database") + .and_then(Value::as_str) + .map(PathBuf::from) +} + +fn clear_journal(dry_run: bool) -> Result<(), String> { + let path = journal_path()?; + if dry_run { + println!("remove {}", path.display()); + return Ok(()); + } + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove the Codex history migration journal {}: {error}", + path.display() + )), + } +} + +/// Quotes a value as a SQL string literal, doubling embedded single quotes. +fn sql_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +#[path = "../../../tests/coverage/agents/codex_history_tests.rs"] +mod tests; diff --git a/crates/cli/src/agents/codex/install.rs b/crates/cli/src/agents/codex/install.rs index c9ff485f7..afd850b5e 100644 --- a/crates/cli/src/agents/codex/install.rs +++ b/crates/cli/src/agents/codex/install.rs @@ -7,10 +7,31 @@ use crate::agents::CodingAgent; use crate::error::CliError; use crate::installation::{InstallRequest, UninstallRequest}; +use super::history; + pub(crate) fn install(command: InstallRequest) -> Result { - crate::installation::marketplace::install(CodingAgent::Codex, command) + let migrate_history = command.migrate_history; + let dry_run = command.dry_run; + let status = crate::installation::marketplace::install(CodingAgent::Codex, command)?; + if status != ExitCode::SUCCESS || !migrate_history { + return Ok(status); + } + // The provider must exist in config.toml before threads are pointed at it, + // so migrate only after the install itself has succeeded. + history::migrate_to_relay(dry_run).map_err(CliError::Install)?; + Ok(status) } pub(crate) fn uninstall(command: UninstallRequest) -> Result { - crate::installation::marketplace::uninstall(CodingAgent::Codex, command) + let skip_history_migration = command.skip_history_migration; + let dry_run = command.dry_run; + let status = crate::installation::marketplace::uninstall(CodingAgent::Codex, command)?; + if status != ExitCode::SUCCESS || skip_history_migration { + return Ok(status); + } + // Reversal is inferred from the migration journal rather than a flag, so a + // user who migrated at install time does not have to remember to ask for it + // again here. + history::restore_from_relay(dry_run).map_err(CliError::Install)?; + Ok(status) } diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index 8076b10ce..4aa860f62 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -8,6 +8,7 @@ use super::AgentDescriptor; pub(super) mod app_server; pub(super) mod assets; pub(crate) mod doctor; +pub(super) mod history; pub(super) mod host; pub(crate) mod install; pub(crate) mod launch; diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 982c5f07e..6bbfefc99 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -21,6 +21,11 @@ pub(crate) struct InstallCommand { pub(crate) dry_run: bool, #[arg(long)] pub(crate) skip_doctor: bool, + /// Experimental: move existing Codex thread history onto the Relay provider so it stays + /// visible in the Codex resume picker. `nemo-relay uninstall codex` reverses this + /// automatically. + #[arg(long)] + pub(crate) migrate_history: bool, } #[derive(Debug, Clone, Args)] @@ -34,6 +39,9 @@ pub(crate) struct UninstallCommand { pub(crate) force: bool, #[arg(long)] pub(crate) dry_run: bool, + /// Leave migrated Codex thread history on the Relay provider instead of restoring it. + #[arg(long)] + pub(crate) skip_history_migration: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -66,6 +74,7 @@ impl InstallCommand { force: self.force, dry_run: self.dry_run, skip_doctor: self.skip_doctor, + migrate_history: self.migrate_history, } } } @@ -76,12 +85,18 @@ impl UninstallCommand { install_dir: self.install_dir, force: self.force, dry_run: self.dry_run, + skip_history_migration: self.skip_history_migration, } } } pub(super) fn install(command: InstallCommand) -> Result { let target = command.host; + if command.migrate_history && matches!(target, InstallTarget::ClaudeCode) { + return Err(CliError::Install( + "--migrate-history applies to the Codex integration only".into(), + )); + } let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { @@ -104,6 +119,11 @@ pub(super) fn install(command: InstallCommand) -> Result { pub(super) fn uninstall(command: UninstallCommand) -> Result { let target = command.host; + if command.skip_history_migration && matches!(target, InstallTarget::ClaudeCode) { + return Err(CliError::Install( + "--skip-history-migration applies to the Codex integration only".into(), + )); + } let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { diff --git a/crates/cli/src/commands/integrations.rs b/crates/cli/src/commands/integrations.rs index d2f1181d5..ee8a8ef83 100644 --- a/crates/cli/src/commands/integrations.rs +++ b/crates/cli/src/commands/integrations.rs @@ -78,6 +78,9 @@ fn refresh(command: RefreshCommand) -> Result { force: true, dry_run: command.dry_run, skip_doctor: false, + // Refresh repairs an existing installation; any recorded history + // migration stays recorded and does not need to run again. + migrate_history: false, }; let result = match crate::agents::install_integration(agent, request) { Ok(status) if status == ExitCode::SUCCESS => Ok(()), diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index a26d40c4e..b5c3fe4aa 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -15,6 +15,8 @@ pub(crate) struct InstallRequest { pub(crate) force: bool, pub(crate) dry_run: bool, pub(crate) skip_doctor: bool, + /// Experimental: rewrite pre-install Codex thread history onto the Relay provider. + pub(crate) migrate_history: bool, } #[derive(Debug, Clone)] @@ -22,4 +24,6 @@ pub(crate) struct UninstallRequest { pub(crate) install_dir: Option, pub(crate) force: bool, pub(crate) dry_run: bool, + /// Skip the reversal that a recorded history migration would otherwise infer. + pub(crate) skip_history_migration: bool, } diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs new file mode 100644 index 000000000..be5edd84f --- /dev/null +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Coverage for the experimental Codex thread-history provider migration. + +use std::ffi::OsStr; +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +use super::*; +use crate::test_support::EnvScope; + +/// Redirects the Codex home and the Relay user config directory into a +/// temporary tree, and seeds a thread database with the given providers. +struct CodexHistoryScope { + _env: EnvScope, + home: TempDir, +} + +impl CodexHistoryScope { + fn enter(threads: &[(&str, &str)]) -> Self { + let home = tempfile::tempdir().expect("temporary home"); + let codex_home = home.path().join(".codex"); + std::fs::create_dir_all(&codex_home).expect("codex home"); + let config_home = home.path().join(".config"); + std::fs::create_dir_all(&config_home).expect("config home"); + let env = EnvScope::set(&[ + ("HOME", Some(home.path().as_os_str())), + ("USERPROFILE", Some(home.path().as_os_str())), + ("CODEX_HOME", Some(codex_home.as_os_str())), + ("XDG_CONFIG_HOME", Some(config_home.as_os_str())), + ("APPDATA", Some(config_home.as_os_str())), + ]); + let scope = Self { _env: env, home }; + seed_database(&scope.database(), threads); + scope + } + + fn database(&self) -> std::path::PathBuf { + self.home.path().join(".codex").join(STATE_DB_FILE) + } + + fn providers(&self) -> Vec<(String, String)> { + let raw = sqlite( + &self.database(), + "SELECT id, model_provider FROM threads ORDER BY id;", + ); + raw.lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let (id, provider) = line.split_once('|').expect("delimited row"); + (id.to_string(), provider.to_string()) + }) + .collect() + } + + fn journal(&self) -> Option { + read_journal().expect("journal reads") + } + + fn backup_dirs(&self) -> Vec { + let mut dirs = std::fs::read_dir(self.home.path().join(".codex")) + .expect("codex home listing") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_dir() + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("nemo-relay-history-backup-")) + }) + .collect::>(); + dirs.sort(); + dirs + } +} + +/// Creates a minimal `threads` table carrying only the columns under test. +fn seed_database(database: &Path, threads: &[(&str, &str)]) { + let mut sql = + String::from("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);"); + for (id, provider) in threads { + sql.push_str(&format!( + "INSERT INTO threads (id, model_provider) VALUES ({}, {});", + sql_string(id), + sql_string(provider) + )); + } + sqlite(database, &sql); +} + +fn sqlite(database: &Path, sql: &str) -> String { + let output = Command::new("sqlite3") + .arg("-noheader") + .arg("-batch") + .arg(database) + .arg(sql) + .output() + .expect("sqlite3 runs"); + assert!( + output.status.success(), + "sqlite3 failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("utf-8 sqlite3 output") +} + +/// Skips a test when the host has no `sqlite3`, which the migration requires. +fn sqlite3_available() -> bool { + Command::new("sqlite3") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) +} + +macro_rules! require_sqlite3_or_skip { + () => { + if !sqlite3_available() { + eprintln!("skipping: no sqlite3 on PATH"); + return; + } + }; +} + +#[test] +fn migrate_moves_openai_threads_onto_the_relay_provider() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[ + ("thread-a", OPENAI_PROVIDER), + ("thread-b", OPENAI_PROVIDER), + ("thread-c", "some-other-provider"), + ]); + + let outcome = migrate_to_relay(/*dry_run*/ false) + .expect("migration succeeds") + .expect("migration reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a", "thread-b"]); + assert_eq!( + scope.providers(), + vec![ + ("thread-a".to_string(), RELAY_PROVIDER.to_string()), + ("thread-b".to_string(), RELAY_PROVIDER.to_string()), + ("thread-c".to_string(), "some-other-provider".to_string()), + ], + "only threads on the built-in provider move" + ); +} + +#[test] +fn migrate_records_a_journal_that_uninstall_can_infer() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + assert!( + !migration_recorded(), + "no migration is recorded before one runs" + ); + + migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + + assert!(migration_recorded(), "uninstall can infer the migration"); + let journal = scope.journal().expect("journal exists"); + assert_eq!(journal["version"], json!(JOURNAL_VERSION)); + assert_eq!(journal["from"], json!(OPENAI_PROVIDER)); + assert_eq!(journal["to"], json!(RELAY_PROVIDER)); + assert_eq!(journal["threadIds"], json!(["thread-a"])); + assert_eq!( + journal["database"], + json!(scope.database()), + "the journal pins the database it rewrote" + ); +} + +#[test] +fn migrate_backs_up_the_database_before_rewriting_it() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + + migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + + let backups = scope.backup_dirs(); + assert_eq!(backups.len(), 1, "exactly one backup directory is created"); + let copied = backups[0].join(STATE_DB_FILE); + assert!(copied.exists(), "the database itself is copied"); + assert_eq!( + sqlite(&copied, "SELECT model_provider FROM threads;").trim(), + OPENAI_PROVIDER, + "the backup holds the pre-migration provider" + ); +} + +#[test] +fn migrate_reports_nothing_to_do_without_built_in_provider_threads() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", RELAY_PROVIDER)]); + + let outcome = migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + + assert_eq!(outcome, None, "an empty migration reports no outcome"); + assert!( + !migration_recorded(), + "an empty migration records no journal to reverse" + ); + assert!( + scope.backup_dirs().is_empty(), + "an empty migration does not back up" + ); +} + +#[test] +fn dry_run_migration_reports_without_touching_the_database() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + + let outcome = migrate_to_relay(/*dry_run*/ true) + .expect("dry run succeeds") + .expect("dry run reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())], + "a dry run leaves the database untouched" + ); + assert!(!migration_recorded(), "a dry run records no journal"); + assert!(scope.backup_dirs().is_empty(), "a dry run does not back up"); +} + +#[test] +fn restore_returns_migrated_threads_to_the_built_in_provider() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[ + ("thread-a", OPENAI_PROVIDER), + ("thread-b", "some-other-provider"), + ]); + migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + + let outcome = restore_from_relay(/*dry_run*/ false) + .expect("restore succeeds") + .expect("restore reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![ + ("thread-a".to_string(), OPENAI_PROVIDER.to_string()), + ("thread-b".to_string(), "some-other-provider".to_string()), + ], + "the round trip restores the original providers" + ); + assert!( + !migration_recorded(), + "a completed restore clears the journal" + ); +} + +#[test] +fn restore_also_moves_threads_created_while_relay_was_installed() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("pre-install", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + // A thread Codex recorded under the Relay provider after the migration ran. + sqlite( + &scope.database(), + &format!( + "INSERT INTO threads (id, model_provider) VALUES ('relay-era', {});", + sql_string(RELAY_PROVIDER) + ), + ); + + let outcome = restore_from_relay(/*dry_run*/ false) + .expect("restore succeeds") + .expect("restore reports an outcome"); + + assert_eq!( + outcome.thread_ids, + vec!["pre-install", "relay-era"], + "reversal covers threads the migration never recorded" + ); + assert_eq!( + scope.providers(), + vec![ + ("pre-install".to_string(), OPENAI_PROVIDER.to_string()), + ("relay-era".to_string(), OPENAI_PROVIDER.to_string()), + ], + "uninstall leaves no thread pointing at a provider that no longer exists" + ); +} + +#[test] +fn restore_without_a_recorded_migration_is_a_no_op() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", RELAY_PROVIDER)]); + + let outcome = restore_from_relay(/*dry_run*/ false).expect("restore succeeds"); + + assert_eq!(outcome, None, "no journal means nothing to reverse"); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), RELAY_PROVIDER.to_string())], + "an uninstall without a migration leaves the database untouched" + ); +} + +#[test] +fn dry_run_restore_reports_without_touching_the_database() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + + let outcome = restore_from_relay(/*dry_run*/ true) + .expect("dry run succeeds") + .expect("dry run reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), RELAY_PROVIDER.to_string())], + "a dry run leaves the database untouched" + ); + assert!( + migration_recorded(), + "a dry run keeps the journal so the real reversal still runs" + ); +} + +#[test] +fn migrate_fails_when_codex_has_no_thread_database() { + let home = tempfile::tempdir().expect("temporary home"); + let codex_home = home.path().join(".codex"); + std::fs::create_dir_all(&codex_home).expect("codex home"); + let _env = EnvScope::set(&[ + ("HOME", Some(home.path().as_os_str())), + ("USERPROFILE", Some(home.path().as_os_str())), + ("CODEX_HOME", Some(codex_home.as_os_str())), + ("XDG_CONFIG_HOME", Some(home.path().as_os_str())), + ("APPDATA", Some(home.path().as_os_str())), + ]); + + let error = migrate_to_relay(/*dry_run*/ false).expect_err("missing database is an error"); + + assert!( + error.contains("no Codex thread database at"), + "unexpected error: {error}" + ); +} + +#[test] +fn sql_string_escapes_embedded_quotes() { + assert_eq!(sql_string("openai"), "'openai'"); + assert_eq!(sql_string("o'brien"), "'o''brien'"); +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index f64f8e8db..66f77944d 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2159,6 +2159,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: true, skip_doctor: true, + migrate_history: false, } ) .unwrap(), @@ -2185,6 +2186,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { install_dir: Some(dir.path().join("dry-run-uninstall")), force: false, dry_run: true, + skip_history_migration: false, }, ) .unwrap(), @@ -2198,6 +2200,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: false, skip_doctor: true, + migrate_history: false, }, ) .expect_err("an unavailable host CLI should fail installation"); @@ -2209,6 +2212,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { install_dir: Some(dir.path().join("failed-uninstall")), force: false, dry_run: false, + skip_history_migration: false, }, ) .expect_err("an unavailable host CLI should fail uninstallation"); diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 2d68de7d1..53a258783 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -277,6 +277,60 @@ Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for install directories, shared-sidecar behavior, rollback behavior, and source marketplace notes. +## Migrate Codex Thread History + + +`--migrate-history` edits Codex's own thread database directly. Codex owns that +schema, provides no supported API for changing a thread's provider, and can +change the storage layout in any release. Treat this as a stopgap until +[openai/codex#27381](https://github.com/openai/codex/issues/27381) is resolved +upstream. + + +Codex records the provider that produced each thread and filters its resume +picker by the provider that is currently active. Installing the Relay +integration switches Codex to the `nemo-relay-openai` provider, so threads +recorded under the built-in `openai` provider stop appearing in the picker. +They remain on disk and stay resumable by thread id; only discovery is +affected. + +To move existing history onto the Relay provider so it stays visible, add +`--migrate-history` at install time: + +```bash +nemo-relay install codex --migrate-history +``` + +Close every Codex session first. The migration takes the database write lock +and fails cleanly with `database is locked` rather than writing partial state +if Codex is still running. It requires `sqlite3` on `PATH`. + +Before rewriting anything, Relay copies the thread database and its +write-ahead-log sidecars into a timestamped `nemo-relay-history-backup-*` +directory inside the Codex home. It then records a migration journal in the +user configuration directory. + +The migration is reversible and the reversal is inferred, so uninstall needs no +flag: + +```bash +nemo-relay uninstall codex +``` + +Uninstall reads the journal, moves every thread still recorded under +`nemo-relay-openai` back to `openai`, and clears the journal. Reversal covers +threads created while Relay was installed as well as the migrated ones: once +uninstall removes the `nemo-relay-openai` provider from `config.toml`, any +thread still pointing at it would be hidden from the picker in the same way the +migration exists to prevent. Pass `--skip-history-migration` to leave thread +history on the Relay provider instead. + +Add `--dry-run` to either command to report the thread counts that would move +without touching the database or the journal. + +`nemo-relay integrations refresh` does not re-run the migration. A recorded +migration stays recorded across reinstalls and upgrades. + ## Configure Transparent Runs Create `$XDG_CONFIG_HOME/nemo-relay/config.toml` (or From 5039e0a08573bf53ef3990017fd230a5865b46c0 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 15:44:38 -0700 Subject: [PATCH 02/10] feat(codex): allow naming the Codex thread database to migrate The thread database file name carries a Codex schema generation, so the hardcoded `state_5.sqlite` stops resolving the moment Codex moves to `state_6.sqlite` or later. Add `--history-database` to `nemo-relay install codex` and `nemo-relay uninstall codex` so the current database can be named without waiting on a Relay release. A bare file name resolves inside the Codex home, which is the common case for a schema bump; a value with a directory component is used as given. At uninstall time the precedence is explicit flag, then the database the migration journal recorded, then the default, so an ordinary uninstall still reverses the right database without being told. Because the path can now come from a flag, validate the schema before copying or rewriting anything: reject a database with no `threads` table or no `model_provider` column. `--history-database` requires `--migrate-history` at install time rather than silently doing nothing. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/history.rs | 73 ++++++++- crates/cli/src/agents/codex/install.rs | 6 +- crates/cli/src/commands/install.rs | 26 ++++ crates/cli/src/commands/integrations.rs | 1 + crates/cli/src/installation/mod.rs | 4 + .../coverage/agents/codex_history_tests.rs | 144 ++++++++++++++++-- .../coverage/agents/plugin_install_tests.rs | 4 + docs/nemo-relay-cli/codex.mdx | 17 +++ 8 files changed, 250 insertions(+), 25 deletions(-) diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs index ac2d4a72f..d90078ba9 100644 --- a/crates/cli/src/agents/codex/history.rs +++ b/crates/cli/src/agents/codex/history.rs @@ -34,7 +34,9 @@ pub(crate) const OPENAI_PROVIDER: &str = "openai"; /// The provider id Relay installs into `config.toml`. pub(crate) const RELAY_PROVIDER: &str = "nemo-relay-openai"; -/// Codex's thread index. The numeric suffix is a Codex schema generation. +/// Codex's thread index. The numeric suffix is a Codex schema generation, so a +/// future Codex release can move this to `state_6.sqlite` or later. Override the +/// default with `--history-database` when that happens. const STATE_DB_FILE: &str = "state_5.sqlite"; /// Journal recording migrations so uninstall can infer and reverse them. const JOURNAL_FILE: &str = "codex-history-migration.json"; @@ -65,15 +67,20 @@ impl MigrationOutcome { /// Rewrites pre-install `openai` threads to the Relay provider. /// /// Returns `None` when there was nothing to migrate. -pub(crate) fn migrate_to_relay(dry_run: bool) -> Result, String> { - let database = state_db_path()?; +pub(crate) fn migrate_to_relay( + dry_run: bool, + database: Option<&Path>, +) -> Result, String> { + let database = resolve_database(database)?; if !database.exists() { return Err(format!( - "no Codex thread database at {}; run Codex at least once before migrating history", + "no Codex thread database at {}; run Codex at least once before migrating history, or \ + name the current database with `--history-database`", database.display() )); } require_sqlite3()?; + ensure_thread_schema(&database)?; let thread_ids = thread_ids_for_provider(&database, OPENAI_PROVIDER)?; if thread_ids.is_empty() { println!( @@ -118,11 +125,23 @@ pub(crate) fn migrate_to_relay(dry_run: bool) -> Result /// fix, mirrored. /// /// Returns `None` when no migration was recorded or there is nothing to move. -pub(crate) fn restore_from_relay(dry_run: bool) -> Result, String> { +pub(crate) fn restore_from_relay( + dry_run: bool, + database: Option<&Path>, +) -> Result, String> { let Some(journal) = read_journal()? else { return Ok(None); }; - let database = journal_database(&journal).unwrap_or(state_db_path()?); + // An explicit override wins, then the database the migration pinned, then + // the default. The journal matters most here: it names the database that + // was actually rewritten, even if the default has since moved on. + let database = match database { + Some(database) => resolve_database(Some(database))?, + None => match journal_database(&journal) { + Some(database) => database, + None => resolve_database(None)?, + }, + }; if !database.exists() { clear_journal(dry_run)?; return Err(format!( @@ -131,6 +150,7 @@ pub(crate) fn restore_from_relay(dry_run: bool) -> Result bool { matches!(read_journal(), Ok(Some(_))) } -fn state_db_path() -> Result { - Ok(codex_home_dir()?.join(STATE_DB_FILE)) +/// Resolves the thread database to operate on. +/// +/// A bare file name such as `state_6.sqlite` resolves inside the Codex home, +/// which is the common case when Codex bumps its schema generation. Anything +/// with a directory component is used as given. +fn resolve_database(explicit: Option<&Path>) -> Result { + let Some(explicit) = explicit else { + return Ok(codex_home_dir()?.join(STATE_DB_FILE)); + }; + if explicit.components().count() == 1 && explicit.is_relative() { + return Ok(codex_home_dir()?.join(explicit)); + } + Ok(explicit.to_path_buf()) +} + +/// Rejects a database that does not carry the columns this migration rewrites. +/// +/// The path can come from `--history-database`, so a typo or an unrelated +/// SQLite file should fail before anything is copied or written. +fn ensure_thread_schema(database: &Path) -> Result<(), String> { + let columns = run_sqlite(database, "PRAGMA table_info(threads);")?; + if columns.trim().is_empty() { + return Err(format!( + "{} has no `threads` table; it does not look like a Codex thread database", + database.display() + )); + } + let has_provider = columns + .lines() + .filter_map(|line| line.split('|').nth(1)) + .any(|column| column == "model_provider"); + if !has_provider { + return Err(format!( + "the `threads` table in {} has no `model_provider` column; this Codex schema is not \ + supported", + database.display() + )); + } + Ok(()) } fn journal_path() -> Result { diff --git a/crates/cli/src/agents/codex/install.rs b/crates/cli/src/agents/codex/install.rs index afd850b5e..ab1a92284 100644 --- a/crates/cli/src/agents/codex/install.rs +++ b/crates/cli/src/agents/codex/install.rs @@ -12,19 +12,21 @@ use super::history; pub(crate) fn install(command: InstallRequest) -> Result { let migrate_history = command.migrate_history; let dry_run = command.dry_run; + let history_database = command.history_database.clone(); let status = crate::installation::marketplace::install(CodingAgent::Codex, command)?; if status != ExitCode::SUCCESS || !migrate_history { return Ok(status); } // The provider must exist in config.toml before threads are pointed at it, // so migrate only after the install itself has succeeded. - history::migrate_to_relay(dry_run).map_err(CliError::Install)?; + history::migrate_to_relay(dry_run, history_database.as_deref()).map_err(CliError::Install)?; Ok(status) } pub(crate) fn uninstall(command: UninstallRequest) -> Result { let skip_history_migration = command.skip_history_migration; let dry_run = command.dry_run; + let history_database = command.history_database.clone(); let status = crate::installation::marketplace::uninstall(CodingAgent::Codex, command)?; if status != ExitCode::SUCCESS || skip_history_migration { return Ok(status); @@ -32,6 +34,6 @@ pub(crate) fn uninstall(command: UninstallRequest) -> Result // Reversal is inferred from the migration journal rather than a flag, so a // user who migrated at install time does not have to remember to ask for it // again here. - history::restore_from_relay(dry_run).map_err(CliError::Install)?; + history::restore_from_relay(dry_run, history_database.as_deref()).map_err(CliError::Install)?; Ok(status) } diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 6bbfefc99..65ce44266 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -26,6 +26,11 @@ pub(crate) struct InstallCommand { /// automatically. #[arg(long)] pub(crate) migrate_history: bool, + /// Codex thread database to migrate, when Codex has moved past the default schema + /// generation. Accepts a bare file name such as `state_6.sqlite`, resolved inside the Codex + /// home, or a full path. Requires `--migrate-history`. + #[arg(long, value_name = "PATH")] + pub(crate) history_database: Option, } #[derive(Debug, Clone, Args)] @@ -42,6 +47,10 @@ pub(crate) struct UninstallCommand { /// Leave migrated Codex thread history on the Relay provider instead of restoring it. #[arg(long)] pub(crate) skip_history_migration: bool, + /// Codex thread database to restore, overriding the one the migration recorded. Accepts a bare + /// file name such as `state_6.sqlite`, resolved inside the Codex home, or a full path. + #[arg(long, value_name = "PATH")] + pub(crate) history_database: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -75,6 +84,7 @@ impl InstallCommand { dry_run: self.dry_run, skip_doctor: self.skip_doctor, migrate_history: self.migrate_history, + history_database: self.history_database, } } } @@ -86,6 +96,7 @@ impl UninstallCommand { force: self.force, dry_run: self.dry_run, skip_history_migration: self.skip_history_migration, + history_database: self.history_database, } } } @@ -97,6 +108,11 @@ pub(super) fn install(command: InstallCommand) -> Result { "--migrate-history applies to the Codex integration only".into(), )); } + if command.history_database.is_some() && !command.migrate_history { + return Err(CliError::Install( + "--history-database requires --migrate-history".into(), + )); + } let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { @@ -124,6 +140,16 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result "--skip-history-migration applies to the Codex integration only".into(), )); } + if command.history_database.is_some() && matches!(target, InstallTarget::ClaudeCode) { + return Err(CliError::Install( + "--history-database applies to the Codex integration only".into(), + )); + } + if command.history_database.is_some() && command.skip_history_migration { + return Err(CliError::Install( + "--history-database cannot be combined with --skip-history-migration".into(), + )); + } let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { diff --git a/crates/cli/src/commands/integrations.rs b/crates/cli/src/commands/integrations.rs index ee8a8ef83..2fcf6746a 100644 --- a/crates/cli/src/commands/integrations.rs +++ b/crates/cli/src/commands/integrations.rs @@ -81,6 +81,7 @@ fn refresh(command: RefreshCommand) -> Result { // Refresh repairs an existing installation; any recorded history // migration stays recorded and does not need to run again. migrate_history: false, + history_database: None, }; let result = match crate::agents::install_integration(agent, request) { Ok(status) if status == ExitCode::SUCCESS => Ok(()), diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index b5c3fe4aa..85b790807 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -17,6 +17,8 @@ pub(crate) struct InstallRequest { pub(crate) skip_doctor: bool, /// Experimental: rewrite pre-install Codex thread history onto the Relay provider. pub(crate) migrate_history: bool, + /// Codex thread database to migrate, when it is not the default generation. + pub(crate) history_database: Option, } #[derive(Debug, Clone)] @@ -26,4 +28,6 @@ pub(crate) struct UninstallRequest { pub(crate) dry_run: bool, /// Skip the reversal that a recorded history migration would otherwise infer. pub(crate) skip_history_migration: bool, + /// Codex thread database to restore, overriding the one the journal recorded. + pub(crate) history_database: Option, } diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index be5edd84f..4c4ae85f7 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -17,10 +17,15 @@ use crate::test_support::EnvScope; struct CodexHistoryScope { _env: EnvScope, home: TempDir, + database: String, } impl CodexHistoryScope { fn enter(threads: &[(&str, &str)]) -> Self { + Self::enter_named(STATE_DB_FILE, threads) + } + + fn enter_named(database: &str, threads: &[(&str, &str)]) -> Self { let home = tempfile::tempdir().expect("temporary home"); let codex_home = home.path().join(".codex"); std::fs::create_dir_all(&codex_home).expect("codex home"); @@ -33,13 +38,17 @@ impl CodexHistoryScope { ("XDG_CONFIG_HOME", Some(config_home.as_os_str())), ("APPDATA", Some(config_home.as_os_str())), ]); - let scope = Self { _env: env, home }; + let scope = Self { + _env: env, + home, + database: database.to_string(), + }; seed_database(&scope.database(), threads); scope } fn database(&self) -> std::path::PathBuf { - self.home.path().join(".codex").join(STATE_DB_FILE) + self.home.path().join(".codex").join(&self.database) } fn providers(&self) -> Vec<(String, String)> { @@ -134,7 +143,7 @@ fn migrate_moves_openai_threads_onto_the_relay_provider() { ("thread-c", "some-other-provider"), ]); - let outcome = migrate_to_relay(/*dry_run*/ false) + let outcome = migrate_to_relay(/*dry_run*/ false, /*database*/ None) .expect("migration succeeds") .expect("migration reports an outcome"); @@ -159,7 +168,7 @@ fn migrate_records_a_journal_that_uninstall_can_infer() { "no migration is recorded before one runs" ); - migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); assert!(migration_recorded(), "uninstall can infer the migration"); let journal = scope.journal().expect("journal exists"); @@ -179,7 +188,7 @@ fn migrate_backs_up_the_database_before_rewriting_it() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); - migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); let backups = scope.backup_dirs(); assert_eq!(backups.len(), 1, "exactly one backup directory is created"); @@ -197,7 +206,8 @@ fn migrate_reports_nothing_to_do_without_built_in_provider_threads() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("thread-a", RELAY_PROVIDER)]); - let outcome = migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + let outcome = + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); assert_eq!(outcome, None, "an empty migration reports no outcome"); assert!( @@ -215,7 +225,7 @@ fn dry_run_migration_reports_without_touching_the_database() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); - let outcome = migrate_to_relay(/*dry_run*/ true) + let outcome = migrate_to_relay(/*dry_run*/ true, /*database*/ None) .expect("dry run succeeds") .expect("dry run reports an outcome"); @@ -236,9 +246,9 @@ fn restore_returns_migrated_threads_to_the_built_in_provider() { ("thread-a", OPENAI_PROVIDER), ("thread-b", "some-other-provider"), ]); - migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); - let outcome = restore_from_relay(/*dry_run*/ false) + let outcome = restore_from_relay(/*dry_run*/ false, /*database*/ None) .expect("restore succeeds") .expect("restore reports an outcome"); @@ -261,7 +271,7 @@ fn restore_returns_migrated_threads_to_the_built_in_provider() { fn restore_also_moves_threads_created_while_relay_was_installed() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("pre-install", OPENAI_PROVIDER)]); - migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); // A thread Codex recorded under the Relay provider after the migration ran. sqlite( &scope.database(), @@ -271,7 +281,7 @@ fn restore_also_moves_threads_created_while_relay_was_installed() { ), ); - let outcome = restore_from_relay(/*dry_run*/ false) + let outcome = restore_from_relay(/*dry_run*/ false, /*database*/ None) .expect("restore succeeds") .expect("restore reports an outcome"); @@ -295,7 +305,8 @@ fn restore_without_a_recorded_migration_is_a_no_op() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("thread-a", RELAY_PROVIDER)]); - let outcome = restore_from_relay(/*dry_run*/ false).expect("restore succeeds"); + let outcome = + restore_from_relay(/*dry_run*/ false, /*database*/ None).expect("restore succeeds"); assert_eq!(outcome, None, "no journal means nothing to reverse"); assert_eq!( @@ -309,9 +320,9 @@ fn restore_without_a_recorded_migration_is_a_no_op() { fn dry_run_restore_reports_without_touching_the_database() { require_sqlite3_or_skip!(); let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); - migrate_to_relay(/*dry_run*/ false).expect("migration succeeds"); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); - let outcome = restore_from_relay(/*dry_run*/ true) + let outcome = restore_from_relay(/*dry_run*/ true, /*database*/ None) .expect("dry run succeeds") .expect("dry run reports an outcome"); @@ -340,7 +351,8 @@ fn migrate_fails_when_codex_has_no_thread_database() { ("APPDATA", Some(home.path().as_os_str())), ]); - let error = migrate_to_relay(/*dry_run*/ false).expect_err("missing database is an error"); + let error = migrate_to_relay(/*dry_run*/ false, /*database*/ None) + .expect_err("missing database is an error"); assert!( error.contains("no Codex thread database at"), @@ -353,3 +365,105 @@ fn sql_string_escapes_embedded_quotes() { assert_eq!(sql_string("openai"), "'openai'"); assert_eq!(sql_string("o'brien"), "'o''brien'"); } + +#[test] +fn a_named_database_resolves_inside_the_codex_home() { + require_sqlite3_or_skip!(); + // The schema generation Codex might move to next. + let scope = CodexHistoryScope::enter_named( + "state_6.sqlite", + &[("thread-a", OPENAI_PROVIDER), ("thread-b", OPENAI_PROVIDER)], + ); + + let outcome = migrate_to_relay(/*dry_run*/ false, Some(Path::new("state_6.sqlite"))) + .expect("migration succeeds") + .expect("migration reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a", "thread-b"]); + assert_eq!( + scope.providers(), + vec![ + ("thread-a".to_string(), RELAY_PROVIDER.to_string()), + ("thread-b".to_string(), RELAY_PROVIDER.to_string()), + ] + ); + assert_eq!( + scope.journal().expect("journal exists")["database"], + json!(scope.database()), + "the journal pins the overridden database" + ); +} + +#[test] +fn a_full_path_database_is_used_as_given() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("ignored", OPENAI_PROVIDER)]); + let elsewhere = scope.home.path().join("elsewhere.sqlite"); + seed_database(&elsewhere, &[("thread-a", OPENAI_PROVIDER)]); + + let outcome = migrate_to_relay(/*dry_run*/ false, Some(&elsewhere)) + .expect("migration succeeds") + .expect("migration reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![("ignored".to_string(), OPENAI_PROVIDER.to_string())], + "the default database is left alone" + ); +} + +#[test] +fn restore_prefers_the_database_the_migration_recorded() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter_named("state_6.sqlite", &[("thread-a", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false, Some(Path::new("state_6.sqlite"))) + .expect("migration succeeds"); + + // No override here: uninstall infers both the reversal and its target. + let outcome = restore_from_relay(/*dry_run*/ false, /*database*/ None) + .expect("restore succeeds") + .expect("restore reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())], + "reversal follows the journal rather than the default file name" + ); +} + +#[test] +fn migrate_rejects_a_database_without_a_threads_table() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + let unrelated = scope.home.path().join("unrelated.sqlite"); + sqlite(&unrelated, "CREATE TABLE notes (id TEXT);"); + + let error = migrate_to_relay(/*dry_run*/ false, Some(&unrelated)) + .expect_err("an unrelated database is rejected"); + + assert!( + error.contains("has no `threads` table"), + "unexpected error: {error}" + ); +} + +#[test] +fn migrate_rejects_a_threads_table_without_a_provider_column() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + let future = scope.home.path().join("state_9.sqlite"); + sqlite( + &future, + "CREATE TABLE threads (id TEXT, provider_ref TEXT);", + ); + + let error = migrate_to_relay(/*dry_run*/ false, Some(&future)) + .expect_err("an unsupported schema is rejected"); + + assert!( + error.contains("has no `model_provider` column"), + "unexpected error: {error}" + ); +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 66f77944d..e362a26d1 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2160,6 +2160,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { dry_run: true, skip_doctor: true, migrate_history: false, + history_database: None, } ) .unwrap(), @@ -2187,6 +2188,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: true, skip_history_migration: false, + history_database: None, }, ) .unwrap(), @@ -2201,6 +2203,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { dry_run: false, skip_doctor: true, migrate_history: false, + history_database: None, }, ) .expect_err("an unavailable host CLI should fail installation"); @@ -2213,6 +2216,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: false, skip_history_migration: false, + history_database: None, }, ) .expect_err("an unavailable host CLI should fail uninstallation"); diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 53a258783..8b6307cc8 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -328,6 +328,23 @@ history on the Relay provider instead. Add `--dry-run` to either command to report the thread counts that would move without touching the database or the journal. +Relay targets Codex's current thread database, `state_5.sqlite`. The numeric +suffix is a Codex schema generation, so a future Codex release can move to +`state_6.sqlite` or later. Name the current database explicitly when that +happens: + +```bash +nemo-relay install codex --migrate-history --history-database state_6.sqlite +``` + +A bare file name resolves inside the Codex home; a value with a directory +component is used as given. Relay rejects a database that has no `threads` +table or no `model_provider` column before it copies or rewrites anything. + +`--history-database` requires `--migrate-history` at install time. At uninstall +time it overrides the database the migration recorded, which is otherwise +preferred over the default; supply it only when that recorded path has moved. + `nemo-relay integrations refresh` does not re-run the migration. A recorded migration stays recorded across reinstalls and upgrades. From eabd520041200ae04421da9c194531b58b3f1b45 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 15:58:54 -0700 Subject: [PATCH 03/10] docs(codex): document history-migration consequences and cross-reference it The Codex history-migration section understated two things a reader needs before running it. `--skip-history-migration` does not merely hide threads from the resume picker: Codex resolves a thread's recorded provider against config.toml on resume, so a thread stranded on a removed provider fails to resume outright. Backups are also never pruned, and each is a full copy of the thread database. The host-neutral installation page was silent about the migration entirely. Its uninstall section enumerates what uninstall removes, so a reader had no way to learn that uninstall may also rewrite the Codex thread database. Cross-reference the Codex page from both the install and uninstall sections rather than duplicating the detail. Also state that Relay does not search for another schema generation when the expected database is missing, matching the explicit-over-discovery behavior of `--history-database`. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- docs/nemo-relay-cli/codex.mdx | 21 ++++++++++++++++----- docs/nemo-relay-cli/plugin-installation.mdx | 12 ++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 8b6307cc8..0861b77a4 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -308,7 +308,10 @@ if Codex is still running. It requires `sqlite3` on `PATH`. Before rewriting anything, Relay copies the thread database and its write-ahead-log sidecars into a timestamped `nemo-relay-history-backup-*` directory inside the Codex home. It then records a migration journal in the -user configuration directory. +user configuration directory. To recover from an unexpected result, quit Codex +and copy the files from the most recent backup directory back over the thread +database. Relay never removes these backups, and each one is a full copy of the +thread database; delete old directories when you no longer need them. The migration is reversible and the reversal is inferred, so uninstall needs no flag: @@ -322,16 +325,24 @@ Uninstall reads the journal, moves every thread still recorded under threads created while Relay was installed as well as the migrated ones: once uninstall removes the `nemo-relay-openai` provider from `config.toml`, any thread still pointing at it would be hidden from the picker in the same way the -migration exists to prevent. Pass `--skip-history-migration` to leave thread -history on the Relay provider instead. +migration exists to prevent. + +Pass `--skip-history-migration` to leave thread history on the Relay provider +instead. Use it only when Relay is about to be reinstalled. Codex resolves a +thread's recorded provider against `config.toml` when it resumes, so a thread +left on `nemo-relay-openai` after the provider is removed does not merely +disappear from the picker: resuming it fails with +``Model provider `nemo-relay-openai` not found``. Reverse it by reinstalling with +`--migrate-history`, or restore the backup described below. Add `--dry-run` to either command to report the thread counts that would move without touching the database or the journal. Relay targets Codex's current thread database, `state_5.sqlite`. The numeric suffix is a Codex schema generation, so a future Codex release can move to -`state_6.sqlite` or later. Name the current database explicitly when that -happens: +`state_6.sqlite` or later. Relay does not search for another generation when +that file is missing; it stops and reports the path it expected. Name the +current database explicitly to proceed: ```bash nemo-relay install codex --migrate-history --history-database state_6.sqlite diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 47fff8c9b..36238d95e 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -86,6 +86,12 @@ each user and host, even when two operations name different install directories. If another operation is still active after a short wait, Relay stops with a timeout instead of changing host-wide plugin state concurrently. +Codex installs accept an experimental `--migrate-history` flag that keeps +existing Codex thread history visible in the resume picker after Relay changes +the active provider. It edits Codex's own thread database, so read +[Migrate Codex Thread History](/nemo-relay-cli/codex#migrate-codex-thread-history) +before using it. + ## What Install Changes For Claude Code and Codex, `nemo-relay install` writes a local marketplace named @@ -331,6 +337,12 @@ metadata. It then removes the host registration and marketplace. Claude Code provider routing is restored from the Relay backup. Unrelated user hooks and configuration remain unchanged. +If a Codex install migrated thread history with `--migrate-history`, uninstall +also reverses that migration. The reversal is inferred from the recorded +migration, so it needs no flag. See +[Migrate Codex Thread History](/nemo-relay-cli/codex#migrate-codex-thread-history) +for what it changes and how to opt out. + ## Compatibility and Migration These integrations require Codex 0.143.0 or Claude Code 2.1.121 at minimum. From 609e8cec79edbfc4faad6e3b46f662d31b497342 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 16:07:53 -0700 Subject: [PATCH 04/10] fix(codex): report a failed history migration without claiming install failed A history-migration failure was returned as `CliError::Install`, which `run_agent_operations` reports as "failed to install one or more integrations". That is wrong and sends the caller to the wrong remedy: the integration is installed and working, and only the opt-in migration failed. The same held for uninstall, where the integration is already removed. Report both as a nonzero exit status with a structured log event and a message naming the actual state and the recovery step, instead of an install error. Also cover the locked-database path with a test. The docs claim a running Codex produces a clean failure rather than partial state; the test holds a competing `BEGIN IMMEDIATE` transaction and asserts the error explains the remedy, the database is unchanged, and no journal is recorded. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/install.rs | 49 +++++++++++++++++-- .../coverage/agents/codex_history_tests.rs | 45 +++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/agents/codex/install.rs b/crates/cli/src/agents/codex/install.rs index ab1a92284..e7e4b84e7 100644 --- a/crates/cli/src/agents/codex/install.rs +++ b/crates/cli/src/agents/codex/install.rs @@ -19,8 +19,27 @@ pub(crate) fn install(command: InstallRequest) -> Result { } // The provider must exist in config.toml before threads are pointed at it, // so migrate only after the install itself has succeeded. - history::migrate_to_relay(dry_run, history_database.as_deref()).map_err(CliError::Install)?; - Ok(status) + // + // A failure here is reported as a nonzero status rather than an error: the + // integration is installed and working, and calling it an install failure + // would send the caller to the wrong remedy. + match history::migrate_to_relay(dry_run, history_database.as_deref()) { + Ok(_) => Ok(status), + Err(error) => { + log::error!( + target: "nemo_relay.installation", + event = "codex_history_migration_failed", + host = "codex", + error_kind = "history_migration"; + "Codex integration installed but thread-history migration failed" + ); + println!("the Codex integration is installed, but history migration failed: {error}"); + println!( + "retry the migration with `nemo-relay install codex --force --migrate-history`." + ); + Ok(ExitCode::FAILURE) + } + } } pub(crate) fn uninstall(command: UninstallRequest) -> Result { @@ -34,6 +53,28 @@ pub(crate) fn uninstall(command: UninstallRequest) -> Result // Reversal is inferred from the migration journal rather than a flag, so a // user who migrated at install time does not have to remember to ask for it // again here. - history::restore_from_relay(dry_run, history_database.as_deref()).map_err(CliError::Install)?; - Ok(status) + // + // As with install, a failure here is a nonzero status rather than an error. + // The integration is already removed; the journal survives so a later + // uninstall can still reverse the migration. + match history::restore_from_relay(dry_run, history_database.as_deref()) { + Ok(_) => Ok(status), + Err(error) => { + log::error!( + target: "nemo_relay.installation", + event = "codex_history_restore_failed", + host = "codex", + error_kind = "history_migration"; + "Codex integration uninstalled but thread-history restore failed" + ); + println!( + "the Codex integration is uninstalled, but restoring thread history failed: {error}" + ); + println!( + "thread history is still recorded under the Relay provider; resuming those threads \ + fails until it is restored." + ); + Ok(ExitCode::FAILURE) + } + } } diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index 4c4ae85f7..92d70a66b 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -467,3 +467,48 @@ fn migrate_rejects_a_threads_table_without_a_provider_column() { "unexpected error: {error}" ); } + +#[test] +fn migrate_fails_cleanly_while_another_writer_holds_the_lock() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + // Stands in for a running Codex: an open write transaction on the database. + let mut holder = Command::new("sqlite3") + .arg(scope.database()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("sqlite3 spawns"); + { + use std::io::Write; + let stdin = holder.stdin.as_mut().expect("sqlite3 stdin"); + writeln!(stdin, "BEGIN IMMEDIATE;").expect("write lock statement"); + stdin.flush().expect("flush lock statement"); + } + // Give the holder time to take the lock before competing for it. + std::thread::sleep(std::time::Duration::from_millis(500)); + + let error = migrate_to_relay(/*dry_run*/ false, /*database*/ None) + .expect_err("a locked database is an error"); + + let _ = holder.kill(); + let _ = holder.wait(); + assert!( + error.contains("locked") || error.contains("busy"), + "unexpected error: {error}" + ); + assert!( + error.contains("quit any running Codex session"), + "the error should say how to recover: {error}" + ); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())], + "a locked migration leaves the database unchanged" + ); + assert!( + !migration_recorded(), + "a locked migration records no journal to reverse" + ); +} From cfdf45b8cb9c7ac22ebf95b9a20243d715630beb Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 16:50:25 -0700 Subject: [PATCH 05/10] docs(codex): state the sqlite3 runtime requirement and its Windows limitation `--migrate-history` shells out to `sqlite3`, which is present by default on macOS and most Linux distributions but not on Windows or in minimal container images. The requirement was mentioned in passing but its practical consequence was not, so a Windows user would only discover it at the point of failure. Call it out where it is decided rather than where it fails: a warning callout in the migration section, a note on the host-neutral installation page, and the `--migrate-history` help text. Also record that uninstall needs `sqlite3` only when a migration was recorded, and that the journal survives a failed reversal so a later uninstall can still complete it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/commands/install.rs | 3 ++- docs/nemo-relay-cli/codex.mdx | 11 ++++++++++- docs/nemo-relay-cli/plugin-installation.mdx | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 65ce44266..3427bc694 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -23,7 +23,8 @@ pub(crate) struct InstallCommand { pub(crate) skip_doctor: bool, /// Experimental: move existing Codex thread history onto the Relay provider so it stays /// visible in the Codex resume picker. `nemo-relay uninstall codex` reverses this - /// automatically. + /// automatically. Requires the `sqlite3` command on PATH, which Windows does not provide by + /// default. #[arg(long)] pub(crate) migrate_history: bool, /// Codex thread database to migrate, when Codex has moved past the default schema diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 0861b77a4..2d2e094f4 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -303,7 +303,16 @@ nemo-relay install codex --migrate-history Close every Codex session first. The migration takes the database write lock and fails cleanly with `database is locked` rather than writing partial state -if Codex is still running. It requires `sqlite3` on `PATH`. +if Codex is still running. + + +This is the only Relay feature that needs the `sqlite3` command on `PATH`. It +is present by default on macOS and most Linux distributions, but not on Windows +or in minimal container images. Without it, `--migrate-history` stops and +reports the missing command; the rest of the Codex integration is unaffected. +Uninstall needs `sqlite3` only when a migration was recorded, and the journal +survives a failed reversal, so a later uninstall can still complete it. + Before rewriting anything, Relay copies the thread database and its write-ahead-log sidecars into a timestamped `nemo-relay-history-backup-*` diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 36238d95e..c223387cb 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -88,7 +88,8 @@ stops with a timeout instead of changing host-wide plugin state concurrently. Codex installs accept an experimental `--migrate-history` flag that keeps existing Codex thread history visible in the resume picker after Relay changes -the active provider. It edits Codex's own thread database, so read +the active provider. It edits Codex's own thread database and is the only Relay +feature that requires the `sqlite3` command on `PATH`, so read [Migrate Codex Thread History](/nemo-relay-cli/codex#migrate-codex-thread-history) before using it. From 6fa55b2301aae853a157c67e6247d9f87e261743 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 2 Sep 2026 17:16:57 -0700 Subject: [PATCH 06/10] skills: teach the install skill about the Codex history migration The install skill told agents never to touch Codex SQLite state as a migration workaround, because no supported path existed. `--migrate-history` is now that path, so the guidance and the generated recovery file were both stale in a way that would send an agent to the wrong answer. Keep the prohibition on hand-editing, which is still correct, and point it at the supported command instead. Add the flag as a third option alongside the two existing exits, and add a section to the recovery asset for keeping threads visible rather than recovering from their disappearance. Present it as experimental in both places, note the `sqlite3` requirement and its Windows limitation, and keep the recovery-file and confirmation steps in place: the flag changes Codex's own thread database. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- .../assets/codex-desktop-recovery.md | 18 +++++++++++++++++- .../references/cli-install.md | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/skills/nemo-relay-install/assets/codex-desktop-recovery.md b/skills/nemo-relay-install/assets/codex-desktop-recovery.md index af7a26189..efaaf19fd 100644 --- a/skills/nemo-relay-install/assets/codex-desktop-recovery.md +++ b/skills/nemo-relay-install/assets/codex-desktop-recovery.md @@ -25,7 +25,8 @@ associated with another provider can disappear from the sidebar after restart. The thread data has not been deleted. Do not inspect, copy, delete, or edit Codex session storage or SQLite state to -repair sidebar visibility. +repair sidebar visibility. The supported way to keep threads visible is the +experimental `nemo-relay install codex --migrate-history` flag described below. ## Restore Normal Codex Desktop Visibility @@ -49,6 +50,21 @@ session data: nemo-relay doctor --plugin codex ``` +## Keep Existing Threads Visible Instead + +This experimental option avoids the disappearance rather than recovering from +it. Fully quit Codex Desktop, then reinstall with the migration flag: + +```bash +nemo-relay install codex --migrate-history +``` + +Relay records existing threads under the Relay provider so they stay listed, +backs up the thread database first, and reverses the change automatically during +`nemo-relay uninstall codex`. It requires the `sqlite3` command on `PATH`, which +Windows does not provide by default. Treat it as experimental: it changes +Codex's own thread database, and its behavior can change with any Codex release. + ## Continue A Thread Through Temporary Relay Wiring Fully quit Codex Desktop before resuming the same local thread, then run: diff --git a/skills/nemo-relay-install/references/cli-install.md b/skills/nemo-relay-install/references/cli-install.md index 2f584d80f..395ce6e96 100644 --- a/skills/nemo-relay-install/references/cli-install.md +++ b/skills/nemo-relay-install/references/cli-install.md @@ -137,10 +137,20 @@ The recovery file must include both supported exits: quitting Desktop and running `nemo-relay codex -- resume --all` or `nemo-relay codex -- resume `. +An experimental third option keeps existing threads visible instead of +recovering from their disappearance. `nemo-relay install codex +--migrate-history` records the existing threads under the Relay provider, and +`nemo-relay uninstall codex` reverses that automatically. It requires the +`sqlite3` command on `PATH`, which Windows does not provide by default. Offer it +only when the user asks to keep history visible, describe it as experimental, +and still create the recovery file: the flag changes Codex's own thread database +and its behavior can change with any Codex release. + Avoid `resume --last` when crossing providers. Never directly inspect, copy, delete, edit, or rewrite Codex session storage, private application -configuration, or SQLite state as a migration workaround. Supported -`nemo-relay` commands may manage Relay-generated provider and hook +configuration, or SQLite state as a migration workaround. Use +`--migrate-history` when that outcome is wanted; do not reproduce it by hand. +Supported `nemo-relay` commands may manage Relay-generated provider and hook configuration. Use these references for the supported installation and host-integration paths: From 80b7067db73fa80e2c61170253e5286db1ac6fe9 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 3 Sep 2026 02:34:21 +0000 Subject: [PATCH 07/10] Fix Codex history migration lock handling --- crates/cli/src/agents/codex/history.rs | 29 ++++++++++++++++--- .../coverage/agents/codex_history_tests.rs | 29 +++++++++++-------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs index d90078ba9..992bc1754 100644 --- a/crates/cli/src/agents/codex/history.rs +++ b/crates/cli/src/agents/codex/history.rs @@ -21,8 +21,9 @@ //! upstream, editing the database directly is the only available mechanism. use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{Value, json}; @@ -262,20 +263,40 @@ fn require_sqlite3() -> Result<(), String> { fn run_sqlite(database: &Path, sql: &str) -> Result { // `.timeout` rather than `PRAGMA busy_timeout`: the pragma emits its value // as a result row, which would contaminate the rows callers parse. - let output = Command::new("sqlite3") + let mut child = Command::new("sqlite3") + // Stop at a failed `BEGIN IMMEDIATE`. Otherwise the SQLite shell keeps + // running subsequent statements and masks a lock error with a failed + // `COMMIT`, notably on Windows. + .arg("-bail") .arg("-noheader") .arg("-batch") .arg("-cmd") .arg(format!(".timeout {BUSY_TIMEOUT_MS}")) .arg(database) - .arg(sql) - .output() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .map_err(|error| { format!( "failed to run sqlite3 against {}: {error}", database.display() ) })?; + let mut stdin = child.stdin.take().expect("piped sqlite3 stdin"); + stdin.write_all(sql.as_bytes()).map_err(|error| { + format!( + "failed to write SQL to sqlite3 for {}: {error}", + database.display() + ) + })?; + drop(stdin); + let output = child.wait_with_output().map_err(|error| { + format!( + "failed to wait for sqlite3 against {}: {error}", + database.display() + ) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stderr = stderr.trim(); diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index 92d70a66b..755437d32 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -4,8 +4,9 @@ //! Coverage for the experimental Codex thread-history provider migration. use std::ffi::OsStr; +use std::io::{BufRead, BufReader, Write}; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; use tempfile::TempDir; @@ -474,20 +475,24 @@ fn migrate_fails_cleanly_while_another_writer_holds_the_lock() { let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); // Stands in for a running Codex: an open write transaction on the database. let mut holder = Command::new("sqlite3") + .arg("-batch") .arg(scope.database()) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) .spawn() .expect("sqlite3 spawns"); - { - use std::io::Write; - let stdin = holder.stdin.as_mut().expect("sqlite3 stdin"); - writeln!(stdin, "BEGIN IMMEDIATE;").expect("write lock statement"); - stdin.flush().expect("flush lock statement"); - } - // Give the holder time to take the lock before competing for it. - std::thread::sleep(std::time::Duration::from_millis(500)); + let mut stdout = BufReader::new(holder.stdout.take().expect("sqlite3 stdout")); + let stdin = holder.stdin.as_mut().expect("sqlite3 stdin"); + writeln!(stdin, "BEGIN IMMEDIATE;").expect("write lock statement"); + writeln!(stdin, "SELECT 'lock-acquired';").expect("write lock readiness marker"); + stdin.flush().expect("flush lock statements"); + + let mut ready = String::new(); + stdout + .read_line(&mut ready) + .expect("read lock readiness marker"); + assert_eq!(ready.trim(), "lock-acquired", "writer acquired its lock"); let error = migrate_to_relay(/*dry_run*/ false, /*database*/ None) .expect_err("a locked database is an error"); From 4f8111e4b9fdf6bbe4d2eb0137290680b5350fa5 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 3 Sep 2026 18:33:52 -0700 Subject: [PATCH 08/10] fix(codex): write the migration journal before changing thread providers The journal was written after the provider update, so a journal write that failed left every thread moved to the Relay provider with no record of the migration. Uninstall infers reversal from that journal, so it would then report success while silently declining to reverse anything, leaving the threads on a provider that uninstall had just removed from config.toml. Resuming one fails outright. The backup still existed, so nothing was lost, but recovery required restoring it by hand. Write the journal first, so a journal failure aborts before the database is touched. Because the update is a single transaction, a failure after the write changed nothing, so clear the journal in that case rather than leaving one for a migration that never happened; the cleanup result is ignored so it cannot mask the original error. If the process dies between the two steps the journal survives, which reversal already tolerates: it finds no threads under the Relay provider, clears the journal, and makes no changes. Add a regression that a journal whose parent cannot be created leaves the providers untouched, and one that reversal clears a journal describing a migration that never completed. The first fails against the previous ordering. Found by review of the equivalent standalone script. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/history.rs | 12 +++- .../coverage/agents/codex_history_tests.rs | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs index 992bc1754..77948f781 100644 --- a/crates/cli/src/agents/codex/history.rs +++ b/crates/cli/src/agents/codex/history.rs @@ -103,8 +103,18 @@ pub(crate) fn migrate_to_relay( return Ok(Some(outcome)); } let backup = back_up_database(&database)?; - update_provider(&database, OPENAI_PROVIDER, RELAY_PROVIDER)?; + // Record the journal before the database changes. Writing it afterwards + // leaves no way back when the write fails: the threads have already moved, + // and uninstall infers reversal from the journal, so it would silently + // decline to reverse anything. write_journal(&database, &backup, &outcome)?; + if let Err(error) = update_provider(&database, OPENAI_PROVIDER, RELAY_PROVIDER) { + // The update is a single transaction, so a failure changed nothing and + // the journal describes a migration that never happened. Drop it, but + // do not let cleanup mask the original failure. + let _ = clear_journal(/*dry_run*/ false); + return Err(error); + } println!("moved {} in {}", outcome.describe(), database.display()); println!( "backed up the previous thread database to {}", diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index 755437d32..585f11076 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -517,3 +517,72 @@ fn migrate_fails_cleanly_while_another_writer_holds_the_lock() { "a locked migration records no journal to reverse" ); } + +#[test] +fn a_failed_journal_write_leaves_the_database_unchanged() { + require_sqlite3_or_skip!(); + let scope = + CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER), ("thread-b", OPENAI_PROVIDER)]); + // Make the journal unwritable by turning its parent directory into a file. + let journal = journal_path().expect("journal path resolves"); + let parent = journal + .parent() + .expect("journal has a parent") + .to_path_buf(); + if parent.exists() { + std::fs::remove_dir_all(&parent).expect("clear the journal directory"); + } + if let Some(grandparent) = parent.parent() { + std::fs::create_dir_all(grandparent).expect("journal grandparent"); + } + std::fs::write(&parent, b"not a directory").expect("block the journal directory"); + + let error = + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect_err("journal write fails"); + + assert!( + error.contains("failed to create") || error.contains("failed to write"), + "unexpected error: {error}" + ); + assert_eq!( + scope.providers(), + vec![ + ("thread-a".to_string(), OPENAI_PROVIDER.to_string()), + ("thread-b".to_string(), OPENAI_PROVIDER.to_string()), + ], + "a journal that cannot be written must abort before the provider update" + ); +} + +#[test] +fn restore_clears_a_journal_for_a_migration_that_never_completed() { + require_sqlite3_or_skip!(); + // The reordered write can leave a journal behind if the process dies + // between recording it and updating the database. + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + let outcome = MigrationOutcome { + from: OPENAI_PROVIDER.to_string(), + to: RELAY_PROVIDER.to_string(), + thread_ids: vec!["thread-a".to_string()], + }; + write_journal( + &scope.database(), + Path::new("/nonexistent-backup"), + &outcome, + ) + .expect("journal writes"); + + let restored = + restore_from_relay(/*dry_run*/ false, /*database*/ None).expect("restore succeeds"); + + assert_eq!(restored, None, "there is nothing to reverse"); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())], + "the database is untouched" + ); + assert!( + !migration_recorded(), + "a journal with nothing to reverse is cleared" + ); +} From c8222992b5bf9e749da7e66200e3a3b3c991c869 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 3 Sep 2026 21:31:47 -0700 Subject: [PATCH 09/10] fix(codex): keep the migration journal when a restore fails Reversal cleared the journal whenever the target database was missing, but the target can come from `--history-database`. A typo in that flag therefore deleted the record of a real, still-unreversed migration. That matters more here than in the standalone script: uninstall infers reversal from the journal, so once it is gone `nemo-relay uninstall codex` reports success while silently declining to move anything, leaving the threads on a provider it just removed from config.toml. Resuming one fails outright. The backup still existed, so nothing was lost, but recovery required restoring it by hand -- for a step whose purpose is reversibility, that is the wrong failure mode. Only a missing *recorded* database says the migration went stale, so clear the journal in that case alone. An override that does not resolve is a caller mistake: fail and say the journal was kept, so retrying with the right path is obviously safe. Add a regression that a failed reversal leaves the providers migrated and the journal in place, and that the correct invocation then still reverses the migration. That last step is the point -- it asserts the failure stayed recoverable, not merely that a file exists. It fails against the previous behavior. Found by review of the equivalent standalone script. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/history.rs | 11 ++++++ .../coverage/agents/codex_history_tests.rs | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs index 77948f781..832bf517c 100644 --- a/crates/cli/src/agents/codex/history.rs +++ b/crates/cli/src/agents/codex/history.rs @@ -146,6 +146,7 @@ pub(crate) fn restore_from_relay( // An explicit override wins, then the database the migration pinned, then // the default. The journal matters most here: it names the database that // was actually rewritten, even if the default has since moved on. + let overridden = database.is_some(); let database = match database { Some(database) => resolve_database(Some(database))?, None => match journal_database(&journal) { @@ -154,6 +155,16 @@ pub(crate) fn restore_from_relay( }, }; if !database.exists() { + // Only a missing *recorded* database says the migration went stale. An + // override that does not resolve is a caller mistake, so keep the + // journal: discarding it would strand the threads on the Relay + // provider with no record of how to move them back. + if overridden { + return Err(format!( + "Codex thread database {} does not exist; kept the migration journal", + database.display() + )); + } clear_journal(dry_run)?; return Err(format!( "recorded Codex thread database {} no longer exists; discarded the migration journal", diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index 585f11076..c96475d97 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -434,6 +434,42 @@ fn restore_prefers_the_database_the_migration_recorded() { ); } +#[test] +fn a_failed_restore_keeps_the_journal_for_a_later_attempt() { + require_sqlite3_or_skip!(); + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("migration succeeds"); + let missing = scope.database().with_file_name("typo.sqlite"); + + let error = restore_from_relay(/*dry_run*/ false, Some(&missing)) + .expect_err("an override that does not resolve fails"); + + assert!( + error.contains("kept the migration journal"), + "the error says the journal survived: {error}" + ); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), RELAY_PROVIDER.to_string())], + "the threads are still migrated" + ); + assert!( + migration_recorded(), + "the journal still records the migration this attempt failed to reverse" + ); + + // The point of keeping it: the correct path still reverses the migration. + let outcome = restore_from_relay(/*dry_run*/ false, /*database*/ None) + .expect("restore succeeds") + .expect("restore reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-a"]); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())] + ); +} + #[test] fn migrate_rejects_a_database_without_a_threads_table() { require_sqlite3_or_skip!(); From 43ecde54371eb716d331ff7bcfb84d4ebcb6461b Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 3 Sep 2026 21:39:25 -0700 Subject: [PATCH 10/10] fix(codex): refuse a second migration while one is outstanding Migration never read the journal it was about to write. Migrating a second database with `--history-database` therefore overwrote the first recovery record, and reversal reads that record to find its target -- so uninstall repaired only the second database and cleared the journal, leaving the first stranded on `nemo-relay-openai`. Uninstall also removes that provider from config.toml, so those threads disappear from the picker and resuming one fails: the exact defect this migration exists to fix, inflicted by the migration itself. The backup still existed, so nothing was lost, but recovery required restoring it by hand. One journal describes one outstanding migration, so reject a target that conflicts with the recorded one and name both paths. Migrating the database the journal already records stays allowed: threads created after a migration land on the built-in provider, and re-migrating the same file loses nothing, since reversal moves every Relay-provider thread back rather than only the recorded ids. Paths are compared canonicalized, so a bare `--history-database` name and the absolute path the journal recorded are one database, not a conflict. Reading the journal first also means a journal path whose parent is a regular file now fails on the read rather than the write, so treat that like an absent journal: the write that follows still reports the directory it cannot create, which is the clearer error. That keeps the existing journal-write regression exercising the ordering it was written for. Add coverage that a second database is refused while a migration is outstanding, that the first stays reversible afterwards, and that re-migrating the recorded database still works. The first fails against the previous behavior. Found by review of the equivalent standalone script. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/codex/history.rs | 39 +++++++- .../coverage/agents/codex_history_tests.rs | 94 ++++++++++++++++--- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs index 832bf517c..7f72afb67 100644 --- a/crates/cli/src/agents/codex/history.rs +++ b/crates/cli/src/agents/codex/history.rs @@ -80,6 +80,21 @@ pub(crate) fn migrate_to_relay( database.display() )); } + // One journal describes one outstanding migration. Overwriting it would + // strand the database it named on the Relay provider: nothing records that + // migration any more, so reversal never visits it and the threads stay + // hidden from the picker. + if let Some(journal) = read_journal()? + && let Some(recorded) = journal_database(&journal) + && !is_same_database(&recorded, &database) + { + return Err(format!( + "the Codex history migration journal already records an outstanding migration of {}; \ + reverse that one with `nemo-relay uninstall codex` before migrating {}", + recorded.display(), + database.display() + )); + } require_sqlite3()?; ensure_thread_schema(&database)?; let thread_ids = thread_ids_for_provider(&database, OPENAI_PROVIDER)?; @@ -421,7 +436,17 @@ fn read_journal() -> Result, String> { path.display() ) }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + // A path whose prefix is not a directory holds no journal, same as one + // that is simply absent. Reporting it here would mask the clearer error + // from the write that follows, which names the directory it cannot make. + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } Err(error) => Err(format!( "failed to read the Codex history migration journal {}: {error}", path.display() @@ -429,6 +454,18 @@ fn read_journal() -> Result, String> { } } +/// Reports whether two paths name the same thread database. +/// +/// Both sides are canonicalized when they resolve, so a bare `--history-database` +/// name and the absolute path the journal recorded are recognized as one file +/// rather than read as a second, conflicting migration. +fn is_same_database(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + fn journal_database(journal: &Value) -> Option { journal .get("database") diff --git a/crates/cli/tests/coverage/agents/codex_history_tests.rs b/crates/cli/tests/coverage/agents/codex_history_tests.rs index c96475d97..d79a9c282 100644 --- a/crates/cli/tests/coverage/agents/codex_history_tests.rs +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -53,17 +53,7 @@ impl CodexHistoryScope { } fn providers(&self) -> Vec<(String, String)> { - let raw = sqlite( - &self.database(), - "SELECT id, model_provider FROM threads ORDER BY id;", - ); - raw.lines() - .filter(|line| !line.is_empty()) - .map(|line| { - let (id, provider) = line.split_once('|').expect("delimited row"); - (id.to_string(), provider.to_string()) - }) - .collect() + providers_in(&self.database()) } fn journal(&self) -> Option { @@ -88,6 +78,21 @@ impl CodexHistoryScope { } } +/// Reads every thread and its provider, for a database the scope does not own. +fn providers_in(database: &Path) -> Vec<(String, String)> { + let raw = sqlite( + database, + "SELECT id, model_provider FROM threads ORDER BY id;", + ); + raw.lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let (id, provider) = line.split_once('|').expect("delimited row"); + (id.to_string(), provider.to_string()) + }) + .collect() +} + /// Creates a minimal `threads` table carrying only the columns under test. fn seed_database(database: &Path, threads: &[(&str, &str)]) { let mut sql = @@ -414,6 +419,73 @@ fn a_full_path_database_is_used_as_given() { ); } +#[test] +fn migrate_rejects_a_second_database_while_one_migration_is_outstanding() { + require_sqlite3_or_skip!(); + // One journal records one migration, so migrating a second database would + // overwrite the record naming the first and strand it on the Relay + // provider: reversal reads the journal, so it would never visit that file. + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + let elsewhere = scope.home.path().join("elsewhere.sqlite"); + seed_database(&elsewhere, &[("thread-b", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("the first migration succeeds"); + + let error = migrate_to_relay(/*dry_run*/ false, Some(&elsewhere)) + .expect_err("a second outstanding migration is refused"); + + assert!( + error.contains("already records an outstanding migration"), + "the error names the conflict: {error}" + ); + assert_eq!( + providers_in(&elsewhere), + vec![("thread-b".to_string(), OPENAI_PROVIDER.to_string())], + "the second database is untouched" + ); + assert_eq!( + scope.journal().expect("journal exists")["database"], + json!(scope.database()), + "the journal still pins the first database" + ); + + // The first migration stays reversible, which is what the refusal protects. + restore_from_relay(/*dry_run*/ false, /*database*/ None) + .expect("restore succeeds") + .expect("restore reports an outcome"); + assert_eq!( + scope.providers(), + vec![("thread-a".to_string(), OPENAI_PROVIDER.to_string())] + ); +} + +#[test] +fn migrate_accepts_the_database_the_journal_already_records() { + require_sqlite3_or_skip!(); + // Threads created after a migration land on the built-in provider, so the + // same database is migrated again. The override spells it as a bare name + // while the journal recorded an absolute path: that is one database, not a + // conflict. + let scope = CodexHistoryScope::enter(&[("thread-a", OPENAI_PROVIDER)]); + migrate_to_relay(/*dry_run*/ false, /*database*/ None).expect("the first migration succeeds"); + sqlite( + &scope.database(), + &format!("INSERT INTO threads VALUES ('thread-b', '{OPENAI_PROVIDER}');"), + ); + + let outcome = migrate_to_relay(/*dry_run*/ false, Some(Path::new(STATE_DB_FILE))) + .expect("migrating the recorded database again succeeds") + .expect("migration reports an outcome"); + + assert_eq!(outcome.thread_ids, vec!["thread-b"]); + assert_eq!( + scope.providers(), + vec![ + ("thread-a".to_string(), RELAY_PROVIDER.to_string()), + ("thread-b".to_string(), RELAY_PROVIDER.to_string()), + ] + ); +} + #[test] fn restore_prefers_the_database_the_migration_recorded() { require_sqlite3_or_skip!();