diff --git a/crates/cli/src/agents/codex/history.rs b/crates/cli/src/agents/codex/history.rs new file mode 100644 index 000000000..7f72afb67 --- /dev/null +++ b/crates/cli/src/agents/codex/history.rs @@ -0,0 +1,506 @@ +// 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::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +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, 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"; +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, + 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, or \ + name the current database with `--history-database`", + 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)?; + 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)?; + // 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 {}", + 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, + database: Option<&Path>, +) -> Result, String> { + let Some(journal) = read_journal()? else { + return Ok(None); + }; + // 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) { + Some(database) => database, + None => resolve_database(None)?, + }, + }; + 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", + database.display() + )); + } + require_sqlite3()?; + ensure_thread_schema(&database)?; + 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(_))) +} + +/// 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 { + 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 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) + .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(); + 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() + ) + }), + // 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() + )), + } +} + +/// 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") + .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..e7e4b84e7 100644 --- a/crates/cli/src/agents/codex/install.rs +++ b/crates/cli/src/agents/codex/install.rs @@ -7,10 +7,74 @@ 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 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. + // + // 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 { - crate::installation::marketplace::uninstall(CodingAgent::Codex, command) + 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); + } + // 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. + // + // 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/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..3427bc694 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -21,6 +21,17 @@ 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. 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 + /// 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)] @@ -34,6 +45,13 @@ 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, + /// 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)] @@ -66,6 +84,8 @@ impl InstallCommand { force: self.force, dry_run: self.dry_run, skip_doctor: self.skip_doctor, + migrate_history: self.migrate_history, + history_database: self.history_database, } } } @@ -76,12 +96,24 @@ impl UninstallCommand { install_dir: self.install_dir, force: self.force, dry_run: self.dry_run, + skip_history_migration: self.skip_history_migration, + history_database: self.history_database, } } } 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(), + )); + } + 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() { @@ -104,6 +136,21 @@ 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(), + )); + } + 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 d2f1181d5..2fcf6746a 100644 --- a/crates/cli/src/commands/integrations.rs +++ b/crates/cli/src/commands/integrations.rs @@ -78,6 +78,10 @@ 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, + 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 a26d40c4e..85b790807 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -15,6 +15,10 @@ 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, + /// Codex thread database to migrate, when it is not the default generation. + pub(crate) history_database: Option, } #[derive(Debug, Clone)] @@ -22,4 +26,8 @@ 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, + /// 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 new file mode 100644 index 000000000..d79a9c282 --- /dev/null +++ b/crates/cli/tests/coverage/agents/codex_history_tests.rs @@ -0,0 +1,696 @@ +// 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::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; + +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, + 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"); + 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, + database: database.to_string(), + }; + seed_database(&scope.database(), threads); + scope + } + + fn database(&self) -> std::path::PathBuf { + self.home.path().join(".codex").join(&self.database) + } + + fn providers(&self) -> Vec<(String, String)> { + providers_in(&self.database()) + } + + 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 + } +} + +/// 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 = + 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, /*database*/ None) + .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, /*database*/ None).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, /*database*/ None).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, /*database*/ None).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, /*database*/ None) + .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, /*database*/ None).expect("migration succeeds"); + + 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()), + ("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, /*database*/ None).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, /*database*/ None) + .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, /*database*/ None).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, /*database*/ None).expect("migration succeeds"); + + let outcome = restore_from_relay(/*dry_run*/ true, /*database*/ None) + .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, /*database*/ None) + .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'"); +} + +#[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 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!(); + 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 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!(); + 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}" + ); +} + +#[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("-batch") + .arg(scope.database()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("sqlite3 spawns"); + 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"); + + 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" + ); +} + +#[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" + ); +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index f64f8e8db..e362a26d1 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2159,6 +2159,8 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: true, skip_doctor: true, + migrate_history: false, + history_database: None, } ) .unwrap(), @@ -2185,6 +2187,8 @@ 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, + history_database: None, }, ) .unwrap(), @@ -2198,6 +2202,8 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { force: false, dry_run: false, skip_doctor: true, + migrate_history: false, + history_database: None, }, ) .expect_err("an unavailable host CLI should fail installation"); @@ -2209,6 +2215,8 @@ 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, + 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 2d68de7d1..2d2e094f4 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -277,6 +277,97 @@ 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. + + +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-*` +directory inside the Codex home. It then records a migration journal in the +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: + +```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. 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. 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 +``` + +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. + ## Configure Transparent Runs Create `$XDG_CONFIG_HOME/nemo-relay/config.toml` (or diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 47fff8c9b..c223387cb 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -86,6 +86,13 @@ 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 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. + ## What Install Changes For Claude Code and Codex, `nemo-relay install` writes a local marketplace named @@ -331,6 +338,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. 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: